diff --git a/server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts b/server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts index c3d182e93..93b42804b 100644 --- a/server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts +++ b/server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts @@ -1,14 +1,23 @@ import { notNullish } from "@autumn/shared"; import type Stripe from "stripe"; +/** Stripe subscription that is trialing with guaranteed trial_end */ +export type TrialingStripeSubscription = Stripe.Subscription & { + status: "trialing"; + trial_end: number; +}; + /** * Checks if a Stripe subscription is in the trialing status. - * @param stripeSubscription - The Stripe subscription to check. - * @returns True if the subscription is in the trialing status, false otherwise. + * Type guard that narrows to TrialingStripeSubscription with defined trial_end. */ export const isStripeSubscriptionTrialing = ( - stripeSubscription: Stripe.Subscription, -) => { + stripeSubscription?: Stripe.Subscription, +): stripeSubscription is TrialingStripeSubscription => { + if (!stripeSubscription) { + return false; + } + return stripeSubscription.status === "trialing"; }; diff --git a/server/src/internal/api/check/checkUtils.ts b/server/src/internal/api/check/checkUtils.ts index fcafd8aff..4ffea7e3e 100644 --- a/server/src/internal/api/check/checkUtils.ts +++ b/server/src/internal/api/check/checkUtils.ts @@ -4,7 +4,7 @@ import { type FreeTrial, type FullCusProduct, findCusPriceByFeature, - isCusProductTrialing, + isCustomerProductTrialing, type ProductItem, priceToInvoiceAmount, UsageModel, @@ -64,7 +64,8 @@ export const getOptions = ({ if ( (freeTrial || - (cusProduct && isCusProductTrialing({ cusProduct, now }))) && + (cusProduct && + isCustomerProductTrialing(cusProduct, { nowMs: now }))) && notNullish(i.interval) ) { priceData = { diff --git a/server/src/internal/billing/v2/billingContext.ts b/server/src/internal/billing/v2/billingContext.ts index 69c4c9dc7..d0c0d7f26 100644 --- a/server/src/internal/billing/v2/billingContext.ts +++ b/server/src/internal/billing/v2/billingContext.ts @@ -19,7 +19,7 @@ export type InvoiceMode = z.infer; export interface TrialContext { freeTrial?: FreeTrial | null; - trialEndsAt?: number; + trialEndsAt: number | null; customFreeTrial?: FreeTrial; } diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts index 8eb9d7814..570fbe86e 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts @@ -1,10 +1,12 @@ import { + cp, cusProductToLineItems, type FullCusProduct, filterUnchangedPricesFromLineItems, type LineItem, } from "@autumn/shared"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import { billingContextHasTrial } from "@/internal/billing/v2/utils/billingContext/billingContextHasTrial"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; export const buildAutumnLineItems = ({ @@ -33,7 +35,13 @@ export const buildAutumnLineItems = ({ // }) // Get line items for ongoing cus product - const deletedLineItems = deletedCustomerProduct + const { valid: isTrialing } = cp(deletedCustomerProduct).trialing({ + nowMs: currentEpochMs, + }); + + const shouldRefundLineItems = deletedCustomerProduct && !isTrialing; + + const deletedLineItems = shouldRefundLineItems ? cusProductToLineItems({ cusProduct: deletedCustomerProduct, nowMs: currentEpochMs, @@ -64,11 +72,29 @@ export const buildAutumnLineItems = ({ }); // All items - const allLineItems = [ + let allLineItems = [ ...filteredDeletedLineItems, ...arrearLineItems, ...filteredNewLineItems, ]; + // If trialing, don't apply free trial? + if (billingContextHasTrial({ billingContext })) { + allLineItems = [ + ...filteredDeletedLineItems, + ...arrearLineItems, + ...filteredNewLineItems, + ].map((item) => ({ ...item, amount: 0, finalAmount: 0 })); + } + + console.log( + "All line items: ", + allLineItems.map((item) => ({ + description: item.description, + amount: item.amount, + finalAmount: item.finalAmount, + })), + ); + return allLineItems; }; diff --git a/server/src/internal/billing/v2/setup/setupBillingCycleAnchor.ts b/server/src/internal/billing/v2/setup/setupBillingCycleAnchor.ts new file mode 100644 index 000000000..19380306b --- /dev/null +++ b/server/src/internal/billing/v2/setup/setupBillingCycleAnchor.ts @@ -0,0 +1,70 @@ +import { + type FullCusProduct, + type FullProduct, + isCustomerProductFree, + isCustomerProductOneOff, + isCustomerProductTrialing, + isFreeProduct, + isOneOffProduct, + secondsToMs, +} from "@autumn/shared"; +import type Stripe from "stripe"; +import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; +import type { TrialContext } from "@/internal/billing/v2/billingContext"; + +/** + * Determine the billing cycle anchor based on product transitions. + */ +export const setupBillingCycleAnchor = ({ + stripeSubscription, + customerProduct, + newFullProduct, + trialContext, + currentEpochMs, +}: { + stripeSubscription?: Stripe.Subscription; + customerProduct?: FullCusProduct; + newFullProduct: FullProduct; + trialContext?: TrialContext; + currentEpochMs: number; +}): number | "now" => { + const currentIsFree = isCustomerProductFree(customerProduct); + const newIsFree = isFreeProduct({ prices: newFullProduct.prices }); + + // Free -> Free: keep original anchor + if (currentIsFree && newIsFree) { + return customerProduct?.created_at ?? "now"; + } + + const currentIsOneOff = isCustomerProductOneOff(customerProduct); + const newIsOneOff = isOneOffProduct({ prices: newFullProduct.prices }); + + // One-off -> One-off: keep original anchor + if (currentIsOneOff && newIsOneOff) { + return customerProduct?.created_at ?? "now"; + } + + // If trialing: + const stripeTrialEndsAtMs = isStripeSubscriptionTrialing(stripeSubscription) + ? secondsToMs(stripeSubscription?.trial_end) + : undefined; + + const currentCustomerProductTrialEndsAtMs = isCustomerProductTrialing( + customerProduct, + { nowMs: currentEpochMs }, + ) + ? customerProduct?.trial_ends_at + : undefined; + + const currentTrialEndsAt = + stripeTrialEndsAtMs ?? currentCustomerProductTrialEndsAtMs; + + const newIsTrialing = + (trialContext?.trialEndsAt && trialContext.trialEndsAt > currentEpochMs) ?? + stripeTrialEndsAtMs; + + // Billing cycle anchor = trial ends at if exists + if (newIsTrialing) return trialContext?.trialEndsAt ?? "now"; + + return secondsToMs(stripeSubscription?.billing_cycle_anchor) ?? "now"; +}; diff --git a/server/src/internal/billing/v2/setup/setupTrialContext.ts b/server/src/internal/billing/v2/setup/setupTrialContext.ts index ff84daca5..709251f5e 100644 --- a/server/src/internal/billing/v2/setup/setupTrialContext.ts +++ b/server/src/internal/billing/v2/setup/setupTrialContext.ts @@ -6,6 +6,7 @@ import type { import { addDuration, initFreeTrial, + isCustomerProductTrialing, isProductPaidAndRecurring, secondsToMs, } from "@autumn/shared"; @@ -25,12 +26,12 @@ export const setupTrialContext = ({ currentEpochMs: number; params: UpdateSubscriptionV0Params; fullProduct: FullProduct; -}): TrialContext => { +}): TrialContext | undefined => { const freeTrialParams = params.free_trial; // Case 1: If free trial is null (removing free trial) if (freeTrialParams === null) { - return { freeTrial: null }; + return { freeTrial: null, trialEndsAt: null }; } // Case 2: If free trial params are passed in @@ -65,17 +66,20 @@ export const setupTrialContext = ({ return { freeTrial: null, - trialEndsAt, + trialEndsAt: trialEndsAt, }; + } else { + return undefined; } - return { - freeTrial: null, - }; } // Case 4: Return free trial / trial ends at from current customer product - return { - freeTrial: customerProduct.free_trial, - trialEndsAt: customerProduct.trial_ends_at ?? undefined, - }; + if (isCustomerProductTrialing(customerProduct, { nowMs: currentEpochMs })) { + return { + freeTrial: customerProduct.free_trial, // can be undefined... + trialEndsAt: customerProduct.trial_ends_at ?? null, + }; + } + + return undefined; }; diff --git a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts index f946c6b35..e2ca62295 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts @@ -1,4 +1,8 @@ -import type { FullCusProduct, FullProduct } from "@autumn/shared"; +import { + type FullCusProduct, + type FullProduct, + formatMs, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers"; @@ -36,7 +40,7 @@ export const computeCustomPlanNewCustomerProduct = ({ cusProduct: customerProduct, }); - console.log("Existing usages", existingUsages); + console.log("Reset cycle anchor: ", formatMs(resetCycleAnchorMs)); // Compute the new full customer product const newFullCustomerProduct = initFullCustomerProduct({ diff --git a/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index cac1bb8d3..3029f35c6 100644 --- a/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -1,6 +1,7 @@ -import { secondsToMs, type UpdateSubscriptionV0Params } from "@autumn/shared"; +import { formatMs, type UpdateSubscriptionV0Params } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext"; +import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor"; import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext"; import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext"; import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext"; @@ -64,19 +65,17 @@ export const setupUpdateSubscriptionBillingContext = async ({ fullProduct, }); - // 2. Initial billing cycle anchor from Stripe subscription - let billingCycleAnchorMs: number | "now" = - secondsToMs(stripeSubscription?.billing_cycle_anchor) ?? "now"; - // 3. Determine final anchor based on product transitions - billingCycleAnchorMs = setupResetCycleAnchor({ - billingCycleAnchorMs, + let billingCycleAnchorMs = setupBillingCycleAnchor({ + stripeSubscription, customerProduct, newFullProduct: fullProduct, + trialContext, + currentEpochMs, }); // 4. Trial ends at overrides reset cycle anchor - if (trialContext.trialEndsAt) { + if (trialContext?.trialEndsAt) { billingCycleAnchorMs = trialContext.trialEndsAt; } @@ -86,6 +85,10 @@ export const setupUpdateSubscriptionBillingContext = async ({ newFullProduct: fullProduct, }); + console.log("Billing cycle anchor: ", formatMs(billingCycleAnchorMs)); + console.log("Trial ends at: ", formatMs(trialContext?.trialEndsAt)); + console.log("Reset cycle anchor: ", formatMs(resetCycleAnchorMs)); + const invoiceMode = setupInvoiceModeContext({ params }); return { diff --git a/server/src/internal/billing/v2/utils/billingContext/billingContextHasTrial.ts b/server/src/internal/billing/v2/utils/billingContext/billingContextHasTrial.ts new file mode 100644 index 000000000..acd2a33c4 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingContext/billingContextHasTrial.ts @@ -0,0 +1,20 @@ +import type { BillingContext } from "@/internal/billing/v2/billingContext"; + +/** + * Check if the billing context will create a trial that ends later than the current epoch. + * @param billingContext - The billing context. + * @returns True if the billing context has a trial, false otherwise. + */ +export const billingContextHasTrial = ({ + billingContext, +}: { + billingContext: BillingContext; +}) => { + const { currentEpochMs, trialContext } = billingContext; + + if (trialContext?.trialEndsAt && trialContext.trialEndsAt > currentEpochMs) { + return true; + } + + return false; +}; diff --git a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts new file mode 100644 index 000000000..c22ead412 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts @@ -0,0 +1,63 @@ +import { + type BillingPreviewResponse, + cp, + cusProductsToPrices, + cusProductToLineItems, + getCycleEnd, + getSmallestInterval, + sumValues, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan"; + +export const billingPlanToNextCyclePreview = ({ + ctx, + billingContext, + billingPlan, +}: { + ctx: AutumnContext; + billingContext: BillingContext; + billingPlan: BillingPlan; +}): BillingPreviewResponse["next_cycle"] => { + // 1. Return undefined if billing cycle anchor is now + const { billingCycleAnchorMs } = billingContext; + + if (billingCycleAnchorMs === "now") return undefined; + const { insertCustomerProducts } = billingPlan.autumn; + + // 2. Get cycle end and if none, return undefined + const customerProducts = insertCustomerProducts.filter( + (customerProduct) => cp(customerProduct).paid().recurring().valid, + ); + + const prices = cusProductsToPrices({ cusProducts: customerProducts }); + const smallestInterval = getSmallestInterval({ prices }); + + if (!smallestInterval) return undefined; + + const nextCycleStart = getCycleEnd({ + anchor: billingCycleAnchorMs, + interval: smallestInterval.interval, + intervalCount: smallestInterval.intervalCount, + now: billingContext.currentEpochMs, + }); + + const autumnLineItems = customerProducts.flatMap((customerProduct) => + cusProductToLineItems({ + cusProduct: customerProduct, + nowMs: nextCycleStart, + billingCycleAnchorMs, + direction: "charge", + org: ctx.org, + logger: ctx.logger, + }), + ); + + const total = sumValues(autumnLineItems.map((line) => line.finalAmount)); + + return { + starts_at: nextCycleStart, + total, + }; +}; diff --git a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts index 200db8f56..a2e1a5d5d 100644 --- a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts +++ b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts @@ -7,6 +7,7 @@ import { Decimal } from "decimal.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan"; +import { billingPlanToNextCyclePreview } from "./billingPlan/billingPlanToNextCyclePreview"; export const billingPlanToPreviewResponse = ({ ctx, @@ -20,10 +21,12 @@ export const billingPlanToPreviewResponse = ({ const { fullCustomer } = billingContext; const autumnBillingPlan = billingPlan.autumn; - const previewImmediateLineItems = autumnBillingPlan.lineItems.filter((line) => line.chargeImmediately).map((line) => ({ - description: line.description, - amount: line.finalAmount, - })); + const previewImmediateLineItems = autumnBillingPlan.lineItems + .filter((line) => line.chargeImmediately) + .map((line) => ({ + description: line.description, + amount: line.finalAmount, + })); const total = new Decimal( sumValues(previewImmediateLineItems.map((line) => line.amount)), @@ -33,10 +36,18 @@ export const billingPlanToPreviewResponse = ({ const currency = orgToCurrency({ org: ctx.org }); + // Get next cycle object + const nextCycle = billingPlanToNextCyclePreview({ + ctx, + billingContext, + billingPlan, + }); + return { customer_id: fullCustomer.id || "", line_items: previewImmediateLineItems, total, currency, + next_cycle: nextCycle, } satisfies BillingPreviewResponse; }; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 993196387..67cd982c1 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -4,7 +4,7 @@ import { type AttachFunctionResponse, AttachFunctionResponseSchema, AttachScenario, - isCusProductTrialing, + isCustomerProductTrialing, MetadataType, SuccessCode, } from "@autumn/shared"; @@ -86,9 +86,8 @@ export const handlePaidProduct = async ({ if (mergeSub && !config.disableMerge) { if (mergeCusProduct?.free_trial) { - trialEndsAt = isCusProductTrialing({ - cusProduct: mergeCusProduct, - now: attachParams.now, + trialEndsAt = isCustomerProductTrialing(mergeCusProduct, { + nowMs: attachParams.now, }) ? mergeCusProduct.trial_ends_at : undefined; diff --git a/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts b/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts index f3b63cf4e..10ce55b7f 100644 --- a/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts @@ -5,7 +5,7 @@ import { AttachFunctionResponseSchema, AttachScenario, CusProductStatus, - isCusProductTrialing, + isCustomerProductTrialing, SuccessCode, } from "@autumn/shared"; import type Stripe from "stripe"; @@ -188,8 +188,7 @@ export const handleMultiAttachFlow = async ({ logger, productOptions, trialEndsAt: - mergeCusProduct && - isCusProductTrialing({ cusProduct: mergeCusProduct }) + mergeCusProduct && isCustomerProductTrialing(mergeCusProduct) ? mergeCusProduct?.trial_ends_at || undefined : undefined, }), diff --git a/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts b/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts index e1d4dc51c..dac13d886 100644 --- a/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts +++ b/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts @@ -4,7 +4,7 @@ import { type FullCustomer, formatAmount, getTotalCusProdQuantity, - isCusProductTrialing, + isCustomerProductTrialing, isFixedPrice, type Organization, type Price, @@ -66,7 +66,7 @@ export const priceToUnusedPreviewItem = ({ anchor?: number; }) => { now = now || Date.now(); - const onTrial = isCusProductTrialing({ cusProduct, now }); + const onTrial = isCustomerProductTrialing(cusProduct, { nowMs: now }); const subItem = findStripeItemForPrice({ price, diff --git a/server/src/internal/customers/attach/attachUtils/attachUtils.ts b/server/src/internal/customers/attach/attachUtils/attachUtils.ts index 7e6796fd3..fe1922f39 100644 --- a/server/src/internal/customers/attach/attachUtils/attachUtils.ts +++ b/server/src/internal/customers/attach/attachUtils/attachUtils.ts @@ -1,4 +1,4 @@ -import { type FullCusProduct, isCusProductTrialing } from "@autumn/shared"; +import { type FullCusProduct, isCustomerProductTrialing } from "@autumn/shared"; import type Stripe from "stripe"; import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; import { subToAutumnInterval } from "@/external/stripe/utils.js"; @@ -66,9 +66,7 @@ export const isMainTrialBranch = ({ attachParams: AttachParams; }) => { const curCusProduct = attachParamsToCurCusProduct({ attachParams }); - if ( - !isCusProductTrialing({ cusProduct: curCusProduct!, now: attachParams.now }) - ) + if (!isCustomerProductTrialing(curCusProduct!, { nowMs: attachParams.now })) return false; const subId = curCusProduct?.subscription_ids?.[0]; diff --git a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts index e417004d8..c79d03181 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts @@ -3,7 +3,7 @@ import { type AttachConfig, BillingInterval, type FullProduct, - isCusProductTrialing, + isCustomerProductTrialing, } from "@autumn/shared"; import { getOptions } from "@/internal/api/check/checkUtils.js"; import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; @@ -114,9 +114,8 @@ export const getNewProductPreview = async ({ if (mergeSub && !config.disableMerge) { if (mergeCusProduct?.free_trial) { if ( - isCusProductTrialing({ - cusProduct: mergeCusProduct, - now: attachParams.now, + isCustomerProductTrialing(mergeCusProduct, { + nowMs: attachParams.now, }) ) { trialEnds = mergeCusProduct.trial_ends_at || undefined; diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts index 1e1cae739..828cc267f 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts @@ -5,7 +5,7 @@ import { cusProductToProduct, type FreeTrial, type FullCusProduct, - isCusProductTrialing, + isCustomerProductTrialing, isPrepaidPrice, OnDecrease, OnIncrease, @@ -53,7 +53,7 @@ const getNextCycleAt = ({ if ( branch === AttachBranch.NewVersion && curCusProduct && - isCusProductTrialing({ cusProduct: curCusProduct, now }) + isCustomerProductTrialing(curCusProduct, { nowMs: now }) ) { return curCusProduct.trial_ends_at; } @@ -170,7 +170,7 @@ export const getUpgradeProductPreview = async ({ if ( config?.carryTrial && curCusProduct?.free_trial && - isCusProductTrialing({ cusProduct: curCusProduct, now }) + isCustomerProductTrialing(curCusProduct, { nowMs: now }) ) { freeTrial = curCusProduct.free_trial; } diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts index d53beef81..a3b2e7836 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts @@ -8,7 +8,7 @@ import { expandIncludes, type FullCusProduct, type FullCustomer, - isCusProductTrialing, + isCustomerProductTrialing, type Subscription, } from "@autumn/shared"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; @@ -93,7 +93,7 @@ export const getApiSubscription = async ({ canceled_at: cusProduct.canceled_at || null, expires_at: cusProduct.ended_at || null, - trial_ends_at: isCusProductTrialing({ cusProduct }) + trial_ends_at: isCustomerProductTrialing(cusProduct) ? cusProduct.trial_ends_at : null, started_at: cusProduct.starts_at, diff --git a/server/tests/_guides/general-test-guide.md b/server/tests/_guides/general-test-guide.md index 60d660e0d..f7da83ce5 100644 --- a/server/tests/_guides/general-test-guide.md +++ b/server/tests/_guides/general-test-guide.md @@ -408,6 +408,158 @@ await expectProductActive({ customer, productId: pro.id }); **Note:** "Canceling" means the product is still active and usable, but is scheduled to end at the next billing cycle. +## Trial Testing Utilities + +### Checking Product Trial State + +Use `expectProductTrialing` and `expectProductNotTrialing` to verify trial state: + +```typescript +import { + expectProductTrialing, + expectProductNotTrialing, + expectFeatureResetAlignedWithTrialEnd, +} from "@tests/billing/utils/expectCustomerProductTrialing"; + +// Verify product is trialing and get trial end time +// Verify product is trialing with expected trial end (10 min tolerance) +const trialEndsAt = await expectProductTrialing({ + customer, + productId: product.id, + trialEndsAt: Date.now() + ms.days(7), // Expected trial end +}); + +// Or check against a previously captured timestamp +await expectProductTrialing({ + customer, + productId: product.id, + trialEndsAt: initialTrialEnd, +}); + +// Verify product is NOT trialing +await expectProductNotTrialing({ + customer, + productId: product.id, +}); + +// Verify feature reset aligns with trial end +await expectFeatureResetAlignedWithTrialEnd({ + customer, + featureId: TestFeature.Messages, + trialEndsAt: trialEndsAt!, +}); +``` + +### Checking Preview next_cycle Field + +Use `expectPreviewNextCycleCorrect` to verify the `next_cycle` field in subscription update previews: + +```typescript +import { expectPreviewNextCycleCorrect } from "@tests/billing/utils/expectPreviewNextCycleCorrect"; + +const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + +// For paid products: check next_cycle is set with expected values +expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(7), // Expected offset from now (1 day tolerance) + total: priceItem.price!, // Expected total in dollars +}); + +// For free-to-free updates: next_cycle should NOT be defined +expectPreviewNextCycleCorrect({ + preview, + expectDefined: false, +}); +``` + +**Note:** Free-to-free updates don't have `next_cycle` since there's no billing cycle. + +### Feature Assertions with Reset Time + +Use `resetsAt` in `expectCustomerFeatureCorrect` to verify the reset cycle anchor: + +```typescript +expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 200, + balance: 200, + usage: 0, + resetsAt: initialResetAt, // Verify reset time hasn't changed (10 min tolerance) +}); +``` + +### Common Trial Test Patterns + +```typescript +// 1. Get initial state before update +const customerBefore = await autumnV1.customers.get(customerId); +const initialTrialEnd = await expectProductTrialing({ + customer: customerBefore, + productId: product.id, +}); +const initialResetAt = customerBefore.features[TestFeature.Messages].next_reset_at; + +// 2. Advance time mid-trial +await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 5, +}); + +// 3. Perform update and verify preview +const preview = await autumnV1.subscriptions.previewUpdate(updateParams); +expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(9), // 14 - 5 = 9 days remaining + total: priceItem.price!, +}); + +// 4. Execute update +await autumnV1.subscriptions.update(updateParams); + +// 5. Verify trial preserved/extended/removed +const customer = await autumnV1.customers.get(customerId); +const newTrialEnd = await expectProductTrialing({ + customer, + productId: product.id, +}); +expect(Math.abs(newTrialEnd! - initialTrialEnd!)).toBeLessThan(ms.minutes(5)); +``` + +## Free-to-Free Tests Don't Need Subscription Checks + +When testing free-to-free product updates, **skip `expectSubToBeCorrect`** since there's no Stripe subscription for free products: + +```typescript +// ✅ GOOD - Free-to-free test, no subscription check needed +expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 200, + balance: 200, + usage: 0, +}); +// No expectSubToBeCorrect needed for free products + +// ✅ GOOD - Free-to-paid test, subscription check needed +await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, +}); +``` + +**When to use `expectSubToBeCorrect`:** +- Free-to-paid upgrades +- Paid-to-paid updates +- Any scenario involving Stripe subscriptions + +**When to skip:** +- Free-to-free updates (no Stripe subscription exists) + ## Common Pitfalls ### Wait for Sync Before Attach (after Track) diff --git a/server/tests/billing/update-subscription/free-trial/update-free-to-paid-trials.test.ts b/server/tests/billing/update-subscription/free-trial/update-free-to-paid-trials.test.ts deleted file mode 100644 index 91486224c..000000000 --- a/server/tests/billing/update-subscription/free-trial/update-free-to-paid-trials.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { expect, test } from "bun:test"; -import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared"; -import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect"; -import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect"; -import { expectProductActive } from "@tests/billing/utils/expectCustomerProductCorrect"; -import { - expectProductNotTrialing, - expectProductTrialing, -} from "@tests/billing/utils/expectCustomerProductTrialing"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { items } from "@tests/utils/fixtures/items.js"; -import { products } from "@tests/utils/fixtures/products.js"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; -import chalk from "chalk"; - -/** - * Free-to-Paid with Trial Tests - * - * Tests for scenarios starting from free products and upgrading to paid with trial. - * Uses `status === "trialing"` and `current_period_end` to verify trial state. - */ - -// 1. Free to paid with `free_trial` param -test.concurrent(`${chalk.yellowBright("f2p-trial: add paid with free_trial param")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const free = products.base({ items: [messagesItem] }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "f2p-trial-param", - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [free] }), - ], - actions: [s.attach({ productId: "base" })], - }); - - // Track some usage before update - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 30, - }, - { timeout: 2000 }, - ); - - const priceItem = items.monthlyPrice(); - - const updateParams = { - customer_id: customerId, - product_id: free.id, - items: [messagesItem, priceItem], - free_trial: { - length: 7, - duration: FreeTrialDuration.Day, - card_required: true, - unique_fingerprint: false, - }, - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Preview total should be 0 during trial - expect(preview.total).toEqual(0); - - // Verify preview has trial info - expect(preview.autumn?.freeTrialPlan?.trialEndsAt).toBeDefined(); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Verify product is trialing (status = "trialing", current_period_end is trial end) - await expectProductTrialing({ - customer, - productId: free.id, - trialEndsAfter: ms.days(6), // At least 6 days from now - trialEndsBefore: ms.days(8), // At most 8 days from now - }); - - // Usage should be preserved - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: messagesItem.included_usage, - balance: messagesItem.included_usage - 30, - usage: 30, - }); - - // No immediate charge during trial - expectCustomerInvoiceCorrect({ - customer, - count: 1, // Just the $0 trial invoice - latestTotal: 0, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// 2. Free to paid, product has trial config in plan, update while trial ongoing -test.concurrent(`${chalk.yellowBright("f2p-trial: product with trial config, update mid-trial")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - - const proTrial = products.proWithTrial({ - items: [messagesItem], - id: "pro-trial", - trialDays: 14, - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "f2p-trial-mid-update", - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [proTrial] }), - ], - actions: [s.attach({ productId: proTrial.id })], - }); - - // Verify initially trialing - const customerBefore = - await autumnV1.customers.get(customerId); - await expectProductTrialing({ - customer: customerBefore, - productId: proTrial.id, - }); - // Get initial trial end from current_period_end - const initialTrialEnd = customerBefore.products?.find( - (p) => p.id === proTrial.id, - )?.current_period_end; - expect(initialTrialEnd).toBeDefined(); - - // Update mid-trial - change included usage (no free_trial param = keep existing trial) - const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 }); - - const updateParams = { - customer_id: customerId, - product_id: proTrial.id, - items: [updatedMessagesItem, items.monthlyPrice()], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Should be 0 during trial - expect(preview.total).toEqual(0); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Trial should be preserved - await expectProductTrialing({ - customer, - productId: proTrial.id, - }); - - // Verify current_period_end (trial end) is approximately the same (allow some variance) - const newTrialEnd = customer.products?.find( - (p) => p.id === proTrial.id, - )?.current_period_end; - expect(newTrialEnd).toBeDefined(); - expect(Math.abs(newTrialEnd! - initialTrialEnd!)).toBeLessThan(60000); // Within 1 minute - - // Feature updated - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: updatedMessagesItem.included_usage, - balance: updatedMessagesItem.included_usage, - usage: 0, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// 3. Free to paid with trial, merging with existing subscription -test.concurrent(`${chalk.yellowBright("f2p-trial: merge with existing subscription")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); - - const free = products.base({ - id: "free", - items: [messagesItem], - }); - - const { customerId, autumnV1, ctx, entities } = await initScenario({ - customerId: "f2p-trial-merge", - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [pro, free] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.attach({ productId: pro.id, entityIndex: 0 }), - s.attach({ productId: free.id, entityIndex: 1 }), - ], - }); - - // Verify entity 0 is on paid pro (not trialing) - const entity1 = await autumnV1.entities.get(customerId, entities[0].id); - await expectProductActive({ customer: entity1, productId: pro.id }); - await expectProductNotTrialing({ customer: entity1, productId: pro.id }); - - // Now upgrade entity 1 from free to paid with trial - const priceItem = items.monthlyPrice(); - - const updateParams = { - customer_id: customerId, - entity_id: entities[1].id, - product_id: free.id, - items: [messagesItem, priceItem], - free_trial: { - length: 7, - duration: FreeTrialDuration.Day, - card_required: true, - unique_fingerprint: false, - }, - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Should be 0 during trial - expect(preview.total).toEqual(0); - - // Verify preview has trial info - expect(preview.autumn?.freeTrialPlan?.trialEndsAt).toBeDefined(); - - await autumnV1.subscriptions.update(updateParams); - - // Verify entity 1 is now trialing - const entity2 = await autumnV1.entities.get(customerId, entities[1].id); - await expectProductTrialing({ - customer: entity2, - productId: free.id, - trialEndsAfter: ms.days(6), - trialEndsBefore: ms.days(8), - }); - - // Entity 0 should still not be trialing - const entity1After = await autumnV1.entities.get(customerId, entities[0].id); - await expectProductNotTrialing({ - customer: entity1After, - productId: pro.id, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); diff --git a/server/tests/billing/update-subscription/free-trial/update-free-with-trial.test.ts b/server/tests/billing/update-subscription/free-trial/update-free-with-trial.test.ts new file mode 100644 index 000000000..0bd9fc608 --- /dev/null +++ b/server/tests/billing/update-subscription/free-trial/update-free-with-trial.test.ts @@ -0,0 +1,495 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductActive } from "@tests/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** + * Free Product with Trial Tests + * + * Tests for scenarios starting from free products with trials. + * Covers free-to-free updates (preserving/extending/removing trials) and free-to-paid upgrades. + * Uses `status === "trialing"` and `current_period_end` to verify trial state. + */ + +// 1. Free to paid with `free_trial` param +test.concurrent(`${chalk.yellowBright("f2p-trial: add paid with free_trial param")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ items: [messagesItem] }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "f2p-trial-param", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [free] }), + ], + actions: [s.attach({ productId: "base" })], + }); + + // Track some usage before update + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 30, + }, + { timeout: 2000 }, + ); + + const priceItem = items.monthlyPrice(); + + const updateParams = { + customer_id: customerId, + product_id: free.id, + items: [messagesItem, priceItem], + free_trial: { + length: 7, + duration: FreeTrialDuration.Day, + card_required: true, + unique_fingerprint: false, + }, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Preview total should be 0 during trial + expect(preview.total).toEqual(0); + + // next_cycle should show when trial ends and what the charge will be + expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(7), + total: priceItem.price!, + }); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product is trialing (status = "trialing", current_period_end is trial end) + await expectProductTrialing({ + customer, + productId: free.id, + trialEndsAt: Date.now() + ms.days(7), + }); + + // Usage should be preserved + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage - 30, + usage: 30, + }); + + // No immediate charge during trial + expectCustomerInvoiceCorrect({ + customer, + count: 1, // Just the $0 trial invoice + latestTotal: 0, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// 2. Free product with trial, update mid-trial (no free_trial param) - trial preserved +test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial -> free, update mid-trial preserves trial")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const freeWithTrial = products.baseWithTrial({ + items: [messagesItem], + id: "free-trial", + trialDays: 14, + }); + + const { customerId, autumnV1, ctx, testClockId } = await initScenario({ + customerId: "f2p-trial-mid-update-preserve", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [freeWithTrial] }), + ], + actions: [s.attach({ productId: freeWithTrial.id })], + }); + + // Verify initially trialing + const customerBefore = + await autumnV1.customers.get(customerId); + const initialTrialEnd = await expectProductTrialing({ + customer: customerBefore, + productId: freeWithTrial.id, + }); + + // Advance 5 days (mid-trial) + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 5, + }); + + // Update mid-trial - change included usage (no free_trial param = keep existing trial) + const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + + const updateParams = { + customer_id: customerId, + product_id: freeWithTrial.id, + items: [updatedMessagesItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should be 0 (still free product) + expect(preview.total).toEqual(0); + + // Free-to-free updates don't have next_cycle + expectPreviewNextCycleCorrect({ + preview, + expectDefined: false, + }); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Trial should be preserved with same end date + await expectProductTrialing({ + customer, + productId: freeWithTrial.id, + trialEndsAt: initialTrialEnd!, + }); + + // Feature updated, reset should align with trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: updatedMessagesItem.included_usage, + balance: updatedMessagesItem.included_usage, + usage: 0, + }); + + // Note: Free-to-free tests don't need expectSubToBeCorrect (no Stripe subscription) +}); + +// 4. Free product with trial, update mid-trial WITH new free_trial param - trial extended +test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-trial extends trial")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const freeWithTrial = products.baseWithTrial({ + items: [messagesItem], + id: "free-trial", + trialDays: 14, + }); + + const { customerId, autumnV1, ctx, testClockId } = await initScenario({ + customerId: "f2p-trial-mid-update-extend", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [freeWithTrial] }), + ], + actions: [s.attach({ productId: freeWithTrial.id })], + }); + + // Verify initially trialing + const customerBefore = + await autumnV1.customers.get(customerId); + const initialTrialEnd = await expectProductTrialing({ + customer: customerBefore, + productId: freeWithTrial.id, + }); + + // Advance 5 days (mid-trial) + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 5, + }); + + // Update mid-trial WITH new free_trial param - extend to 30 days from now + const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + + const updateParams = { + customer_id: customerId, + product_id: freeWithTrial.id, + items: [updatedMessagesItem], + free_trial: { + length: 30, + duration: FreeTrialDuration.Day, + card_required: false, + unique_fingerprint: false, + }, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should be 0 (still free product) + expect(preview.total).toEqual(0); + + // Free-to-free updates don't have next_cycle + expectPreviewNextCycleCorrect({ + preview, + expectDefined: false, + }); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Trial should be extended to 30 days from now + const newTrialEnd = await expectProductTrialing({ + customer, + productId: freeWithTrial.id, + trialEndsAt: Date.now() + ms.days(35), // 5 days advanced + 30 day new trial + }); + + // New trial end should be later than original + expect(newTrialEnd!).toBeGreaterThan(initialTrialEnd!); + + // Feature updated + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: updatedMessagesItem.included_usage, + balance: updatedMessagesItem.included_usage, + usage: 0, + }); + + // Note: Free-to-free tests don't need expectSubToBeCorrect (no Stripe subscription) +}); + +// 5. Free product with trial, update mid-trial to PAID product +test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-trial to paid")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const freeWithTrial = products.baseWithTrial({ + items: [messagesItem], + id: "free-trial", + trialDays: 14, + }); + + const { customerId, autumnV1, ctx, testClockId } = await initScenario({ + customerId: "f2p-trial-mid-update-to-paid", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [freeWithTrial] }), + ], + actions: [s.attach({ productId: freeWithTrial.id })], + }); + + // Verify initially trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: freeWithTrial.id, + }); + + // Advance 5 days (mid-trial) + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 5, + }); + + // Update mid-trial to PAID product (add price item) + const priceItem = items.monthlyPrice(); + + const updateParams = { + customer_id: customerId, + product_id: freeWithTrial.id, + items: [messagesItem, priceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge full price since trial doesn't carry over + expect(preview.total).toEqual(priceItem.price!); + + // next_cycle should be ~1 month from now (regular billing cycle) + expectPreviewNextCycleCorrect({ + preview, + expectDefined: false, + }); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Trial should NOT carry over - product should no longer be trialing + await expectProductNotTrialing({ + customer, + productId: freeWithTrial.id, + }); + + // Product should be active + await expectProductActive({ + customer, + productId: freeWithTrial.id, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// 6. Free product (no trial) → Free product with trial, items undefined +test.concurrent(`${chalk.yellowBright("f2p-trial: free no trial -> free with trial, items undefined")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const free = products.base({ + items: [messagesItem], + id: "free-no-trial", + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "f2p-trial-items-undefined", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [free] }), + ], + actions: [s.attach({ productId: free.id })], + }); + + // Verify initially NOT trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductNotTrialing({ + customer: customerBefore, + productId: free.id, + }); + + // Add trial without passing items (items undefined) + const updateParams = { + customer_id: customerId, + product_id: free.id, + // items is NOT specified (undefined) + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + card_required: false, + unique_fingerprint: false, + }, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should be 0 (free product) + expect(preview.total).toEqual(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Product should now be trialing + await expectProductTrialing({ + customer, + productId: free.id, + trialEndsAt: Date.now() + ms.days(14), + }); + + // Feature should still have correct values (unchanged since items undefined) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage, + usage: 0, + }); + + // Note: Free-to-free tests don't need expectSubToBeCorrect (no Stripe subscription) +}); + +// 7. Free product with trial, update mid-trial WITH free_trial: null - trial removed +test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-trial removes trial")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const freeWithTrial = products.baseWithTrial({ + items: [messagesItem], + id: "free-trial", + trialDays: 14, + }); + + const { customerId, autumnV1, ctx, testClockId } = await initScenario({ + customerId: "f2p-trial-mid-update-remove", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [freeWithTrial] }), + ], + actions: [s.attach({ productId: freeWithTrial.id })], + }); + + // Verify initially trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: freeWithTrial.id, + }); + + // Advance 5 days (mid-trial) + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 5, + }); + + // Update mid-trial WITH free_trial: null - remove trial + const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + + const updateParams = { + customer_id: customerId, + product_id: freeWithTrial.id, + items: [updatedMessagesItem], + free_trial: null, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should be 0 (still free product) + expect(preview.total).toEqual(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Product should no longer be trialing + await expectProductNotTrialing({ + customer, + productId: freeWithTrial.id, + }); + + // Product should now be active + await expectProductActive({ + customer, + productId: freeWithTrial.id, + }); + + // Feature updated + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: updatedMessagesItem.included_usage, + balance: updatedMessagesItem.included_usage, + usage: 0, + }); + + // Note: Free-to-free tests don't need expectSubToBeCorrect (no Stripe subscription) +}); diff --git a/server/tests/billing/update-subscription/free-trial/update-paid-trials.test.ts b/server/tests/billing/update-subscription/free-trial/update-paid-trials.test.ts index 1d645961a..e0b91ee22 100644 --- a/server/tests/billing/update-subscription/free-trial/update-paid-trials.test.ts +++ b/server/tests/billing/update-subscription/free-trial/update-paid-trials.test.ts @@ -7,6 +7,7 @@ import { expectProductNotTrialing, expectProductTrialing, } from "@tests/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/billing/utils/expectPreviewNextCycleCorrect"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; @@ -32,7 +33,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial while running")}` trialDays: 14, }); - const { customerId, autumnV1, ctx } = await initScenario({ + const { customerId, autumnV1, ctx, testClockId } = await initScenario({ customerId: "p2p-remove-trial-active", setup: [ s.customer({ testClock: true, paymentMethod: "success" }), @@ -49,6 +50,13 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial while running")}` productId: proTrial.id, }); + // Advance to mid-trial (7 days into 14-day trial) + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 7, + }); + // Remove the trial by passing free_trial: null const updateParams = { customer_id: customerId, @@ -62,6 +70,13 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial while running")}` // Should charge full price since trial is being removed expect(preview.total).toEqual(20); + // When trial is removed, next_cycle should start in ~1 month (regular billing) + expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(30), + total: items.monthlyPrice().price!, + }); + await autumnV1.subscriptions.update(updateParams); const customer = await autumnV1.customers.get(customerId); @@ -211,6 +226,13 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: trial carries over when undefi // Should be 0 during trial expect(preview.total).toEqual(0); + // next_cycle should align with existing trial (~14 days) + expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(14), + total: items.monthlyPrice().price!, + }); + await autumnV1.subscriptions.update(updateParams); const customer = await autumnV1.customers.get(customerId); @@ -269,8 +291,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial") await expectProductTrialing({ customer: customerBefore, productId: proTrial.id, - trialEndsAfter: ms.days(6), - trialEndsBefore: ms.days(8), + trialEndsAt: Date.now() + ms.days(7), }); // Replace with a new 30-day trial @@ -282,6 +303,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial") length: 30, duration: FreeTrialDuration.Day, card_required: true, + unique_fingerprint: false, }, }; @@ -290,6 +312,13 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial") // Should be 0 during trial expect(preview.total).toEqual(0); + // next_cycle should show new 30-day trial end + expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(30), + total: items.monthlyPrice().price!, + }); + await autumnV1.subscriptions.update(updateParams); const customer = await autumnV1.customers.get(customerId); @@ -298,8 +327,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial") await expectProductTrialing({ customer, productId: proTrial.id, - trialEndsAfter: ms.days(29), // At least 29 days from now - trialEndsBefore: ms.days(31), // At most 31 days from now + trialEndsAt: Date.now() + ms.days(30), }); await expectSubToBeCorrect({ @@ -310,7 +338,87 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial") }); }); -// 5. New trial after old expired +// 5. Paid product (no trial) → Paid product with trial, items undefined +test.concurrent(`${chalk.yellowBright("p2p-trial: paid no trial -> paid with trial, items undefined")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice(); + + const pro = products.pro({ + items: [messagesItem, priceItem], + id: "pro-no-trial", + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "p2p-trial-items-undefined", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Verify initially NOT trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductNotTrialing({ + customer: customerBefore, + productId: pro.id, + }); + + // Add trial without passing items (items undefined) + const updateParams = { + customer_id: customerId, + product_id: pro.id, + // items is NOT specified (undefined) + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + card_required: true, + unique_fingerprint: false, + }, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should be 0 during trial (trial being added) + expect(preview.total).toEqual(0); + + // next_cycle should show when trial ends + expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(14), + total: priceItem.price!, + }); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Product should now be trialing + await expectProductTrialing({ + customer, + productId: pro.id, + trialEndsAt: Date.now() + ms.days(14), + }); + + // Feature should still have correct values (unchanged since items undefined) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage, + usage: 0, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// 6. New trial after old expired test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")}`, async () => { const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -357,6 +465,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")} length: 14, duration: FreeTrialDuration.Day, card_required: true, + unique_fingerprint: false, }, }; @@ -365,6 +474,13 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")} // Should be 0 during new trial expect(preview.total).toEqual(0); + // next_cycle should show new 14-day trial end + expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(14), + total: items.monthlyPrice().price!, + }); + await autumnV1.subscriptions.update(updateParams); const customer = await autumnV1.customers.get(customerId); @@ -373,8 +489,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")} await expectProductTrialing({ customer, productId: proTrial.id, - trialEndsAfter: ms.days(13), - trialEndsBefore: ms.days(15), + trialEndsAt: Date.now() + ms.days(14), }); await expectSubToBeCorrect({ diff --git a/server/tests/billing/update-subscription/free-trial/update-quantity-with-trial.test.ts b/server/tests/billing/update-subscription/free-trial/update-quantity-with-trial.test.ts index 833a033b9..0c824683d 100644 --- a/server/tests/billing/update-subscription/free-trial/update-quantity-with-trial.test.ts +++ b/server/tests/billing/update-subscription/free-trial/update-quantity-with-trial.test.ts @@ -1,11 +1,12 @@ import { expect, test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; +import { type ApiCustomerV3, ms } from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect"; import { expectFeatureResetAlignedWithTrialEnd, expectPeriodEndsAlignedWithTrialEnd, expectProductTrialing, } from "@tests/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/billing/utils/expectPreviewNextCycleCorrect"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; @@ -62,10 +63,13 @@ test.concurrent(`${chalk.yellowBright("trial-qty: update prepaid quantity while options: [{ feature_id: TestFeature.Messages, quantity: 200 }], }; - const _preview = await autumnV1.subscriptions.previewUpdate(updateParams); + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - // Should be some charge for additional prepaid (depends on trial behavior) - // But trial should be preserved + // next_cycle should align with existing 14-day trial + expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(14), + }); await autumnV1.subscriptions.update(updateParams); @@ -142,6 +146,12 @@ test.concurrent(`${chalk.yellowBright("trial-qty: update allocated seats while t // During trial, no proration should occur expect(preview.total).toEqual(0); + // next_cycle should align with existing 14-day trial + expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(14), + }); + await autumnV1.subscriptions.update(updateParams); const customer = await autumnV1.customers.get(customerId); diff --git a/server/tests/billing/update-subscription/free-trial/update-trial-multi-product.test.ts b/server/tests/billing/update-subscription/free-trial/update-trial-multi-product.test.ts index 9a7f467f1..928dfb3ad 100644 --- a/server/tests/billing/update-subscription/free-trial/update-trial-multi-product.test.ts +++ b/server/tests/billing/update-subscription/free-trial/update-trial-multi-product.test.ts @@ -6,11 +6,16 @@ import { expectProductCanceling, expectProductScheduled, } from "@tests/billing/utils/expectCustomerProductCorrect"; -import { expectProductTrialing } from "@tests/billing/utils/expectCustomerProductTrialing"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/billing/utils/expectPreviewNextCycleCorrect"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; @@ -55,15 +60,15 @@ test.concurrent(`${chalk.yellowBright("trial-multi: separate entities have separ await expectProductTrialing({ customer: entity0, productId: proTrial.id, - trialEndsAfter: ms.days(13), - trialEndsBefore: ms.days(15), + trialEndsAt: Date.now() + ms.days(14), }); - // Verify entity 1 is NOT trialing (free product) + // Verify entity 1 is also trialing (merged with entity 0's trial subscription) const entity1 = await autumnV1.entities.get(customerId, entities[1].id); - await expectProductActive({ + await expectProductTrialing({ customer: entity1, productId: free.id, + trialEndsAt: Date.now() + ms.days(14), }); // Upgrade entity 1 to paid with a different trial length @@ -88,8 +93,7 @@ test.concurrent(`${chalk.yellowBright("trial-multi: separate entities have separ await expectProductTrialing({ customer: entity1After, productId: free.id, - trialEndsAfter: ms.days(6), - trialEndsBefore: ms.days(8), + trialEndsAt: Date.now() + ms.days(7), }); // Entity 0 should still have its original 14-day trial @@ -97,8 +101,7 @@ test.concurrent(`${chalk.yellowBright("trial-multi: separate entities have separ await expectProductTrialing({ customer: entity0After, productId: proTrial.id, - trialEndsAfter: ms.days(13), - trialEndsBefore: ms.days(15), + trialEndsAt: Date.now() + ms.days(14), }); // Verify the trial end dates are different @@ -237,6 +240,15 @@ test.concurrent(`${chalk.yellowBright("trial-multi: trial preserved when schedul // No free_trial param - should preserve existing trial }; + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // next_cycle should align with existing 14-day trial + expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(14), + total: items.monthlyPrice().price!, + }); + await autumnV1.subscriptions.update(updateParams); const customer = await autumnV1.customers.get(customerId); @@ -269,3 +281,209 @@ test.concurrent(`${chalk.yellowBright("trial-multi: trial preserved when schedul env: ctx.env, }); }); + +// 4. Free to paid with trial, merging with existing subscription +test.concurrent(`${chalk.yellowBright("trial-multi: free to paid with trial merges with existing subscription")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const free = products.base({ + id: "free", + items: [messagesItem], + }); + + const { customerId, autumnV1, ctx, entities } = await initScenario({ + customerId: "trial-multi-merge", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, free] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: pro.id, entityIndex: 0 }), + s.attach({ productId: free.id, entityIndex: 1 }), + ], + }); + + // Verify entity 0 is on paid pro (not trialing) + const entity1 = await autumnV1.entities.get(customerId, entities[0].id); + await expectProductActive({ customer: entity1, productId: pro.id }); + await expectProductNotTrialing({ customer: entity1, productId: pro.id }); + + // Now upgrade entity 1 from free to paid with trial + const priceItem = items.monthlyPrice(); + + const updateParams = { + customer_id: customerId, + entity_id: entities[1].id, + product_id: free.id, + items: [messagesItem, priceItem], + free_trial: { + length: 7, + duration: FreeTrialDuration.Day, + card_required: true, + unique_fingerprint: false, + }, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should be 0 during trial + expect(preview.total).toEqual(0); + + // next_cycle should show when 7-day trial ends + expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(7), + total: priceItem.price!, + }); + + await autumnV1.subscriptions.update(updateParams); + + // Verify entity 1 is now trialing + const entity2 = await autumnV1.entities.get(customerId, entities[1].id); + await expectProductTrialing({ + customer: entity2, + productId: free.id, + trialEndsAt: Date.now() + ms.days(7), + }); + + // Entity 0 should still not be trialing + const entity1After = await autumnV1.entities.get(customerId, entities[0].id); + await expectProductNotTrialing({ + customer: entity1After, + productId: pro.id, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// 5. Free customer -> entities subscribe to trial product -> advance cycle -> update free to paid (merges with existing) +test.concurrent(`${chalk.yellowBright("trial-multi: free to paid after trial cycle merges with subscription")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice(); + + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [messagesItem, priceItem], + trialDays: 7, + }); + + const free = products.base({ + id: "free", + items: [messagesItem], + }); + + const { customerId, autumnV1, ctx, entities, testClockId } = + await initScenario({ + customerId: "trial-multi-after-cycle", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [proTrial, free] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: proTrial.id, entityIndex: 0 }), // Entity 0 gets trial product + s.attach({ productId: free.id, entityIndex: 1 }), // Entity 1 gets free product + ], + }); + + // Verify entity 0 is trialing + const entity0Before = await autumnV1.entities.get(customerId, entities[0].id); + await expectProductTrialing({ + customer: entity0Before, + productId: proTrial.id, + trialEndsAt: Date.now() + ms.days(7), + }); + + // Verify entity 1 is NOT trialing (free product) + const entity1Before = await autumnV1.entities.get(customerId, entities[1].id); + await expectProductActive({ + customer: entity1Before, + productId: free.id, + }); + + // Advance past trial period (10 days to be safe) + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 10, + }); + + // Verify entity 0 is no longer trialing (trial ended, now active) + const entity0AfterAdvance = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + await expectProductNotTrialing({ + customer: entity0AfterAdvance, + productId: proTrial.id, + }); + await expectProductActive({ + customer: entity0AfterAdvance, + productId: proTrial.id, + }); + + // Now upgrade entity 1 from free to paid with trial - should merge with existing subscription + const updateParams = { + customer_id: customerId, + entity_id: entities[1].id, + product_id: free.id, + items: [messagesItem, priceItem], + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + card_required: true, + unique_fingerprint: false, + }, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should be 0 during trial + expect(preview.total).toEqual(0); + + // next_cycle should show when 14-day trial ends + expectPreviewNextCycleCorrect({ + preview, + startsAt: ms.days(14), + total: priceItem.price!, + }); + + await autumnV1.subscriptions.update(updateParams); + + // Verify entity 1 is now trialing with 14-day trial + const entity1After = await autumnV1.entities.get(customerId, entities[1].id); + await expectProductTrialing({ + customer: entity1After, + productId: free.id, + trialEndsAt: Date.now() + ms.days(14), + }); + + // Entity 0 should still be active (not trialing) + const entity0After = await autumnV1.entities.get(customerId, entities[0].id); + await expectProductNotTrialing({ + customer: entity0After, + productId: proTrial.id, + }); + await expectProductActive({ + customer: entity0After, + productId: proTrial.id, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); diff --git a/server/tests/billing/update-subscription/update-quantity/multi-entity-quantity.test.ts b/server/tests/billing/update-subscription/update-quantity/multi-entity-quantity.test.ts index 3d4e6d75f..dbdc5434f 100644 --- a/server/tests/billing/update-subscription/update-quantity/multi-entity-quantity.test.ts +++ b/server/tests/billing/update-subscription/update-quantity/multi-entity-quantity.test.ts @@ -1,8 +1,8 @@ import { expect, test } from "bun:test"; import { type ApiCustomerV3, OnDecrease, OnIncrease } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect.js"; import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect.js"; -import { expectEntityFeatureCorrect } from "@tests/billing/utils/expectEntityFeatureCorrect.js"; -import { expectEntityProductActive } from "@tests/billing/utils/expectEntityProductCorrect.js"; +import { expectProductActive } from "@tests/billing/utils/expectCustomerProductCorrect.js"; import { expectLatestInvoiceCorrect } from "@tests/billing/utils/expectLatestInvoiceCorrect.js"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js"; import { TestFeature } from "@tests/setup/v2Features.js"; @@ -97,16 +97,16 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 1 increases // Verify entity 1 has new balance const entity1 = await autumnV1.entities.get(customerId, entities[0].id); - await expectEntityFeatureCorrect({ - entity: entity1, + await expectCustomerFeatureCorrect({ + customer: entity1, featureId: TestFeature.Messages, balance: newQuantity1, }); // Verify entity 2 is unchanged const entity2 = await autumnV1.entities.get(customerId, entities[1].id); - await expectEntityFeatureCorrect({ - entity: entity2, + await expectCustomerFeatureCorrect({ + customer: entity2, featureId: TestFeature.Messages, balance: initialQuantity2, }); @@ -196,16 +196,16 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 2 decreases // Verify entity 2 has new balance const entity2 = await autumnV1.entities.get(customerId, entities[1].id); - await expectEntityFeatureCorrect({ - entity: entity2, + await expectCustomerFeatureCorrect({ + customer: entity2, featureId: TestFeature.Messages, balance: newQuantity2, }); // Verify entity 1 is unchanged const entity1 = await autumnV1.entities.get(customerId, entities[0].id); - await expectEntityFeatureCorrect({ - entity: entity1, + await expectCustomerFeatureCorrect({ + customer: entity1, featureId: TestFeature.Messages, balance: initialQuantity1, }); @@ -302,13 +302,13 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: mixed changes acro const entity1 = await autumnV1.entities.get(customerId, entities[0].id); const entity2 = await autumnV1.entities.get(customerId, entities[1].id); - await expectEntityFeatureCorrect({ - entity: entity1, + await expectCustomerFeatureCorrect({ + customer: entity1, featureId: TestFeature.Messages, balance: newQuantity1, }); - await expectEntityFeatureCorrect({ - entity: entity2, + await expectCustomerFeatureCorrect({ + customer: entity2, featureId: TestFeature.Messages, balance: newQuantity2, }); @@ -398,8 +398,8 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: OnDecrease.None cr // Balance is updated immediately with OnDecrease.None const entity1After = await autumnV1.entities.get(customerId, entities[0].id); - await expectEntityFeatureCorrect({ - entity: entity1After, + await expectCustomerFeatureCorrect({ + customer: entity1After, featureId: TestFeature.Messages, balance: newQuantity1, }); @@ -413,8 +413,8 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: OnDecrease.None cr // Entity 2 should be unchanged const entity2 = await autumnV1.entities.get(customerId, entities[1].id); - await expectEntityFeatureCorrect({ - entity: entity2, + await expectCustomerFeatureCorrect({ + customer: entity2, featureId: TestFeature.Messages, balance: initialQuantity2, }); @@ -497,24 +497,24 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products // Verify entity 2 (pro) has new balance const entity2 = await autumnV1.entities.get(customerId, entities[1].id); - await expectEntityProductActive({ - entity: entity2, + await expectProductActive({ + customer: entity2, productId: proProduct.id, }); - await expectEntityFeatureCorrect({ - entity: entity2, + await expectCustomerFeatureCorrect({ + customer: entity2, featureId: TestFeature.Messages, balance: newQuantityPro, }); // Verify entity 1 (base) is unchanged const entity1 = await autumnV1.entities.get(customerId, entities[0].id); - await expectEntityProductActive({ - entity: entity1, + await expectProductActive({ + customer: entity1, productId: baseProduct.id, }); - await expectEntityFeatureCorrect({ - entity: entity1, + await expectCustomerFeatureCorrect({ + customer: entity1, featureId: TestFeature.Messages, balance: initialQuantityBase, }); @@ -618,26 +618,26 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: multiple features // Verify entity 1 features const entity1 = await autumnV1.entities.get(customerId, entities[0].id); - await expectEntityFeatureCorrect({ - entity: entity1, + await expectCustomerFeatureCorrect({ + customer: entity1, featureId: TestFeature.Messages, balance: 100, }); - await expectEntityFeatureCorrect({ - entity: entity1, + await expectCustomerFeatureCorrect({ + customer: entity1, featureId: TestFeature.Words, balance: 100, }); // Verify entity 2 is unchanged const entity2 = await autumnV1.entities.get(customerId, entities[1].id); - await expectEntityFeatureCorrect({ - entity: entity2, + await expectCustomerFeatureCorrect({ + customer: entity2, featureId: TestFeature.Messages, balance: 100, }); - await expectEntityFeatureCorrect({ - entity: entity2, + await expectCustomerFeatureCorrect({ + customer: entity2, featureId: TestFeature.Words, balance: 500, }); diff --git a/server/tests/billing/utils/expectCustomerFeatureCorrect.ts b/server/tests/billing/utils/expectCustomerFeatureCorrect.ts index 4c2eddee7..6c34ad9ae 100644 --- a/server/tests/billing/utils/expectCustomerFeatureCorrect.ts +++ b/server/tests/billing/utils/expectCustomerFeatureCorrect.ts @@ -1,18 +1,21 @@ import { expect } from "bun:test"; -import { type ApiCustomerV3, ApiVersion } from "@autumn/shared"; +import { + type ApiCustomerV3, + type ApiEntityV0, + ApiVersion, +} from "@autumn/shared"; import type { Customer } from "autumn-js"; import { AutumnInt } from "@/external/autumn/autumnCli"; const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 }); - export const expectCustomerFeatureExists = async ({ customerId, customer: providedCustomer, featureId, }: { customerId?: string; - customer?: Customer; + customer?: Customer | ApiEntityV0; featureId: string; }) => { const customer = providedCustomer @@ -26,7 +29,7 @@ export const expectCustomerFeatureExists = async ({ const TEN_MINUTES_MS = 10 * 60 * 1000; -export const expectCustomerFeatureCorrect = async ({ +export const expectCustomerFeatureCorrect = ({ customerId, customer: providedCustomer, featureId, @@ -36,23 +39,31 @@ export const expectCustomerFeatureCorrect = async ({ resetsAt, }: { customerId?: string; - customer?: ApiCustomerV3; + customer?: ApiCustomerV3 | ApiEntityV0; featureId: string; includedUsage?: number; balance?: number; usage?: number; resetsAt?: number; }) => { - const customer = providedCustomer - ? providedCustomer - : await defaultAutumn.customers.get(customerId!); - const feature = customer.features?.[featureId]; + if (!providedCustomer && !customerId) { + throw new Error("Either customer or customerId must be provided"); + } - expect(feature).toMatchObject({ - included_usage: includedUsage, - balance, - usage, - }); + const feature = providedCustomer?.features?.[featureId]; + expect(feature, `Feature ${featureId} not found`).toBeDefined(); + + if (includedUsage !== undefined) { + expect(feature?.included_usage).toBe(includedUsage); + } + + if (balance !== undefined) { + expect(feature?.balance).toBe(balance); + } + + if (usage !== undefined) { + expect(feature?.usage).toBe(usage); + } if (resetsAt !== undefined) { const actualResetsAt = feature?.next_reset_at ?? 0; diff --git a/server/tests/billing/utils/expectCustomerProductCorrect.ts b/server/tests/billing/utils/expectCustomerProductCorrect.ts index 40466ce26..8d7626602 100644 --- a/server/tests/billing/utils/expectCustomerProductCorrect.ts +++ b/server/tests/billing/utils/expectCustomerProductCorrect.ts @@ -1,5 +1,5 @@ import { expect } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; +import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; import { ApiVersion } from "@autumn/shared"; import { AutumnInt } from "@/external/autumn/autumnCli"; @@ -21,7 +21,7 @@ export const expectCustomerProductCorrect = async ({ state, }: { customerId?: string; - customer?: ApiCustomerV3; + customer?: ApiCustomerV3 | ApiEntityV0; productId: string; state: ProductState; }) => { @@ -66,7 +66,7 @@ export const expectCustomerProductCorrect = async ({ */ export const expectProductActive = async (params: { customerId?: string; - customer?: ApiCustomerV3; + customer?: ApiCustomerV3 | ApiEntityV0; productId: string; }) => expectCustomerProductCorrect({ ...params, state: "active" }); @@ -77,7 +77,7 @@ export const expectProductActive = async (params: { */ export const expectProductCanceling = async (params: { customerId?: string; - customer?: ApiCustomerV3; + customer?: ApiCustomerV3 | ApiEntityV0; productId: string; }) => expectCustomerProductCorrect({ ...params, state: "canceled" }); @@ -86,7 +86,7 @@ export const expectProductCanceling = async (params: { */ export const expectProductScheduled = async (params: { customerId?: string; - customer?: ApiCustomerV3; + customer?: ApiCustomerV3 | ApiEntityV0; productId: string; }) => expectCustomerProductCorrect({ ...params, state: "scheduled" }); @@ -95,6 +95,6 @@ export const expectProductScheduled = async (params: { */ export const expectProductNotPresent = async (params: { customerId?: string; - customer?: ApiCustomerV3; + customer?: ApiCustomerV3 | ApiEntityV0; productId: string; }) => expectCustomerProductCorrect({ ...params, state: "undefined" }); diff --git a/server/tests/billing/utils/expectCustomerProductTrialing.ts b/server/tests/billing/utils/expectCustomerProductTrialing.ts index 8786179e3..585d3ce64 100644 --- a/server/tests/billing/utils/expectCustomerProductTrialing.ts +++ b/server/tests/billing/utils/expectCustomerProductTrialing.ts @@ -1,5 +1,5 @@ import { expect } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; +import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; import { ApiVersion } from "@autumn/shared"; import { AutumnInt } from "@/external/autumn/autumnCli"; @@ -8,6 +8,8 @@ const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 }); const ONE_HOUR_MS = 60 * 60 * 1000; const ONE_DAY_MS = 24 * ONE_HOUR_MS; +const TEN_MINUTES_MS = 10 * 60 * 1000; + /** * Verify a customer product is currently trialing with the expected trial end time. * Uses `status === "trialing"` and `current_period_end` to determine trial state. @@ -16,16 +18,13 @@ export const expectProductTrialing = async ({ customerId, customer: providedCustomer, productId, - trialEndsAfter, - trialEndsBefore, + trialEndsAt: expectedTrialEndsAt, }: { customerId?: string; - customer?: ApiCustomerV3; + customer?: ApiCustomerV3 | ApiEntityV0; productId: string; - /** Lower bound - current_period_end should be after this (ms from now) */ - trialEndsAfter?: number; - /** Upper bound - current_period_end should be before this (ms from now) */ - trialEndsBefore?: number; + /** Expected trial end timestamp (10 min tolerance) */ + trialEndsAt?: number; }) => { const customer = providedCustomer ? providedCustomer @@ -52,20 +51,11 @@ export const expectProductTrialing = async ({ `Product ${productId} should have current_period_end defined when trialing`, ).toBeDefined(); - const now = Date.now(); - - // Verify trial_ends_at is within expected range - if (trialEndsAfter !== undefined) { + // Verify trial_ends_at matches expected timestamp (with tolerance) + if (expectedTrialEndsAt !== undefined) { expect( - trialEndsAt! > now + trialEndsAfter - ONE_HOUR_MS, - `Product ${productId} current_period_end (${trialEndsAt}) should be after ${trialEndsAfter}ms from now`, - ).toBe(true); - } - - if (trialEndsBefore !== undefined) { - expect( - trialEndsAt! < now + trialEndsBefore + ONE_HOUR_MS, - `Product ${productId} current_period_end (${trialEndsAt}) should be before ${trialEndsBefore}ms from now`, + Math.abs(trialEndsAt! - expectedTrialEndsAt) < TEN_MINUTES_MS, + `Product ${productId} current_period_end (${trialEndsAt}) should be within 10 min of ${expectedTrialEndsAt}`, ).toBe(true); } @@ -81,7 +71,7 @@ export const expectProductNotTrialing = async ({ productId, }: { customerId?: string; - customer?: ApiCustomerV3; + customer?: ApiCustomerV3 | ApiEntityV0; productId: string; }) => { const customer = providedCustomer @@ -112,7 +102,7 @@ export const expectFeatureResetAlignedWithTrialEnd = async ({ trialEndsAt, }: { customerId?: string; - customer?: ApiCustomerV3; + customer?: ApiCustomerV3 | ApiEntityV0; featureId: string; trialEndsAt: number; }) => { @@ -120,7 +110,12 @@ export const expectFeatureResetAlignedWithTrialEnd = async ({ ? providedCustomer : await defaultAutumn.customers.get(customerId!); - const feature = customer.features[featureId]; + expect( + customer.features, + "Customer features not found for reset alignment check", + ).toBeDefined(); + + const feature = customer.features![featureId]; expect( feature, `Feature ${featureId} not found for reset alignment check`, @@ -148,7 +143,7 @@ export const expectPeriodEndsAlignedWithTrialEnd = async ({ trialEndsAt, }: { customerId?: string; - customer?: ApiCustomerV3; + customer?: ApiCustomerV3 | ApiEntityV0; productId: string; trialEndsAt: number; }) => { @@ -196,7 +191,7 @@ export const getTrialEndsAt = async ({ productId, }: { customerId?: string; - customer?: ApiCustomerV3; + customer?: ApiCustomerV3 | ApiEntityV0; productId: string; }): Promise => { const customer = providedCustomer diff --git a/server/tests/billing/utils/expectEntityFeatureCorrect.ts b/server/tests/billing/utils/expectEntityFeatureCorrect.ts deleted file mode 100644 index 61b8aef55..000000000 --- a/server/tests/billing/utils/expectEntityFeatureCorrect.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { expect } from "bun:test"; -import { type ApiEntityV0, ApiVersion } from "@autumn/shared"; -import { AutumnInt } from "@/external/autumn/autumnCli"; - -const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 }); - -const ONE_HOUR_MS = 60 * 60 * 1000; - -/** - * Verify an entity has the expected feature with correct balance/usage values. - * Uses ApiEntityV0 which has `features` with `balance` property (V1.2 format). - */ -export const expectEntityFeatureCorrect = async ({ - customerId, - entityId, - entity: providedEntity, - featureId, - balance, - usage, - resetsAt, -}: { - customerId?: string; - entityId?: string; - entity?: ApiEntityV0; - featureId: string; - balance?: number; - usage?: number; - resetsAt?: number; -}) => { - const entity = providedEntity - ? providedEntity - : await defaultAutumn.entities.get(customerId!, entityId!); - - const feature = entity.features?.[featureId]; - - if (balance !== undefined) { - expect(feature?.balance).toBe(balance); - } - - if (usage !== undefined) { - expect(feature?.usage).toBe(usage); - } - - if (resetsAt !== undefined) { - const actualResetsAt = feature?.next_reset_at ?? 0; - expect(actualResetsAt).toBeDefined(); - expect(Math.abs(actualResetsAt - resetsAt)).toBeLessThanOrEqual( - ONE_HOUR_MS, - ); - } -}; - -/** - * Verify an entity has a specific feature defined. - */ -export const expectEntityFeatureExists = async ({ - customerId, - entityId, - entity: providedEntity, - featureId, -}: { - customerId?: string; - entityId?: string; - entity?: ApiEntityV0; - featureId: string; -}) => { - const entity = providedEntity - ? providedEntity - : await defaultAutumn.entities.get(customerId!, entityId!); - - const feature = entity.features?.[featureId]; - expect(feature).toBeDefined(); -}; diff --git a/server/tests/billing/utils/expectEntityProductCorrect.ts b/server/tests/billing/utils/expectEntityProductCorrect.ts deleted file mode 100644 index 25bbc03c5..000000000 --- a/server/tests/billing/utils/expectEntityProductCorrect.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { expect } from "bun:test"; -import { type ApiEntityV0, ApiVersion } from "@autumn/shared"; -import { AutumnInt } from "@/external/autumn/autumnCli"; - -const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 }); - -type ProductState = "active" | "canceled" | "scheduled" | "undefined"; - -/** - * Verify an entity has the expected product in the expected state. - */ -export const expectEntityProductCorrect = async ({ - customerId, - entityId, - entity: providedEntity, - productId, - state, -}: { - customerId?: string; - entityId?: string; - entity?: ApiEntityV0; - productId: string; - state: ProductState; -}) => { - const entity = providedEntity - ? providedEntity - : await defaultAutumn.entities.get(customerId!, entityId!); - - const products = entity.products ?? []; - const product = products.find((p) => p.id === productId); - - if (state === "undefined") { - expect(product, `Product ${productId} should not exist`).toBeUndefined(); - return; - } - - if (!product) { - throw new Error( - `Product ${productId} not found on entity but expected state: ${state}`, - ); - } - - if (state === "active") { - expect(String(product.status)).toBe("active"); - expect(product.canceled_at == null).toBe(true); - } else if (state === "canceled") { - expect(product.canceled_at).toBeDefined(); - } else if (state === "scheduled") { - expect(String(product.status)).toBe("scheduled"); - } -}; - -/** - * Shorthand for checking entity product is active - */ -export const expectEntityProductActive = async (params: { - customerId?: string; - entityId?: string; - entity?: ApiEntityV0; - productId: string; -}) => expectEntityProductCorrect({ ...params, state: "active" }); - -/** - * Shorthand for checking entity product is canceled - */ -export const expectEntityProductCanceled = async (params: { - customerId?: string; - entityId?: string; - entity?: ApiEntityV0; - productId: string; -}) => expectEntityProductCorrect({ ...params, state: "canceled" }); - -/** - * Shorthand for checking entity product is scheduled - */ -export const expectEntityProductScheduled = async (params: { - customerId?: string; - entityId?: string; - entity?: ApiEntityV0; - productId: string; -}) => expectEntityProductCorrect({ ...params, state: "scheduled" }); - -/** - * Shorthand for checking entity product does not exist - */ -export const expectEntityProductNotPresent = async (params: { - customerId?: string; - entityId?: string; - entity?: ApiEntityV0; - productId: string; -}) => expectEntityProductCorrect({ ...params, state: "undefined" }); diff --git a/server/tests/billing/utils/expectPreviewNextCycleCorrect.ts b/server/tests/billing/utils/expectPreviewNextCycleCorrect.ts new file mode 100644 index 000000000..d141ecb41 --- /dev/null +++ b/server/tests/billing/utils/expectPreviewNextCycleCorrect.ts @@ -0,0 +1,61 @@ +import { expect } from "bun:test"; +import type { BillingPreviewResponse } from "@autumn/shared"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Verify a billing preview's next_cycle field has the expected values. + * Used to check when trial ends and what charge will be. + */ +export const expectPreviewNextCycleCorrect = ({ + preview, + expectDefined = true, + startsAt, + total, + toleranceMs = ONE_DAY_MS, +}: { + preview: BillingPreviewResponse; + /** Whether next_cycle should be defined (default: true) */ + expectDefined?: boolean; + /** Expected starts_at offset from now (ms from now) */ + startsAt?: number; + /** Expected total amount (in dollars) */ + total?: number; + /** Tolerance in ms (default: 1 day) */ + toleranceMs?: number; +}) => { + if (!expectDefined) { + expect( + preview.next_cycle, + "Preview next_cycle should not be defined", + ).toBeUndefined(); + return; + } + + expect( + preview.next_cycle, + "Preview next_cycle should be defined", + ).toBeDefined(); + + const nextCycle = preview.next_cycle!; + + if (startsAt !== undefined) { + const now = Date.now(); + const expectedStartsAt = now + startsAt; + const diff = Math.abs(nextCycle.starts_at - expectedStartsAt); + + expect( + diff < toleranceMs, + `Preview next_cycle.starts_at (${nextCycle.starts_at}) should be within ${toleranceMs}ms of ${expectedStartsAt}, but diff is ${diff}ms`, + ).toBe(true); + } + + if (total !== undefined) { + expect( + nextCycle.total, + `Preview next_cycle.total should be ${total}`, + ).toEqual(total); + } + + return nextCycle; +}; diff --git a/server/tests/utils/fixtures/products.ts b/server/tests/utils/fixtures/products.ts index 6d6c2b468..521250613 100644 --- a/server/tests/utils/fixtures/products.ts +++ b/server/tests/utils/fixtures/products.ts @@ -97,8 +97,37 @@ const proWithTrial = ({ }, }); +/** + * Base (free) product with free trial - no base price, with configurable trial + * @param items - Product items (features) + * @param id - Product ID (default: "base-trial") + * @param trialDays - Number of trial days (default: 7) + * @param cardRequired - Whether card is required for trial (default: false) + */ +const baseWithTrial = ({ + items, + id = "base-trial", + trialDays = 7, + cardRequired = false, +}: { + items: ProductItem[]; + id?: string; + trialDays?: number; + cardRequired?: boolean; +}): ProductV2 => ({ + ...constructRawProduct({ id, items }), + is_default: false, + free_trial: { + length: trialDays, + duration: FreeTrialDuration.Day, + unique_fingerprint: false, + card_required: cardRequired, + }, +}); + export const products = { base, + baseWithTrial, pro, proAnnual, proWithTrial, diff --git a/shared/api/billing/common/billingPreviewResponse.ts b/shared/api/billing/common/billingPreviewResponse.ts index a0f9a83d1..51b575d4f 100644 --- a/shared/api/billing/common/billingPreviewResponse.ts +++ b/shared/api/billing/common/billingPreviewResponse.ts @@ -11,6 +11,13 @@ export const BillingPreviewResponseSchema = z.object({ total: z.number(), currency: z.string(), + + next_cycle: z + .object({ + starts_at: z.number(), + total: z.number(), + }) + .optional(), }); export type BillingPreviewResponse = z.infer< diff --git a/shared/utils/common/unixUtils.ts b/shared/utils/common/unixUtils.ts index 8819fb943..420ab7a55 100644 --- a/shared/utils/common/unixUtils.ts +++ b/shared/utils/common/unixUtils.ts @@ -32,9 +32,10 @@ export const isValidMsTimestamp = (unixTimestamp: number): boolean => { * Validates that a timestamp is in seconds, then converts to milliseconds. * Returns undefined if input is undefined or not a valid seconds timestamp. */ -export const secondsToMs = ( - seconds: number | undefined, -): number | undefined => { +export function secondsToMs(seconds: number): number; +export function secondsToMs(seconds: undefined): undefined; +export function secondsToMs(seconds: number | undefined): number | undefined; +export function secondsToMs(seconds: number | undefined): number | undefined { if (seconds === undefined) { return undefined; } @@ -49,7 +50,7 @@ export const secondsToMs = ( } return seconds * 1000; -}; +} export const msToSeconds = (ms: number): number => { return Math.floor(ms / 1000); diff --git a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts index 37267dd6b..e0b14ebb0 100644 --- a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts +++ b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts @@ -79,18 +79,14 @@ export const isCustomerProductExpired = ( ); }; -export const isCusProductTrialing = ({ - cusProduct, - now, -}: { - cusProduct?: FullCusProduct; - now?: number; -}) => { - if (!cusProduct) return false; +export const isCustomerProductTrialing = ( + customerProduct?: FullCusProduct, + params?: { nowMs?: number }, +) => { + if (!customerProduct) return false; - return ( - cusProduct.trial_ends_at && cusProduct.trial_ends_at > (now || Date.now()) - ); + const nowMs = params?.nowMs ?? Date.now(); + return customerProduct.trial_ends_at && customerProduct.trial_ends_at > nowMs; }; export const customerProductHasRelevantStatus = (cp?: FullCusProduct) => { diff --git a/shared/utils/cusProductUtils/classifyCustomerProduct/cpBuilder.ts b/shared/utils/cusProductUtils/classifyCustomerProduct/cpBuilder.ts index d29c65fcb..a6ed1f743 100644 --- a/shared/utils/cusProductUtils/classifyCustomerProduct/cpBuilder.ts +++ b/shared/utils/cusProductUtils/classifyCustomerProduct/cpBuilder.ts @@ -5,7 +5,6 @@ import { customerProductHasRelevantStatus, customerProductHasSubscriptionSchedule, isCusProductOnEntity, - isCusProductTrialing, isCustomerProductAddOn, isCustomerProductCanceling, isCustomerProductFree, @@ -16,6 +15,7 @@ import { isCustomerProductPaid, isCustomerProductRecurring, isCustomerProductScheduled, + isCustomerProductTrialing, } from "./classifyCustomerProduct"; type Predicate = (cp: FullCusProduct) => boolean; @@ -120,10 +120,8 @@ class CustomerProductChecker { } /** Product is trialing */ - trialing({ now }: { now?: number } = {}) { - this.predicates.push( - (cp) => !!isCusProductTrialing({ cusProduct: cp, now }), - ); + trialing({ nowMs }: { nowMs?: number } = {}) { + this.predicates.push((cp) => !!isCustomerProductTrialing(cp, { nowMs })); return this; } diff --git a/shared/utils/intervalUtils/priceIntervalUtils.ts b/shared/utils/intervalUtils/priceIntervalUtils.ts index 9072b7311..84fd50350 100644 --- a/shared/utils/intervalUtils/priceIntervalUtils.ts +++ b/shared/utils/intervalUtils/priceIntervalUtils.ts @@ -81,8 +81,6 @@ export const getSmallestInterval = ({ ents?: Entitlement[]; excludeOneOff?: boolean; }) => { - // let sortedPrices = structuredClone(prices); - // sortPricesByInterval(sortedPrices); let allPriceIntervals = prices.map((p) => { return { interval: p.config.interval, diff --git a/vite/src/components/forms/attach-product/use-attach-preview.ts b/vite/src/components/forms/attach-product/use-attach-preview.ts index 3efae1746..9279699a5 100644 --- a/vite/src/components/forms/attach-product/use-attach-preview.ts +++ b/vite/src/components/forms/attach-product/use-attach-preview.ts @@ -58,7 +58,7 @@ export function useAttachPreview(params: AttachPreviewParams = {}) { } const response = await axiosInstance.post( - "/v1/checkout", + "/v1/subscriptions/preview_update", attachBody, ); diff --git a/vite/src/components/forms/update-subscription/get-update-subscription-body.ts b/vite/src/components/forms/update-subscription/get-update-subscription-body.ts new file mode 100644 index 000000000..0e34c658e --- /dev/null +++ b/vite/src/components/forms/update-subscription/get-update-subscription-body.ts @@ -0,0 +1,78 @@ +import type { + CreateFreeTrial, + FeatureOptions, + ProductV2, +} from "@autumn/shared"; + +export const getUpdateSubscriptionBody = ({ + customerId, + product, + entityId, + optionsInput, + useInvoice, + enableProductImmediately = true, + successUrl, + version, + isCustom = false, + freeTrial, +}: { + customerId: string; + product: ProductV2; + entityId?: string; + optionsInput?: FeatureOptions[]; + useInvoice?: boolean; + enableProductImmediately?: boolean; + successUrl?: string; + version?: number; + isCustom?: boolean; + // Free trial param - null removes trial, undefined preserves existing + freeTrial?: CreateFreeTrial | null; +}) => { + const customData = isCustom + ? { + items: product.items, + free_trial: product.free_trial, + } + : {}; + + // Determine free_trial value: + // 1. If freeTrial is explicitly set (including null), use it + // 2. If isCustom, use product.free_trial + // 3. Otherwise, undefined (preserve existing) + const getFreeTrialValue = () => { + if (freeTrial !== undefined) { + return freeTrial; + } + if (isCustom) { + return product.free_trial || undefined; + } + return undefined; + }; + + return { + customer_id: customerId, + product_id: product.id, + entity_id: entityId || undefined, + options: optionsInput + ? optionsInput.map((option) => ({ + feature_id: option.feature_id, + quantity: option.quantity || 0, + })) + : undefined, + is_custom: isCustom, + ...customData, + free_trial: getFreeTrialValue(), + + invoice: useInvoice, + enable_product_immediately: useInvoice + ? enableProductImmediately + : undefined, + finalize_invoice: useInvoice ? false : undefined, + + force_checkout: + useInvoice && enableProductImmediately === false ? true : undefined, + + success_url: successUrl, + version: version ? Number(version) : undefined, + }; +}; diff --git a/vite/src/components/forms/update-subscription/use-update-subscription-body-builder.ts b/vite/src/components/forms/update-subscription/use-update-subscription-body-builder.ts new file mode 100644 index 000000000..2de85aa19 --- /dev/null +++ b/vite/src/components/forms/update-subscription/use-update-subscription-body-builder.ts @@ -0,0 +1,123 @@ +import { + AppEnv, + type CreateFreeTrial, + type ProductV2, + UsageModel, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import { useMemo } from "react"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { useHasChanges, useProductStore } from "@/hooks/stores/useProductStore"; +import { useEntity } from "@/hooks/stores/useSubscriptionStore"; +import { useEnv } from "@/utils/envUtils"; +import { getRedirectUrl } from "@/utils/genUtils"; +import { getUpdateSubscriptionBody } from "./get-update-subscription-body"; + +interface UpdateSubscriptionBodyBuilderParams { + customerId?: string; + productId?: string; + product?: ProductV2; + entityId?: string; + prepaidOptions?: Record; + version?: number; + useInvoice?: boolean; + enableProductImmediately?: boolean; + + // Free trial param - null removes trial, undefined preserves existing + freeTrial?: CreateFreeTrial | null; +} + +/** + * Shared hook to build update subscription body from explicit params. + * Similar to useAttachBodyBuilder but includes free_trial support. + */ +export function useUpdateSubscriptionBodyBuilder( + params: UpdateSubscriptionBodyBuilderParams = {}, +) { + const { products } = useProductsQuery(); + const hasChanges = useHasChanges(); + const storeProduct = useProductStore((s) => s.product); + const { entityId: storeEntityId } = useEntity(); + const env = useEnv(); + + // Memoized builder function that can be called with runtime params + const buildUpdateSubscriptionBody = useMemo( + () => (runtimeParams?: UpdateSubscriptionBodyBuilderParams) => { + const mergedParams = { ...params, ...runtimeParams }; + + const redirectUrl = getRedirectUrl( + `/customers/${mergedParams.customerId}`, + env, + ); + + // Resolve the product: use provided product or find by ID + const product = + mergedParams.product || + products.find((p) => p.id === mergedParams.productId); + + if (!product || !mergedParams.customerId) { + return null; + } + + // Determine if this is a custom product (from store with changes) + const isCustom = + hasChanges && !!storeProduct?.id && product === storeProduct + ? true + : undefined; + const version = storeProduct?.id ? storeProduct.version : undefined; + + // Convert prepaidOptions to options array + const options = mergedParams.prepaidOptions + ? Object.entries(mergedParams.prepaidOptions).map( + ([featureId, quantity]) => { + const prepaidItem = product?.items.find( + (item) => + item.feature_id === featureId && + item.usage_model === UsageModel.Prepaid, + ); + + if (!prepaidItem) { + return { + feature_id: featureId, + quantity: quantity, + }; + } + + return { + feature_id: featureId, + quantity: new Decimal(quantity || 0) + .mul(prepaidItem.billing_units || 1) + .toNumber(), + }; + }, + ) + : []; + + // Build the body using getUpdateSubscriptionBody (includes freeTrial support) + return getUpdateSubscriptionBody({ + customerId: mergedParams.customerId, + product, + entityId: mergedParams.entityId ?? storeEntityId ?? undefined, + optionsInput: options.length > 0 ? options : undefined, + isCustom, + version, + useInvoice: mergedParams.useInvoice, + enableProductImmediately: mergedParams.enableProductImmediately, + successUrl: + env === AppEnv.Sandbox + ? `${import.meta.env.VITE_FRONTEND_URL}${redirectUrl}` + : undefined, + freeTrial: mergedParams.freeTrial, + }); + }, + [products, hasChanges, storeProduct, storeEntityId, params, env], + ); + + // For simple usage, return the built body with current params + const updateSubscriptionBody = useMemo( + () => buildUpdateSubscriptionBody(), + [buildUpdateSubscriptionBody], + ); + + return { updateSubscriptionBody, buildUpdateSubscriptionBody }; +} diff --git a/vite/src/components/forms/update-subscription/use-update-subscription-preview.ts b/vite/src/components/forms/update-subscription/use-update-subscription-preview.ts new file mode 100644 index 000000000..3108f86d2 --- /dev/null +++ b/vite/src/components/forms/update-subscription/use-update-subscription-preview.ts @@ -0,0 +1,90 @@ +import type { + CheckoutResponseV0, + CreateFreeTrial, + ProductV2, +} from "@autumn/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { useUpdateSubscriptionBodyBuilder } from "./use-update-subscription-body-builder"; + +interface UpdateSubscriptionPreviewParams { + // Required params - no fallbacks + customerId?: string; + product?: ProductV2; + entityId?: string; + prepaidOptions?: Record; + version?: number; + + // Free trial param - null removes trial, undefined preserves existing + freeTrial?: CreateFreeTrial | null; + + // Control behavior + enabled?: boolean; +} + +export function useUpdateSubscriptionPreview( + params: UpdateSubscriptionPreviewParams = {}, +) { + const axiosInstance = useAxiosInstance(); + + // Build update subscription body using shared hook with explicit params + const { updateSubscriptionBody } = useUpdateSubscriptionBodyBuilder({ + customerId: params.customerId, + product: params.product, + entityId: params.entityId, + prepaidOptions: params.prepaidOptions, + version: params.version, + freeTrial: params.freeTrial, + }); + + // Auto-enable if not explicitly set and all required data is present + const shouldEnable = + params.enabled !== undefined + ? params.enabled + : !!(params.customerId && params.product && updateSubscriptionBody); + + // Create a stable serialized key from updateSubscriptionBody (which already captures all dependencies) + const queryKeyDeps = useMemo( + () => JSON.stringify(updateSubscriptionBody), + [updateSubscriptionBody], + ); + + // Debounce the query key to delay API calls by 150ms + const [debouncedQueryKey, setDebouncedQueryKey] = useState(queryKeyDeps); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQueryKey(queryKeyDeps); + }, 300); + return () => clearTimeout(timer); + }, [queryKeyDeps]); + + // Track if we're in a debouncing state (query key has changed but debounce hasn't completed) + const isDebouncing = queryKeyDeps !== debouncedQueryKey; + + const query = useQuery({ + queryKey: ["update-subscription-preview", debouncedQueryKey], + queryFn: async () => { + if (!updateSubscriptionBody || !params.customerId) { + return null; + } + + const response = await axiosInstance.post( + "/v1/subscriptions/preview_update", + updateSubscriptionBody, + ); + + return response.data; + }, + enabled: shouldEnable, + staleTime: 0, // Always fetch fresh pricing + }); + + // Override isLoading to include debouncing state + // This prevents showing stale data during the transition between diff plans in the selector + return { + ...query, + isLoading: query.isLoading || isDebouncing, + }; +} diff --git a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx index 092d342b3..3774d854d 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx @@ -2,7 +2,7 @@ import { CusProductStatus, type Entity, featureToOptions, - isCusProductTrialing, + isCustomerProductTrialing, isOneOffProductV2, type ProductItem, UsageModel, @@ -290,9 +290,8 @@ export function SubscriptionDetailSheet() { status={cusProduct.status} canceled={cusProduct.canceled} trialing={ - isCusProductTrialing({ - cusProduct, - now: Date.now(), + isCustomerProductTrialing(cusProduct, { + nowMs: Date.now(), }) || false } trial_ends_at={cusProduct.trial_ends_at ?? undefined} diff --git a/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx index 3d38cb118..5dfa07227 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx @@ -6,7 +6,7 @@ import { type FullCusProduct, type FullCustomer, getProductItemDisplay, - isCusProductTrialing, + isCustomerProductTrialing, type ProductItem, type ProductV2, stripeToAtmnAmount, @@ -17,7 +17,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; -import { DateInputUnix } from "@/components/general/DateInputUnix"; +import { AttachProductLineItems } from "@/components/forms/attach-product/attach-product-line-items"; +import { AttachProductTotals } from "@/components/forms/attach-product/attach-product-totals"; +import { useUpdateSubscriptionPreview } from "@/components/forms/update-subscription/use-update-subscription-preview"; import { Popover, PopoverContent, @@ -25,6 +27,7 @@ import { } from "@/components/ui/popover"; import { Button } from "@/components/v2/buttons/Button"; import { IconButton } from "@/components/v2/buttons/IconButton"; +import { LoadingShimmerText } from "@/components/v2/LoadingShimmerText"; import { SheetHeader } from "@/components/v2/sheets/InlineSheet"; import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery"; import { usePrepaidItems } from "@/hooks/stores/useProductStore"; @@ -126,7 +129,7 @@ function FreeTrialEditor({ onTrialCardRequiredChange, onRemoveTrialChange, }: FreeTrialEditorProps) { - const isCurrentlyTrialing = isCusProductTrialing({ cusProduct }); + const isCurrentlyTrialing = isCustomerProductTrialing(cusProduct); return (
@@ -1145,13 +1148,13 @@ function SheetContent({ initialPrepaidOptions, ); - const [planCustomStartDate, setPlanCustomStartDate] = useState( + const [planCustomStartDate, _setPlanCustomStartDate] = useState< + number | null + >(null); + const [planCustomEndDate, _setPlanCustomEndDate] = useState( null, ); - const [planCustomEndDate, setPlanCustomEndDate] = useState( - null, - ); - const [billingCycleAnchor, setBillingCycleAnchor] = useState( + const [billingCycleAnchor, _setBillingCycleAnchor] = useState( null, ); @@ -1269,6 +1272,32 @@ function SheetContent({ enabled: !!requestBody, }); + // Compute freeTrial value for preview + const previewFreeTrial = useMemo(() => { + if (removeTrial) { + return null; + } + if (trialLength) { + return { + length: trialLength, + duration: trialDuration, + card_required: trialCardRequired, + unique_fingerprint: false, + }; + } + return undefined; + }, [removeTrial, trialLength, trialDuration, trialCardRequired]); + + // Checkout preview query with free trial support + const checkoutPreviewQuery = useUpdateSubscriptionPreview({ + customerId, + product, + entityId, + prepaidOptions: prepaidOptions ?? undefined, + version: product?.version, + freeTrial: previewFreeTrial, + }); + // Update mutation with invoice handling const updateMutation = useSubscriptionUpdate({ customerId, @@ -1471,82 +1500,22 @@ function SheetContent({ onRemoveTrialChange={setRemoveTrial} /> - {/* Custom Plan Dates */} + {/* Checkout Preview Response (same as SubscriptionUpdateSheet) */}
-

Custom Plan Dates

+

Checkout Preview Response

-
-
- -
- -
- {planCustomStartDate ? ( - - ) : null} + {checkoutPreviewQuery.isLoading ? ( + + ) : ( +
+ +
-
- -
- -
- {planCustomEndDate ? ( - - ) : null} -
-
- -
- -
- {billingCycleAnchor ? ( - - ) : null} -
-
+ )}
{/* Preview Result */} diff --git a/vite/src/views/customers2/components/table/customer-list/CustomerListColumns.tsx b/vite/src/views/customers2/components/table/customer-list/CustomerListColumns.tsx index 7042a1aaa..2e072b25b 100644 --- a/vite/src/views/customers2/components/table/customer-list/CustomerListColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-list/CustomerListColumns.tsx @@ -2,7 +2,7 @@ import { CusProductStatus, type CustomerSchema, type FullCusProduct, - isCusProductTrialing, + isCustomerProductTrialing, } from "@autumn/shared"; import type { ColumnDef, Row } from "@tanstack/react-table"; import type { z } from "zod/v4"; @@ -93,9 +93,8 @@ const getCusProductsInfo = ({ } tooltip={true} trialing={ - isCusProductTrialing({ - cusProduct: cusProduct as FullCusProduct, - now: Date.now(), + isCustomerProductTrialing(cusProduct as FullCusProduct, { + nowMs: Date.now(), }) || false } trial_ends_at={ diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx index 08b92282b..28ecc4d54 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx @@ -1,4 +1,4 @@ -import { type FullCusProduct, isCusProductTrialing } from "@autumn/shared"; +import { type FullCusProduct, isCustomerProductTrialing } from "@autumn/shared"; import type { Row, Table } from "@tanstack/react-table"; import { ArrowRightLeft, Delete } from "lucide-react"; import { TableDropdownMenuCell } from "@/components/general/table/table-dropdown-menu-cell"; @@ -49,12 +49,7 @@ export const CustomerProductsColumns = [ status={row.original.status} starts_at={row.original.starts_at ?? undefined} canceled={row.original.canceled} - trialing={ - isCusProductTrialing({ - cusProduct: row.original, - now: Date.now(), - }) || false - } + trialing={isCustomerProductTrialing(row.original) || false} trial_ends_at={row.original.trial_ends_at ?? undefined} /> );