diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts b/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts index 406581f16..43462b5fa 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts @@ -81,11 +81,11 @@ export const handleSubscriptionUpdated = async ({ status: subStatusMap[subscription.status] || CusProductStatus.Unknown, canceled_at: canceled ? canceledAt : null, collection_method: fullSub.collection_method as CollectionMethod, - trial_ends_at: - previousAttributes.status === "trialing" && - subscription.status === "active" - ? null - : undefined, + // trial_ends_at: + // previousAttributes.status === "trialing" && + // subscription.status === "active" + // ? null + // : undefined, }, }); diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 191d39520..d508da58a 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -32,6 +32,8 @@ import { getCustomerSub } from "../../attachUtils/convertAttachParams.js"; import { paramsToSubItems } from "../../mergeUtils/paramsToSubItems.js"; import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js"; import { subToNewSchedule } from "../../mergeUtils/subToNewSchedule.js"; +import { isTrialing } from "@/internal/customers/cusProducts/cusProductUtils.js"; +import { formatUnixToDate } from "@/utils/genUtils.js"; export const handlePaidProduct = async ({ req, @@ -69,19 +71,42 @@ export const handlePaidProduct = async ({ let subscriptions: Stripe.Subscription[] = []; // Only merge if no free trials - let mergeCusProduct = undefined; - if (!config.disableMerge && !freeTrial) { - mergeCusProduct = cusProducts?.find((cp) => - products.some((p) => p.group == cp.product.group) - ); - } - const mergeSub = await getCustomerSub({ attachParams }); + // if (!config.disableMerge && !freeTrial) { + // mergeCusProduct = cusProducts?.find((cp) => + // products.some((p) => p.group == cp.product.group) + // ); + // } + + const { sub: mergeSub, cusProduct: mergeCusProduct } = await getCustomerSub({ + attachParams, + }); + let sub: Stripe.Subscription | null = null; let schedule: Stripe.SubscriptionSchedule | null = null; + let trialEndsAt = undefined; + + console.log("Merge sub:", mergeSub?.id); + console.log("Merge cus product:", mergeCusProduct?.product.id); + console.log( + "Trial ends at:", + formatUnixToDate(mergeCusProduct?.trial_ends_at || 0) + ); + + throw new Error("test"); // 1. If merge sub if (mergeSub) { + if (mergeCusProduct?.free_trial) { + if (isTrialing({ cusProduct: mergeCusProduct, now: attachParams.now })) { + attachParams.freeTrial = mergeCusProduct.free_trial; + trialEndsAt = mergeCusProduct.trial_ends_at; + } else { + attachParams.freeTrial = null; + trialEndsAt = undefined; + } + } + // 1. If merged sub is canceled, also add to current schedule const newItemSet = await paramsToSubItems({ req, @@ -194,6 +219,7 @@ export const handlePaidProduct = async ({ anchorToUnix, carryExistingUsages: config.carryUsage, scenario: AttachScenario.New, + trialEndsAt: trialEndsAt || undefined, logger, }) ); diff --git a/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts b/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts index 2c8dac240..7bfa58b9f 100644 --- a/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts +++ b/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts @@ -104,11 +104,19 @@ export const handleScheduleFunction2 = async ({ if (currentPhaseIndex == newItems.phases.length - 1) { console.log(`NO SUBSEQUENT PHASES, RELEASING SCHEDULE`); await stripeCli.subscriptionSchedules.release(schedule!.id); + await CusProductService.updateByStripeScheduledId({ + db: req.db, + stripeScheduledId: schedule!.id, + updates: { scheduled_ids: [] }, + }); + await CusProductService.update({ db: req.db, cusProductId: curCusProduct!.id, updates: { - scheduled_ids: [], + canceled: true, + canceled_at: Date.now(), + ended_at: expectedEnd * 1000, }, }); schedule = undefined; @@ -126,6 +134,7 @@ export const handleScheduleFunction2 = async ({ cusProductId: curCusProduct!.id, updates: { scheduled_ids: [schedule!.id], + canceled_at: Date.now(), canceled: true, ended_at: expectedEnd * 1000, }, diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts index 276c6a321..87b6727dd 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts @@ -26,14 +26,9 @@ import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSu import { updateStripeSub2 } from "./updateStripeSub2.js"; import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import { paramsToSubItems } from "../../mergeUtils/paramsToSubItems.js"; -import { paramsToScheduleItems } from "../../mergeUtils/paramsToScheduleItems.js"; -import { updateCurSchedule } from "../../mergeUtils/updateCurSchedule.js"; -import { - getCurrentPhaseIndex, - logPhases, -} from "../../mergeUtils/phaseUtils/phaseUtils.js"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; import { shouldCancelSub } from "./upgradeFlowUtils.js"; +import { handleUpgradeFlowSchedule } from "./handleUpgradeFlowSchedule.js"; export const handleUpgradeFlow = async ({ req, @@ -65,7 +60,7 @@ export const handleUpgradeFlow = async ({ const newItemSet = await paramsToSubItems({ req, - sub: curSub!, + sub: curSub, attachParams, config, }); @@ -90,9 +85,12 @@ export const handleUpgradeFlow = async ({ } } - // Cancel sub... let canceled = false; - if (shouldCancelSub({ sub: curSub!, newSubItems: subItems })) { + // SCENARIO 1, NO SUB: + if (!curSub) { + console.log("UPGRADE FLOW, NO SUB (FROM CANCEL MAYBE...?)"); + // Do something about current sub... + } else if (shouldCancelSub({ sub: curSub!, newSubItems: subItems })) { console.log( `UPGRADE FLOW, CANCELLING SUB ${curSub!.id}, PRORATE: ${config.proration}` ); @@ -106,8 +104,8 @@ export const handleUpgradeFlow = async ({ }, }); } else if (subItems.length > 0) { + console.log(`UPGRADE FLOW, UPDATING SUB ${curSub!.id}`); itemSet.subItems = subItems; - console.log("New sub items:", subItems); const res = await updateStripeSub2({ req, @@ -119,45 +117,14 @@ export const handleUpgradeFlow = async ({ }); const schedule = await paramsToCurSubSchedule({ attachParams }); - - // Add to schedule? if (schedule) { - console.log("CUR ITEMS:"); - await logPhases({ - phases: schedule.phases as any, - db: req.db, - }); - - const currentPhaseIndex = getCurrentPhaseIndex({ + await handleUpgradeFlowSchedule({ + req, + attachParams, + config, schedule, - now: attachParams.now, + curSub, }); - - const nextPhaseIndex = currentPhaseIndex + 1; - - if (nextPhaseIndex < schedule.phases.length) { - const newItems = await paramsToScheduleItems({ - req, - schedule, - attachParams, - config, - billingPeriodEnd: schedule?.phases?.[nextPhaseIndex]?.start_date, - }); - - console.log("NEW ITEMS:"); - await logPhases({ - phases: newItems.phases, - db: req.db, - }); - - await updateCurSchedule({ - req, - attachParams, - schedule, - newPhases: newItems.phases, - sub: curSub!, - }); - } } attachParams.replaceables = res.replaceables || []; @@ -227,9 +194,3 @@ export const handleUpgradeFlow = async ({ } } }; - -// const newSubItems = await removeCurCusProductItems({ -// sub: curSub, -// cusProduct: curCusProduct!, -// subItems: itemSet.subItems, -// }); diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlowSchedule.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlowSchedule.ts new file mode 100644 index 000000000..c501c7545 --- /dev/null +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlowSchedule.ts @@ -0,0 +1,112 @@ +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { AttachConfig } from "@autumn/shared"; + +import Stripe from "stripe"; +import { paramsToScheduleItems } from "../../mergeUtils/paramsToScheduleItems.js"; +import { + logPhases, + getCurrentPhaseIndex, +} from "../../mergeUtils/phaseUtils/phaseUtils.js"; +import { updateCurSchedule } from "../../mergeUtils/updateCurSchedule.js"; +import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { + ACTIVE_STATUSES, + CusProductService, +} from "@/internal/customers/cusProducts/CusProductService.js"; +import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js"; +import { isFreeProduct } from "@/internal/products/productUtils.js"; + +export const handleUpgradeFlowSchedule = async ({ + req, + attachParams, + config, + schedule, + curSub, +}: { + req: ExtendedRequest; + attachParams: AttachParams; + config: AttachConfig; + schedule: Stripe.SubscriptionSchedule; + curSub: Stripe.Subscription; +}) => { + console.log(`UPGRADE FLOW, UPDATING SCHEDULE ${schedule.id}`); + const { stripeCli, customer, prices } = attachParams; + const curCusProduct = attachParamsToCurCusProduct({ attachParams }); + + // console.log("CUR ITEMS:"); + // await logPhases({ + // phases: schedule.phases as any, + // db: req.db, + // }); + + const currentPhaseIndex = getCurrentPhaseIndex({ + schedule, + now: attachParams.now, + }); + + const nextPhaseIndex = currentPhaseIndex + 1; + + if (currentPhaseIndex == -1 || nextPhaseIndex >= schedule.phases.length) + return; + + const newItems = await paramsToScheduleItems({ + req, + schedule, + attachParams, + config, + billingPeriodEnd: schedule?.phases?.[nextPhaseIndex]?.start_date, + }); + + // Should release schedule... + const newCurPhaseIndex = getCurrentPhaseIndex({ + schedule: { phases: newItems.phases } as any, + now: attachParams.now, + }); + + // If there are no subsequent phases, release schedule... + // Example: mergedUpgrade4.test.ts, mergedCancel2.test.ts + // pro, pro -> free, pro -> premium, pro (need to cancel initial schedule) + if (newCurPhaseIndex == newItems.phases.length - 1) { + console.log(`NO SUBSEQUENT PHASES, RELEASING SCHEDULE`); + await stripeCli.subscriptionSchedules.release(schedule!.id); + await CusProductService.updateByStripeScheduledId({ + db: req.db, + stripeScheduledId: schedule!.id, + updates: { scheduled_ids: [] }, + }); + + // Should we cancel the sub...? + // If all other products are canceled, and new product is free, cancel the sub... + const shouldCancelSub = + customer.customer_products + .filter( + (cp) => + cp.id !== curCusProduct?.id && + cp.subscription_ids?.includes(curSub.id) && + ACTIVE_STATUSES.includes(cp.status) + ) + .every((cp) => cp.canceled) && isFreeProduct(prices); + + if (shouldCancelSub) { + console.log(`UPGRADE FLOW, CANCELING SUB: ${curSub.id}`); + await stripeCli.subscriptions.update(curSub.id, { + cancel_at_period_end: true, + }); + } + + return; + } + + await logPhases({ + phases: newItems.phases, + db: req.db, + }); + + await updateCurSchedule({ + req, + attachParams, + schedule, + newPhases: newItems.phases, + sub: curSub!, + }); +}; diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts index 160b59fc8..469e080c1 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts @@ -40,15 +40,13 @@ export const updateStripeSub2 = async ({ }); } - let trialEnd = config.disableTrial - ? undefined - : freeTrialToStripeTimestamp({ - freeTrial: attachParams.freeTrial, - now: attachParams.now, - }); - - console.log("Sub items:", sanitizeSubItems(itemSet.subItems)); - console.log("Invoice items:", itemSet.invoiceItems); + let trialEnd = + config.disableTrial || config.carryTrial + ? undefined + : freeTrialToStripeTimestamp({ + freeTrial: attachParams.freeTrial, + now: attachParams.now, + }); // 1. Update subscription let updatedSub = await stripeCli.subscriptions.update(curSub.id, { diff --git a/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts b/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts index 92cf6d521..787443db9 100644 --- a/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts +++ b/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts @@ -34,7 +34,7 @@ export const priceToUnusedPreviewItem = ({ org?: Organization; }) => { now = now || Date.now(); - const onTrial = isTrialing(cusProduct); + const onTrial = isTrialing({ cusProduct, now }); // 1. Get price from stripe items const subItem = findStripeItemForPrice({ diff --git a/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts b/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts index cf91088d6..0c0b33015 100644 --- a/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts @@ -3,6 +3,7 @@ import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExi import { CusProductStatus } from "@autumn/shared"; import Stripe from "stripe"; import { cusProductToProduct } from "../../cusProducts/cusProductUtils/convertCusProduct.js"; +import { isTrialing } from "../../cusProducts/cusProductUtils.js"; export const attachParamsToCurCusProduct = ({ attachParams, @@ -118,17 +119,34 @@ export const getCustomerSub = async ({ return 0; }); - const subId = cusProducts.flatMap((cp) => cp.subscription_ids || [])?.[0]; + // const subId = cusProducts.flatMap((cp) => cp.subscription_ids || [])?.[0]; + const cusProduct = cusProducts.find( + (cp) => cp.subscription_ids && cp.subscription_ids.length > 0 + ); - if (!subId) { - return undefined; + if (!cusProduct) return { sub: undefined, cusProduct: undefined }; + const subId = cusProduct.subscription_ids![0]; + + // If there's only one customer product on sub, and it's still trialing, return undefined, because should just replace sub. + const curCusProduct = attachParamsToCurCusProduct({ attachParams }); + const cusProductsOnSub = cusProducts.filter( + (cp) => + cp.subscription_ids?.includes(cusProduct.subscription_ids![0]) && + curCusProduct?.id == cp.id + ); + + if ( + cusProductsOnSub.length === 1 && + isTrialing({ cusProduct, now: attachParams.now }) + ) { + return { sub: undefined, cusProduct: undefined }; } const sub = await stripeCli.subscriptions.retrieve(subId, { expand: ["items.data.price.tiers"], }); - return sub; + return { sub, cusProduct }; }; export const paramsToCurSub = async ({ diff --git a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts index 1f28d15c4..fb38e3ac3 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts @@ -288,7 +288,7 @@ const getChangeProductBranch = async ({ let isUpgrade = isProductUpgrade({ prices1: curPrices, prices2: newPrices }); if (isUpgrade) { - if (isTrialing(curMainProduct!)) { + if (isTrialing({ cusProduct: curMainProduct!, now: attachParams.now })) { return AttachBranch.MainIsTrial; } diff --git a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts index a800b5b60..25cfc2b6d 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts @@ -172,15 +172,6 @@ export const runAttachFunction = async ({ }); } - // // 1. Cancel future schedule before creating a new one... - // await deleteCurrentScheduledProduct({ - // req, - // org, - // attachParams, - // attachFunc: attachFunction, - // logger, - // }); - // 2. If main is trial, cancel it... if (branch == AttachBranch.MainIsTrial) { await CusProductService.update({ @@ -271,3 +262,12 @@ export const runAttachFunction = async ({ }); } }; + +// // 1. Cancel future schedule before creating a new one... +// await deleteCurrentScheduledProduct({ +// req, +// org, +// attachParams, +// attachFunc: attachFunction, +// logger, +// }); diff --git a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts index e852d4f62..3527040f4 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts @@ -26,6 +26,8 @@ import { import { isFreeProduct } from "@/internal/products/productUtils.js"; import { getMergeCusProduct } from "../attachFunctions/addProductFlow/getMergeCusProduct.js"; import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; +import { formatUnixToDate } from "@/utils/genUtils.js"; +import { isTrialing } from "../../cusProducts/cusProductUtils.js"; const getNextCycleItems = async ({ newProduct, @@ -35,6 +37,7 @@ const getNextCycleItems = async ({ withPrepaid, logger, config, + trialEnds, }: { newProduct: FullProduct; attachParams: AttachParams; @@ -43,6 +46,7 @@ const getNextCycleItems = async ({ withPrepaid?: boolean; logger: any; config: AttachConfig; + trialEnds?: number | null; }) => { // 1. If one off, return null if (branch == AttachBranch.OneOff) return null; @@ -50,11 +54,15 @@ const getNextCycleItems = async ({ // 2. If free trial let nextCycleAt = undefined; if (attachParams.freeTrial) { - nextCycleAt = - freeTrialToStripeTimestamp({ - freeTrial: attachParams.freeTrial, - now: attachParams.now, - })! * 1000; + if (trialEnds) { + nextCycleAt = trialEnds; + } else { + nextCycleAt = + freeTrialToStripeTimestamp({ + freeTrial: attachParams.freeTrial, + now: attachParams.now, + })! * 1000; + } } else if (anchorToUnix) { // Yearly one const largestInterval = getLargestInterval({ prices: newProduct.prices }); @@ -113,17 +121,35 @@ export const getNewProductPreview = async ({ // products: [newProduct], // config, // }); - const mergeSub = await getCustomerSub({ attachParams }); + const { sub: mergeSub, cusProduct: mergeCusProduct } = await getCustomerSub({ + attachParams, + }); - // console.log("Merge sub:", mergeSub); + // console.log("Merge sub:", mergeSub?.id); + // console.log("Merge cus product:", mergeCusProduct?.product.id); + // console.log( + // "Trial ends at:", + // formatUnixToDate(mergeCusProduct?.trial_ends_at || 0) + // ); + let trialEnds = undefined; if (mergeSub) { const { start } = subToPeriodStartEnd({ sub: mergeSub }); + if (mergeCusProduct?.free_trial) { + // 1. If still on trial + if (isTrialing({ cusProduct: mergeCusProduct, now: attachParams.now })) { + trialEnds = mergeCusProduct.trial_ends_at; + attachParams.freeTrial = mergeCusProduct.free_trial; + } else { + attachParams.freeTrial = null; + } + } const smallestInterval = getSmallestInterval({ prices: newProduct.prices, excludeOneOff: true, }); + if (smallestInterval) { anchorToUnix = addBillingIntervalUnix({ unixTimestamp: start * 1000, @@ -154,6 +180,7 @@ export const getNewProductPreview = async ({ withPrepaid, logger, config, + trialEnds, }); // console.log("Due next cycle", dueNextCycle); diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts index 50fc112e9..2625bbcde 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts @@ -60,7 +60,7 @@ const getNextCycleAt = ({ if ( branch == AttachBranch.NewVersion && curCusProduct && - isTrialing(curCusProduct) + isTrialing({ cusProduct: curCusProduct, now }) ) { return { next_cycle_at: curCusProduct.trial_ends_at, diff --git a/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts b/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts index 6917bf43c..819f8c2d6 100644 --- a/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts +++ b/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts @@ -102,17 +102,6 @@ export const paramsToSubItems = async ({ }) => { const { logger } = req; let curSubItems = sub?.items.data || []; - // if (scheduleSet) { - // const scheduleItems = scheduleSet.schedule.phases[0].items.map((item) => ({ - // id: item.price, - // price: { - // id: item.price, - // }, - // quantity: item.quantity, - // })); - - // curSubItems = scheduleItems as any; - // } const itemSet = await getStripeSubItems2({ attachParams, @@ -127,13 +116,6 @@ export const paramsToSubItems = async ({ curSubItems, }); - // console.log("New sub items:"); - // await logPhaseItems({ - // db: req.db, - // items: newSubItems as any, - // }); - // console.log("--------------------------------"); - const allCusProducts = attachParams.customer.customer_products; // 3. Remove items related to cus products to remove diff --git a/server/src/internal/customers/cancel/handleCancelProduct.ts b/server/src/internal/customers/cancel/handleCancelProduct.ts index f61ee2677..17eacdf63 100644 --- a/server/src/internal/customers/cancel/handleCancelProduct.ts +++ b/server/src/internal/customers/cancel/handleCancelProduct.ts @@ -23,6 +23,7 @@ import { handleRenewProduct } from "../attach/attachFunctions/handleRenewProduct import { getDefaultAttachConfig } from "../attach/attachUtils/getAttachConfig.js"; import { handleScheduleFunction2 } from "../attach/attachFunctions/scheduleFlow/handleScheduleFlow2.js"; import { handleUpgradeFlow } from "../attach/attachFunctions/upgradeFlow/handleUpgradeFlow.js"; +import { activateDefaultProduct } from "../cusProducts/cusProductUtils.js"; export const handleCancelProduct = async ({ req, @@ -181,6 +182,14 @@ export const handleCancelProduct = async ({ : ProrationBehavior.None, }, }); + + // Activate default product + await activateDefaultProduct({ + req, + productGroup: cusProduct.product.group, + fullCus, + curCusProduct: cusProduct, + }); return; // Expire product immediately diff --git a/server/src/internal/customers/cusProducts/cusProductUtils.ts b/server/src/internal/customers/cusProducts/cusProductUtils.ts index 4fc87208f..4c4b2f725 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils.ts @@ -579,8 +579,16 @@ export const searchCusProducts = ({ }); }; -export const isTrialing = (cusProduct: FullCusProduct) => { - return cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now(); +export const isTrialing = ({ + cusProduct, + now, +}: { + cusProduct: FullCusProduct; + now?: number; +}) => { + return ( + cusProduct.trial_ends_at && cusProduct.trial_ends_at > (now || Date.now()) + ); }; export const getMainCusProduct = async ({ diff --git a/server/src/internal/customers/cusProducts/cusProductUtils/classifyCusProduct.ts b/server/src/internal/customers/cusProducts/cusProductUtils/classifyCusProduct.ts new file mode 100644 index 000000000..4ddb8b6b4 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusProductUtils/classifyCusProduct.ts @@ -0,0 +1,5 @@ +import { FullCusProduct } from "@autumn/shared"; + +export const isCanceled = ({ cusProduct }: { cusProduct: FullCusProduct }) => { + return cusProduct.canceled; +}; diff --git a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts index 4494bfbef..b28121db3 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts @@ -161,8 +161,8 @@ export const cusProductToSchedule = async ({ } ); - if (schedule.status == "canceled") { - return null; + if (schedule.status == "canceled" || schedule.status == "released") { + return undefined; } return schedule; diff --git a/server/src/internal/products/prices/billingIntervalUtils.ts b/server/src/internal/products/prices/billingIntervalUtils.ts index 4272ddd5c..b2a328879 100644 --- a/server/src/internal/products/prices/billingIntervalUtils.ts +++ b/server/src/internal/products/prices/billingIntervalUtils.ts @@ -21,7 +21,7 @@ import { subYears, } from "date-fns"; import { UTCDate } from "@date-fns/utc"; -import { formatUnixToDate, formatUnixToDateTime } from "@/utils/genUtils.js"; +import { formatUnixToDateTime } from "@/utils/genUtils.js"; export const subtractBillingIntervalUnix = ({ unixTimestamp, diff --git a/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts b/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts index 6837d8d9a..8f83cf9f0 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts @@ -6,6 +6,7 @@ import { isProductUpgrade, } from "../../productUtils.js"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; +import { isCanceled } from "@/internal/customers/cusProducts/cusProductUtils/classifyCusProduct.js"; export const getAttachScenario = ({ fullCus, @@ -19,6 +20,7 @@ export const getAttachScenario = ({ let { curMainProduct, curScheduledProduct } = getExistingCusProducts({ product: fullProduct, cusProducts: fullCus?.customer_products || [], + internalEntityId: fullCus?.entity?.internal_id, }); if (!curMainProduct || fullProduct.is_add_on) return AttachScenario.New; @@ -29,7 +31,7 @@ export const getAttachScenario = ({ // 1. If current product is the same as the product, return active if (curMainProduct?.product.id == fullProduct.id) { - if (curMainProduct.canceled_at != null) { + if (isCanceled({ cusProduct: curMainProduct })) { return AttachScenario.Renew; } else return AttachScenario.Active; } diff --git a/server/tests/core/cancel/cancel2.ts b/server/tests/core/cancel/cancel2.ts new file mode 100644 index 000000000..fb6453b64 --- /dev/null +++ b/server/tests/core/cancel/cancel2.ts @@ -0,0 +1,185 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + CusProductStatus, + Organization, + priceToInvoiceAmount, +} from "@autumn/shared"; +import { + constructArrearItem, + constructArrearProratedItem, + constructPrepaidItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { + addPrefixToProducts, + getBasePrice, +} from "tests/utils/testProductUtils/testProductUtils.js"; +import { expect } from "chai"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addWeeks } from "date-fns"; +import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js"; +import { timeout } from "@/utils/genUtils.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { isPrepaidPrice } from "@shared/utils/productUtils/priceUtils.js"; +import { isContUsePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js"; +import { Decimal } from "decimal.js"; +import { advanceToNextInvoice } from "tests/utils/testAttachUtils/testAttachUtils.js"; + +let premium = constructProduct({ + id: "premium", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "premium", +}); + +const wordsUsage = 300000; +const ops = [ + { + product: premium, + results: [{ product: premium, status: CusProductStatus.Active }], + }, +]; + +const testCase = "cancel2"; +describe(`${chalk.yellowBright("cancel2: Testing cancel at period end (with usage)")}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + + let stripeCli: Stripe; + let testClockId: string; + let curUnix: number; + let db: DrizzleCli; + let org: Organization; + let env: AppEnv; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [premium], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [premium], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + it("should run operations", async function () { + await autumn.entities.create(customerId, entities); + + for (let index = 0; index < ops.length; index++) { + const op = ops[index]; + try { + await attachAndExpectCorrect({ + autumn, + customerId, + product: op.product, + stripeCli, + db, + org, + env, + }); + } catch (error) { + console.log(`Operation failed: ${op.product.id}, index: ${index}`); + throw error; + } + } + }); + + it("should track usage cancel, advance test clock and have correct invoice", async function () { + const cus1 = await autumn.customers.get(customerId); + const prod = cus1.products.find((p) => p.id === premium.id); + const proration = { + start: prod?.current_period_start!, + end: prod?.current_period_end!, + }; + + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Words, + value: wordsUsage, + }); + + await autumn.cancel({ + customer_id: customerId, + product_id: premium.id, + cancel_immediately: false, + }); + + await advanceToNextInvoice({ + stripeCli, + testClockId, + }); + + const wordsAmount = await getExpectedInvoiceTotal({ + db, + org, + env, + onlyIncludeArrear: true, + usage: [ + { + featureId: TestFeature.Words, + value: wordsUsage, + }, + ], + stripeCli, + customerId, + productId: premium.id, + expectExpired: true, + }); + + const cus = await autumn.customers.get(customerId); + const prods = cus.products.filter((p) => p.group == premium.group); + expect(prods.length).to.equal(0); + + expect(cus.invoices.length).to.equal(2); + expect(cus.invoices[0].total).to.equal(wordsAmount); + }); +}); diff --git a/server/tests/core/cancel/cancel3.ts b/server/tests/core/cancel/cancel3.ts new file mode 100644 index 000000000..d1b3be60d --- /dev/null +++ b/server/tests/core/cancel/cancel3.ts @@ -0,0 +1,161 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + CusProductStatus, + Organization, + priceToInvoiceAmount, +} from "@autumn/shared"; +import { + constructArrearItem, + constructArrearProratedItem, + constructFeatureItem, + constructPrepaidItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { + addPrefixToProducts, + getBasePrice, +} from "tests/utils/testProductUtils/testProductUtils.js"; +import { expect } from "chai"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addWeeks } from "date-fns"; +import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js"; +import { timeout } from "@/utils/genUtils.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { isPrepaidPrice } from "@shared/utils/productUtils/priceUtils.js"; +import { isContUsePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js"; +import { Decimal } from "decimal.js"; +import { advanceToNextInvoice } from "tests/utils/testAttachUtils/testAttachUtils.js"; + +let premium = constructProduct({ + id: "premium", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "premium", +}); +let free = constructProduct({ + id: "free", + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + }), + ], + type: "free", + isDefault: false, +}); + +const ops = [ + { + product: free, + results: [{ product: free, status: CusProductStatus.Active }], + skipSubCheck: true, + }, +]; + +const testCase = "cancel3"; +describe(`${chalk.yellowBright("cancel3: Cancelling free product")}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + + let stripeCli: Stripe; + let testClockId: string; + let curUnix: number; + let db: DrizzleCli; + let org: Organization; + let env: AppEnv; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [free], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [free], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + it("should run operations", async function () { + await autumn.entities.create(customerId, entities); + + for (let index = 0; index < ops.length; index++) { + const op = ops[index]; + try { + await attachAndExpectCorrect({ + autumn, + customerId, + product: op.product, + stripeCli, + db, + org, + env, + skipSubCheck: op.skipSubCheck, + }); + } catch (error) { + console.log(`Operation failed: ${op.product.id}, index: ${index}`); + throw error; + } + } + }); + + it("should track usage cancel, advance test clock and have correct invoice", async function () { + const cus1 = await autumn.customers.get(customerId); + + await autumn.cancel({ + customer_id: customerId, + product_id: free.id, + cancel_immediately: true, + }); + + const cus = await autumn.customers.get(customerId); + const prods = cus.products.filter((p) => p.group == free.group); + expect(prods.length).to.equal(0); + }); +}); diff --git a/server/tests/core/cancel/cancel4.ts b/server/tests/core/cancel/cancel4.ts new file mode 100644 index 000000000..93e0bfe08 --- /dev/null +++ b/server/tests/core/cancel/cancel4.ts @@ -0,0 +1,152 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + CusProductStatus, + Organization, +} from "@autumn/shared"; +import { + constructArrearItem, + constructFeatureItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import { expect } from "chai"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; + +let premium = constructProduct({ + id: "premium", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "premium", +}); +let addOn = constructProduct({ + id: "free_add_on", + items: [constructFeatureItem({ featureId: TestFeature.Credits })], + type: "free", + isAddOn: true, + isDefault: false, +}); + +const ops = [ + { + product: premium, + results: [{ product: premium, status: CusProductStatus.Active }], + skipSubCheck: true, + }, + { + product: addOn, + results: [ + { product: premium, status: CusProductStatus.Active }, + { product: addOn, status: CusProductStatus.Active }, + ], + }, +]; + +const testCase = "cancel4"; +describe(`${chalk.yellowBright("cancel4: Cancelling free add on product")}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + + let stripeCli: Stripe; + let testClockId: string; + let curUnix: number; + let db: DrizzleCli; + let org: Organization; + let env: AppEnv; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [premium, addOn], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [premium, addOn], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + it("should run operations", async function () { + await autumn.entities.create(customerId, entities); + + for (let index = 0; index < ops.length; index++) { + const op = ops[index]; + try { + await attachAndExpectCorrect({ + autumn, + customerId, + product: op.product, + stripeCli, + db, + org, + env, + skipSubCheck: op.skipSubCheck, + }); + } catch (error) { + console.log(`Operation failed: ${op.product.id}, index: ${index}`); + throw error; + } + } + }); + + it("should track usage cancel, advance test clock and have correct invoice", async function () { + await autumn.cancel({ + customer_id: customerId, + product_id: addOn.id, + cancel_immediately: true, + }); + + const cus = await autumn.customers.get(customerId); + expectProductAttached({ + customer: cus, + product: premium, + }); + + const products = cus.products.filter((p) => p.group == addOn.group); + expect(products.length).to.equal(1); + }); +}); diff --git a/server/tests/core/cancel/mergedCancel1.test.ts b/server/tests/core/cancel/mergedCancel1.test.ts new file mode 100644 index 000000000..b26471fe9 --- /dev/null +++ b/server/tests/core/cancel/mergedCancel1.test.ts @@ -0,0 +1,200 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + AttachBranch, + AttachScenario, + CusProductStatus, + Organization, +} from "@autumn/shared"; +import { + constructArrearItem, + constructFeatureItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import { expect } from "chai"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { expectSubToBeCorrect } from "tests/merged/mergeUtils/expectSubCorrect.js"; + +let premium = constructProduct({ + id: "premium", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "premium", +}); + +const ops = [ + { + entityId: "1", + product: premium, + results: [{ product: premium, status: CusProductStatus.Active }], + skipSubCheck: true, + }, + { + entityId: "2", + product: premium, + results: [{ product: premium, status: CusProductStatus.Active }], + }, +]; + +const cancels = [ + { + entityId: "1", + product: premium, + }, + { + entityId: "2", + product: premium, + shouldBeCanceled: true, + }, +]; + +const renewals = [ + { + entityId: "1", + product: premium, + }, + + { + entityId: "2", + product: premium, + }, +]; + +const testCase = "mergedCancel1"; +describe(`${chalk.yellowBright("mergedCancel1: Merged cancel")}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + + let stripeCli: Stripe; + let testClockId: string; + let curUnix: number; + let db: DrizzleCli; + let org: Organization; + let env: AppEnv; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [premium], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [premium], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + it("should run operations", async function () { + await autumn.entities.create(customerId, entities); + + for (let index = 0; index < ops.length; index++) { + const op = ops[index]; + try { + await attachAndExpectCorrect({ + autumn, + customerId, + product: op.product, + stripeCli, + db, + org, + env, + skipSubCheck: op.skipSubCheck, + entityId: op.entityId, + }); + } catch (error) { + console.log(`Operation failed: ${op.product.id}, index: ${index}`); + throw error; + } + } + }); + + it("should track usage cancel, advance test clock and have correct invoice", async function () { + for (const cancel of cancels) { + await autumn.cancel({ + customer_id: customerId, + product_id: cancel.product.id, + entity_id: cancel.entityId, + cancel_immediately: false, + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + shouldBeCanceled: cancel.shouldBeCanceled, + }); + } + }); + + it("should renew both entities", async function () { + for (const renewal of renewals) { + const checkout = await autumn.checkout({ + customer_id: customerId, + product_id: renewal.product.id, + entity_id: renewal.entityId, + }); + + expect(checkout.product.scenario).to.equal(AttachScenario.Renew); + expect(checkout.total).to.equal(0); + + const attach = await autumn.attach({ + customer_id: customerId, + product_id: renewal.product.id, + entity_id: renewal.entityId, + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + }); + } + }); +}); diff --git a/server/tests/core/cancel/mergedCancel2.test.ts b/server/tests/core/cancel/mergedCancel2.test.ts new file mode 100644 index 000000000..bb96e4d27 --- /dev/null +++ b/server/tests/core/cancel/mergedCancel2.test.ts @@ -0,0 +1,160 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + CusProductStatus, + Organization, +} from "@autumn/shared"; + +import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import { expect } from "chai"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubToBeCorrect } from "tests/merged/mergeUtils/expectSubCorrect.js"; + +// Premium, Premium +// Cancel End, Cancel Immediately +// Results: Canceled sub + +let premium = constructProduct({ + id: "premium", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "premium", +}); + +const ops = [ + { + entityId: "1", + product: premium, + results: [{ product: premium, status: CusProductStatus.Active }], + }, + { + entityId: "2", + product: premium, + results: [{ product: premium, status: CusProductStatus.Active }], + }, +]; + +const cancels = [ + { + entityId: "1", + product: premium, + }, + { + entityId: "2", + product: premium, + cancelImmediately: true, + shouldBeCanceled: true, + }, +]; + +const testCase = "mergedCancel2"; +describe(`${chalk.yellowBright("mergedCancel2: Testing cancel immediately")}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + + let stripeCli: Stripe; + let testClockId: string; + let curUnix: number; + let db: DrizzleCli; + let org: Organization; + let env: AppEnv; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [premium], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [premium], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + it("should run operations", async function () { + await autumn.entities.create(customerId, entities); + + for (let index = 0; index < ops.length; index++) { + const op = ops[index]; + try { + await attachAndExpectCorrect({ + autumn, + customerId, + product: op.product, + stripeCli, + db, + org, + env, + entityId: op.entityId, + }); + } catch (error) { + console.log(`Operation failed: ${op.product.id}, index: ${index}`); + throw error; + } + } + }); + + it("should track usage cancel, advance test clock and have correct invoice", async function () { + for (const cancel of cancels) { + await autumn.cancel({ + customer_id: customerId, + product_id: cancel.product.id, + entity_id: cancel.entityId, + cancel_immediately: cancel.cancelImmediately ?? false, + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + shouldBeCanceled: cancel.shouldBeCanceled, + }); + } + }); +}); diff --git a/server/tests/core/cancel/mergedCancel3.test.ts b/server/tests/core/cancel/mergedCancel3.test.ts new file mode 100644 index 000000000..108aa5141 --- /dev/null +++ b/server/tests/core/cancel/mergedCancel3.test.ts @@ -0,0 +1,165 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + AttachScenario, + CusProductStatus, + Organization, +} from "@autumn/shared"; + +import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import { expect } from "chai"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubToBeCorrect } from "tests/merged/mergeUtils/expectSubCorrect.js"; + +// Premium, Premium +// Cancel Immediately, Cancel Immediately +// Results: No sub + +let premium = constructProduct({ + id: "premium", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "premium", +}); + +const ops = [ + { + entityId: "1", + product: premium, + results: [{ product: premium, status: CusProductStatus.Active }], + }, + { + entityId: "2", + product: premium, + results: [{ product: premium, status: CusProductStatus.Active }], + }, +]; + +const cancels = [ + { + entityId: "1", + product: premium, + cancelImmediately: true, + }, + { + entityId: "2", + product: premium, + cancelImmediately: true, + shouldBeCanceled: true, + skipSubCheck: true, + }, +]; + +const testCase = "mergedCancel3"; +describe(`${chalk.yellowBright("mergedCancel3: Testing cancel immediately")}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + + let stripeCli: Stripe; + let testClockId: string; + let curUnix: number; + let db: DrizzleCli; + let org: Organization; + let env: AppEnv; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [premium], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [premium], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + it("should run operations", async function () { + await autumn.entities.create(customerId, entities); + + for (let index = 0; index < ops.length; index++) { + const op = ops[index]; + try { + await attachAndExpectCorrect({ + autumn, + customerId, + product: op.product, + stripeCli, + db, + org, + env, + entityId: op.entityId, + }); + } catch (error) { + console.log(`Operation failed: ${op.product.id}, index: ${index}`); + throw error; + } + } + }); + + it("should track usage cancel, advance test clock and have correct invoice", async function () { + for (const cancel of cancels) { + await autumn.cancel({ + customer_id: customerId, + product_id: cancel.product.id, + entity_id: cancel.entityId, + cancel_immediately: cancel.cancelImmediately ?? false, + }); + + if (cancel.skipSubCheck) continue; + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + shouldBeCanceled: cancel.shouldBeCanceled, + }); + } + }); +}); diff --git a/server/tests/merged/downgrade/mergedDowngrade1.ts b/server/tests/merged/downgrade/mergedDowngrade1.ts index 17eef8adc..3e0fd79b5 100644 --- a/server/tests/merged/downgrade/mergedDowngrade1.ts +++ b/server/tests/merged/downgrade/mergedDowngrade1.ts @@ -19,6 +19,11 @@ import { expect } from "chai"; import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +// OPERATIONS: +// Premium, Premium +// Pro, Pro +// Premium, Premium + // UNCOMMENT FROM HERE let premium = constructProduct({ id: "premium", diff --git a/server/tests/merged/downgrade/mergedDowngrade2.ts b/server/tests/merged/downgrade/mergedDowngrade2.ts index 682966aca..8ae090351 100644 --- a/server/tests/merged/downgrade/mergedDowngrade2.ts +++ b/server/tests/merged/downgrade/mergedDowngrade2.ts @@ -24,7 +24,12 @@ import { expect } from "chai"; import { advanceToNextInvoice } from "tests/utils/testAttachUtils/testAttachUtils.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; -// UNCOMMENT FROM HERE +// OPERATIONS: +// Premium +// Free +// Free, Premium +// Free, Pro + let free = constructProduct({ id: "free", items: [constructFeatureItem({ featureId: TestFeature.Words })], diff --git a/server/tests/merged/downgrade/mergedDowngrade3.ts b/server/tests/merged/downgrade/mergedDowngrade3.ts index 3db5ab796..6de123e58 100644 --- a/server/tests/merged/downgrade/mergedDowngrade3.ts +++ b/server/tests/merged/downgrade/mergedDowngrade3.ts @@ -22,7 +22,10 @@ import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; import { expect } from "chai"; -// UNCOMMENT FROM HERE +// OPERATIONS: +// Pro, Pro +// Free, Premium + let free = constructProduct({ id: "free", items: [constructFeatureItem({ featureId: TestFeature.Words })], diff --git a/server/tests/merged/downgrade/mergedDowngrade4.ts b/server/tests/merged/downgrade/mergedDowngrade4.ts index 8e1e86c83..306239ec5 100644 --- a/server/tests/merged/downgrade/mergedDowngrade4.ts +++ b/server/tests/merged/downgrade/mergedDowngrade4.ts @@ -25,7 +25,10 @@ import { advanceTestClock } from "tests/utils/stripeUtils.js"; import { addMonths } from "date-fns"; import { advanceToNextInvoice } from "tests/utils/testAttachUtils/testAttachUtils.js"; -// UNCOMMENT FROM HERE +// OPERATIONS: +// PremiumAnnual, Premium +// PremiumAnnual, Pro + let premiumAnnual = constructProduct({ id: "premiumAnnual", items: [constructArrearItem({ featureId: TestFeature.Words })], diff --git a/server/tests/merged/downgrade/mergedDowngrade5.test.ts b/server/tests/merged/downgrade/mergedDowngrade5.test.ts index 8f7d7a3a3..bf58de750 100644 --- a/server/tests/merged/downgrade/mergedDowngrade5.test.ts +++ b/server/tests/merged/downgrade/mergedDowngrade5.test.ts @@ -24,7 +24,10 @@ import { expect } from "chai"; import { advanceToNextInvoice } from "tests/utils/testAttachUtils/testAttachUtils.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; -// UNCOMMENT FROM HERE +// OPERATIONS: +// Premium, Premium +// Free, Free +// Pro, Free let pro = constructProduct({ id: "pro", diff --git a/server/tests/merged/downgrade/mergedDowngrade6.test.ts b/server/tests/merged/downgrade/mergedDowngrade6.test.ts index 91af1875b..94cc72dd2 100644 --- a/server/tests/merged/downgrade/mergedDowngrade6.test.ts +++ b/server/tests/merged/downgrade/mergedDowngrade6.test.ts @@ -21,7 +21,12 @@ import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUti import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; -// UNCOMMENT FROM HERE +// OPERATIONS: +// Growth, Growth +// Free +// Pro +// Premium +// Free let pro = constructProduct({ id: "pro", diff --git a/server/tests/merged/trial/mergedTrial1.test.ts b/server/tests/merged/trial/mergedTrial1.test.ts new file mode 100644 index 000000000..1fa4bfb52 --- /dev/null +++ b/server/tests/merged/trial/mergedTrial1.test.ts @@ -0,0 +1,154 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + CusProductStatus, + Organization, +} from "@autumn/shared"; + +import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import { expect } from "chai"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubToBeCorrect } from "tests/merged/mergeUtils/expectSubCorrect.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addDays } from "date-fns"; + +// Premium, Premium +// Cancel End, Cancel Immediately +// Results: Canceled sub + +let premium = constructProduct({ + id: "premium", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "premium", + trial: true, +}); + +const ops = [ + { + entityId: "1", + product: premium, + results: [{ product: premium, status: CusProductStatus.Active }], + }, + // { + // entityId: "2", + // product: premium, + // results: [{ product: premium, status: CusProductStatus.Active }], + // }, +]; + +const testCase = "mergedTrial1"; +describe(`${chalk.yellowBright("mergedTrial1: Testing trial")}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + + let stripeCli: Stripe; + let testClockId: string; + let curUnix: number; + let db: DrizzleCli; + let org: Organization; + let env: AppEnv; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [premium], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [premium], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + it("should attach first trial, and advance clock past trial", async function () { + await autumn.entities.create(customerId, entities); + + await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + entity_id: "1", + }); + + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addDays(new Date(), 2).getTime(), + }); + + const entity1 = await autumn.entities.get(customerId, "1"); + const premium1 = entity1.products.find((p: any) => p.id == premium.id); + + const checkout = await autumn.checkout({ + customer_id: customerId, + product_id: premium.id, + entity_id: "2", + }); + + const nextCycle = checkout.next_cycle; + expect(nextCycle?.starts_at); + expect(nextCycle?.starts_at).to.approximately( + premium1?.current_period_end, + 60000 + ); // 1 min + + await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + entity_id: "2", + }); + + const entity2 = await autumn.entities.get(customerId, "2"); + const premium2 = entity2.products.find((p: any) => p.id == premium.id); + expect(premium2?.status).to.equal(CusProductStatus.Trialing); + expect(premium2?.current_period_end).to.approximately( + premium1?.current_period_end, + 60000 + ); // 1 min + }); +}); diff --git a/server/tests/merged/trial/mergedTrial2.test.ts b/server/tests/merged/trial/mergedTrial2.test.ts new file mode 100644 index 000000000..7954af178 --- /dev/null +++ b/server/tests/merged/trial/mergedTrial2.test.ts @@ -0,0 +1,150 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + CusProductStatus, + Organization, +} from "@autumn/shared"; + +import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import { expect } from "chai"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addDays } from "date-fns"; + +let premium = constructProduct({ + id: "premium", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "premium", + trial: true, +}); + +const ops = [ + { + entityId: "1", + product: premium, + results: [{ product: premium, status: CusProductStatus.Active }], + }, + // { + // entityId: "2", + // product: premium, + // results: [{ product: premium, status: CusProductStatus.Active }], + // }, +]; + +const testCase = "mergedTrial2"; +describe(`${chalk.yellowBright("mergedTrial2: Testing add second trial product after first trial ends")}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + + let stripeCli: Stripe; + let testClockId: string; + let curUnix: number; + let db: DrizzleCli; + let org: Organization; + let env: AppEnv; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [premium], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [premium], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + it("should attach first trial, and advance clock past trial", async function () { + await autumn.entities.create(customerId, entities); + + await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + entity_id: "1", + }); + + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addDays(new Date(), 8).getTime(), + }); + + return; + + const entity1 = await autumn.entities.get(customerId, "1"); + const premium1 = entity1.products.find((p: any) => p.id == premium.id); + + const checkout = await autumn.checkout({ + customer_id: customerId, + product_id: premium.id, + entity_id: "2", + }); + + const nextCycle = checkout.next_cycle; + expect(nextCycle?.starts_at); + expect(nextCycle?.starts_at).to.approximately( + premium1?.current_period_end, + 60000 + ); // 1 min + + await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + entity_id: "2", + }); + + const entity2 = await autumn.entities.get(customerId, "2"); + const premium2 = entity2.products.find((p: any) => p.id == premium.id); + expect(premium2?.status).to.equal(CusProductStatus.Trialing); + expect(premium2?.current_period_end).to.approximately( + premium1?.current_period_end, + 60000 + ); // 1 min + }); +}); diff --git a/server/tests/merged/trial/trial1.test.ts b/server/tests/merged/trial/trial1.test.ts new file mode 100644 index 000000000..737a11014 --- /dev/null +++ b/server/tests/merged/trial/trial1.test.ts @@ -0,0 +1,143 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + CusProductStatus, + Organization, +} from "@autumn/shared"; + +import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import { expect } from "chai"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubToBeCorrect } from "tests/merged/mergeUtils/expectSubCorrect.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addDays } from "date-fns"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; + +// Premium, Premium +// Cancel End, Cancel Immediately +// Results: Canceled sub + +let pro = constructProduct({ + id: "pro", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "pro", + trial: true, +}); + +let premium = constructProduct({ + id: "premium", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "premium", + trial: true, +}); + +const ops = [ + { + product: pro, + results: [{ product: pro, status: CusProductStatus.Trialing }], + }, + // { + // entityId: "2", + // product: premium, + // results: [{ product: premium, status: CusProductStatus.Active }], + // }, +]; + +const testCase = "trial1"; +describe(`${chalk.yellowBright("trial1: Testing trial")}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + + let stripeCli: Stripe; + let testClockId: string; + let curUnix: number; + let db: DrizzleCli; + let org: Organization; + let env: AppEnv; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro, premium], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [pro, premium], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + it("should attach first trial, and advance clock past trial", async function () { + for (const op of ops) { + await attachAndExpectCorrect({ + autumn, + customerId, + product: op.product, + stripeCli, + db, + org, + env, + }); + } + + const customer = await autumn.customers.get(customerId); + expectProductAttached({ + customer, + product: pro, + status: CusProductStatus.Trialing, + }); + }); + + it("should advance test clock to before trial ends and attach premium", async function () { + curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addDays(new Date(), 2).getTime(), + }); + + const checkout = await autumn.checkout({ + customer_id: customerId, + product_id: premium.id, + }); + + expect(checkout.next_cycle?.starts_at).to.be.approximately( + addDays(curUnix, 7).getTime(), + 60000 + ); + + expect(checkout.total).to.equal(0); + }); +}); diff --git a/server/tests/merged/upgrade/mergedUpgrade1.ts b/server/tests/merged/upgrade/mergedUpgrade1.test.ts similarity index 100% rename from server/tests/merged/upgrade/mergedUpgrade1.ts rename to server/tests/merged/upgrade/mergedUpgrade1.test.ts diff --git a/server/tests/merged/upgrade/mergedUpgrade4.test.ts b/server/tests/merged/upgrade/mergedUpgrade4.test.ts new file mode 100644 index 000000000..29e927de3 --- /dev/null +++ b/server/tests/merged/upgrade/mergedUpgrade4.test.ts @@ -0,0 +1,163 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + CusProductStatus, + Organization, +} from "@autumn/shared"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; + +// OPERATIONS: +// Pro, Pro +// Free +// Premium + +let pro = constructProduct({ + id: "pro", + items: [constructFeatureItem({ featureId: TestFeature.Words })], + type: "pro", +}); + +let free = constructProduct({ + id: "free", + items: [constructFeatureItem({ featureId: TestFeature.Words })], + type: "free", + isDefault: false, +}); + +const premium = constructProduct({ + id: "premium", + items: [constructFeatureItem({ featureId: TestFeature.Words })], + type: "premium", +}); +const growth = constructProduct({ + id: "growth", + items: [constructFeatureItem({ featureId: TestFeature.Words })], + type: "growth", +}); + +const ops = [ + { + entityId: "1", + product: pro, + results: [{ product: pro, status: CusProductStatus.Active }], + }, + { + entityId: "2", + product: pro, + results: [{ product: pro, status: CusProductStatus.Active }], + }, + { + entityId: "1", + product: free, + results: [ + { product: pro, status: CusProductStatus.Active }, + { product: free, status: CusProductStatus.Scheduled }, + ], + }, + { + entityId: "1", + product: premium, + results: [{ product: premium, status: CusProductStatus.Active }], + }, +]; + +const testCase = "mergedUpgrade4"; +describe(`${chalk.yellowBright("mergedUpgrade4: Upgrading when there's a cancel")}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + + let stripeCli: Stripe; + let testClockId: string; + let curUnix: number; + let db: DrizzleCli; + let org: Organization; + let env: AppEnv; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro, free, premium, growth], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [pro, free, premium, growth], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + it("should run operations", async function () { + await autumn.entities.create(customerId, entities); + + for (let index = 0; index < ops.length; index++) { + const op = ops[index]; + await attachAndExpectCorrect({ + autumn, + customerId, + product: op.product, + stripeCli, + db, + org, + env, + entities, + entityId: op.entityId, + }); + + for (const result of op.results) { + const entity = await autumn.entities.get(customerId, op.entityId); + expectProductAttached({ + customer: entity, + product: result.product, + status: result.status, + }); + } + } + }); +});