Files
cfw-autumn/server/src/internal/customers/change-product/scheduleUtils.ts
2025-03-31 21:03:42 +01:00

350 lines
9.3 KiB
TypeScript

import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js";
import {
getStripeSchedules,
getStripeSubs,
} from "@/external/stripe/stripeSubUtils.js";
import {
AppEnv,
CusProductStatus,
FullCusProduct,
FullProduct,
Organization,
} from "@autumn/shared";
import Stripe from "stripe";
import { fullCusProductToProduct } from "../products/cusProductUtils.js";
import { CusProductService } from "../products/CusProductService.js";
import { SupabaseClient } from "@supabase/supabase-js";
import { AttachParams } from "../products/AttachParams.js";
import { getExistingCusProducts } from "../add-product/handleExistingProduct.js";
import { createLogtailWithContext } from "@/external/logtail/logtailUtils.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
export const getPricesForCusProduct = ({
cusProduct,
}: {
cusProduct: FullCusProduct;
}) => {
return cusProduct.customer_prices.map((price) => price.price);
};
export const getFilteredScheduleItems = ({
scheduleItems,
cusProducts,
}: {
scheduleItems: any[];
cusProducts: (FullCusProduct | null | undefined)[];
}) => {
let curPrices: any[] = [];
for (const cusProduct of cusProducts) {
if (cusProduct) {
curPrices = curPrices.concat(getPricesForCusProduct({ cusProduct }));
}
}
return scheduleItems.filter(
(scheduleItem: any) =>
!curPrices.some(
(price) => price.config?.stripe_price_id === scheduleItem.price
)
);
};
export const updateScheduledSubWithNewItems = async ({
scheduleObj,
newItems,
cusProducts,
stripeCli,
}: {
scheduleObj: any;
newItems: any[];
cusProducts: (FullCusProduct | null | undefined)[];
stripeCli: Stripe;
}) => {
const { schedule, interval } = scheduleObj;
let filteredScheduleItems = getFilteredScheduleItems({
scheduleItems: schedule.phases[0].items,
cusProducts: cusProducts,
});
// 2. Add new schedule items
let newScheduleItems = filteredScheduleItems
.map((item: any) => ({
price: item.price,
}))
.concat(
...newItems.map((item: any) => ({
price: item.price,
}))
);
await stripeCli.subscriptionSchedules.update(schedule.id, {
phases: [
{
items: newScheduleItems,
start_date: schedule.phases[0].start_date,
},
],
});
};
export const getScheduleIdsFromCusProducts = ({
cusProducts,
}: {
cusProducts: (FullCusProduct | null | undefined)[];
}) => {
let scheduleIds: string[] = [];
for (const cusProduct of cusProducts) {
if (cusProduct) {
scheduleIds = scheduleIds.concat(cusProduct.scheduled_ids || []);
}
}
return scheduleIds;
};
// CANCELLING FUTURE PRODUCT
export const cancelFutureProductSchedule = async ({
sb,
org,
stripeCli,
cusProducts,
product,
includeOldItems = true,
logger,
inIntervals,
}: {
sb: SupabaseClient;
org: Organization;
stripeCli: Stripe;
cusProducts: FullCusProduct[];
product: FullProduct;
includeOldItems?: boolean;
logger: any;
inIntervals?: string[];
}) => {
// 1. Get main and scheduled products
const { curMainProduct, curScheduledProduct } = await getExistingCusProducts({
product,
cusProducts,
});
if (!curMainProduct) {
return;
}
// 2. Get schedules
const schedules = await getStripeSchedules({
stripeCli: stripeCli,
scheduleIds: getScheduleIdsFromCusProducts({
cusProducts: [curMainProduct, curScheduledProduct],
}),
});
// 3. Get current subs
const curSubs = await getStripeSubs({
stripeCli: stripeCli,
subIds: curMainProduct.subscription_ids || [],
});
// 3. Get old item sets
let fullCurProduct = fullCusProductToProduct(curMainProduct);
let oldItemSets = await getStripeSubItems({
attachParams: {
customer: curMainProduct.customer,
org: org,
products: [fullCurProduct],
prices: fullCurProduct.prices,
entitlements: fullCurProduct.entitlements,
freeTrial: null,
optionsList: [],
entities: [],
},
});
/*
4. For each schedule, either do:
- Update schedule with old items
- Cancel schedule
*/
for (const scheduleObj of schedules) {
const { schedule, interval } = scheduleObj;
if (inIntervals && !inIntervals.includes(interval!)) {
continue;
}
const filteredScheduleItems = getFilteredScheduleItems({
scheduleItems: schedule.phases[0].items,
cusProducts: [curMainProduct, curScheduledProduct],
});
let updateSchedule = filteredScheduleItems.length > 0;
if (updateSchedule) {
// If scheduled items are all active cus products, can just cancel schedule...
const activeCusProducts = cusProducts.filter(
(cusProduct) => cusProduct.status === CusProductStatus.Active
);
const scheduledItemsWithoutActiveCusProducts = getFilteredScheduleItems({
scheduleItems: filteredScheduleItems,
cusProducts: activeCusProducts,
});
if (scheduledItemsWithoutActiveCusProducts.length == 0) {
updateSchedule = false;
}
}
if (updateSchedule) {
let oldItemSet = oldItemSets.find(
(itemSet) => itemSet.interval === interval
);
await updateScheduledSubWithNewItems({
scheduleObj: scheduleObj,
newItems: includeOldItems ? oldItemSet?.items || [] : [],
cusProducts: [curMainProduct, curScheduledProduct],
stripeCli: stripeCli,
});
// Put back schedule id into curMainProduct
if (includeOldItems) {
await CusProductService.update({
sb,
cusProductId: curMainProduct!.id,
updates: {
scheduled_ids: [
...(curMainProduct!.scheduled_ids || []),
schedule.id,
],
},
});
} else {
// Remove schedule id from curMainProduct
await CusProductService.update({
sb,
cusProductId: curMainProduct!.id,
updates: {
scheduled_ids: curMainProduct!.scheduled_ids?.filter(
(id) => id !== schedule.id
),
},
});
}
logger.info(`✅ Updated schedule: ${schedule.id}`);
} else {
try {
await stripeCli.subscriptionSchedules.cancel(schedule.id);
} catch (error: any) {
logger.warn(
`❌ Error cancelling schedule: ${schedule.id}, ${error.message}`
);
}
const subWithSameInterval = curSubs.find(
(sub) =>
sub.items.data.length > 0 &&
sub.items.data[0]?.price?.recurring?.interval === interval
);
if (subWithSameInterval) {
await stripeCli.subscriptions.update(subWithSameInterval.id, {
cancel_at: null,
});
}
logger.info(`✅ Cancelled schedule: ${schedule.id}`);
}
}
// Handle case where scheduled product is free
if (
includeOldItems &&
curScheduledProduct &&
isFreeProduct(getPricesForCusProduct({ cusProduct: curScheduledProduct! }))
) {
logger.info("Handling case if curScheduledProduct is free");
// 1. Look at main product
let curMainSubIds = curMainProduct.subscription_ids;
// 2. Check if there are other products with same subscription and scheduled ids
let otherCusProductsWithSameSub: FullCusProduct[] = [];
for (const cusProduct of cusProducts) {
// 1. Check if contains one of the curMainSubIds
if (
cusProduct.id === curMainProduct.id ||
!curMainSubIds?.some((subId) =>
cusProduct?.subscription_ids?.includes(subId)
)
) {
continue;
}
// 2. Check if there's a scheduled product
let { curScheduledProduct: otherScheduledProduct } =
await getExistingCusProducts({
product: cusProduct.product,
cusProducts: cusProducts,
});
if (otherScheduledProduct) {
otherCusProductsWithSameSub.push(otherScheduledProduct);
}
}
if (otherCusProductsWithSameSub.length > 0) {
let schedules = await getStripeSchedules({
stripeCli: stripeCli,
scheduleIds: getScheduleIdsFromCusProducts({
cusProducts: otherCusProductsWithSameSub,
}),
});
for (const scheduleObj of schedules) {
const { schedule, interval } = scheduleObj;
// Get new items
let oldItemSet = oldItemSets.find(
(itemSet) => itemSet.interval === interval
);
await updateScheduledSubWithNewItems({
scheduleObj: scheduleObj,
newItems: oldItemSet?.items || [],
cusProducts: [],
stripeCli: stripeCli,
});
// Put back schedule id into curMainProduct
await CusProductService.update({
sb,
cusProductId: curMainProduct!.id,
updates: {
scheduled_ids: [
...(curMainProduct!.scheduled_ids || []),
schedule.id,
],
},
});
logger.info(
`✅ Added old items for product ${fullCurProduct.name} to schedule: ${schedule.id}`
);
}
} else {
// TODO: Check if this is correct
logger.info(
"No other cus products with same sub, uncanceling current main product"
);
if (curMainSubIds && curMainSubIds.length > 0) {
await stripeCli.subscriptions.update(curMainSubIds[0], {
cancel_at: null,
});
}
}
}
};