diff --git a/.claude/skills/write-test/references/GOTCHAS.md b/.claude/skills/write-test/references/GOTCHAS.md index ea6c2bb6b..b5c90dd25 100644 --- a/.claude/skills/write-test/references/GOTCHAS.md +++ b/.claude/skills/write-test/references/GOTCHAS.md @@ -229,6 +229,33 @@ expectCustomerFeatureCorrect({ ## Billing & Invoices +### Trial Invoice Count +When a Stripe subscription is created (even with a trial), Stripe generates a $0 invoice: +```typescript +// WRONG - Trial subscription DOES create an invoice +await expectCustomerInvoiceCorrect({ + customer, + count: 0, // Wrong! Trial creates $0 invoice +}); + +// RIGHT - Trial subscription creates 1 invoice with $0 total +await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, +}); + +// RIGHT - Free product (no Stripe subscription) has no invoice +await expectCustomerInvoiceCorrect({ + customer, + count: 0, // Correct for free products +}); +``` +Rules: +- **Stripe subscription created (even trialing)**: `count: 1, latestTotal: 0` +- **Subscription updated while trialing**: Invoice count increases by 1 (still `latestTotal: 0`) +- **Free product (no Stripe subscription)**: `count: 0` is correct + ### Consumable Overage: Not Charged on Update ```typescript expect(preview.total).toBe(0); // Even with existing overage diff --git a/scripts/testGroups/attach.sh b/scripts/testGroups/attach.sh index 55f2c90ca..fc0174da5 100755 --- a/scripts/testGroups/attach.sh +++ b/scripts/testGroups/attach.sh @@ -8,5 +8,7 @@ set -e BUN_PARALLEL_V2 \ 'attach/immediate-switch' \ + 'attach/scheduled-switch' \ + 'attach/free-trial' \ # 'attach/new-plan' diff --git a/server/package.json b/server/package.json index 6038fff39..88c92a438 100644 --- a/server/package.json +++ b/server/package.json @@ -27,7 +27,7 @@ "cm": "ENV_FILE=.env infisical run --env=dev -- bun tests/clearMaster.ts", "ts": "bunx tsgo --build --noEmit", "test:integration": "ENV_FILE=.env infisical run --env=dev -- bun test --timeout 0 --preload ./tests/setup-integration-tests.ts", - "cm": "ENV_FILE=.env infisical run --env=dev -- bun tests/clearMaster.ts" + }, "mocha": { "node-option": [ diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 87b5e4b00..03e8a5548 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -9,7 +9,6 @@ import { type ApiCusProductV3, type ApiEntityV0, type AttachBodyV0, - type AttachParamsV0, type AttachParamsV0Input, type BalancesUpdateParams, type BillingPreviewResponse, @@ -816,7 +815,7 @@ export class AutumnInt { billing = { attach: async ( - params: AttachParamsV0, + params: AttachParamsV0Input, { skipWebhooks, idempotencyKey, diff --git a/server/src/internal/billing/v2/actions/attach/attach.ts b/server/src/internal/billing/v2/actions/attach/attach.ts index 39e56235a..79f79d171 100644 --- a/server/src/internal/billing/v2/actions/attach/attach.ts +++ b/server/src/internal/billing/v2/actions/attach/attach.ts @@ -55,7 +55,6 @@ export async function attach({ ctx, billingContext, autumnBillingPlan, - params, }); // 4. Evaluate Stripe billing plan (handles checkout mode internally) diff --git a/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts b/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts index 3d4c37150..5cf1ef6cc 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts @@ -1,18 +1,12 @@ -import { filterUnchangedPricesFromLineItems } from "@autumn/shared"; +import type { AttachBillingContext, AutumnBillingPlan } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems"; -import type { - AttachBillingContext, - AutumnBillingPlan, -} from "@autumn/shared"; +import { finalizeLineItems } from "@/internal/billing/v2/compute/finalize/finalizeLineItems"; /** - * Finalizes the attach billing plan by: - * 1. Filtering out unchanged prices (refund + charge pairs that cancel out) - * 2. Applying Stripe discounts to line items + * Finalizes the attach billing plan by processing line items. */ export const finalizeAttachPlan = ({ - ctx: _ctx, + ctx, plan, attachBillingContext, }: { @@ -20,18 +14,12 @@ export const finalizeAttachPlan = ({ plan: AutumnBillingPlan; attachBillingContext: AttachBillingContext; }): AutumnBillingPlan => { - // 1. Filter out unchanged prices (refund + charge pairs that cancel out) - plan.lineItems = filterUnchangedPricesFromLineItems({ + plan.lineItems = finalizeLineItems({ + ctx, lineItems: plan.lineItems ?? [], + billingContext: attachBillingContext, + autumnBillingPlan: plan, }); - // 2. Apply Stripe discounts if present - if (attachBillingContext.stripeDiscounts?.length) { - plan.lineItems = applyStripeDiscountsToLineItems({ - lineItems: plan.lineItems ?? [], - discounts: attachBillingContext.stripeDiscounts, - }); - } - return plan; }; diff --git a/server/src/internal/billing/v2/actions/attach/logs/logAttachContext.ts b/server/src/internal/billing/v2/actions/attach/logs/logAttachContext.ts index ed6dcd356..63bb40a60 100644 --- a/server/src/internal/billing/v2/actions/attach/logs/logAttachContext.ts +++ b/server/src/internal/billing/v2/actions/attach/logs/logAttachContext.ts @@ -25,6 +25,7 @@ export const logAttachContext = ({ isCustom, billingCycleAnchorMs, resetCycleAnchorMs, + trialContext, } = billingContext; addToExtraLogs({ @@ -58,6 +59,10 @@ export const logAttachContext = ({ .map((fq) => `${fq.feature_id}: ${fq.quantity}`) .join(", ") : "none", + + trialContext: trialContext + ? `trial ends at: ${formatMs(trialContext.trialEndsAt)} | free trial ID: ${trialContext.freeTrial?.id ?? "none"} | appliesToBilling: ${trialContext.appliesToBilling}` + : "none", }, }, }); diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index 7990d7059..60a609ab8 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -15,6 +15,7 @@ import { setupAttachCheckoutMode } from "./setupAttachCheckoutMode"; import { setupAttachEndOfCycleMs } from "./setupAttachEndOfCycleMs"; import { setupAttachProductContext } from "./setupAttachProductContext"; import { setupAttachTransitionContext } from "./setupAttachTransitionContext"; +import { setupAttachTrialContext } from "./setupAttachTrialContext"; /** * Assembles the full billing context for attaching a product. @@ -70,22 +71,36 @@ export const setupAttachBillingContext = async ({ // Timestamp context const currentEpochMs = testClockFrozenTime ?? Date.now(); - const billingCycleAnchorMs = setupBillingCycleAnchor({ + + // Setup trial context + const trialContext = await setupAttachTrialContext({ + ctx, + params, + currentContext: { + fullCustomer, + attachProduct, + stripeSubscription, + currentEpochMs, + currentCustomerProduct, + }, + }); + + let billingCycleAnchorMs = setupBillingCycleAnchor({ stripeSubscription, customerProduct: currentCustomerProduct, newFullProduct: attachProduct, - trialContext: undefined, + trialContext, currentEpochMs, }); - // if (trialContext?.trialEndsAt) { - // // 4. Trial ends at overrides reset cycle anchor - // billingCycleAnchorMs = trialContext.trialEndsAt; - // } + // Trial ends at overrides billing cycle anchor + if (trialContext?.trialEndsAt) { + billingCycleAnchorMs = trialContext.trialEndsAt; + } const resetCycleAnchorMs = setupResetCycleAnchor({ billingCycleAnchorMs, - customerProduct: currentCustomerProduct, + customerProduct: undefined, // don't pass in current customer product here (paid products should have the reset cycle anchor correctly...) newFullProduct: attachProduct, }); @@ -101,6 +116,7 @@ export const setupAttachBillingContext = async ({ redirectMode: params.redirect_mode, attachProduct, stripeSubscription, + trialContext, }); return { @@ -131,6 +147,7 @@ export const setupAttachBillingContext = async ({ customPrices, customEnts, isCustom, + trialContext, billingVersion: BillingVersion.V2, }; diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachCheckoutMode.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachCheckoutMode.ts index 71b15b6fb..4f9b58e4f 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachCheckoutMode.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachCheckoutMode.ts @@ -1,4 +1,4 @@ -import type { CheckoutMode } from "@autumn/shared"; +import type { CheckoutMode, TrialContext } from "@autumn/shared"; import { type FullCusProduct, type FullProduct, @@ -18,12 +18,14 @@ export const setupAttachCheckoutMode = ({ attachProduct, currentCustomerProduct, stripeSubscription, + trialContext, }: { paymentMethod?: Stripe.PaymentMethod; currentCustomerProduct?: FullCusProduct; redirectMode?: RedirectMode; attachProduct: FullProduct; stripeSubscription?: Stripe.Subscription; + trialContext?: TrialContext; }): CheckoutMode => { const hasPaymentMethod = !!paymentMethod; const hasExistingSubscription = !!stripeSubscription; @@ -39,8 +41,13 @@ export const setupAttachCheckoutMode = ({ if (productIsOneOff) return "stripe_checkout"; - if (!hasExistingSubscription && productIsPaidRecurring) + if (!hasExistingSubscription && productIsPaidRecurring) { + // If trial no card required, direct billing + if (trialContext?.trialEndsAt && trialContext?.cardRequired === false) { + return null; + } return "stripe_checkout"; + } return null; }; diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext.ts index d09e9760e..e774c4740 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext.ts @@ -1,3 +1,4 @@ +import type { PlanTiming } from "@autumn/shared"; import { cusProductToPrices, type FullCustomer, @@ -7,7 +8,6 @@ import { isOneOffProduct, isProductUpgrade, } from "@autumn/shared"; -import type { PlanTiming } from "@autumn/shared"; /** * Sets up the transition context for attaching a product. diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachTrialContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachTrialContext.ts new file mode 100644 index 000000000..ff2d26bc3 --- /dev/null +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachTrialContext.ts @@ -0,0 +1,79 @@ +import type { + AttachParamsV0, + FullCusProduct, + FullCustomer, + FullProduct, + TrialContext, +} from "@autumn/shared"; +import { isProductPaidAndRecurring } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { + applyProductTrialConfig, + handleFreeTrialParam, + inheritTrialFromSubscription, +} from "@/internal/billing/v2/setup/trialContext"; +import { isAttachUpgrade } from "../utils/isAttachUpgrade"; + +/** + * Sets up trial context for attach operations. + * + * Logic: + * 1. If free_trial param passed → Use it (null removes trial, value sets fresh trial) + * 2. If NOT upgrade AND stripeSubscription exists → Inherit from subscription + * 3. Otherwise (upgrade OR fresh attach) → Apply product's trial config with dedup + */ +export const setupAttachTrialContext = async ({ + ctx, + params, + currentContext, +}: { + ctx: AutumnContext; + params: AttachParamsV0; + currentContext: { + fullCustomer: FullCustomer; + attachProduct: FullProduct; + stripeSubscription?: Stripe.Subscription; + currentEpochMs: number; + currentCustomerProduct?: FullCusProduct; + }; +}): Promise => { + const { + fullCustomer, + attachProduct, + stripeSubscription, + currentEpochMs, + currentCustomerProduct, + } = currentContext; + + // Handle explicit free_trial param (null or value) + if (params.free_trial !== undefined) { + return handleFreeTrialParam({ + freeTrialParams: params.free_trial, + stripeSubscription, + fullProduct: attachProduct, + currentEpochMs, + }); + } + + const newProductIsPaidRecurring = isProductPaidAndRecurring(attachProduct); + + // Determine if this is an upgrade + const isUpgrade = isAttachUpgrade({ + currentCustomerProduct, + attachProduct, + }); + + // Inherit from subscription (merge/downgrade - NOT upgrade) + if (newProductIsPaidRecurring && stripeSubscription && !isUpgrade) { + return inheritTrialFromSubscription({ stripeSubscription }); + } + + // Apply product's trial config (upgrade or fresh attach) with dedup check + return applyProductTrialConfig({ + ctx, + fullProduct: attachProduct, + fullCustomer, + currentEpochMs, + }); +}; diff --git a/server/src/internal/billing/v2/actions/attach/utils/isAttachUpgrade.ts b/server/src/internal/billing/v2/actions/attach/utils/isAttachUpgrade.ts new file mode 100644 index 000000000..7845aa84b --- /dev/null +++ b/server/src/internal/billing/v2/actions/attach/utils/isAttachUpgrade.ts @@ -0,0 +1,31 @@ +import { + cusProductToPrices, + type FullCusProduct, + type FullProduct, + isProductUpgrade, +} from "@autumn/shared"; + +/** + * Determines if attaching a product is an upgrade from the current product. + * An upgrade occurs when the new product has a higher price or longer billing interval. + */ +export const isAttachUpgrade = ({ + currentCustomerProduct, + attachProduct, +}: { + currentCustomerProduct?: FullCusProduct; + attachProduct: FullProduct; +}): boolean => { + if (!currentCustomerProduct) { + return false; + } + + const currentPrices = cusProductToPrices({ + cusProduct: currentCustomerProduct, + }); + + return isProductUpgrade({ + prices1: currentPrices, + prices2: attachProduct.prices, + }); +}; diff --git a/server/src/internal/billing/v2/actions/legacy/setup/attachParamsToLegacyBillingContext.ts b/server/src/internal/billing/v2/actions/legacy/setup/attachParamsToLegacyBillingContext.ts index 87ad2011d..c2f9ac007 100644 --- a/server/src/internal/billing/v2/actions/legacy/setup/attachParamsToLegacyBillingContext.ts +++ b/server/src/internal/billing/v2/actions/legacy/setup/attachParamsToLegacyBillingContext.ts @@ -11,9 +11,9 @@ import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions/ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { setupAttachEndOfCycleMs } from "@/internal/billing/v2/actions/attach/setup/setupAttachEndOfCycleMs"; import { setupUpgradeDowngradeBillingContext } from "@/internal/billing/v2/actions/legacy/setup/setupUpgradeBillingContext"; +import { setupUpdateSubscriptionTrialContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext"; import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling"; import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionScheduleForBilling"; -import { setupTrialContext } from "@/internal/billing/v2/setup/setupTrialContext"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams"; export const attachParamsToAttachBillingContext = async ({ @@ -85,7 +85,7 @@ export const attachParamsToAttachBillingContext = async ({ const paramsFreeTrial = attachParams.freeTrial; let trialContext: TrialContext | undefined; if (paramsFreeTrial && !attachParams.config?.disableTrial) { - trialContext = setupTrialContext({ + trialContext = setupUpdateSubscriptionTrialContext({ stripeSubscription, customerProduct: currentCustomerProduct, currentEpochMs, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts index 0565d1956..81ed08eb6 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts @@ -1,15 +1,16 @@ import { - filterUnchangedPricesFromLineItems, + type AutumnBillingPlan, isCustomerProductOneOff, + type UpdateSubscriptionBillingContext, type UpdateSubscriptionV0Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { UpdateSubscriptionBillingContext } from "@autumn/shared"; -import { buildSharedSubscriptionTrialLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems"; -import { filterLineItemsForTrialTransition } from "@/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition"; -import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems"; -import type { AutumnBillingPlan } from "@autumn/shared"; +import { finalizeLineItems } from "@/internal/billing/v2/compute/finalize/finalizeLineItems"; +/** + * Finalizes the update subscription billing plan by processing line items + * and applying update-subscription-specific guards. + */ export const finalizeUpdateSubscriptionPlan = ({ ctx, plan, @@ -21,35 +22,15 @@ export const finalizeUpdateSubscriptionPlan = ({ billingContext: UpdateSubscriptionBillingContext; params: UpdateSubscriptionV0Params; }): AutumnBillingPlan => { - // Filter line items based on trial state transitions - plan.lineItems = filterLineItemsForTrialTransition({ + // Finalize line items (shared logic) + plan.lineItems = finalizeLineItems({ ctx, lineItems: plan.lineItems ?? [], billingContext, - }); - - // Filter out unchanged prices (refund + charge pairs that cancel out) - plan.lineItems = filterUnchangedPricesFromLineItems({ - lineItems: plan.lineItems, - }); - - // Add line items for sibling products affected by trial state changes - const sharedTrialLineItems = buildSharedSubscriptionTrialLineItems({ - ctx, - billingContext, autumnBillingPlan: plan, }); - plan.lineItems = [...plan.lineItems, ...sharedTrialLineItems]; - // Apply discounts - if (billingContext.stripeDiscounts?.length) { - plan.lineItems = applyStripeDiscountsToLineItems({ - lineItems: plan.lineItems, - discounts: billingContext.stripeDiscounts, - }); - } - - // Guard: if current customer product is one off, make sure there are no line items. + // Guard: if current customer product is one off, make sure there are no line items if (isCustomerProductOneOff(billingContext.customerProduct)) { plan.lineItems = []; } diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index ab48a6e89..99e4a9601 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -14,7 +14,7 @@ import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setup import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext"; import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext"; import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor"; -import { setupTrialContext } from "@/internal/billing/v2/setup/setupTrialContext"; +import { setupUpdateSubscriptionTrialContext } from "./setupUpdateSubscriptionTrialContext"; /** * Fetch the context for updating a subscription @@ -64,7 +64,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ const currentEpochMs = testClockFrozenTime ?? Date.now(); // 1. Setup trial context first - const trialContext = setupTrialContext({ + const trialContext = setupUpdateSubscriptionTrialContext({ stripeSubscription, customerProduct, currentEpochMs, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext.ts new file mode 100644 index 000000000..50d00898b --- /dev/null +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext.ts @@ -0,0 +1,59 @@ +import type { + BillingParamsBase, + FullCusProduct, + FullProduct, + TrialContext, +} from "@autumn/shared"; +import { isProductPaidAndRecurring } from "@autumn/shared"; +import type Stripe from "stripe"; +import { + handleFreeTrialParam, + inheritTrialFromCustomerProduct, + inheritTrialFromSubscription, +} from "@/internal/billing/v2/setup/trialContext"; + +/** + * Sets up trial context for update subscription operations. + * + * Logic: + * 1. If free_trial param passed → Use it (null removes trial, value sets fresh trial) + * 2. If paid product with trialing subscription → Inherit from subscription + * 3. If customer product is trialing (free product case) → Inherit from customer product + * 4. Otherwise → No trial context + */ +export const setupUpdateSubscriptionTrialContext = ({ + stripeSubscription, + customerProduct, + currentEpochMs, + params, + fullProduct, +}: { + stripeSubscription?: Stripe.Subscription; + customerProduct?: FullCusProduct; + currentEpochMs: number; + fullProduct: FullProduct; + params: BillingParamsBase; +}): TrialContext | undefined => { + // Handle explicit free_trial param (null or value) + if (params.free_trial !== undefined) { + return handleFreeTrialParam({ + freeTrialParams: params.free_trial, + stripeSubscription, + customerProduct, + fullProduct, + currentEpochMs, + }); + } + + // Inherit from stripe subscription (paid product case) + if (isProductPaidAndRecurring(fullProduct) && stripeSubscription) { + return inheritTrialFromSubscription({ stripeSubscription }); + } + + // Inherit from customer product (free product case) + if (customerProduct) { + return inheritTrialFromCustomerProduct({ customerProduct, currentEpochMs }); + } + + return undefined; +}; diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts index 0dd200161..4d4796c8e 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts @@ -1,9 +1,13 @@ -import { cp, type FullCusProduct, type LineItem } from "@autumn/shared"; +import type { AutumnBillingPlan } from "@autumn/shared"; +import { + type BillingContext, + cp, + type FullCusProduct, + type LineItem, +} from "@autumn/shared"; import chalk from "chalk"; import type { Logger } from "@/external/logtail/logtailUtils"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { UpdateSubscriptionBillingContext } from "@autumn/shared"; -import type { AutumnBillingPlan } from "@autumn/shared"; import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition"; import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct"; import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToLineItems"; @@ -100,11 +104,11 @@ export const buildSharedSubscriptionTrialLineItems = ({ autumnBillingPlan, }: { ctx: AutumnContext; - billingContext: UpdateSubscriptionBillingContext; + billingContext: BillingContext; autumnBillingPlan: AutumnBillingPlan; }): LineItem[] => { const { logger } = ctx; - const { fullCustomer, stripeSubscription } = billingContext; + const { fullCustomer, stripeSubscription, trialContext } = billingContext; if (!stripeSubscription) return []; @@ -120,6 +124,7 @@ export const buildSharedSubscriptionTrialLineItems = ({ direction = "refund"; // Starting trial → refund sibling products } + if (!trialContext?.appliesToBilling) return []; if (!direction) return []; const siblingCustomerProducts = getSiblingCustomerProducts({ diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts index c8dde3872..5da13846c 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts @@ -1,8 +1,8 @@ import type { + BillingParamsBase, EntitlementWithFeature, FeatureOptions, Price, - UpdateSubscriptionV0Params, } from "@autumn/shared"; import { notNullish, roundUsageToNearestBillingUnit } from "@autumn/shared"; import { Decimal } from "decimal.js"; @@ -12,7 +12,7 @@ export const paramsToFeatureOptions = ({ price, entitlement, }: { - params: UpdateSubscriptionV0Params; + params: BillingParamsBase; price: Price; entitlement: EntitlementWithFeature; }): FeatureOptions | undefined => { diff --git a/server/src/internal/billing/v2/compute/finalize/finalizeLineItems.ts b/server/src/internal/billing/v2/compute/finalize/finalizeLineItems.ts new file mode 100644 index 000000000..30f472de4 --- /dev/null +++ b/server/src/internal/billing/v2/compute/finalize/finalizeLineItems.ts @@ -0,0 +1,59 @@ +import { + type AutumnBillingPlan, + type BillingContext, + filterUnchangedPricesFromLineItems, + type LineItem, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildSharedSubscriptionTrialLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems"; +import { filterLineItemsForTrialTransition } from "@/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition"; +import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems"; + +/** + * Finalizes line items for a billing plan by: + * 1. Filtering line items based on trial state transitions + * 2. Filtering out unchanged prices (refund + charge pairs that cancel out) + * 3. Adding line items for sibling products affected by trial state changes + * 4. Applying Stripe discounts to line items + */ +export const finalizeLineItems = ({ + ctx, + lineItems, + billingContext, + autumnBillingPlan, +}: { + ctx: AutumnContext; + lineItems: LineItem[]; + billingContext: BillingContext; + autumnBillingPlan: AutumnBillingPlan; +}): LineItem[] => { + // 1. Filter line items based on trial state transitions + let finalizedLineItems = filterLineItemsForTrialTransition({ + ctx, + lineItems, + billingContext, + }); + + // 2. Filter out unchanged prices (refund + charge pairs that cancel out) + finalizedLineItems = filterUnchangedPricesFromLineItems({ + lineItems: finalizedLineItems, + }); + + // 3. Add line items for sibling products affected by trial state changes + const sharedTrialLineItems = buildSharedSubscriptionTrialLineItems({ + ctx, + billingContext, + autumnBillingPlan, + }); + finalizedLineItems = [...finalizedLineItems, ...sharedTrialLineItems]; + + // 4. Apply Stripe discounts if present + if (billingContext.stripeDiscounts?.length) { + finalizedLineItems = applyStripeDiscountsToLineItems({ + lineItems: finalizedLineItems, + discounts: billingContext.stripeDiscounts, + }); + } + + return finalizedLineItems; +}; diff --git a/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts b/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts index 401c305c1..42617f849 100644 --- a/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts +++ b/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts @@ -1,11 +1,11 @@ import { + type BillingParamsBase, cusProductToConvertedFeatureOptions, type FeatureOptions, type FullCusProduct, type FullProduct, isPrepaidPrice, priceToEnt, - type UpdateSubscriptionV0Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions"; @@ -15,6 +15,7 @@ import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAut * For each prepaid price, uses new quantity from params or falls back to existing subscription. */ export const setupFeatureQuantitiesContext = ({ + // biome-ignore lint/correctness/noUnusedFunctionParameters: Might be used in the future ctx, featureQuantitiesParams, fullProduct, @@ -22,7 +23,7 @@ export const setupFeatureQuantitiesContext = ({ initializeUndefinedQuantities = false, }: { ctx: AutumnContext; - featureQuantitiesParams: UpdateSubscriptionV0Params; + featureQuantitiesParams: BillingParamsBase; fullProduct: FullProduct; currentCustomerProduct?: FullCusProduct; initializeUndefinedQuantities?: boolean; diff --git a/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts b/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts index 6e0379239..699f657fc 100644 --- a/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts +++ b/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts @@ -1,9 +1,12 @@ -import type { UpdateSubscriptionV0Params } from "@autumn/shared"; +import type { + AttachParamsV0, + UpdateSubscriptionV0Params, +} from "@autumn/shared"; export const setupInvoiceModeContext = ({ params, }: { - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV0Params | AttachParamsV0; }) => { if (params?.invoice !== true) { return undefined; diff --git a/server/src/internal/billing/v2/setup/setupTrialContext.ts b/server/src/internal/billing/v2/setup/setupTrialContext.ts deleted file mode 100644 index 8799511ae..000000000 --- a/server/src/internal/billing/v2/setup/setupTrialContext.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { - FreeTrial, - FullCusProduct, - FullProduct, - TrialContext, -} from "@autumn/shared"; -import { - addDuration, - isCustomerProductTrialing, - isProductPaidAndRecurring, - secondsToMs, -} from "@autumn/shared"; -import type Stripe from "stripe"; -import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; -import { initFreeTrial } from "@/internal/products/free-trials/initFreeTrial"; - -export const setupTrialContext = ({ - stripeSubscription, - customerProduct, - currentEpochMs, - params, - fullProduct, -}: { - stripeSubscription?: Stripe.Subscription; - customerProduct?: FullCusProduct; - currentEpochMs: number; - params: { free_trial: FreeTrial | null }; - fullProduct: FullProduct; -}): TrialContext | undefined => { - const freeTrialParams = params.free_trial; - const newProductIsPaidRecurring = isProductPaidAndRecurring(fullProduct); - - // Case 1: If free trial is null (removing free trial) - if (freeTrialParams === null) { - // If currently trialing, then return this object, if not don't return anything - - if ( - isStripeSubscriptionTrialing(stripeSubscription) || - isCustomerProductTrialing(customerProduct, { nowMs: currentEpochMs }) - ) { - return { - freeTrial: null, - trialEndsAt: null, - appliesToBilling: newProductIsPaidRecurring, - cardRequired: true, - }; - } else { - return undefined; - } - // return { freeTrial: null, trialEndsAt: null }; - } - - // Case 2: If free trial params are passed in - if (freeTrialParams) { - const dbFreeTrial = initFreeTrial({ - freeTrialParams, - internalProductId: fullProduct.internal_id, - }); - - const trialEndsAt = addDuration({ - now: currentEpochMs, - durationType: dbFreeTrial.duration, - durationLength: dbFreeTrial.length, - }); - - return { - freeTrial: dbFreeTrial, - trialEndsAt, - customFreeTrial: dbFreeTrial, - appliesToBilling: newProductIsPaidRecurring, - cardRequired: dbFreeTrial.card_required, - }; - } - - // Case 3: If new product is paid and recurring - if (isProductPaidAndRecurring(fullProduct)) { - if ( - stripeSubscription && - isStripeSubscriptionTrialing(stripeSubscription) - ) { - const trialEndsAt = secondsToMs( - stripeSubscription.trial_end ?? undefined, - ); - - return { - freeTrial: null, - trialEndsAt: trialEndsAt, - appliesToBilling: newProductIsPaidRecurring, - cardRequired: true, - }; - } else { - return undefined; - } - } - - // Case 4: Return free trial / trial ends at from current customer product - if ( - customerProduct && - isCustomerProductTrialing(customerProduct, { nowMs: currentEpochMs }) - ) { - return { - freeTrial: customerProduct.free_trial, // can be undefined... - trialEndsAt: customerProduct.trial_ends_at ?? null, - appliesToBilling: false, - cardRequired: true, - }; - } - - return undefined; -}; diff --git a/server/src/internal/billing/v2/setup/trialContext/applyProductTrialConfig.ts b/server/src/internal/billing/v2/setup/trialContext/applyProductTrialConfig.ts new file mode 100644 index 000000000..b2a611186 --- /dev/null +++ b/server/src/internal/billing/v2/setup/trialContext/applyProductTrialConfig.ts @@ -0,0 +1,75 @@ +import type { FullCustomer, FullProduct, TrialContext } from "@autumn/shared"; +import { addDuration, isProductPaidAndRecurring } from "@autumn/shared"; +import type Stripe from "stripe"; +import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { getFreeTrialAfterFingerprint } from "@/internal/products/free-trials/freeTrialUtils"; + +/** + * Applies the product's trial configuration with deduplication check. + * Used for upgrades and fresh attaches. + * + * Deduplication is skipped if: + * - org.config.multiple_trials is true + * - Product's free_trial.unique_fingerprint is false + * + * Returns undefined if product has no trial config or dedup check fails. + */ +export const applyProductTrialConfig = async ({ + ctx, + fullProduct, + fullCustomer, + stripeSubscription, + currentEpochMs, +}: { + ctx: AutumnContext; + fullProduct: FullProduct; + fullCustomer: FullCustomer; + stripeSubscription?: Stripe.Subscription; + currentEpochMs: number; +}): Promise => { + const newProductIsPaidRecurring = isProductPaidAndRecurring(fullProduct); + + if (!fullProduct.free_trial) { + const isCurrentlyTrialing = + isStripeSubscriptionTrialing(stripeSubscription); + + if (isCurrentlyTrialing) { + return { + freeTrial: null, + trialEndsAt: null, + appliesToBilling: newProductIsPaidRecurring, + cardRequired: false, + }; + } + return undefined; + } + + const multipleTrialsAllowed = ctx.org.config?.multiple_trials ?? false; + + const freeTrial = await getFreeTrialAfterFingerprint({ + db: ctx.db, + freeTrial: fullProduct.free_trial, + productId: fullProduct.id, + fingerprint: fullCustomer.fingerprint, + internalCustomerId: fullCustomer.internal_id, + multipleAllowed: multipleTrialsAllowed, + }); + + if (!freeTrial) { + return undefined; + } + + const trialEndsAt = addDuration({ + now: currentEpochMs, + durationType: freeTrial.duration, + durationLength: freeTrial.length, + }); + + return { + freeTrial, + trialEndsAt, + appliesToBilling: newProductIsPaidRecurring, + cardRequired: freeTrial.card_required, + }; +}; diff --git a/server/src/internal/billing/v2/setup/trialContext/handleFreeTrialParam.ts b/server/src/internal/billing/v2/setup/trialContext/handleFreeTrialParam.ts new file mode 100644 index 000000000..269fa2c3e --- /dev/null +++ b/server/src/internal/billing/v2/setup/trialContext/handleFreeTrialParam.ts @@ -0,0 +1,69 @@ +import type { FullCusProduct, FullProduct, TrialContext } from "@autumn/shared"; +import { + addDuration, + isCustomerProductTrialing, + isProductPaidAndRecurring, +} from "@autumn/shared"; +import type { FreeTrialParamsV0 } from "@shared/api/billing/common/freeTrial/freeTrialParamsV0"; +import type Stripe from "stripe"; +import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; +import { initFreeTrial } from "@/internal/products/free-trials/initFreeTrial"; + +/** + * Handles explicit free_trial parameter passed to attach/update subscription. + * + * - `free_trial: null` → Removes trial if currently trialing + * - `free_trial: { length, duration }` → Starts fresh trial (bypasses dedup) + */ +export const handleFreeTrialParam = ({ + freeTrialParams, + stripeSubscription, + customerProduct, + fullProduct, + currentEpochMs, +}: { + freeTrialParams: FreeTrialParamsV0 | null; + stripeSubscription?: Stripe.Subscription; + customerProduct?: FullCusProduct; + fullProduct: FullProduct; + currentEpochMs: number; +}): TrialContext | undefined => { + const newProductIsPaidRecurring = isProductPaidAndRecurring(fullProduct); + + // free_trial: null → Remove trial if currently trialing + if (freeTrialParams === null) { + const isCurrentlyTrialing = + isStripeSubscriptionTrialing(stripeSubscription) || + isCustomerProductTrialing(customerProduct, { nowMs: currentEpochMs }); + + if (isCurrentlyTrialing) { + return { + freeTrial: null, + trialEndsAt: null, + appliesToBilling: newProductIsPaidRecurring, + cardRequired: true, + }; + } + return undefined; + } + + // free_trial: { length, duration } → Fresh trial + const dbFreeTrial = initFreeTrial({ + freeTrialParams, + internalProductId: fullProduct.internal_id, + }); + + const trialEndsAt = addDuration({ + now: currentEpochMs, + durationType: dbFreeTrial.duration, + durationLength: dbFreeTrial.length, + }); + + return { + freeTrial: dbFreeTrial, + trialEndsAt, + customFreeTrial: dbFreeTrial, + appliesToBilling: newProductIsPaidRecurring, + cardRequired: dbFreeTrial.card_required, + }; +}; diff --git a/server/src/internal/billing/v2/setup/trialContext/index.ts b/server/src/internal/billing/v2/setup/trialContext/index.ts new file mode 100644 index 000000000..9fa8e5f9b --- /dev/null +++ b/server/src/internal/billing/v2/setup/trialContext/index.ts @@ -0,0 +1,4 @@ +export { applyProductTrialConfig } from "./applyProductTrialConfig"; +export { handleFreeTrialParam } from "./handleFreeTrialParam"; +export { inheritTrialFromCustomerProduct } from "./inheritTrialFromCustomerProduct"; +export { inheritTrialFromSubscription } from "./inheritTrialFromSubscription"; diff --git a/server/src/internal/billing/v2/setup/trialContext/inheritTrialFromCustomerProduct.ts b/server/src/internal/billing/v2/setup/trialContext/inheritTrialFromCustomerProduct.ts new file mode 100644 index 000000000..bd2692313 --- /dev/null +++ b/server/src/internal/billing/v2/setup/trialContext/inheritTrialFromCustomerProduct.ts @@ -0,0 +1,27 @@ +import type { FullCusProduct, TrialContext } from "@autumn/shared"; +import { isCustomerProductTrialing } from "@autumn/shared"; + +/** + * Inherits trial state from an existing customer product. + * Used by update subscription for free products (no Stripe subscription). + * + * Returns undefined if customer product is not trialing. + */ +export const inheritTrialFromCustomerProduct = ({ + customerProduct, + currentEpochMs, +}: { + customerProduct: FullCusProduct; + currentEpochMs: number; +}): TrialContext | undefined => { + if (!isCustomerProductTrialing(customerProduct, { nowMs: currentEpochMs })) { + return undefined; + } + + return { + freeTrial: customerProduct.free_trial, + trialEndsAt: customerProduct.trial_ends_at ?? null, + appliesToBilling: false, + cardRequired: true, + }; +}; diff --git a/server/src/internal/billing/v2/setup/trialContext/inheritTrialFromSubscription.ts b/server/src/internal/billing/v2/setup/trialContext/inheritTrialFromSubscription.ts new file mode 100644 index 000000000..cbe3d2e1e --- /dev/null +++ b/server/src/internal/billing/v2/setup/trialContext/inheritTrialFromSubscription.ts @@ -0,0 +1,29 @@ +import type { TrialContext } from "@autumn/shared"; +import { secondsToMs } from "@autumn/shared"; +import type Stripe from "stripe"; +import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; + +/** + * Inherits trial state from an existing Stripe subscription. + * Used when merging into or downgrading from a trialing subscription. + * + * Returns undefined if subscription is not trialing. + */ +export const inheritTrialFromSubscription = ({ + stripeSubscription, +}: { + stripeSubscription: Stripe.Subscription; +}): TrialContext | undefined => { + if (!isStripeSubscriptionTrialing(stripeSubscription)) { + return undefined; + } + + const trialEndsAt = secondsToMs(stripeSubscription.trial_end ?? undefined); + + return { + freeTrial: null, + trialEndsAt, + appliesToBilling: true, // Always true - we only call this for paid recurring products + cardRequired: true, + }; +}; diff --git a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts index e43306885..8a4f735bd 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts @@ -1,4 +1,8 @@ -import type { BillingContext, BillingPlan } from "@autumn/shared"; +import type { + BillingContext, + BillingPlan, + FullCusProduct, +} from "@autumn/shared"; import { type BillingPreviewResponse, cp, @@ -13,6 +17,20 @@ import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils import { customerProductToLineItems } from "../lineItems/customerProductToLineItems"; import { lineItemToPreviewLineItem } from "../lineItems/lineItemToPreviewLineItem"; +export type NextCyclePreviewDebug = { + allCustomerProducts: FullCusProduct[]; + currentCustomerProducts: FullCusProduct[]; + smallestInterval: { interval: string; intervalCount: number } | null; + anchorMs: number; + nextCycleStart: number | null; + filteredCustomerProducts: FullCusProduct[]; +}; + +export type NextCyclePreviewResult = { + nextCycle: BillingPreviewResponse["next_cycle"]; + debug: NextCyclePreviewDebug; +}; + export const billingPlanToNextCyclePreview = ({ ctx, billingContext, @@ -21,7 +39,7 @@ export const billingPlanToNextCyclePreview = ({ ctx: AutumnContext; billingContext: BillingContext; billingPlan: BillingPlan; -}): BillingPreviewResponse["next_cycle"] => { +}): NextCyclePreviewResult => { const { billingCycleAnchorMs } = billingContext; const updatedCustomerProduct = billingPlanToUpdatedCustomerProduct({ @@ -35,7 +53,7 @@ export const billingPlanToNextCyclePreview = ({ ...(updatedCustomerProduct ? [updatedCustomerProduct] : []), ]; - let customerProducts = allCustomerProducts.filter( + const customerProducts = allCustomerProducts.filter( (customerProduct) => cp(customerProduct).paid().recurring().hasRelevantStatus().valid, ); @@ -45,11 +63,6 @@ export const billingPlanToNextCyclePreview = ({ cp(customerProduct).paid().recurring().hasActiveStatus().valid, ); - console.log( - "currentCustomerProducts", - currentCustomerProducts.map((cp) => cp.product.name), - ); - const currentPrices = cusProductsToPrices({ cusProducts: currentCustomerProducts, filters: { excludeOneOffPrices: true }, @@ -57,18 +70,28 @@ export const billingPlanToNextCyclePreview = ({ const smallestInterval = getSmallestInterval({ prices: currentPrices }); - console.log("smallestInterval", smallestInterval); - - // Return undefined if there's no recurring interval (not a subscription) - if (!smallestInterval) return undefined; - - // Calculate next cycle start - // If billing cycle anchor is "now" (new subscription), calculate from current time + // Calculate anchor const anchorMs = billingCycleAnchorMs === "now" ? billingContext.currentEpochMs : billingCycleAnchorMs; + const baseDebug = { + allCustomerProducts, + currentCustomerProducts, + smallestInterval, + anchorMs, + }; + + // Return undefined if there's no recurring interval (not a subscription) + if (!smallestInterval) { + return { + nextCycle: undefined, + debug: { ...baseDebug, nextCycleStart: null, filteredCustomerProducts: [] }, + }; + } + + // Calculate next cycle start const nextCycleStart = getCycleEnd({ anchor: anchorMs, interval: smallestInterval.interval, @@ -77,15 +100,22 @@ export const billingPlanToNextCyclePreview = ({ floor: anchorMs, }); - customerProducts = customerProducts.filter((customerProduct) => { - return !hasCustomerProductEnded(customerProduct, { - nowMs: nextCycleStart, - }); - }); + const filteredCustomerProducts = customerProducts.filter( + (customerProduct) => { + return !hasCustomerProductEnded(customerProduct, { + nowMs: nextCycleStart, + }); + }, + ); - if (customerProducts.length === 0) return undefined; + if (filteredCustomerProducts.length === 0) { + return { + nextCycle: undefined, + debug: { ...baseDebug, nextCycleStart, filteredCustomerProducts }, + }; + } - const autumnLineItems = customerProducts.flatMap((customerProduct) => + const autumnLineItems = filteredCustomerProducts.flatMap((customerProduct) => customerProductToLineItems({ ctx, customerProduct: customerProduct, @@ -101,8 +131,11 @@ export const billingPlanToNextCyclePreview = ({ const total = sumValues(autumnLineItems.map((line) => line.finalAmount)); return { - starts_at: nextCycleStart, - total, - line_items: previewLineItems, + nextCycle: { + starts_at: nextCycleStart, + total, + line_items: previewLineItems, + }, + debug: { ...baseDebug, nextCycleStart, filteredCustomerProducts }, }; }; diff --git a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts index 848c8d0bf..85ab308a6 100644 --- a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts +++ b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts @@ -8,6 +8,7 @@ import { Decimal } from "decimal.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { billingPlanToNextCyclePreview } from "./billingPlan/billingPlanToNextCyclePreview"; import { lineItemToPreviewLineItem } from "./lineItems/lineItemToPreviewLineItem"; +import { logBillingPreview } from "./logs/logBillingPreview"; export const billingPlanToPreviewResponse = ({ ctx, @@ -21,9 +22,9 @@ export const billingPlanToPreviewResponse = ({ const { fullCustomer } = billingContext; const autumnBillingPlan = billingPlan.autumn; - const planLineItems = autumnBillingPlan.lineItems ?? []; + const allLineItems = autumnBillingPlan.lineItems ?? []; - const immediateLineItems = planLineItems.filter( + const immediateLineItems = allLineItems.filter( (line) => line.chargeImmediately, ); @@ -40,12 +41,22 @@ export const billingPlanToPreviewResponse = ({ const currency = orgToCurrency({ org: ctx.org }); // Get next cycle object - const nextCycle = billingPlanToNextCyclePreview({ + const { nextCycle, debug: nextCycleDebug } = billingPlanToNextCyclePreview({ ctx, billingContext, billingPlan, }); + logBillingPreview({ + ctx, + allLineItems, + immediateLineItems, + total, + currency, + nextCycleDebug, + nextCycle, + }); + // Extract billing period from first line item with a billing period const firstLineWithPeriod = immediateLineItems.find( (line) => line.context.billingPeriod, diff --git a/server/src/internal/billing/v2/utils/logs/logBillingPreview.ts b/server/src/internal/billing/v2/utils/logs/logBillingPreview.ts new file mode 100644 index 000000000..bda6fbece --- /dev/null +++ b/server/src/internal/billing/v2/utils/logs/logBillingPreview.ts @@ -0,0 +1,76 @@ +import type { + BillingPreviewResponse, + FullCusProduct, + LineItem, +} from "@autumn/shared"; +import { formatMs } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { addToExtraLogs } from "@/utils/logging/addToExtraLogs"; +import type { NextCyclePreviewDebug } from "../billingPlan/billingPlanToNextCyclePreview"; + +const formatCustomerProduct = (customerProduct: FullCusProduct) => + `${customerProduct.product.name} (${customerProduct.product_id}) [${customerProduct.status}]`; + +const formatLineItem = (item: LineItem) => + `${item.description}: ${item.finalAmount} (charge: ${item.chargeImmediately})`; + +export const logBillingPreview = ({ + ctx, + allLineItems, + immediateLineItems, + total, + currency, + nextCycleDebug, + nextCycle, +}: { + ctx: AutumnContext; + allLineItems: LineItem[]; + immediateLineItems: LineItem[]; + total: number; + currency: string; + nextCycleDebug: NextCyclePreviewDebug; + nextCycle: BillingPreviewResponse["next_cycle"]; +}) => { + const { + allCustomerProducts, + currentCustomerProducts, + smallestInterval, + anchorMs, + nextCycleStart, + filteredCustomerProducts, + } = nextCycleDebug; + + addToExtraLogs({ + ctx, + extras: { + billingPreview: { + // Immediate charge breakdown + total: `${total} ${currency}`, + allLineItems: + allLineItems.map(formatLineItem).join(" | ") || "none", + immediateLineItems: + immediateLineItems.map(formatLineItem).join(" | ") || "none", + + // Next cycle calculation + nextCycle: { + allCustomerProducts: + allCustomerProducts.map(formatCustomerProduct).join(", ") || "none", + currentCustomerProducts: + currentCustomerProducts.map(formatCustomerProduct).join(", ") || + "none", + smallestInterval: smallestInterval + ? `${smallestInterval.intervalCount} ${smallestInterval.interval}` + : "none (not a subscription)", + anchor: formatMs(anchorMs), + nextCycleStart: nextCycleStart ? formatMs(nextCycleStart) : "n/a", + filteredCustomerProducts: + filteredCustomerProducts.map(formatCustomerProduct).join(", ") || + "none", + result: nextCycle + ? `starts: ${formatMs(nextCycle.starts_at)} | total: ${nextCycle.total} | items: ${nextCycle.line_items.length}` + : "undefined", + }, + }, + }, + }); +}; diff --git a/server/src/internal/customers/actions/createWithDefaults/createCustomerContext.ts b/server/src/internal/customers/actions/createWithDefaults/createCustomerContext.ts index d45ed9c46..ced93146f 100644 --- a/server/src/internal/customers/actions/createWithDefaults/createCustomerContext.ts +++ b/server/src/internal/customers/actions/createWithDefaults/createCustomerContext.ts @@ -1,5 +1,9 @@ -import type { FullCustomer, FullProduct } from "@autumn/shared"; -import type { BillingContext, TrialContext } from "@autumn/shared"; +import type { + BillingContext, + FullCustomer, + FullProduct, + TrialContext, +} from "@autumn/shared"; export interface CreateCustomerContextFree { fullCustomer: FullCustomer; diff --git a/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomerBillingContext.ts b/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomerBillingContext.ts index e02f3b33d..bd240cc63 100644 --- a/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomerBillingContext.ts +++ b/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomerBillingContext.ts @@ -1,6 +1,6 @@ +import { type BillingContext, BillingVersion } from "@autumn/shared"; import { getOrCreateStripeCustomer } from "@/external/stripe/customers/index.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import type { BillingContext } from "@autumn/shared"; import type { CreateCustomerContext } from "../createCustomerContext.js"; /** @@ -40,5 +40,6 @@ export const setupCreateCustomerBillingContext = async ({ customPrices: [], customEnts: [], isCustom: false, + billingVersion: BillingVersion.V2, }; }; diff --git a/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomerTrialContext.ts b/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomerTrialContext.ts index dcace0702..b2db36a67 100644 --- a/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomerTrialContext.ts +++ b/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomerTrialContext.ts @@ -1,10 +1,10 @@ +import type { TrialContext } from "@autumn/shared"; import { addDuration, FreeTrialDuration, type FullProduct, InternalError, } from "@autumn/shared"; -import type { TrialContext } from "@autumn/shared"; export const setupCreateCustomerTrialContext = ({ paidProducts, diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts index 3c8a5edf0..0b2a8e635 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts @@ -10,12 +10,12 @@ import { type UpdateSubscriptionV0Params, } from "@autumn/shared"; import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.js"; +import { setupUpdateSubscriptionTrialContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext"; import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan.js"; import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.js"; import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.js"; import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult.js"; import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling.js"; -import { setupTrialContext } from "@/internal/billing/v2/setup/setupTrialContext.js"; import { billingResultToResponse } from "@/internal/billing/v2/utils/billingResult/billingResultToResponse.js"; import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan.js"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; @@ -65,7 +65,7 @@ export const handleUpdateQuantityFunction = async ({ secondsToMs(stripeSubscription?.billing_cycle_anchor) ?? "now"; // 1. Setup trial context first - const trialContext = setupTrialContext({ + const trialContext = setupUpdateSubscriptionTrialContext({ stripeSubscription, customerProduct: currentCustomerProduct, currentEpochMs, diff --git a/server/src/internal/products/free-trials/initFreeTrial.ts b/server/src/internal/products/free-trials/initFreeTrial.ts index 280fba6c3..bc6541c03 100644 --- a/server/src/internal/products/free-trials/initFreeTrial.ts +++ b/server/src/internal/products/free-trials/initFreeTrial.ts @@ -3,6 +3,7 @@ import { CreateFreeTrialSchema, type FreeTrial, } from "@autumn/shared"; +import type { FreeTrialParamsV0 } from "@shared/api/billing/common/freeTrial/freeTrialParamsV0"; import { generateId } from "@/utils/genUtils"; export const initFreeTrial = ({ @@ -10,7 +11,7 @@ export const initFreeTrial = ({ internalProductId, isCustom = false, }: { - freeTrialParams: CreateFreeTrial; + freeTrialParams: CreateFreeTrial | FreeTrialParamsV0; internalProductId: string; isCustom?: boolean; }): FreeTrial => { diff --git a/server/tests/integration/billing/attach/attachTests.md b/server/tests/integration/billing/attach/attachTests.md index 9bbcb0239..34c960720 100644 --- a/server/tests/integration/billing/attach/attachTests.md +++ b/server/tests/integration/billing/attach/attachTests.md @@ -30,7 +30,33 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit ## Key Gotchas -1. **Always use `product.id`, never string literals** +1. **Trial Invoice Count - Stripe creates $0 invoice on subscription creation** + ```typescript + // ❌ WRONG - Trial subscription DOES create an invoice + await expectCustomerInvoiceCorrect({ + customer, + count: 0, // Wrong! Trial creates $0 invoice + }); + + // ✅ RIGHT - Trial subscription creates 1 invoice with $0 total + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); + + // ✅ RIGHT - Free product (no Stripe subscription) has no invoice + await expectCustomerInvoiceCorrect({ + customer, + count: 0, // Correct for free products + }); + ``` + Rules: + - **Stripe subscription created (even trialing)**: `count: 1, latestTotal: 0` + - **Subscription updated while trialing**: Invoice count increases by 1 (still `latestTotal: 0`) + - **Free product (no Stripe subscription)**: `count: 0` is correct + +2. **Always use `product.id`, never string literals** ```typescript // ✅ GOOD s.billing.attach({ productId: pro.id }) @@ -39,26 +65,26 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit s.billing.attach({ productId: "pro" }) ``` -2. **Multiple products need unique IDs** +3. **Multiple products need unique IDs** - Without `isAddOn: true`, second product **replaces** the first ```typescript const prod1 = constructProduct({ type: "free", id: "prod1", items: [...] }); const prod2 = constructProduct({ type: "free", id: "prod2", isAddOn: true, items: [...] }); ``` -3. **Payment method required for paid features** +4. **Payment method required for paid features** ```typescript s.customer({ paymentMethod: "success" }) // Required for overage, per-seat, usage-based, base price ``` -4. **Wait 2000ms after `track` before `attach`** +5. **Wait 2000ms after `track` before `attach`** ```typescript await autumnV1.track({ ... }); await new Promise(r => setTimeout(r, 2000)); // track syncs to Postgres async await autumnV1.attach({ ... }); ``` -5. **Prepaid items require `options` with `quantity` on attach** +6. **Prepaid items require `options` with `quantity` on attach** - The `quantity` represents actual units (e.g., 100 messages), NOT number of packs - If `billingUnits: 100` and you want 1 pack, pass `quantity: 100` ```typescript @@ -69,7 +95,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit }) ``` -5b. **Prepaid `includedUsage` must be a multiple of `billingUnits` (or 0)** +6b. **Prepaid `includedUsage` must be a multiple of `billingUnits` (or 0)** - When Stripe tiered pricing is created, `up_to` = `includedUsage / billingUnits` - Stripe requires `up_to` to be a positive integer or "inf" - If this results in a decimal (e.g., 50/100=0.5), Stripe rejects it @@ -89,20 +115,20 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit }); ``` -6. **Use `products.base()` for free products** (no base price) +7. **Use `products.base()` for free products** (no base price) - `products.pro()` already includes $20/mo base price — don't add `monthlyPrice()` -7. **Lifetime interval: `null` vs `"one_off"`** +8. **Lifetime interval: `null` vs `"one_off"`** - Constructing: use `null` → `constructFeatureItem({ interval: null })` - In API responses: use `ResetInterval.OneOff` -8. **Canceling/Downgrading is NOT a status** +9. **Canceling/Downgrading is NOT a status** - Use `expectProductCanceling` helper, not `expect(product.status).toBe("canceling")` -9. **Server logs not visible in tests** - - Console logs in server code don't appear in test output +10. **Server logs not visible in tests** + - Console logs in server code don't appear in test output -10. **ALWAYS verify Stripe subscription state after billing calls** +11. **ALWAYS verify Stripe subscription state after billing calls** - After EVERY `billing.attach()` call, verify the Stripe subscription state matches Autumn - For paid products: use `expectSubToBeCorrect` - For free products (no Stripe subscription): use `expectNoStripeSubscription` @@ -167,7 +193,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit s.billing.attach({ productId: pro.id, isAddOn: true }); ``` -19. **Use `s.billing.attach()` and `autumnV1.billing.attach()` - NOT the legacy attach** +13. **Use `s.billing.attach()` and `autumnV1.billing.attach()` - NOT the legacy attach** - These tests are for the NEW billing.attach endpoint (V2 attach flow) - Never use `s.attach()` or `autumnV1.attach()` in these test files ```typescript @@ -180,7 +206,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit await autumnV1.attach({ customer_id: customerId, product_id: pro.id }) ``` -20. **For scheduled switches (downgrades), always call previewAttach first with exact `startsAt` verification** +14. **For scheduled switches (downgrades), always call previewAttach first with exact `startsAt` verification** - Preview should return `total: 0` since the change is scheduled, not immediate - Use `expectPreviewNextCycleCorrect` to verify `next_cycle.starts_at` and `next_cycle.total` - Pass the EXACT `startsAt` using `addMonths(advancedTo, 1).getTime()` - do NOT use approximates @@ -215,7 +241,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit }); ``` -21. **Do NOT create a new initScenario to advance the test clock** +15. **Do NOT create a new initScenario to advance the test clock** - WRONG: Creating a second `initScenario` with the same customerId to advance time - RIGHT: Keep downgrade attach OUT of initScenario, call it in test body, then use helpers to advance ```typescript @@ -249,7 +275,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit // B. Use advanceTestClock helper from the same ctx ``` -22. **Prepaid next_cycle.total depends on quantity passed at attach time** +16. **Prepaid next_cycle.total depends on quantity passed at attach time** - If `options: [{ quantity: 100 }]` passed → `next_cycle.total` = price for 100 units - If no options passed → inherits current product's quantity (if any), else 0 ```typescript @@ -269,7 +295,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit }); ``` -13. **Product IDs in expectations - just use `product.id`** +17. **Product IDs in expectations - just use `product.id`** - `initScenario` already prefixes product IDs with `customerId` - Don't double-prefix in expectations ```typescript @@ -280,7 +306,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit expectProductActive({ customer, productId: `${pro.id}_${customerId}` }); ``` -15. **Use `expectCustomerProducts` batch helper when checking multiple products** +18. **Use `expectCustomerProducts` batch helper when checking multiple products** - When verifying 2+ product states, use the batch helper instead of individual calls - More concise and easier to read ```typescript @@ -297,7 +323,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit await expectProductNotPresent({ customer, productId: free.id }); ``` -14. **Always pass `redirect_mode: "if_required"` to attach calls** +19. **Always pass `redirect_mode: "if_required"` to attach calls** - Prevents checkout redirect when customer already has a payment method - Without this, the endpoint may redirect to Stripe Checkout even when payment method exists ```typescript @@ -308,12 +334,12 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit }); ``` -16. **One-time products do NOT replace/expire other products** +20. **One-time products do NOT replace/expire other products** - Attaching a one-time product will NOT cancel or replace existing main products - One-time products are always treated as add-ons (they stack with existing products) - Only recurring products can replace other recurring products -17. **Set up scenario state in `initScenario`, test only the action being tested** +21. **Set up scenario state in `initScenario`, test only the action being tested** - All prerequisite state (existing products, entities, usage) should be set up in `initScenario` actions - The test body should only call the single action being tested and verify results ```typescript @@ -338,7 +364,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit await autumnV1.billing.attach({ customer_id: customerId, product_id: oneOff.id }); ``` -18. **Scheduled-switch tests must advance test clock with `advanceToNextInvoice()`** +22. **Scheduled-switch tests must advance test clock with `advanceToNextInvoice()`** - After scheduling a downgrade, advance the test clock to verify: - A. Next cycle invoice is correct - B. Products on customer are correct after cycle diff --git a/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-trial.test.ts b/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-trial.test.ts index eda7cbbac..9d0fef9b0 100644 --- a/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-trial.test.ts +++ b/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-trial.test.ts @@ -101,10 +101,11 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: trial card required")}`, usage: 0, }); - // Verify no invoice yet (trial) + // Verify $0 invoice for trial (Stripe creates invoice for trial subscriptions) await expectCustomerInvoiceCorrect({ customer, - count: 0, + count: 1, + latestTotal: 0, }); }); @@ -183,9 +184,10 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: trial subscription_data" usage: 0, }); - // Verify no invoice yet (trial) + // Verify $0 invoice for trial (Stripe creates invoice for trial subscriptions) await expectCustomerInvoiceCorrect({ customer, - count: 0, + count: 1, + latestTotal: 0, }); }); diff --git a/server/tests/integration/billing/attach/free-trial/override/trial-override-basic.test.ts b/server/tests/integration/billing/attach/free-trial/override/trial-override-basic.test.ts new file mode 100644 index 000000000..1c4e791f3 --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/override/trial-override-basic.test.ts @@ -0,0 +1,336 @@ +/** + * Free Trial Override Basic Tests (Attach V2) + * + * Tests for basic free_trial parameter override behaviors. + * + * Key behaviors: + * - free_trial param overrides product's trial config + * - free_trial param bypasses deduplication logic + * - Trial always starts from now + trial_days + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Fresh attach with free_trial override (product has no trial config) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Product has no trial configuration + * - Attach with free_trial: { length: 7, duration: "day" } + * + * Expected Result: + * - Trial starts, ends at now + 7 days + * - No immediate charge + * - Preview next_cycle shows correct trial end and charge + */ +test.concurrent(`${chalk.yellowBright("trial-override-basic 1: fresh attach with free_trial override")}`, async () => { + const customerId = "trial-override-basic-fresh"; + + const messagesItem = items.monthlyMessages({ includedUsage: 500 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + // No trial config + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + // 1. Preview attach with free_trial override + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + free_trial: { + length: 7, + duration: FreeTrialDuration.Day, + }, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(7), + total: 20, + }); + + // 2. Attach with free_trial override + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + free_trial: { + length: 7, + duration: FreeTrialDuration.Day, + }, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product is active and trialing + await expectProductActive({ + customer, + productId: pro.id, + }); + + await expectProductTrialing({ + customer, + productId: pro.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify features available with resetsAt aligned to trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + resetsAt: advancedTo + ms.days(7), // Reset aligns with trial end + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Override product's trial config +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Product has 14-day trial configuration + * - Attach with free_trial: { length: 30, duration: "day" } + * + * Expected Result: + * - Trial uses override (30 days), NOT product config (14 days) + */ +test.concurrent(`${chalk.yellowBright("trial-override-basic 2: override product's trial config")}`, async () => { + const customerId = "trial-override-basic-override-config"; + + const messagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [messagesItem], + trialDays: 14, // Product config: 14 days + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [], + }); + + // 1. Preview attach with override (30 days instead of 14) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + free_trial: { + length: 30, + duration: FreeTrialDuration.Day, + }, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(30), // Override, not product config + total: 20, + }); + + // 2. Attach with override + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + free_trial: { + length: 30, + duration: FreeTrialDuration.Day, + }, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify trial uses override (30 days), NOT product config (14 days) + await expectProductTrialing({ + customer, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(30), + }); + + // Verify feature reset aligns with overridden trial (30 days, not 14) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + resetsAt: advancedTo + ms.days(30), // Reset aligns with override, not product config + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Override bypasses deduplication (reattach after cancel) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer previously had trial, used it, then cancelled + * - Reattach with free_trial override + * + * Expected Result: + * - Gets fresh trial (deduplication bypassed) + */ +test.concurrent(`${chalk.yellowBright("trial-override-basic 3: override bypasses deduplication")}`, async () => { + const customerId = "trial-override-basic-bypass-dedup"; + + const messagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [messagesItem], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id }), + s.advanceTestClock({ days: 3 }), // Mid-trial + s.updateSubscription({ + productId: proTrial.id, + cancelAction: "cancel_immediately" as const, + }), + ], + }); + + // Verify product is cancelled + const customerBefore = + await autumnV1.customers.get(customerId); + const hasProduct = customerBefore.products.some( + (p) => p.id === proTrial.id && p.status === "active", + ); + expect(hasProduct).toBe(false); + + // 1. Preview reattach with free_trial override (bypasses dedup) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + }, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), + total: 20, + }); + + // 2. Reattach with free_trial override + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + }, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify gets fresh 14-day trial (deduplication bypassed) + await expectProductActive({ + customer, + productId: proTrial.id, + }); + + await expectProductTrialing({ + customer, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify feature reset aligns with fresh trial (14 days from now) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + resetsAt: advancedTo + ms.days(14), // Fresh trial, reset at trial end + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/override/trial-override-entity.test.ts b/server/tests/integration/billing/attach/free-trial/override/trial-override-entity.test.ts new file mode 100644 index 000000000..3854cd223 --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/override/trial-override-entity.test.ts @@ -0,0 +1,389 @@ +/** + * Free Trial Override Entity Tests (Attach V2) + * + * Tests for free_trial parameter override in multi-entity scenarios. + * + * Key behaviors: + * - Entity attach with free_trial affects shared subscription (all entities) + * - Entity attach with free_trial: null ends trial for all entities + * - Upgrade with free_trial override affects all entities on subscription + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type ApiEntityV0, + FreeTrialDuration, + ms, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { timeout } from "@tests/utils/genUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Entity attach with free_trial to active subscription +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity-1 has Pro (active, not trialing) + * - Entity-2 attaches Pro with free_trial: { length: 14 } + * + * Expected Result: + * - Both entities move to trial (shared subscription) + */ +test.concurrent(`${chalk.yellowBright("trial-override-entity 1: entity attach with free_trial to active sub")}`, async () => { + const customerId = "trial-override-entity-active"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const { autumnV1, ctx, advancedTo, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial state - entity-1 has pro, not trialing + const entity1Before = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductNotTrialing({ + customer: entity1Before, + productId: pro.id, + nowMs: advancedTo, + }); + + // 1. Preview entity-2 attach with free_trial override + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entity2Id, + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + }, + }); + // Entity-1's pro refunded (-$20), entity-2's pro free during trial = -$20 + expect(preview.total).toBe(-20); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), + total: 20, // Pro ($20) for entity 2 after trial + }); + + // 2. Attach with free_trial override + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entity2Id, + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + }, + redirect_mode: "if_required", + }); + + // Wait for webhook to sync trial status + await timeout(4000); + + // Verify entity-2 is trialing + const entity2 = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductTrialing({ + customer: entity2, + productId: pro.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify entity-1 is also trialing (shared subscription) + const entity1 = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductTrialing({ + customer: entity1, + productId: pro.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify invoices: pro ($20) + refund (-$20) + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 3, + latestTotal: 0, + }); + + expect(customer.invoices?.[1]?.total).toBe(-20); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Entity attach with free_trial: null to trialing subscription +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity-1 has Pro (trialing) + * - Entity-2 attaches Pro with free_trial: null + * + * Expected Result: + * - Trial ends for both entities, both charged + */ +test.concurrent(`${chalk.yellowBright("trial-override-entity 2: entity attach with free_trial: null to trialing sub")}`, async () => { + const customerId = "trial-override-entity-null"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: proTrial.id, entityIndex: 0 })], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial state - entity-1 is trialing + const entity1Before = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductTrialing({ + customer: entity1Before, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // 1. Preview entity-2 attach with free_trial: null + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + entity_id: entity2Id, + free_trial: null, + }); + // Entity-1's pro ($20) + entity-2's pro ($20) = $40 + expect(preview.total).toBe(40); + + // 2. Attach with free_trial: null + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + entity_id: entity2Id, + free_trial: null, + redirect_mode: "if_required", + }); + + // Wait for webhook to sync trial status + await timeout(4000); + + // Verify entity-2 is NOT trialing + const entity2 = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductNotTrialing({ + customer: entity2, + productId: proTrial.id, + nowMs: advancedTo, + }); + + // Verify entity-1 is also NOT trialing (shared subscription) + const entity1 = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductNotTrialing({ + customer: entity1, + productId: proTrial.id, + nowMs: advancedTo, + }); + + // Verify invoices: $40 charge + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 40, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Entity upgrade with free_trial override +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity-1 & 2 both on Pro (active, not trialing) + * - Entity-1 upgrades to Premium with free_trial: { length: 14 } + * + * Expected Result: + * - Both entities get 14-day trial (shared subscription) + */ +test.concurrent(`${chalk.yellowBright("trial-override-entity 3: entity upgrade with free_trial override")}`, async () => { + const customerId = "trial-override-entity-upgrade"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premium = products.premium({ + id: "premium", + items: [premiumMessagesItem], + }); + + const { autumnV1, ctx, advancedTo, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.billing.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial state - both entities on pro, not trialing + const entity1Before = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductNotTrialing({ + customer: entity1Before, + productId: pro.id, + nowMs: advancedTo, + }); + + // 1. Preview entity-1 upgrade to premium with free_trial override + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + entity_id: entity1Id, + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + }, + }); + // Refund: entity-1 pro ($20) + entity-2 pro ($20) = -$40 + expect(preview.total).toBe(-40); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), + total: 50, // Premium ($50) after trial + }); + + // 2. Upgrade with free_trial override + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + entity_id: entity1Id, + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + }, + redirect_mode: "if_required", + }); + + // Wait for webhook to sync trial status + await timeout(4000); + + // Verify entity-1 has premium and is trialing + const entity1 = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductTrialing({ + customer: entity1, + productId: premium.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify entity-2 still has pro and is also trialing (shared subscription) + const entity2 = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductTrialing({ + customer: entity2, + productId: pro.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify invoices: 2x pro ($40) + refund (-$40) + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 4, + latestTotal: 0, + }); + + expect(customer.invoices?.[1]?.total).toBe(-40); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/override/trial-override-merge.test.ts b/server/tests/integration/billing/attach/free-trial/override/trial-override-merge.test.ts new file mode 100644 index 000000000..5ab739d1e --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/override/trial-override-merge.test.ts @@ -0,0 +1,356 @@ +/** + * Free Trial Override Merge Tests (Attach V2) + * + * Tests for free_trial parameter override with add-ons and merges. + * + * Key behaviors: + * - Add-on with free_trial override moves entire subscription to trial + * - Add-on with free_trial: null ends subscription trial + * - Add-on with free_trial override replaces existing trial + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Add-on with free_trial override to active subscription +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has Pro (active, not trialing) + * - Attaches add-on with free_trial: { length: 7 } + * + * Expected Result: + * - Subscription moves to trial, both products trial + * - Pro is refunded, no charge for add-on + */ +test.concurrent(`${chalk.yellowBright("trial-override-merge 1: add-on with free_trial override to active sub")}`, async () => { + const customerId = "trial-override-merge-addon-active"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const addonItem = items.dashboard(); + const addon = products.recurringAddOn({ + id: "addon", + items: [addonItem], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + // Verify initial state - pro is active, not trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductNotTrialing({ + customer: customerBefore, + productId: pro.id, + nowMs: advancedTo, + }); + + // 1. Preview add-on with free_trial override + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: addon.id, + free_trial: { + length: 7, + duration: FreeTrialDuration.Day, + }, + }); + // Pro refunded (-$20), add-on free during trial = -$20 + expect(preview.total).toBe(-20); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(7), + total: 40, // Pro ($20) + Add-on ($20) after trial + }); + + // 2. Attach add-on with free_trial override + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: addon.id, + free_trial: { + length: 7, + duration: FreeTrialDuration.Day, + }, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify both products are active + await expectCustomerProducts({ + customer, + active: [pro.id, addon.id], + }); + + // Verify both products are trialing with same trial end + await expectProductTrialing({ + customer, + productId: pro.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + await expectProductTrialing({ + customer, + productId: addon.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify invoices: pro ($20) + refund (-$20) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: -20, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Add-on with free_trial: null to trialing subscription +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has Pro (trialing) + * - Attaches add-on with free_trial: null + * + * Expected Result: + * - Trial ends, both products charged immediately + */ +test.concurrent(`${chalk.yellowBright("trial-override-merge 2: add-on with free_trial: null to trialing sub")}`, async () => { + const customerId = "trial-override-merge-addon-null"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const addonItem = items.dashboard(); + const addon = products.recurringAddOn({ + id: "addon", + items: [addonItem], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, addon] }), + ], + actions: [s.billing.attach({ productId: proTrial.id })], + }); + + // Verify initial state - pro is trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // 1. Preview add-on with free_trial: null + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: addon.id, + free_trial: null, + }); + // Pro ($20) + Add-on ($20) = $40 + expect(preview.total).toBe(40); + + // 2. Attach add-on with free_trial: null + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: addon.id, + free_trial: null, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify both products are active + await expectCustomerProducts({ + customer, + active: [proTrial.id, addon.id], + }); + + // Verify both products are NOT trialing + await expectProductNotTrialing({ + customer, + productId: proTrial.id, + nowMs: advancedTo, + }); + + await expectProductNotTrialing({ + customer, + productId: addon.id, + nowMs: advancedTo, + }); + + // Verify invoices: $40 charge + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 40, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Add-on with free_trial override to trialing subscription (replaces trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has Pro (trialing, 5 days left) + * - Attaches add-on with free_trial: { length: 14 } + * + * Expected Result: + * - Fresh 14-day trial for both (replaces existing trial) + */ +test.concurrent(`${chalk.yellowBright("trial-override-merge 3: add-on with free_trial override replaces existing trial")}`, async () => { + const customerId = "trial-override-merge-addon-replace"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const addonItem = items.dashboard(); + const addon = products.recurringAddOn({ + id: "addon", + items: [addonItem], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, addon] }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id }), + s.advanceTestClock({ days: 2 }), // 5 days remaining on trial + ], + }); + + // Verify initial state - pro is trialing with ~5 days remaining + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(5), // 7 - 2 = 5 days remaining + }); + + // 1. Preview add-on with free_trial override (14 days) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: addon.id, + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + }, + }); + expect(preview.total).toBe(0); // No charge during trial + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), // Fresh 14-day trial + total: 40, // Pro ($20) + Add-on ($20) after trial + }); + + // 2. Attach add-on with free_trial override + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: addon.id, + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + }, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify both products are active + await expectCustomerProducts({ + customer, + active: [proTrial.id, addon.id], + }); + + // Verify both products have fresh 14-day trial (NOT the remaining 5 days) + await expectProductTrialing({ + customer, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + await expectProductTrialing({ + customer, + productId: addon.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + // Count is 2: initial trial invoice ($0) + subscription update invoice ($0) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/override/trial-override-null.test.ts b/server/tests/integration/billing/attach/free-trial/override/trial-override-null.test.ts new file mode 100644 index 000000000..fe65ddd0a --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/override/trial-override-null.test.ts @@ -0,0 +1,393 @@ +/** + * Free Trial Override Null Tests (Attach V2) + * + * Tests for free_trial: null parameter behavior. + * + * Key behaviors: + * - free_trial: null prevents trial even if product has trial config + * - free_trial: null on free product works (no subscription, no trial) + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, type ApiEntityV0, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { timeout } from "@/utils/genUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Attach with free_trial: null (product has trial config) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Product has 14-day trial configuration + * - Attach with free_trial: null + * + * Expected Result: + * - No trial, charged immediately + */ +test.concurrent(`${chalk.yellowBright("trial-override-null 1: attach with free_trial: null (product has trial)")}`, async () => { + const customerId = "trial-override-null-has-trial"; + + const messagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [messagesItem], + trialDays: 14, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [], + }); + + // 1. Preview attach with free_trial: null + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + free_trial: null, + }); + expect(preview.total).toBe(20); // Charged immediately + + // 2. Attach with free_trial: null + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + free_trial: null, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product is active and NOT trialing + await expectProductActive({ + customer, + productId: proTrial.id, + }); + + await expectProductNotTrialing({ + customer, + productId: proTrial.id, + nowMs: advancedTo, + }); + + // Verify features available with resetsAt at normal billing cycle (no trial) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + resetsAt: advancedTo + ms.days(30), // No trial, reset at billing cycle end + }); + + // Verify invoice: $20 charge + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 20, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Free product with free_trial: null (no Stripe sub) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Free product (no price) has trial config + * - Attach with free_trial: null + * + * Expected Result: + * - No trial, product active immediately + * - No Stripe subscription created + */ +test.concurrent(`${chalk.yellowBright("trial-override-null 2: free product with free_trial: null")}`, async () => { + const customerId = "trial-override-null-free-product"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeWithTrial = products.baseWithTrial({ + id: "free-trial", + items: [messagesItem], + trialDays: 7, + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({}), // No payment method needed for free product + s.products({ list: [freeWithTrial] }), + ], + actions: [], + }); + + // 1. Preview attach with free_trial: null + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: freeWithTrial.id, + free_trial: null, + }); + expect(preview.total).toBe(0); // Free product + + // 2. Attach with free_trial: null + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: freeWithTrial.id, + free_trial: null, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product is active and NOT trialing + await expectProductActive({ + customer, + productId: freeWithTrial.id, + }); + + await expectProductNotTrialing({ + customer, + productId: freeWithTrial.id, + }); + + // Verify features available + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + // Verify no invoice (free product) + await expectCustomerInvoiceCorrect({ + customer, + count: 0, + }); + + // Verify no Stripe subscription + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Entity pro isolated from customer-level attach with free_trial: null +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity-1 and Entity-2 both have Pro products (on Stripe subscription, NOT trialing) + * - Advance test clock (entities have been subscribed for a billing cycle) + * - Customer-level attaches a free product with free_trial: null + * + * Expected Result: + * - Entity-1 and Entity-2's Pro products remain untouched (on subscription, not trialing) + * - Free product is active immediately with no trial (due to free_trial: null) + * - No interference between customer-level free product and entity-level subscriptions + */ +test.concurrent(`${chalk.yellowBright("trial-override-null 3: entity pro isolated from customer-level attach with free_trial: null")}`, async () => { + const customerId = "trial-override-null-entity-isolated"; + + // Pro product for entities (paid, on subscription) + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + // Free product for customer level (with trial config, but we'll use free_trial: null) + const freeMessagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeWithTrial = products.baseWithTrial({ + id: "free-trial", + items: [freeMessagesItem], + trialDays: 7, + cardRequired: false, + }); + + const { autumnV1, ctx, advancedTo, entities, testClockId } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, freeWithTrial] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + // Attach pro to both entities (on Stripe subscription, NOT trialing) + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.billing.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial state - both entities have Pro, NOT trialing + const entity1Before = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductActive({ customer: entity1Before, productId: pro.id }); + await expectProductNotTrialing({ + customer: entity1Before, + productId: pro.id, + nowMs: advancedTo, + }); + + const entity2Before = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductActive({ customer: entity2Before, productId: pro.id }); + await expectProductNotTrialing({ + customer: entity2Before, + productId: pro.id, + nowMs: advancedTo, + }); + + // Verify initial invoices - 2 Pro attaches + const customerBefore = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerBefore, + count: 2, + latestTotal: 20, + }); + + // Advance test clock to next invoice (entities have been subscribed for a billing cycle) + const advancedToAfterCycle = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + // Verify entities are still active after billing cycle + const entity1AfterCycle = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductActive({ customer: entity1AfterCycle, productId: pro.id }); + await expectProductNotTrialing({ + customer: entity1AfterCycle, + productId: pro.id, + nowMs: advancedToAfterCycle, + }); + + const entity2AfterCycle = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductActive({ customer: entity2AfterCycle, productId: pro.id }); + await expectProductNotTrialing({ + customer: entity2AfterCycle, + productId: pro.id, + nowMs: advancedToAfterCycle, + }); + + // Attach free product with free_trial: null at CUSTOMER level (not entity level) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: freeWithTrial.id, + free_trial: null, + redirect_mode: "if_required", + }); + + await timeout(2000); + + // Verify customer-level free product is active and NOT trialing (due to free_trial: null) + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterAttach, + productId: freeWithTrial.id, + }); + await expectProductNotTrialing({ + customer: customerAfterAttach, + productId: freeWithTrial.id, + nowMs: advancedToAfterCycle, + }); + + // Verify entities' Pro products are still NOT trialing (unaffected by customer-level attach) + const entity1AfterAttach = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductActive({ + customer: entity1AfterAttach, + productId: pro.id, + }); + await expectProductNotTrialing({ + customer: entity1AfterAttach, + productId: pro.id, + nowMs: advancedToAfterCycle, + }); + + const entity2AfterAttach = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductActive({ + customer: entity2AfterAttach, + productId: pro.id, + }); + await expectProductNotTrialing({ + customer: entity2AfterAttach, + productId: pro.id, + nowMs: advancedToAfterCycle, + }); + + // Verify Stripe subscription is correct (entity subs, not trialing) + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { + checkNotTrialing: true, + }, + }); + + // Verify invoices - should have renewal invoices for entity pro subscriptions + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 3, // 2 initial Pro attaches + 1 renewal + latestTotal: 40, // Renewal for both entities ($20 each) + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { + checkNotTrialing: true, + }, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/override/trial-override-upgrade.test.ts b/server/tests/integration/billing/attach/free-trial/override/trial-override-upgrade.test.ts new file mode 100644 index 000000000..0282ea885 --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/override/trial-override-upgrade.test.ts @@ -0,0 +1,249 @@ +/** + * Free Trial Override Upgrade Tests (Attach V2) + * + * Tests for free_trial parameter override during upgrades. + * + * Key behaviors: + * - Upgrade with free_trial override starts fresh trial + * - Upgrade with free_trial: null prevents trial + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Upgrade with free_trial override (customer is active, not trialing) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer on Pro (active, not trialing) + * - Upgrades to Premium with free_trial: { length: 14 } + * + * Expected Result: + * - Premium starts 14-day trial + * - Pro is replaced, customer refunded + */ +test.concurrent(`${chalk.yellowBright("trial-override-upgrade 1: upgrade active customer with free_trial override")}`, async () => { + const customerId = "trial-override-upgrade-active"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premium = products.premium({ + id: "premium", + items: [premiumMessagesItem], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + // Verify initial state - pro is active, not trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerBefore, + productId: pro.id, + }); + await expectProductNotTrialing({ + customer: customerBefore, + productId: pro.id, + nowMs: advancedTo, + }); + + // 1. Preview upgrade with free_trial override + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + }, + }); + // Should refund Pro ($20) and not charge for Premium during trial + expect(preview.total).toBe(-20); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), + total: 50, // Premium price after trial + }); + + // 2. Upgrade with free_trial override + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + }, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product states + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + // Verify premium is trialing + await expectProductTrialing({ + customer, + productId: premium.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify features with resetsAt aligned to new trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + balance: 1000, + usage: 0, + resetsAt: advancedTo + ms.days(14), // Reset changed to new trial end + }); + + // Verify invoices: initial pro ($20) + refund (-$20) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: -20, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Upgrade with free_trial: null (product has trial config) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer on Pro (active) + * - Upgrades to Premium (has 14-day trial config) with free_trial: null + * + * Expected Result: + * - No trial, charged immediately + */ +test.concurrent(`${chalk.yellowBright("trial-override-upgrade 2: upgrade with free_trial: null (no trial)")}`, async () => { + const customerId = "trial-override-upgrade-null"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premiumTrial] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + // 1. Preview upgrade with free_trial: null + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premiumTrial.id, + free_trial: null, + }); + // Premium ($50) - Pro refund ($20) = $30 + expect(preview.total).toBe(30); + + // 2. Upgrade with free_trial: null + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premiumTrial.id, + free_trial: null, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product states + await expectCustomerProducts({ + customer, + active: [premiumTrial.id], + notPresent: [pro.id], + }); + + // Verify premium is NOT trialing + await expectProductNotTrialing({ + customer, + productId: premiumTrial.id, + nowMs: advancedTo, + }); + + // Verify features with resetsAt at normal billing cycle (no trial) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + balance: 1000, + usage: 0, + resetsAt: addMonths(advancedTo, 1).getTime(), + }); + + // Verify invoices: pro ($20) + upgrade ($30) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 30, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/trial-basic.test.ts b/server/tests/integration/billing/attach/free-trial/trial-basic.test.ts new file mode 100644 index 000000000..3bec99701 --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/trial-basic.test.ts @@ -0,0 +1,229 @@ +/** + * Free Trial Basic Tests (Attach V2) + * + * Tests for basic product-level trial behavior when attaching products. + * + * Key behaviors: + * - New subscription with trial product starts in trial + * - Trial end timestamp is calculated from attach time + * - Product without trial attached to new customer has no trial + * - Preview shows $0 total during trial (no immediate charge) + * - Preview next_cycle shows trial end date and first charge amount + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: New subscription with trial product +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has no existing product + * - Attach proWithTrial ($20/mo, 7-day trial) + * + * Expected Result: + * - Product is active and trialing + * - Trial ends at advancedTo + 7 days + * - No immediate charge (preview.total = 0) + * - next_cycle.starts_at = trial end, next_cycle.total = $20 + * - Invoice count = 0 (no invoice during trial) + */ +test.concurrent(`${chalk.yellowBright("trial-basic 1: new subscription with trial product")}`, async () => { + const customerId = "trial-basic-new-sub"; + + const messagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [messagesItem], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [], + }); + + // 1. Preview attach - should show $0 during trial, next_cycle shows first charge + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(7), + total: 20, // Pro base price after trial + }); + + // 2. Attach product with trial + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product is active + await expectProductActive({ + customer, + productId: proTrial.id, + }); + + // Verify product is trialing with correct end date + await expectProductTrialing({ + customer, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify feature balance is available during trial with resetsAt aligned to trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + resetsAt: advancedTo + ms.days(7), + }); + + // Verify no invoice during trial + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: Free-to-trial (no existing Stripe subscription) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has free product (no Stripe subscription) + * - Attach proWithTrial ($20/mo, 7-day trial) + * + * Expected Result: + * - New Stripe subscription is created in trial + * - Product is trialing + * - Free product is removed + * - next_cycle shows first charge after trial + */ +test.concurrent(`${chalk.yellowBright("trial-basic 5: free to trial product")}`, async () => { + const customerId = "trial-basic-free-to-trial"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ + id: "free", + items: [messagesItem], + }); + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free, proTrial] }), + ], + actions: [s.billing.attach({ productId: free.id })], + }); + + // 1. Preview upgrade to trial product - should show $0, next_cycle = $20 + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(7), + total: 20, + }); + + // 2. Attach trial product + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product states + await expectCustomerProducts({ + customer, + active: [proTrial.id], + notPresent: [free.id], + }); + + // Verify product is trialing + await expectProductTrialing({ + customer, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify feature balance is pro's balance with resetsAt aligned to trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + resetsAt: advancedTo + ms.days(7), + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/trial-conversion.test.ts b/server/tests/integration/billing/attach/free-trial/trial-conversion.test.ts new file mode 100644 index 000000000..e2edd99a0 --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/trial-conversion.test.ts @@ -0,0 +1,389 @@ +/** + * Free Trial Conversion Tests (Attach V2) + * + * Tests for trial end/conversion behaviors. + * + * Key behaviors: + * - Trial end triggers first charge + * - Billing cycle starts from trial end + * - Features continue after conversion + * - Arrears usage billed at trial end + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Trial ends naturally - first charge +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has proWithTrial (7-day trial) + * - Advance past trial end + * + * Expected Result: + * - Product converts to active (not trialing) + * - First charge of $20 + * - Features continue working + */ +test.concurrent(`${chalk.yellowBright("trial-conversion 1: trial ends naturally - first charge")}`, async () => { + const customerId = "trial-conv-natural-end"; + + const messagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [messagesItem], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id }), + s.advanceTestClock({ toNextInvoice: true }), // Advance past trial + ], + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product is active and NOT trialing + await expectProductActive({ + customer, + productId: proTrial.id, + }); + + await expectProductNotTrialing({ + customer, + productId: proTrial.id, + nowMs: advancedTo, + }); + + // Verify first charge invoice + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 20, // Pro base price + latestInvoiceProductId: proTrial.id, + }); + + // Verify features still available with resetsAt aligned to monthly billing cycle + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, // Reset after trial end + usage: 0, + resetsAt: advancedTo + ms.days(30), + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Trial ends with usage - reset balances +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has proWithTrial with monthly messages + * - Use some messages during trial + * - Trial ends + * + * Expected Result: + * - Balance resets at trial end (new billing cycle) + * - First charge processed + */ +test.concurrent(`${chalk.yellowBright("trial-conversion 2: trial ends with usage - reset balances")}`, async () => { + const customerId = "trial-conv-usage-reset"; + + const messagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [messagesItem], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [s.billing.attach({ productId: proTrial.id })], + }); + + // Use some messages during trial + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 200, + }); + + // Wait for track to sync + await new Promise((r) => setTimeout(r, 2000)); + + // Verify usage during trial + const customerDuringTrial = + await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerDuringTrial, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 300, // 500 - 200 = 300 + usage: 200, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + }); + + const advancedToAfter = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product is NOT trialing + await expectProductNotTrialing({ + customer, + productId: proTrial.id, + nowMs: advancedToAfter, + }); + + // Verify balance reset (new billing cycle) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, // Reset to full + usage: 0, // Reset to 0 + }); + + // Verify first charge + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 20, + latestInvoiceProductId: proTrial.id, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Trial ends with add-on - both products charged +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has proWithTrial (trialing) + add-on (inheriting trial) + * - Trial ends + * + * Expected Result: + * - Both products charged: pro ($20) + add-on ($20) = $40 + */ +test.concurrent(`${chalk.yellowBright("trial-conversion 4: trial ends with add-on - both charged")}`, async () => { + const customerId = "trial-conv-with-addon"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const addonItem = items.dashboard(); + const addonWords = items.monthlyWords({ includedUsage: 100 }); + const addon = products.recurringAddOn({ + id: "addon", + items: [addonItem, addonWords], + }); + + let { autumnV1, ctx, advancedTo, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, addon] }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id }), + s.advanceTestClock({ days: 3 }), + s.billing.attach({ productId: addon.id, timeout: 4000 }), + ], + }); + + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 0, + latestInvoiceProductIds: [addon.id], + }); + + const customerAfterAddonAttach = + await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer: customerAfterAddonAttach, + featureId: TestFeature.Words, + includedUsage: 100, + balance: 100, + usage: 0, + resetsAt: advancedTo + ms.days(4), + }); + + advancedTo = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 14, + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify both products are NOT trialing + await expectProductNotTrialing({ + customer, + productId: proTrial.id, + nowMs: advancedTo, + }); + + await expectProductNotTrialing({ + customer, + productId: addon.id, + nowMs: advancedTo, + }); + + // Verify first charge includes both products + await expectCustomerInvoiceCorrect({ + customer, + count: 3, + latestTotal: 40, // Pro ($20) + Add-on ($20) + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: Scheduled downgrade activates after trial ends +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has premiumWithTrial (trialing) + * - Downgrade to pro (scheduled for trial end) + * - Trial ends + * + * Expected Result: + * - Pro becomes active (not premium) + * - Pro price charged ($20, not $50) + */ +test.concurrent(`${chalk.yellowBright("trial-conversion 5: scheduled downgrade activates after trial")}`, async () => { + const customerId = "trial-conv-scheduled-downgrade"; + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premiumTrial, pro] }), + ], + actions: [ + s.billing.attach({ productId: premiumTrial.id }), + s.billing.attach({ productId: pro.id }), // Downgrade - scheduled + s.advanceTestClock({ toNextInvoice: true }), + ], + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify pro is active (not premium) + await expectProductActive({ + customer, + productId: pro.id, + }); + + // Verify premium is no longer present + const hasPremium = customer.products.some((p) => p.id === premiumTrial.id); + expect(hasPremium).toBe(false); + + // Verify pro is NOT trialing + await expectProductNotTrialing({ + customer, + productId: pro.id, + nowMs: advancedTo, + }); + + // Verify pro price charged (not premium) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 20, // Pro price, not premium + latestInvoiceProductId: pro.id, + }); + + // Verify feature balance is pro's balance with resetsAt aligned to monthly billing cycle + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + resetsAt: Date.now() + ms.days(14) + ms.days(30), + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/trial-downgrade.test.ts b/server/tests/integration/billing/attach/free-trial/trial-downgrade.test.ts new file mode 100644 index 000000000..c0efe1539 --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/trial-downgrade.test.ts @@ -0,0 +1,750 @@ +/** + * Free Trial Downgrade Tests (Attach V2) + * + * Tests for downgrade scenarios (scheduled switches). + * + * Key behaviors: + * - DOWNGRADE: Inherits subscription's current trial state + * - Product's trial config is IGNORED on downgrade + * - Scheduled downgrade activates with NO trial (regardless of product config) + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductActive, + expectProductCanceling, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Downgrade from trialing premium to pro with trial (inherits trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has premiumWithTrial (14-day trial, currently trialing) + * - Downgrade to proWithTrial (7-day trial config - IGNORED) + * + * Expected Result: + * - Premium stays active and trialing (canceling at end of trial) + * - Pro is scheduled + * - Trial state is preserved (premium's trial continues) + */ +test.concurrent(`${chalk.yellowBright("trial-downgrade 1: trialing premium to pro with trial")}`, async () => { + const customerId = "trial-downgrade-premium-to-pro-trial"; + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premiumTrial, proTrial] }), + ], + actions: [s.billing.attach({ productId: premiumTrial.id })], + }); + + // Verify initial state - premium is trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // 1. Preview downgrade - should show $0 (scheduled, no immediate charge) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + }); + expect(preview.total).toBe(0); + + // Verify next_cycle info for scheduled downgrade + expectPreviewNextCycleCorrect({ + preview, + total: 20, // Pro's price after trial ends + startsAt: advancedTo + ms.days(14), // Trial end = cycle start + }); + + // 2. Attach pro (downgrade - scheduled) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify premium is canceling (still active, will be removed at trial end) + await expectProductCanceling({ + customer, + productId: premiumTrial.id, + }); + + // Verify pro is scheduled + await expectProductScheduled({ + customer, + productId: proTrial.id, + }); + + // Verify premium is STILL trialing (trial inherited) + await expectProductTrialing({ + customer, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify feature balance is still premium's balance (until switch) with resetsAt aligned to trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + balance: 1000, + usage: 0, + resetsAt: advancedTo + ms.days(14), + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + // Count is 2: initial trial ($0) + scheduled downgrade ($0) + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 20, + waitForSeconds: 30, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 20, + latestInvoiceProductId: proTrial.id, + }); + + await expectProductActive({ + customer, + productId: proTrial.id, + }); + + await expectProductNotTrialing({ + customer, + productId: proTrial.id, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Downgrade from trialing premium to pro without trial (inherits trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has premiumWithTrial (14-day trial, currently trialing) + * - Downgrade to pro (NO trial config) + * + * Expected Result: + * - Premium stays active and trialing + * - Pro is scheduled + * - Trial state is preserved (product config ignored on downgrade) + */ +test.concurrent(`${chalk.yellowBright("trial-downgrade 2: trialing premium to pro without trial")}`, async () => { + const customerId = "trial-downgrade-premium-to-pro-no-trial"; + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premiumTrial, pro] }), + ], + actions: [s.billing.attach({ productId: premiumTrial.id })], + }); + + // Verify initial state - premium is trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // 1. Preview downgrade - should show $0 (scheduled), next_cycle = $20 at trial end + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), // Trial end + total: 20, // Pro's price after trial ends + }); + + // 2. Attach pro (downgrade - scheduled) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify premium is canceling + await expectProductCanceling({ + customer, + productId: premiumTrial.id, + }); + + // Verify pro is scheduled + await expectProductScheduled({ + customer, + productId: pro.id, + }); + + // Verify premium is STILL trialing + await expectProductTrialing({ + customer, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // ADVANCE PAST TRIAL: Verify scheduled downgrade activates correctly + // ═══════════════════════════════════════════════════════════════════════════ + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 20, + waitForSeconds: 30, + }); + + const customerAfter = await autumnV1.customers.get(customerId); + + // Verify pro is now active (not scheduled) + await expectProductActive({ + customer: customerAfter, + productId: pro.id, + }); + + // Verify pro is NOT trialing (downgrades don't get trial) + await expectProductNotTrialing({ + customer: customerAfter, + productId: pro.id, + }); + + // Verify invoice: $0 trial + $20 for pro after trial ends + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 2, + latestTotal: 20, + latestInvoiceProductId: pro.id, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Downgrade from non-trialing premium to pro with trial (no trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has premium ($50/mo, NOT trialing) + * - Downgrade to proWithTrial (7-day trial config - IGNORED) + * + * Expected Result: + * - Premium stays active (canceling at end of cycle) + * - Pro is scheduled + * - NO trial (inherits non-trialing state) + */ +test.concurrent(`${chalk.yellowBright("trial-downgrade 3: non-trialing premium to pro with trial (no trial)")}`, async () => { + const customerId = "trial-downgrade-notrial-premium-to-pro-trial"; + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premium = products.premium({ + id: "premium", + items: [premiumMessagesItem], + }); + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, proTrial] }), + ], + actions: [s.billing.attach({ productId: premium.id })], + }); + + // Verify initial state - premium is NOT trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductNotTrialing({ + customer: customerBefore, + productId: premium.id, + nowMs: advancedTo, + }); + + // 1. Preview downgrade - should show $0 (scheduled) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + }); + expect(preview.total).toBe(0); + + // Verify next_cycle shows pro's charge (NO trial on scheduled activation) + expectPreviewNextCycleCorrect({ + preview, + total: 20, // Pro's price - no trial on downgrade activation + startsAt: addMonths(advancedTo, 1).getTime(), + }); + + // 2. Attach pro (downgrade - scheduled) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify premium is canceling + await expectProductCanceling({ + customer, + productId: premium.id, + }); + + // Verify pro is scheduled + await expectProductScheduled({ + customer, + productId: proTrial.id, + }); + + // Verify premium is NOT trialing (no change) + await expectProductNotTrialing({ + customer, + productId: premium.id, + nowMs: advancedTo, + }); + + // Verify invoice for premium + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 50, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); + + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 20, + latestInvoiceProductId: proTrial.id, + }); + + await expectProductActive({ + customer, + productId: proTrial.id, + }); + + await expectProductNotTrialing({ + customer, + productId: proTrial.id, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Downgrade from premium to free (no trial on scheduled free) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has premium ($50/mo, NOT trialing) + * - Downgrade to free product + * + * Expected Result: + * - Premium stays active until end of cycle + * - Free is scheduled + * - No trial (free products don't need trials) + */ +test.concurrent(`${chalk.yellowBright("trial-downgrade 4: premium to free (no trial)")}`, async () => { + const customerId = "trial-downgrade-premium-to-free"; + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premium = products.premium({ + id: "premium", + items: [premiumMessagesItem], + }); + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ + id: "free", + items: [messagesItem], + }); + + const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, free] }), + ], + actions: [s.billing.attach({ productId: premium.id })], + }); + + // 1. Preview downgrade - should show $0 (scheduled) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: free.id, + }); + expect(preview.total).toBe(0); + + // Verify next_cycle shows $0 for free product + expectPreviewNextCycleCorrect({ + preview, + total: 0, + startsAt: addMonths(advancedTo, 1).getTime(), + }); + + // 2. Attach free (downgrade - scheduled) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: free.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify premium is canceling + await expectProductCanceling({ + customer, + productId: premium.id, + }); + + // Verify free is scheduled + await expectProductScheduled({ + customer, + productId: free.id, + }); + + // Verify invoice for premium + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 50, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // ADVANCE PAST CYCLE: Verify scheduled downgrade activates correctly + // ═══════════════════════════════════════════════════════════════════════════ + + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + const customerAfter = await autumnV1.customers.get(customerId); + + // Verify free is now active (not scheduled) + await expectProductActive({ + customer: customerAfter, + productId: free.id, + }); + + // Verify feature balance is now free's balance + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + // Verify no additional invoice (free product has no charge) + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 1, + latestTotal: 50, // Still the original premium invoice + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: Downgrade from trialing premium to free (inherits trial until switch) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has premiumWithTrial (14-day trial, currently trialing) + * - Downgrade to free product + * + * Expected Result: + * - Premium stays active and trialing until trial end + * - Free is scheduled + * - Trial continues until trial end, then free activates + */ +test.concurrent(`${chalk.yellowBright("trial-downgrade 5: trialing premium to free (inherits trial)")}`, async () => { + const customerId = "trial-downgrade-trialing-premium-to-free"; + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ + id: "free", + items: [messagesItem], + }); + + const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premiumTrial, free] }), + ], + actions: [s.billing.attach({ productId: premiumTrial.id })], + }); + + // Verify initial state - premium is trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // 1. Preview downgrade - should show $0, next_cycle = $0 at trial end + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: free.id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), // Trial end + total: 0, // Free product + }); + + // 2. Attach free (downgrade - scheduled) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: free.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify premium is canceling + await expectProductCanceling({ + customer, + productId: premiumTrial.id, + }); + + // Verify free is scheduled + await expectProductScheduled({ + customer, + productId: free.id, + }); + + // Verify premium is STILL trialing + await expectProductTrialing({ + customer, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify feature balance is still premium's balance with resetsAt aligned to trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + balance: 1000, + usage: 0, + resetsAt: advancedTo + ms.days(14), + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // ADVANCE PAST TRIAL: Verify scheduled downgrade activates correctly + // ═══════════════════════════════════════════════════════════════════════════ + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 20, + waitForSeconds: 30, + }); + + const customerAfter = await autumnV1.customers.get(customerId); + + // Verify free is now active (not scheduled) + await expectProductActive({ + customer: customerAfter, + productId: free.id, + }); + + // Verify feature balance is now free's balance + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + // Verify no additional paid invoice (free product has no charge) + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 1, + latestTotal: 0, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/trial-entity-upgrade.test.ts b/server/tests/integration/billing/attach/free-trial/trial-entity-upgrade.test.ts new file mode 100644 index 000000000..43315587b --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/trial-entity-upgrade.test.ts @@ -0,0 +1,726 @@ +/** + * Free Trial Entity Upgrade Tests (Attach V2) + * + * Tests for entity upgrades that affect shared subscription trial state. + * + * Key behaviors: + * - Entity upgrade to product with trial → fresh trial for ALL entities + * - Entity upgrade to product without trial → trial ends for ALL entities + * - All entities share the same subscription/trial state + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, type ApiEntityV0, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductActive, + expectProductCanceling, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { timeout } from "@/utils/genUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Entity upgrade to product with trial (fresh trial for ALL) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity-1 has proWithTrial (7-day trial, trialing) + * - Entity-2 has same proWithTrial (sharing trial) + * - Entity-1 upgrades to premiumWithTrial (14-day trial) + * + * Expected Result: + * - Entity-1 gets fresh 14-day trial on premium + * - Entity-2 continues with inherited trial state + */ +test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 1: entity upgrade to product with trial")}`, async () => { + const customerId = "trial-ent-upgrade-with-trial"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, premiumTrial] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id, entityIndex: 0 }), + s.billing.attach({ productId: proTrial.id, entityIndex: 1 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial state - both entities trialing + const entity1Before = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductTrialing({ + customer: entity1Before, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // 1. Preview upgrade entity-1 to premium - should show $0 (fresh trial), next_cycle shows combined charge + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premiumTrial.id, + entity_id: entity1Id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), // Fresh 14-day trial + total: 50, // Premium ($50) + }); + + // 2. Upgrade entity-1 to premium + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premiumTrial.id, + entity_id: entity1Id, + redirect_mode: "if_required", + }); + await timeout(4000); + + // Verify entity-1 has premium with fresh 14-day trial + const entity1 = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductActive({ + customer: entity1, + productId: premiumTrial.id, + }); + await expectProductTrialing({ + customer: entity1, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify entity-2 still has pro (unchanged) + const entity2 = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductTrialing({ + customer: entity2, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(14), // Updated to match new subscription trial + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + // Count is 3: entity-1 trial ($0) + entity-2 trial ($0) + upgrade ($0) + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 3, + latestTotal: 0, + latestInvoiceProductId: premiumTrial.id, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Entity upgrade to product without trial (trial ends for ALL) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity-1 has proWithTrial (7-day trial, trialing) + * - Entity-2 has same proWithTrial (sharing trial) + * - Entity-1 upgrades to premium (NO trial) + * + * Expected Result: + * - Trial ends for ALL entities + * - Entity-1 charged for premium + * - Entity-2's pro is now charged (trial ended) + */ +test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 2: entity upgrade to product without trial (trial ends)")}`, async () => { + const customerId = "trial-ent-upgrade-no-trial"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premium = products.premium({ + id: "premium", + items: [premiumMessagesItem], + }); + + const { autumnV1, ctx, advancedTo, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, premium] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id, entityIndex: 0 }), + s.billing.attach({ productId: proTrial.id, entityIndex: 1 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial state - both entities trialing + const entity1Before = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductTrialing({ + customer: entity1Before, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // 1. Preview upgrade entity-1 to premium (no trial) + // Should show premium ($50) + pro for entity-2 ($20) = $70 + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + entity_id: entity1Id, + }); + expect(preview.total).toBe(70); // Premium + Pro (trial ends for both) + + // 2. Upgrade entity-1 to premium + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + entity_id: entity1Id, + redirect_mode: "if_required", + }); + + // Verify entity-1 has premium and NOT trialing + const entity1 = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductActive({ + customer: entity1, + productId: premium.id, + }); + await expectProductNotTrialing({ + customer: entity1, + productId: premium.id, + nowMs: advancedTo, + }); + + // Verify entity-2 pro is NOT trialing (trial ended for all) + const entity2 = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductNotTrialing({ + customer: entity2, + productId: proTrial.id, + nowMs: advancedTo, + }); + + // Verify invoice for both products + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 3, + latestTotal: 70, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Entity downgrade during trial (scheduled, inherits trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity-1 and Entity-2 both have premiumWithTrial (14-day trial, trialing) + * - Entity-1 downgrades to proWithTrial (scheduled) + * - Entity-2 stays on premiumWithTrial + * + * Expected Result: + * - Premium on Entity-1 stays trialing (canceling at trial end) + * - Pro on Entity-1 is scheduled + * - Premium on Entity-2 stays trialing + * - After trial ends: Entity-1 has pro (not trialing), Entity-2 has premium (not trialing) + */ +test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 3: entity downgrade during trial (scheduled)")}`, async () => { + const customerId = "trial-ent-downgrade-scheduled"; + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + let { autumnV1, ctx, advancedTo, entities, testClockId } = await initScenario( + { + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premiumTrial, proTrial] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: premiumTrial.id, entityIndex: 0 }), + s.billing.attach({ productId: premiumTrial.id, entityIndex: 1 }), + ], + }, + ); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // 1. Preview downgrade - should show $0 (scheduled), next_cycle shows pro + premium price + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + entity_id: entity1Id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), // Trial end (pro activates) + total: 20, // Pro ($20) + }); + + // 2. Downgrade entity-1 to pro (scheduled) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + entity_id: entity1Id, + redirect_mode: "if_required", + }); + + const entity1 = await autumnV1.entities.get( + customerId, + entity1Id, + ); + + // Verify premium is canceling but still trialing on entity-1 + await expectProductCanceling({ + customer: entity1, + productId: premiumTrial.id, + }); + await expectProductTrialing({ + customer: entity1, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify pro is scheduled on entity-1 + await expectProductScheduled({ + customer: entity1, + productId: proTrial.id, + }); + + // Verify entity-2 still has premium and is trialing + const entity2 = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductActive({ + customer: entity2, + productId: premiumTrial.id, + }); + await expectProductTrialing({ + customer: entity2, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // ADVANCE PAST TRIAL: Verify scheduled downgrade activates correctly + // ═══════════════════════════════════════════════════════════════════════════ + + advancedTo = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + // Verify entity-1 now has pro (not scheduled, not trialing) + const entity1After = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductActive({ + customer: entity1After, + productId: proTrial.id, + }); + await expectProductNotTrialing({ + customer: entity1After, + productId: proTrial.id, + nowMs: advancedTo, + }); + + // Verify entity-2 still has premium and is NOT trialing (trial ended) + const entity2After = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductActive({ + customer: entity2After, + productId: premiumTrial.id, + }); + await expectProductNotTrialing({ + customer: entity2After, + productId: premiumTrial.id, + nowMs: advancedTo, + }); + + // Verify invoice: $0 trial invoice + $70 renewal (pro $20 + premium $50) + const customerAfter = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 3, + latestTotal: 70, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: One entity upgrades, another stays on trial product +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity-1 and Entity-2 both have proWithTrial (7-day trial, trialing) + * - Advance 3 days into trial + * - Entity-1 upgrades to premiumWithTrial (14-day trial) + * - Entity-2 stays on proWithTrial + * + * Expected Result: + * - Both entities share the new trial end (now + 14 days from upgrade time) + * - Entity-1 has premium, Entity-2 has pro + */ +test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 4: mixed products after entity upgrade")}`, async () => { + const customerId = "trial-ent-upgrade-mixed"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo, entities, testClockId } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, premiumTrial] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id, entityIndex: 0 }), + s.billing.attach({ productId: proTrial.id, entityIndex: 1 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial state - both entities trialing with 7-day trial + const entity1Before = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductTrialing({ + customer: entity1Before, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Advance 3 days into trial + const advancedTo3Days = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + // New trial end is 14 days from upgrade time (now + 14 days) + const newTrialEnd = advancedTo3Days + ms.days(14); + + // 1. Preview upgrade - should show $0 (fresh trial), next_cycle shows combined charge + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premiumTrial.id, + entity_id: entity1Id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: newTrialEnd, // Fresh 14-day trial from now + total: 70, // Premium ($50) + Pro ($20) after trial + }); + + // 2. Upgrade entity-1 to premium + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premiumTrial.id, + entity_id: entity1Id, + redirect_mode: "if_required", + }); + + // Wait for webhook to sync trial status + await timeout(4000); + + // Verify entity-1 has premium with fresh 14-day trial + const entity1 = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductActive({ + customer: entity1, + productId: premiumTrial.id, + }); + await expectProductTrialing({ + customer: entity1, + productId: premiumTrial.id, + trialEndsAt: newTrialEnd, + }); + + // Verify entity-2 still has pro but now shares premium's 14-day trial end + const entity2 = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductActive({ + customer: entity2, + productId: proTrial.id, + }); + await expectProductTrialing({ + customer: entity2, + productId: proTrial.id, + trialEndsAt: newTrialEnd, // Shares new 14-day trial end + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 3, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: Non-trialing entity upgrade to trial product +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity-1 has pro ($20/mo, NOT trialing) + * - Entity-2 has same pro (NOT trialing) + * - Entity-1 upgrades to premiumWithTrial + * + * Expected Result: + * - Fresh trial starts for entity-1's premium + * - Entity-2's pro gets refunded (subscription moved to trial) + */ +test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 5: non-trialing upgrade to trial product")}`, async () => { + const customerId = "trial-ent-notrial-to-trial"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premiumTrial] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.billing.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial state - neither entity is trialing + const entity1Before = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductNotTrialing({ + customer: entity1Before, + productId: pro.id, + nowMs: advancedTo, + }); + + // 1. Preview upgrade to premium with trial + // Should show negative (refund for both pro products) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premiumTrial.id, + entity_id: entity1Id, + }); + // Refund for entity-1 pro (-$20) + refund for entity-2 pro (-$20) = -$40 + expect(preview.total).toBe(-40); + + // 2. Upgrade entity-1 to premium + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premiumTrial.id, + entity_id: entity1Id, + redirect_mode: "if_required", + }); + + // Verify invoices: initial charges + refunds + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 3, // entity-1 pro ($20) + entity-2 pro ($20) + refund (-$40) + latestTotal: -40, + }); + + await timeout(4000); + + // Verify entity-1 has premium with fresh 14-day trial + const entity1 = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductTrialing({ + customer: entity1, + productId: premiumTrial.id, + }); + + // Verify entity-2's pro is now trialing (subscription moved to trial) + const entity2 = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductTrialing({ + customer: entity2, + productId: pro.id, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/trial-features.test.ts b/server/tests/integration/billing/attach/free-trial/trial-features.test.ts new file mode 100644 index 000000000..3aebe7275 --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/trial-features.test.ts @@ -0,0 +1,430 @@ +/** + * Free Trial Features Tests (Attach V2) + * + * Tests for prepaid, allocated, and consumable features during trials. + * + * Key behaviors: + * - Prepaid items: Balance available during trial, no charge until trial ends + * - Allocated seats: Seats available during trial, prorated charge after trial + * - Consumable (arrears): Usage tracked, billed at trial end + * - Feature balance is available during trial period + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Prepaid messages during trial +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer attaches product with prepaid messages and trial + * - Product includes: 100 included messages, prepaid at $10/100 messages + * - Customer uses some prepaid messages during trial + * + * Expected Result: + * - Balance is available during trial + * - Balance decreases as used + * - No charge during trial + */ +test.concurrent(`${chalk.yellowBright("trial-features 1: prepaid messages during trial")}`, async () => { + const customerId = "trial-feat-prepaid-messages"; + + const prepaidItem = items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 10, + }); + const priceItem = items.monthlyPrice({ price: 20 }); + const productWithTrial = products.base({ + id: "prepaid-trial", + items: [prepaidItem, priceItem], + trialDays: 7, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [productWithTrial] }), + ], + actions: [], + }); + + // 1. Preview attach with quantity - should show $0 during trial, next_cycle shows base + prepaid + // quantity becomes the new includedUsage, so 200 - 100 original = 1 pack to purchase + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: productWithTrial.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(7), // Trial end + total: 30, // Base ($20) + 1 pack x $10 = $30 after trial + }); + + // 2. Attach product with prepaid + // quantity becomes the new includedUsage (200), so 1 pack purchased + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: productWithTrial.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + redirect_mode: "if_required", + }); + + let customer = await autumnV1.customers.get(customerId); + + // Verify product is trialing + await expectProductTrialing({ + customer, + productId: productWithTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify prepaid balance is available + // quantity (200) becomes the new includedUsage and balance + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 200, + balance: 200, + usage: 0, + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); + + // 3. Track some usage during trial + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + await new Promise((r) => setTimeout(r, 2000)); + + customer = await autumnV1.customers.get(customerId); + + // Verify balance decreased (200 - 50 = 150) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 200, + balance: 150, + usage: 50, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Allocated seats during trial +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer attaches product with allocated seats and trial + * - Product includes: 3 included seats, $10/seat overage + * - Customer tracks seat usage during trial (5 seats - 2 over included) + * + * Expected Result: + * - Seats are available during trial + * - Seat usage tracked with overage + * - No charge during trial (prorated charge happens at trial end) + */ +test.concurrent(`${chalk.yellowBright("trial-features 2: allocated seats during trial")}`, async () => { + const customerId = "trial-feat-allocated-seats"; + + const seatsItem = items.allocatedUsers({ includedUsage: 3 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const productWithTrial = products.base({ + id: "seats-trial", + items: [seatsItem, priceItem], + trialDays: 7, + }); + + const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [productWithTrial] }), + ], + actions: [], + }); + + // 1. Preview attach - should show $0 during trial, next_cycle shows base price + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: productWithTrial.id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(7), // Trial end + total: 20, // Base price after trial + }); + + // 2. Attach product with allocated seats + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: productWithTrial.id, + redirect_mode: "if_required", + }); + + let customer = await autumnV1.customers.get(customerId); + + // Verify product is trialing + await expectProductTrialing({ + customer, + productId: productWithTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify seats are available + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Users, + includedUsage: 3, + balance: 3, + usage: 0, + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); + + // 3. Track seat usage (set to 5 seats - 2 over included) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 5, + }); + await new Promise((r) => setTimeout(r, 2000)); + + customer = await autumnV1.customers.get(customerId); + + // Verify seat usage is tracked + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Users, + includedUsage: 3, + balance: -2, // 3 included - 5 used = -2 overage + usage: 5, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); + + const advancedToAfter = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + customer = await autumnV1.customers.get(customerId); + + // Verify product is NOT trialing + await expectProductNotTrialing({ + customer, + productId: productWithTrial.id, + nowMs: advancedToAfter, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 3, + latestTotal: 10 + 2 * 10, // Base price + 2 seats x $10/seat + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Users, + includedUsage: 3, + balance: -2, // 3 included - 5 used = -2 overage + usage: 5, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Consumable (arrears) during trial +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer attaches product with consumable messages and trial + * - Product includes: 100 included, $0.10/message overage + * - Customer tracks usage to overage during trial (150 used = 50 overage) + * - Trial ends + * + * Expected Result: + * - Usage is tracked during trial + * - After trial ends: consumable does NOT reset, overage is NOT charged + * - Invoice only contains base price ($20) + */ +test.concurrent(`${chalk.yellowBright("trial-features 3: consumable (arrears) during trial")}`, async () => { + const customerId = "trial-feat-consumable"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const productWithTrial = products.base({ + id: "consumable-trial", + items: [consumableItem, priceItem], + trialDays: 7, + }); + + const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [productWithTrial] }), + ], + actions: [], + }); + + // 1. Preview attach - should show $0 during trial, next_cycle shows base price + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: productWithTrial.id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(7), // Trial end + total: 20, // Base price after trial (arrears usage billed separately) + }); + + // 2. Attach product with consumable + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: productWithTrial.id, + redirect_mode: "if_required", + }); + + let customer = await autumnV1.customers.get(customerId); + + // Verify product is trialing + await expectProductTrialing({ + customer, + productId: productWithTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify consumable balance (included usage available) with resetsAt aligned to trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + resetsAt: advancedTo + ms.days(7), + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); + + // 3. Track usage to overage (150 used = 50 over the 100 included) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 150, + }); + await new Promise((r) => setTimeout(r, 2000)); + + customer = await autumnV1.customers.get(customerId); + + // Verify usage during trial (150 used, 50 overage) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: -50, // 100 - 150 = -50 overage + usage: 150, + }); + + // 4. Advance test clock past trial end + const advancedToAfter = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + customer = await autumnV1.customers.get(customerId); + + // Verify product is NOT trialing + await expectProductNotTrialing({ + customer, + productId: productWithTrial.id, + nowMs: advancedToAfter, + }); + + // Verify consumable does NOT reset - usage stays at 150, balance stays at -50 + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: -50, // Still -50, no reset + usage: 150, // Still 150, no reset + }); + + // Verify invoice only contains base price ($20), overage is NOT charged + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 20, // Only base price, no overage + latestInvoiceProductId: productWithTrial.id, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/trial-free-product.test.ts b/server/tests/integration/billing/attach/free-trial/trial-free-product.test.ts new file mode 100644 index 000000000..6ae735902 --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/trial-free-product.test.ts @@ -0,0 +1,588 @@ +/** + * Free Trial Free Product Tests (Attach V2) + * + * Tests for free product trials (no Stripe subscription). + * + * Key behaviors: + * - Free products with trial are isolated from Stripe subscription + * - Trial gates features until trial ends + * - Product's trial config always applies for free products + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, type ApiEntityV0, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import { timeout } from "@/utils/genUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Free product with trial (no Stripe subscription) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer attaches free product with trial (baseWithTrial) + * - No base price, just features + * + * Expected Result: + * - Product is trialing + * - No Stripe subscription created + * - Features available during trial + */ +test.concurrent(`${chalk.yellowBright("trial-free-product 1: free product with trial")}`, async () => { + const customerId = "trial-free-prod-basic"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeWithTrial = products.baseWithTrial({ + id: "free-trial", + items: [messagesItem], + trialDays: 7, + cardRequired: false, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({}), // No payment method needed + s.products({ list: [freeWithTrial] }), + ], + actions: [], + }); + + // 1. Preview attach - should show $0 (free product), next_cycle shows $0 (free continues) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: freeWithTrial.id, + }); + expect(preview.total).toBe(0); + + // 2. Attach free product with trial + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: freeWithTrial.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product is active + await expectProductActive({ + customer, + productId: freeWithTrial.id, + }); + + // Verify product is trialing + await expectProductTrialing({ + customer, + productId: freeWithTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify features available with resetsAt aligned to trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + resetsAt: advancedTo + ms.days(7), + }); + + // Verify no invoice (free product) + await expectCustomerInvoiceCorrect({ + customer, + count: 0, + }); + + // Verify no Stripe subscription + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Free trial product to paid product (creates Stripe subscription) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has free product with trial + * - Upgrade to paid pro product + * + * Expected Result: + * - Stripe subscription created + * - Pro is active, free is removed + */ +test.concurrent(`${chalk.yellowBright("trial-free-product 3: free trial to paid product")}`, async () => { + const customerId = "trial-free-prod-to-paid"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeWithTrial = products.baseWithTrial({ + id: "free-trial", + items: [messagesItem], + trialDays: 7, + cardRequired: false, + }); + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [freeWithTrial, pro] }), + ], + actions: [s.billing.attach({ productId: freeWithTrial.id })], + }); + + // 1. Preview upgrade - should show $20 (pro price) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + }); + expect(preview.total).toBe(20); + + // 2. Upgrade to pro + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product states + await expectCustomerProducts({ + customer, + active: [pro.id], + notPresent: [freeWithTrial.id], + }); + + // Verify pro is NOT trialing + await expectProductNotTrialing({ + customer, + productId: pro.id, + nowMs: advancedTo, + }); + + // Verify feature balance is pro's balance with resetsAt at billing cycle (no trial) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + resetsAt: addMonths(Date.now(), 1).getTime(), + }); + + // Verify invoice for pro + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 20, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Free trial product to paid trial product +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has free product with trial + * - Upgrade to proWithTrial + * + * Expected Result: + * - Pro starts fresh trial + * - No immediate charge + */ +test.concurrent(`${chalk.yellowBright("trial-free-product 4: free trial to paid trial product")}`, async () => { + const customerId = "trial-free-prod-to-paid-trial"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeWithTrial = products.baseWithTrial({ + id: "free-trial", + items: [messagesItem], + trialDays: 7, + cardRequired: false, + }); + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [freeWithTrial, proTrial] }), + ], + actions: [s.billing.attach({ productId: freeWithTrial.id })], + }); + + // 1. Preview upgrade - should show $0 (new trial), next_cycle shows pro price + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), // Fresh 14-day trial + total: 20, // Pro price after trial + }); + + // 2. Upgrade to proTrial + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product states + await expectCustomerProducts({ + customer, + active: [proTrial.id], + notPresent: [freeWithTrial.id], + }); + + // Verify pro is trialing with fresh 14-day trial + await expectProductTrialing({ + customer, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify feature balance is pro's balance with resetsAt aligned to trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + resetsAt: advancedTo + ms.days(14), + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 6: Multiple free products with different trial configs +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer attaches free product with 7-day trial + * - Upgrade to different free product with 14-day trial + * + * Expected Result: + * - New product gets fresh 14-day trial + * - Old product removed + */ +test.concurrent(`${chalk.yellowBright("trial-free-product 6: free to different free with trial")}`, async () => { + const customerId = "trial-free-prod-free-to-free"; + + const messagesItem1 = items.monthlyMessages({ includedUsage: 100 }); + const free1 = products.baseWithTrial({ + id: "free1-trial", + items: [messagesItem1], + trialDays: 7, + cardRequired: false, + }); + + const messagesItem2 = items.monthlyMessages({ includedUsage: 200 }); + const free2 = products.baseWithTrial({ + id: "free2-trial", + items: [messagesItem2], + trialDays: 14, + cardRequired: false, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [s.customer({}), s.products({ list: [free1, free2] })], + actions: [s.billing.attach({ productId: free1.id })], + }); + + // Verify initial state - free1 is trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: free1.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // 1. Preview switch to free2 - should show $0, next_cycle shows $0 (free continues) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: free2.id, + }); + expect(preview.total).toBe(0); + + // 2. Switch to free2 + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: free2.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product states + await expectCustomerProducts({ + customer, + active: [free2.id], + notPresent: [free1.id], + }); + + // Verify free2 is trialing with fresh 14-day trial + await expectProductTrialing({ + customer, + productId: free2.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify feature balance is free2's balance with resetsAt aligned to trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 200, + balance: 200, + usage: 0, + resetsAt: advancedTo + ms.days(14), + }); + + // Verify no invoice + await expectCustomerInvoiceCorrect({ + customer, + count: 0, + }); + + // Verify no Stripe subscription + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 7: Entity pro products isolated from customer-level free trial +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity-1 and Entity-2 both have Pro products (on Stripe subscription, NOT trialing) + * - Customer-level attaches a free product with trial + * - Advance test clock past the free product's trial end + * + * Expected Result: + * - Entity-1 and Entity-2's Pro products remain untouched (on subscription, not trialing) + * - Free product's trial converts independently at customer level + * - Free product has its own billing cycle separate from the subscription + */ +test.concurrent(`${chalk.yellowBright("trial-free-product 7: entity pro isolated from customer-level free trial")}`, async () => { + const customerId = "trial-free-prod-entity-isolated"; + + // Pro product for entities (paid, on subscription) + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + // Free product for customer level (with 7-day trial) + const freeMessagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeWithTrial = products.baseWithTrial({ + id: "free-trial", + items: [freeMessagesItem], + trialDays: 7, + cardRequired: false, + }); + + const { autumnV1, ctx, advancedTo, entities, testClockId } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, freeWithTrial] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + // Attach pro to both entities (on Stripe subscription, NOT trialing) + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.billing.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial state - both entities have Pro, NOT trialing + const entity1Before = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductActive({ customer: entity1Before, productId: pro.id }); + await expectProductNotTrialing({ + customer: entity1Before, + productId: pro.id, + nowMs: advancedTo, + }); + + const entity2Before = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductActive({ customer: entity2Before, productId: pro.id }); + await expectProductNotTrialing({ + customer: entity2Before, + productId: pro.id, + nowMs: advancedTo, + }); + + // Attach free product with trial at CUSTOMER level (not entity level) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: freeWithTrial.id, + redirect_mode: "if_required", + }); + + await timeout(2000); + + // Verify customer-level free product is trialing + const customerDuringTrial = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerDuringTrial, + productId: freeWithTrial.id, + }); + await expectProductTrialing({ + customer: customerDuringTrial, + productId: freeWithTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify entities' Pro products are still NOT trialing (unaffected by customer-level free trial) + const entity1DuringTrial = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductNotTrialing({ + customer: entity1DuringTrial, + productId: pro.id, + nowMs: advancedTo, + }); + + const entity2DuringTrial = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductNotTrialing({ + customer: entity2DuringTrial, + productId: pro.id, + nowMs: advancedTo, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { + checkNotTrialing: true, + }, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerDuringTrial, + count: 2, + latestTotal: 20, + }); + + // Advance test clock past the free product's trial end (7 days) + const advancedToAfterTrial = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + // Verify free product trial has ended and is now active (no longer trialing) + const customerAfterTrial = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterTrial, + productId: freeWithTrial.id, + }); + await expectProductNotTrialing({ + customer: customerAfterTrial, + productId: freeWithTrial.id, + nowMs: advancedToAfterTrial, + }); + + // Verify entities' Pro products are STILL not trialing and remain on subscription billing cycle + const entity1AfterTrial = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductActive({ customer: entity1AfterTrial, productId: pro.id }); + await expectProductNotTrialing({ + customer: entity1AfterTrial, + productId: pro.id, + nowMs: advancedToAfterTrial, + }); + + const entity2AfterTrial = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductActive({ customer: entity2AfterTrial, productId: pro.id }); + await expectProductNotTrialing({ + customer: entity2AfterTrial, + productId: pro.id, + nowMs: advancedToAfterTrial, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterTrial, + count: 3, // Only the 2 Pro attaches + latestTotal: 40, // Each Pro attach is $20 + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/trial-merge.test.ts b/server/tests/integration/billing/attach/free-trial/trial-merge.test.ts new file mode 100644 index 000000000..791102978 --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/trial-merge.test.ts @@ -0,0 +1,462 @@ +/** + * Free Trial Merge Tests (Attach V2) + * + * Tests for add-on and entity scenarios where subscription trial state is inherited. + * + * Key behaviors: + * - ADD-ONS: Inherit subscription's current trial state + * - NEW ENTITIES: Inherit subscription's current trial state + * - Product's trial config is IGNORED for merges + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, type ApiEntityV0, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { timeout } from "@/utils/genUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Add-on to trialing subscription (inherits trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has proWithTrial (7-day trial, currently trialing) + * - Attach add-on product ($20/mo) + * + * Expected Result: + * - Add-on inherits subscription's trial state + * - Add-on is trialing with same trial end as main product + * - No charge for add-on during trial + */ +test.concurrent(`${chalk.yellowBright("trial-merge 1: add-on to trialing subscription (inherits trial)")}`, async () => { + const customerId = "trial-merge-addon-trialing"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const addonItem = items.monthlyMessages({ includedUsage: 100 }); + const addon = products.recurringAddOn({ + id: "addon", + items: [addonItem], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, addon] }), + ], + actions: [s.billing.attach({ productId: proTrial.id })], + }); + + // Verify initial state - pro is trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // 1. Preview add-on - should show $0 (inherits trial), next_cycle shows combined charge + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: addon.id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(7), + total: 20, // Add-on ($20) after trial + }); + + // 2. Attach add-on + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: addon.id, + redirect_mode: "if_required", + }); + + await timeout(4000); + + const customer = await autumnV1.customers.get(customerId); + + // Verify both products are active + await expectCustomerProducts({ + customer, + active: [proTrial.id, addon.id], + }); + + // Verify pro is still trialing + await expectProductTrialing({ + customer, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify add-on inherits trial state (same trial end) + await expectProductTrialing({ + customer, + productId: addon.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + // Count is 2: initial trial ($0) + add addon ($0) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Add-on to non-trialing subscription (no trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has pro ($20/mo, NOT trialing) + * - Attach add-on with trial config ($20/mo, 7-day trial - IGNORED) + * + * Expected Result: + * - Add-on does NOT get trial (inherits non-trialing state) + * - Charged immediately for add-on ($20) + */ +test.concurrent(`${chalk.yellowBright("trial-merge 2: add-on to non-trialing subscription (no trial)")}`, async () => { + const customerId = "trial-merge-addon-not-trialing"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + // Add-on with trial config - should be IGNORED + const addonItem = items.monthlyMessages({ includedUsage: 100 }); + const addonWithTrial = products.base({ + id: "addon-trial", + items: [addonItem, items.monthlyPrice({ price: 20 })], + isAddOn: true, + trialDays: 7, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addonWithTrial] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + // Verify initial state - pro is NOT trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductNotTrialing({ + customer: customerBefore, + productId: pro.id, + nowMs: advancedTo, + }); + + // 1. Preview add-on - should show $20 (no trial, product config ignored) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: addonWithTrial.id, + }); + expect(preview.total).toBe(20); + + // 2. Attach add-on with trial config + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: addonWithTrial.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify both products are active + await expectCustomerProducts({ + customer, + active: [pro.id, addonWithTrial.id], + }); + + // Verify pro is NOT trialing + await expectProductNotTrialing({ + customer, + productId: pro.id, + nowMs: advancedTo, + }); + + // Verify add-on is NOT trialing (inherits non-trialing state) + await expectProductNotTrialing({ + customer, + productId: addonWithTrial.id, + nowMs: advancedTo, + }); + + // Verify invoices: pro ($20) + add-on ($20) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 20, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Entity attach to trialing subscription (inherits trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has proWithTrial on entity-1 (7-day trial, trialing) + * - Attach same product to entity-2 + * + * Expected Result: + * - Entity-2 inherits subscription's trial state + * - Both entities have same trial end + */ +test.concurrent(`${chalk.yellowBright("trial-merge 3: entity attach to trialing subscription (inherits trial)")}`, async () => { + const customerId = "trial-merge-entity-trialing"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: proTrial.id, entityIndex: 0 })], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial state - entity-1 is trialing + const entity1Before = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductTrialing({ + customer: entity1Before, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // 1. Preview attach to entity-2 - should show $0 (inherits trial), next_cycle shows charge for both entities + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + entity_id: entity2Id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(7), // Trial end + total: 20, // 2 entities x $20 = $40 after trial + }); + + // 2. Attach to entity-2 + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + entity_id: entity2Id, + redirect_mode: "if_required", + }); + + await timeout(4000); + + // Verify entity-1 is still trialing + const entity1 = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductTrialing({ + customer: entity1, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify entity-2 inherits trial (same trial end) + const entity2 = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductTrialing({ + customer: entity2, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + // Count is 2: initial trial ($0) + add entity ($0) + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Entity attach to non-trialing subscription (no trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has pro on entity-1 (NOT trialing) + * - Attach proWithTrial to entity-2 (trial config IGNORED) + * + * Expected Result: + * - Entity-2 does NOT get trial (inherits non-trialing state) + * - Charged immediately for entity-2 + */ +test.concurrent(`${chalk.yellowBright("trial-merge 4: entity attach to non-trialing subscription (no trial)")}`, async () => { + const customerId = "trial-merge-entity-not-trialing"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + // Product with trial config - should be IGNORED on entity add + const proTrialMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proTrialMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, ctx, entities, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, proTrial] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial state - entity-1 is NOT trialing + const entity1Before = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductNotTrialing({ + customer: entity1Before, + productId: pro.id, + nowMs: advancedTo, + }); + + // 1. Preview attach trial product to entity-2 - should show $20 (no trial) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + entity_id: entity2Id, + }); + expect(preview.total).toBe(20); + + // 2. Attach trial product to entity-2 + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + entity_id: entity2Id, + redirect_mode: "if_required", + }); + + // Verify entity-1 is NOT trialing + const entity1 = await autumnV1.entities.get( + customerId, + entity1Id, + ); + await expectProductNotTrialing({ + customer: entity1, + productId: pro.id, + nowMs: advancedTo, + }); + + // Verify entity-2 is NOT trialing (trial config ignored) + const entity2 = await autumnV1.entities.get( + customerId, + entity2Id, + ); + await expectProductNotTrialing({ + customer: entity2, + productId: proTrial.id, + nowMs: advancedTo, + }); + + // Verify invoices: entity-1 pro ($20) + entity-2 pro ($20) + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 20, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/trial-payment-method.test.ts b/server/tests/integration/billing/attach/free-trial/trial-payment-method.test.ts new file mode 100644 index 000000000..bb923d544 --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/trial-payment-method.test.ts @@ -0,0 +1,124 @@ +/** + * Free Trial Payment Method Tests (Attach V2) + * + * Tests for card_required vs card not required trial behavior. + * + * Key behaviors: + * - card_required: true → Must have payment method to start trial + * - card_required: false → Can start trial without payment method + * - Payment method can be added during trial + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Card required - without payment method (should redirect to checkout) +// ═══════════════════════════════════════════════════════════════════════════════ + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Card NOT required - without payment method +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Product has card_required: false + * - Customer does NOT have payment method + * + * Expected Result: + * - Trial starts without payment method + * - No checkout redirect + */ +test.concurrent(`${chalk.yellowBright("trial-payment 3: card not required - no payment method")}`, async () => { + const customerId = "trial-payment-no-card-req"; + + const messagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrialNoCard = products.proWithTrial({ + id: "pro-trial-nocard", + items: [messagesItem], + trialDays: 7, + cardRequired: false, // Card NOT required + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({}), // No payment method + s.products({ list: [proTrialNoCard] }), + ], + actions: [], + }); + + // 1. Preview attach - should show $0 (trial), next_cycle shows pro price + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrialNoCard.id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(7), // Trial end + total: 20, // Pro price after trial + }); + + // 2. Attach product - should succeed without payment method + const result = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrialNoCard.id, + redirect_mode: "if_required", + }); + + // Should NOT redirect to checkout + expect(result.checkout_url).toBeUndefined(); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product is active and trialing + await expectProductActive({ + customer, + productId: proTrialNoCard.id, + }); + + await expectProductTrialing({ + customer, + productId: proTrialNoCard.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // Verify features available with resetsAt aligned to trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + resetsAt: advancedTo + ms.days(7), + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); + + // Verify Stripe subscription state (trial without card) + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/trial-reattach.test.ts b/server/tests/integration/billing/attach/free-trial/trial-reattach.test.ts new file mode 100644 index 000000000..3cdb8e8f8 --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/trial-reattach.test.ts @@ -0,0 +1,199 @@ +/** + * Free Trial Reattach Tests (Attach V2) + * + * Tests for cancel/reattach scenarios and trial prevention. + * + * Key behaviors: + * - Cancel during trial cancels subscription + * - Reattach same product gets fresh trial (based on unique_fingerprint) + * - Scheduled switches can be cancelled + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductActive, + expectProductNotPresent, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Reattach same product after cancel (fresh trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer had proWithTrial, cancelled during trial + * - Reattach same product (unique_fingerprint: false) + * + * Expected Result: + * - Gets fresh trial (unique_fingerprint not enforced) + */ +test.concurrent(`${chalk.yellowBright("trial-reattach 2: reattach same product (fresh trial)")}`, async () => { + const customerId = "trial-reattach-same-product"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id }), + s.advanceTestClock({ days: 3 }), + s.updateSubscription({ + productId: proTrial.id, + cancelAction: "cancel_immediately" as const, + }), + ], + }); + + // Verify product is removed after cancel + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfterCancel, + productId: proTrial.id, + }); + + // 1. Preview reattach - should show $0 deduplicate trial + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proTrial.id, + }); + expect(preview.total).toBe(20); + + // 2. Reattach same product + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proTrial.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product is active + await expectProductActive({ + customer, + productId: proTrial.id, + }); + + // Verify gets fresh 7-day trial (from current time) + await expectProductNotTrialing({ + customer, + productId: proTrial.id, + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 20, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Downgrade during trial then cancel scheduled +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has premiumWithTrial (trialing) + * - Downgrade to pro (scheduled) + * - Cancel the scheduled pro + * + * Expected Result: + * - Premium remains active and trialing + * - Pro scheduled attachment is cancelled + */ +test.concurrent(`${chalk.yellowBright("trial-reattach 3: cancel scheduled downgrade during trial")}`, async () => { + const customerId = "trial-reattach-cancel-scheduled"; + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premiumTrial, pro] }), + ], + actions: [ + s.billing.attach({ productId: premiumTrial.id }), + s.billing.attach({ productId: pro.id }), // Downgrade - scheduled + ], + }); + + // Cancel the scheduled pro + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: premiumTrial.id, + cancel_action: "uncancel" as const, + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify premium is active and still trialing + await expectProductActive({ + customer, + productId: premiumTrial.id, + }); + + await expectProductTrialing({ + customer, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify pro scheduled is cancelled (not present) + await expectProductNotPresent({ + customer, + productId: pro.id, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); diff --git a/server/tests/integration/billing/attach/free-trial/trial-upgrade.test.ts b/server/tests/integration/billing/attach/free-trial/trial-upgrade.test.ts new file mode 100644 index 000000000..26f55b96a --- /dev/null +++ b/server/tests/integration/billing/attach/free-trial/trial-upgrade.test.ts @@ -0,0 +1,483 @@ +/** + * Free Trial Upgrade Tests (Attach V2) + * + * Tests for upgrade scenarios where the new product's trial config applies. + * + * Key behaviors: + * - UPGRADE: New product's trial config applies + * - If new product has trial → Fresh trial starts + * - If new product has NO trial → Trial ends, charge immediately + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import { timeout } from "@/utils/genUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Upgrade from trialing pro to premium with trial (fresh trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has proWithTrial (7-day trial, currently trialing) + * - Upgrade to premiumWithTrial (14-day trial) + * + * Expected Result: + * - Fresh 14-day trial starts from upgrade time + * - Old trial is replaced with new trial + * - No charge during trial + */ +test.concurrent(`${chalk.yellowBright("trial-upgrade 1: trialing pro to premium with trial (fresh trial)")}`, async () => { + const customerId = "trial-upgrade-pro-to-premium-trial"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, premiumTrial] }), + ], + actions: [s.billing.attach({ productId: proTrial.id })], + }); + + // Verify initial state - pro is trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(7), + }); + + // 1. Preview upgrade - should show $0 (new trial), next_cycle = $50 at 14 days + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premiumTrial.id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), + total: 50, // Premium base price after fresh trial + }); + + // 2. Attach premium with trial (upgrade) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premiumTrial.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product states + await expectCustomerProducts({ + customer, + active: [premiumTrial.id], + notPresent: [proTrial.id], + }); + + // Verify premium is trialing with FRESH 14-day trial + await expectProductTrialing({ + customer, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify feature balance is premium's balance with resetsAt aligned to trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + balance: 1000, + usage: 0, + resetsAt: advancedTo + ms.days(14), + }); + + // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) + // Count is 2: initial trial ($0) + upgrade ($0) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Upgrade from trialing pro to premium without trial (trial ends) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has proWithTrial (7-day trial, currently trialing) + * - Upgrade to premium (NO trial) + * + * Expected Result: + * - Trial ends immediately + * - Charged for premium ($50) + */ +test.concurrent(`${chalk.yellowBright("trial-upgrade 2: trialing pro to premium without trial (trial ends)")}`, async () => { + const customerId = "trial-upgrade-pro-to-premium-no-trial"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premium = products.premium({ + id: "premium", + items: [premiumMessagesItem], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, premium] }), + ], + actions: [s.billing.attach({ productId: proTrial.id })], + }); + + const now = Date.now(); + // Verify initial state - pro is trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: proTrial.id, + trialEndsAt: now + ms.days(7), + }); + + // 1. Preview upgrade - should show $50 (no trial, full charge) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + expect(preview.total).toBe(50); + + // 2. Attach premium without trial (upgrade - ends trial) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product states + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [proTrial.id], + }); + + // Verify premium is NOT trialing + await expectProductNotTrialing({ + customer, + productId: premium.id, + nowMs: advancedTo, + }); + + // Verify feature balance is premium's balance with resetsAt at billing cycle (no trial) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + balance: 1000, + usage: 0, + resetsAt: addMonths(now, 1).getTime(), + }); + + // Verify invoice for premium + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 50, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkNotTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Upgrade from non-trialing pro to premium with trial (fresh trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has pro ($20/mo, NOT trialing) + * - Upgrade to premiumWithTrial (14-day trial) + * + * Expected Result: + * - Fresh 14-day trial starts + * - Existing pro charge is refunded (prorated credit) + * - No new charge during trial + */ +test.concurrent(`${chalk.yellowBright("trial-upgrade 3: non-trialing pro to premium with trial")}`, async () => { + const customerId = "trial-upgrade-notrial-to-trial"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premiumTrial] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + // Verify initial state - pro is NOT trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductNotTrialing({ + customer: customerBefore, + productId: pro.id, + nowMs: advancedTo, + }); + + // 1. Preview upgrade - should show negative (refund for unused pro), next_cycle = $50 + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premiumTrial.id, + }); + // At start of cycle, full refund of pro: -$20 + expect(preview.total).toBe(-20); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), + total: 50, // Premium price after fresh trial + }); + + // 2. Attach premium with trial (upgrade) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premiumTrial.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Verify product states + await expectCustomerProducts({ + customer, + active: [premiumTrial.id], + notPresent: [pro.id], + }); + + // Verify premium is trialing with fresh 14-day trial + await expectProductTrialing({ + customer, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify feature balance is premium's balance with resetsAt aligned to trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + balance: 1000, + usage: 0, + resetsAt: advancedTo + ms.days(14), + }); + + // Verify invoices: pro charge ($20) + refund (-$20) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: -20, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Mid-trial upgrade from pro to premium with trial (fresh trial) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has proWithTrial (7-day trial) + * - Advance 3 days (mid-trial) + * - Upgrade to premiumWithTrial (14-day trial) + * + * Expected Result: + * - Fresh 14-day trial starts from upgrade time (NOT from original attach) + * - Old partial trial is discarded + * - No invoice generated (both trials are $0) + */ +test.concurrent(`${chalk.yellowBright("trial-upgrade 4: mid-trial upgrade to premium with trial (fresh trial)")}`, async () => { + const customerId = "trial-upgrade-mid-trial-fresh"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: true, + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [premiumMessagesItem], + trialDays: 14, + cardRequired: true, + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, premiumTrial] }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id }), + s.advanceTestClock({ days: 3 }), // Mid-trial: 4 days remaining + ], + }); + + // advancedTo is now 3 days after initial attach + // Original pro trial would end at: advancedTo + 4 days (7 - 3 = 4 days remaining) + + // Verify pro is still trialing with 4 days remaining + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: proTrial.id, + trialEndsAt: advancedTo + ms.days(4), // 4 days remaining from current time + }); + + // 1. Preview upgrade - should show $0 (new trial), next_cycle = $50 at 14 days FROM NOW + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premiumTrial.id, + }); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), // Fresh 14-day trial from upgrade time + total: 50, + }); + + // 2. Attach premium with trial (upgrade mid-trial) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premiumTrial.id, + redirect_mode: "if_required", + }); + + await timeout(4000); + const customer = await autumnV1.customers.get(customerId); + + // Verify product states + await expectCustomerProducts({ + customer, + active: [premiumTrial.id], + notPresent: [proTrial.id], + }); + + // Verify premium has FRESH 14-day trial from upgrade time (NOT 4 days remaining) + await expectProductTrialing({ + customer, + productId: premiumTrial.id, + trialEndsAt: advancedTo + ms.days(14), + }); + + // Verify feature balance is premium's balance with resetsAt aligned to new trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + balance: 1000, + usage: 0, + resetsAt: advancedTo + ms.days(14), + }); + + // Verify NO paid invoice generated - both are $0 trial invoices + // Count is 2: initial trial ($0) + upgrade ($0) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 0, + }); + + // Verify Stripe subscription state + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + flags: { checkTrialing: true }, + }); +}); diff --git a/server/tests/integration/billing/attach/immediate-switch.md b/server/tests/integration/billing/attach/immediate-switch.md deleted file mode 100644 index 2e7af7580..000000000 --- a/server/tests/integration/billing/attach/immediate-switch.md +++ /dev/null @@ -1,118 +0,0 @@ -# immediate-switch/ Test Cases - -Covers **upgrades** — when attaching a higher-tier product that takes effect **immediately**. - ---- - -## immediate-switch-basic.test.ts - -Basic upgrade scenarios. - -- **immediate-switch: free to pro** — Free product → Pro. Verify pro is active, free is removed, invoice for pro base price. -- **immediate-switch: pro to premium** — Pro ($20/mo) → Premium ($50/mo). Verify prorated charge for price difference. -- **immediate-switch: pro to premium mid-cycle** — Attach pro, advance 15 days, upgrade to premium. Verify prorated charge. -- **immediate-switch: pro to free to premium** — Pro → Free (downgrade, scheduled) → Premium (upgrade). Verify scheduled downgrade is cancelled, premium is active immediately. -- **immediate-switch: premium to pro to ultra** — Premium → Pro (downgrade, scheduled) → Ultra. Verify scheduled downgrade is cancelled, ultra is active immediately. -- **immediate-switch: upgrade with consumable features, verify usage resets** — Pro with consumable + free consumable + prepaid consumable → Premium. Verify all **usage resets**. -- **immediate-switch: upgrade with allocated features, verify usage carries over** — Pro with free allocated + allocated + prepaid allocated → Premium. Verify all **usage carries over**. - -Fixtures: `products.pro()`, `products.premium()`, `products.ultra()`, `products.base()` - ---- - -## immediate-switch-consumable.test.ts - -Upgrades involving consumable features. - -- **immediate-switch: pro with consumable, track usage, to premium** — Pro with consumable messages, track some usage (and also into overage). Upgrade to premium. Verify overage NOT charged on upgrade (billed at cycle end), and **usage resets** after upgrade. - ---- - -## immediate-switch-allocated.test.ts - -Upgrades involving allocated (seat-based) features. - -### Same included usage (pro → pro-variant) -- **immediate-switch: free with free allocated to pro with allocated** — Free with free allocated users → Pro with allocated users (same included). Verify usage carries over. -- **immediate-switch: pro with allocated, under limit, to pro-variant** — Pro with 3 allocated (using 2) → Pro-variant with 3 allocated. Verify no overage, usage carries over. -- **immediate-switch: pro with allocated, at limit, to pro-variant** — Pro with 3 allocated (using 3) → Pro-variant with 3 allocated. Verify usage carries over. - -### Included usage changes (pro → premium with higher limit) -- **immediate-switch: pro with allocated, under limit, to premium with higher limit** — Pro with 3 allocated (using 2) → Premium with 5 allocated. Verify no overage charge, usage carries over. -- **immediate-switch: pro with allocated, over limit, to premium with higher limit** — Pro with 3 allocated (using 5) → Premium with 10 allocated. Verify existing overage handled, usage carries over. - -### Replaceable (TBD) -- **immediate-switch: allocated with replaceable entities (track negative), upgrade** — Error: "behavior undefined". Will implement later. - ---- - -## immediate-switch-prepaid.test.ts - -Upgrades involving prepaid features. - -### No options passed -- **immediate-switch: free to pro with prepaid, no options** — Free → Pro with prepaid, no options passed. Verify quantity defaults to 0, only base price charged. - -### Same config (quantity change only) -- **immediate-switch: pro with prepaid, increase quantity** — Pro with prepaid messages (2 packs) → same product with 5 packs. Verify refund old + charge new. -- **immediate-switch: pro with prepaid, decrease quantity** — Pro with prepaid (5 packs) → same with 2 packs. Verify credit issued. - -### Billing units change -- **immediate-switch: prepaid billing units change (100 → 50)** — Pro prepaid (100 units/pack) → Premium prepaid (50 units/pack). Verify correct recalculation. -- **immediate-switch: prepaid billing units change (50 → 100)** — Pro prepaid (50 units/pack) → Premium prepaid (100 units/pack). Verify correct recalculation. - -### Price change -- **immediate-switch: prepaid price increase** — Pro prepaid ($10/pack) → Premium prepaid ($15/pack). Verify correct charge difference. -- **immediate-switch: prepaid price decrease** — Pro prepaid ($15/pack) → Premium prepaid ($10/pack). Verify credit issued. - -### Included usage change -- **immediate-switch: prepaid included usage increase** — Pro prepaid (0 included) → Premium prepaid (100 included). Verify correct handling. -- **immediate-switch: prepaid included usage decrease** — Pro prepaid (100 included) → Premium prepaid (0 included). Verify correct handling. - -### Upcoming quantity (proration None) -- **immediate-switch: prepaid with upcoming_quantity populated, upgrade** — Pro with prepaid, decrease quantity with proration `None` (sets `upcoming_quantity`), then upgrade to premium. Verify correct handling of pending quantity change. - ---- - -## immediate-switch-billing-interval.test.ts - -Upgrades involving billing interval changes. - -- **immediate-switch: monthly to annual** — Pro monthly → Pro annual. Verify correct charge for annual. -- **immediate-switch: monthly to monthly + annual** — Pro monthly → Pro with both monthly and annual components. - ---- - -## immediate-switch-entities.test.ts - -Multi-entity upgrade scenarios. - -### Basic entity upgrades -- **immediate-switch: entity 1 free, entity 2 free, upgrade entity 2 to pro** — Two entities on free, upgrade one to pro. Verify independent states. -- **immediate-switch: entity 1 pro, entity 2 free, upgrade entity 2 to pro** — Mixed entity states, upgrade the free one. -- **immediate-switch: entity 1 pro, entity 2 pro, upgrade entity 2 to premium** — Both on pro, upgrade one to premium. -- **immediate-switch: entity 1 pro, entity 2 pro, upgrade entity 2 to pro annual** — Both on pro monthly, upgrade one to annual. - -### Upgrade with scheduled downgrade -- **immediate-switch: entity 1 premium, entity 2 premium, downgrade entity 1 to pro, then upgrade entity 1 to growth** — Premium on both, downgrade one (scheduled), then upgrade that same entity. Verify scheduled downgrade is cancelled, growth is active. -- **immediate-switch: entity 1 premium, entity 2 premium, downgrade both to pro, upgrade entity 2 to growth** — Both scheduled for downgrade, upgrade one. Verify one still scheduled, one upgraded. - -### Upgrade when cancel is scheduled -- **immediate-switch: entity 1 pro, entity 2 pro, cancel entity 1 (to free), upgrade entity 1 to premium** — Pro on both, cancel one (scheduled to free), then upgrade that entity to premium. Verify cancel is overridden, premium is active. - -### Track and upgrade -- **immediate-switch: entity 1 pro, entity 2 pro, track usage on both, advance 2 weeks, upgrade entity 1 to premium** — Both on pro with tracked usage, mid-cycle upgrade one. Verify correct invoice at end of cycle (entity 2 overage + base prices). - ---- - -## Summary - -| File | Test Count | -|------|------------| -| `immediate-switch-basic.test.ts` | 7 | -| `immediate-switch-consumable.test.ts` | 1 | -| `immediate-switch-allocated.test.ts` | 6 | -| `immediate-switch-prepaid.test.ts` | 10 | -| `immediate-switch-billing-interval.test.ts` | 2 | -| `immediate-switch-entities.test.ts` | 8 | -| **Total** | **34** | diff --git a/server/tests/integration/billing/attach/new-plan.md b/server/tests/integration/billing/attach/new-plan.md deleted file mode 100644 index fd6cd2843..000000000 --- a/server/tests/integration/billing/attach/new-plan.md +++ /dev/null @@ -1,51 +0,0 @@ -# new-plan/ Test Cases - -Covers attaching products when customer has **no existing product** for that group. - ---- - -## attach-free.test.ts - -- **new-plan: attach free product** — Free product with monthly messages. Verify balance, usage, no invoice. -- **new-plan: attach free with multiple features** — Free with messages + words + dashboard + unlimited. Verify all features present with correct balances. - -Fixtures: `items.monthlyMessages()`, `items.monthlyWords()`, `items.dashboard()`, `items.unlimitedMessages()`, `products.base()` - ---- - -## attach-paid.test.ts - -- **new-plan: attach pro with mixed features** — Pro ($20/mo) with consumable words + prepaid messages + allocated users. Verify invoice = base + prepaid, all features correct. -- **new-plan: attach pro with allocated, create entities** — Pro with allocated users (3 included). Create 5 user entities via track. Verify users usage = 5, overage invoice created. -- **new-plan: attach base with prepaid messages, no options** — Base product with prepaid messages, attach without passing `options`. Expect error: "behavior undefined". -- **new-plan: attach pro with prepaid messages, no options** — Pro with prepaid messages, attach without passing `options`. Expect error: "behavior undefined". -- **new-plan: attach pro with prepaid messages, quantity 0** — Pro with prepaid messages, pass `options` with `quantity: 0`. Verify no prepaid charged, only base price. - -Fixtures: `items.consumableMessages()`, `items.prepaidMessages()`, `items.allocatedUsers()`, `products.pro()`, `products.base()` - ---- - -## attach-one-time.test.ts - -- **new-plan: attach one-time purchase** — One-time product with prepaid messages. Verify invoice, balance added, no recurring subscription. -- **new-plan: attach one-time purchase twice** — Attach same one-time product twice. Verify balance is cumulative (not replaced). -- **new-plan: attach pro then one-time as main** — Attach pro, then attach one-time **without** `isAddOn`. Should replace pro (user forgot to toggle). -- **new-plan: attach one-time with quantity=0 for one feature** — One-time with messages (qty=100) + words (qty=0). Verify messages added, words not charged. -- **new-plan: attach one-time as add-on to pro** — Attach pro, then attach one-time with `isAddOn: true`. Verify both products exist, balances combined. -- **new-plan: attach one-time with multiple features** — One-time with messages + words + storage (all one-off). Verify all balances correct. -- **new-plan: attach one-time to entity** — Create entity, attach one-time to entity. Verify entity has balance, customer does not. - -Fixtures: `items.oneOffMessages()`, `items.oneOffPrice()`, `products.oneOff()` - ---- - -## attach-entities.test.ts - -- **new-plan: create entity, attach pro to entity** — Create entity, attach pro to entity (not customer). Verify entity has product, customer does not. -- **new-plan: create 2 entities, attach pro to each** — Create 2 entities, attach pro to each. Verify independent balances, 2 separate subscriptions. -- **new-plan: attach pro to entity 1, advance 2 weeks, attach pro to entity 2** — Mid-cycle attach to second entity. Verify prorated billing for entity 2. -- **new-plan: attach pro annual to entity** — Attach annual product to entity. Verify correct billing interval. -- **new-plan: attach pro to customer, then pro to entity** — Attach pro to customer first, then attach pro to entity. Verify both have product independently. -- **new-plan: attach free to customer, then free to entity** — Attach free to customer first, then attach free to entity. Verify both have product independently. - -Fixtures: `s.entities({ count: 2 })`, `products.pro()`, `products.proAnnual()`, `products.base()` diff --git a/server/tests/integration/billing/attach/scheduled-switch.md b/server/tests/integration/billing/attach/scheduled-switch.md deleted file mode 100644 index 0ac10673f..000000000 --- a/server/tests/integration/billing/attach/scheduled-switch.md +++ /dev/null @@ -1,120 +0,0 @@ -# scheduled-switch/ Test Cases - -Covers **downgrades** — when attaching a lower-tier product that takes effect at **end of billing cycle**. - ---- - -## scheduled-switch-basic.test.ts - -Basic downgrade scenarios. - -- **scheduled-switch: pro to free** — Pro → Free. Verify pro is "canceling" (active with canceled_at), free is "scheduled". At cycle end, pro removed, free active. -- **scheduled-switch: premium to pro** — Premium ($50/mo) → Pro ($20/mo). Verify premium canceling, pro scheduled. At cycle end, premium removed, pro active. -- **scheduled-switch: premium to pro to free** — Premium → Pro (scheduled) → Free (scheduled). Verify pro scheduled is replaced by free scheduled. At cycle end, premium removed, free active. -- **scheduled-switch: premium to free to pro** — Premium → Free (scheduled) → Pro (upgrade, immediate). Verify scheduled downgrade cancelled, pro active immediately. -- **scheduled-switch: premium to pro, then upgrade to growth** — Premium → Pro (scheduled) → Growth (immediate). Verify scheduled pro is cancelled, growth active. -- **scheduled-switch: premium to free, then upgrade to pro** — Premium → Free (scheduled) → Pro (immediate). Verify scheduled free is cancelled, pro active. -- **scheduled-switch: premium annual + monthly to premium monthly** — Premium with annual + monthly components → Premium monthly only. Verify correct handling of mixed intervals on downgrade. - ---- - -## scheduled-switch-prepaid.test.ts - -Downgrades with prepaid quantities. - -> **Key behavior:** Total prepaid quantity is preserved (rounded to new billing units). -> Example: 5 packs × 100 units = 500 units → new plan with 50 units/pack = 10 packs. - -### Quantity handling -- **scheduled-switch: prepaid 5 packs to 2 packs (explicit options)** — Premium 5 packs (100 units/pack) → Pro 2 packs. Verify 2 packs on next cycle. -- **scheduled-switch: prepaid, no options passed in** — Premium 5 packs → Pro (no options). Verify total units preserved, quantity converted to new billing units. -- **scheduled-switch: prepaid, no options, different billing units** — Premium 5 packs (100 units/pack = 500 units) → Pro (50 units/pack). Verify 10 packs on next cycle. -- **scheduled-switch: prepaid to quantity 0** — Premium 5 packs → Pro with quantity: 0. Verify no prepaid charged on next cycle. - -### Feature changes -- **scheduled-switch: prepaid to product without prepaid feature** — Premium with prepaid → Free (no prepaid). Verify balance lost at cycle end. -- **scheduled-switch: prepaid with different price per pack** — Premium ($15/pack) → Pro ($10/pack). Verify next cycle uses new price. - -### Included usage change -- **scheduled-switch: prepaid included usage increase** — Premium prepaid (0 included) → Pro prepaid (100 included). Verify included usage changes on next cycle. -- **scheduled-switch: prepaid included usage decrease** — Premium prepaid (100 included) → Pro prepaid (0 included). Verify included usage changes on next cycle. - ---- - -## scheduled-switch-consumable.test.ts - -Downgrades with consumable features. - -> **Note:** Consumable overage is charged at cycle end via invoice-created webhook. These tests verify the downgrade flow works correctly with consumable usage. - -- **scheduled-switch: pro with consumable, usage under limit, to free** — Pro with consumable (used 50/100 included) → Free. Verify scheduled downgrade, no overage charged at cycle end. -- **scheduled-switch: pro with consumable, into overage, to free** — Pro with consumable (used 150/100, 50 overage) → Free. Verify overage charged at cycle end when downgrade completes. -- **scheduled-switch: premium with consumable overage, downgrade to pro** — Premium ($50/mo) with consumable (200 used, 100 overage) → Pro ($20/mo). Advance cycle. Verify overage billed to Premium, Pro active with balance reset. (from invoice-created-consumable-edge-cases.test.ts) - ---- - -## scheduled-switch-allocated.test.ts - -Downgrades with allocated (seat-based) features. - -> **Note:** These cases have undefined behavior. Tests should throw error "behavior undefined" until we clarify how allocated seats are handled on scheduled downgrade. - -- **scheduled-switch: pro with allocated, under limit, to free** — Pro with 5 allocated (using 3) → Free. Error: "behavior undefined". TBD: How are seats handled at cycle end? -- **scheduled-switch: pro with allocated, over limit, to free** — Pro with 5 allocated (using 7) → Free. Error: "behavior undefined". TBD: How is existing overage handled on downgrade? - ---- - -## scheduled-switch-entities.test.ts - -Multi-entity downgrade scenarios. - -### Basic entity downgrades -- **scheduled-switch: entity 1 pro, entity 2 pro, downgrade entity 1 to free** — Both on pro, downgrade one. Verify entity 1 has pro canceling + free scheduled, entity 2 unchanged. -- **scheduled-switch: entity 1 pro, entity 2 pro, downgrade both to free** — Both on pro, downgrade both. Verify both have free scheduled. Advance cycle, verify both on free. - -### Downgrade + upgrade on different entities simultaneously -- **scheduled-switch: entity 1 premium to pro, entity 2 pro to premium** — Premium on entity 1 → Pro (scheduled), Pro on entity 2 → Premium (immediate). Verify independent states. -- **scheduled-switch: entity 1 pro to premium, entity 2 premium to pro** — Pro on entity 1 → Premium (immediate), Premium on entity 2 → Pro (scheduled). Verify independent states. - -### Change scheduled product (replace) -- **scheduled-switch: entity 1 & 2 premium, downgrade both to free, entity 2 changes to pro** — Premium on both → Free scheduled on both → Entity 2 changes scheduled to pro. Verify entity 1 has free scheduled, entity 2 has pro scheduled. - -### Post-cycle upgrade -- **scheduled-switch: entity 1 premium to free, entity 2 premium to pro, advance cycle, upgrade entity 1 to premium** — After downgrade completes (entity 1 now free, entity 2 now pro), upgrade entity 1 back to premium. -- **scheduled-switch: entity 1 premiumAnnual to pro, entity 2 premium to pro, advance cycle, upgrade entity 2 to premium** — After monthly downgrade completes (entity 2 now pro), upgrade entity 2 back to premium. Entity 1 still has annual + scheduled pro. - -### Chained downgrades -- **scheduled-switch: entity 1 premium, entity 2 premium, downgrade both to pro, then downgrade entity 1 to free** — Premium on both → Pro scheduled on both → Free scheduled on entity 1 (replaces pro). Verify entity 1 has free scheduled, entity 2 has pro scheduled. - ---- - -## scheduled-switch-multi-interval.test.ts - -Mixed billing interval scenarios (annual + monthly entities). - -- **scheduled-switch: entity 1 premiumAnnual, entity 2 premium, downgrade both to pro, advance monthly cycle** — Annual on entity 1, monthly on entity 2 → both scheduled for pro. Advance 1 month. Entity 1 still on premiumAnnual + pro scheduled (annual not ended), entity 2 now on pro. -- **scheduled-switch: entity 1 premiumAnnual, entity 2 premium, downgrade both to pro, re-upgrade both** — Annual on entity 1, monthly on entity 2 → both scheduled for pro → both re-upgrade (premiumAnnual and premium). Verify scheduled downgrades cancelled, both back to original products. -- **scheduled-switch: entity 1 premiumAnnual, entity 2 premium, downgrade both to pro, advance full year** — Same setup but advance a full year to see the annual downgrade complete as well. Verify both entities now on pro. - ---- - -## scheduled-switch-edge-cases.test.ts - -Edge cases and complex scenarios. - -- **scheduled-switch: multiple scheduled changes on same entity** — Growth → Free (scheduled) → Pro (replaces) → Premium (replaces) → Free (replaces). Verify each change replaces the previous scheduled product. - ---- - -## Summary - -| File | Test Count | -|------|------------| -| `scheduled-switch-basic.test.ts` | 7 | -| `scheduled-switch-prepaid.test.ts` | 8 | -| `scheduled-switch-consumable.test.ts` | 3 | -| `scheduled-switch-allocated.test.ts` | 2 | -| `scheduled-switch-entities.test.ts` | 8 | -| `scheduled-switch-multi-interval.test.ts` | 3 | -| `scheduled-switch-edge-cases.test.ts` | 1 | -| **Total** | **32** | diff --git a/server/tests/integration/billing/attach/trials.md b/server/tests/integration/billing/attach/trials.md deleted file mode 100644 index e48e069f0..000000000 --- a/server/tests/integration/billing/attach/trials.md +++ /dev/null @@ -1,312 +0,0 @@ -# Trials Test Plan - -Tests for free trial logic in attach operations. - ---- - -## File Structure - -| File | Test Count | Description | -|------|------------|-------------| -| `trials-basic.test.ts` | 5 | Basic trial attachment and states | -| `trials-conversion.test.ts` | 5 | Trial to paid conversion | -| `trials-cancel.test.ts` | 5 | Canceling trials (immediately, end-of-cycle) | -| `trials-upgrade.test.ts` | 4 | Upgrading while in trial | -| `trials-entities.test.ts` | 4 | Entity-scoped trials | -| `trials-payment-method.test.ts` | 4 | Card required vs not required | - -**Total: 27 tests** - ---- - -## Trial Product Types - -| Type | Property | Description | -|------|----------|-------------| -| Card Required | `cardRequired: true` | Customer must have PM before trial starts | -| No Card Required | `cardRequired: false` | Customer can start trial without PM | - -```typescript -// Card required trial (default for proWithTrial) -const proTrial = products.proWithTrial({ - id: "pro-trial", - items: [messagesItem], - trialDays: 7, - cardRequired: true, -}); - -// No card required trial (default for baseWithTrial) -const freeTrial = products.baseWithTrial({ - id: "free-trial", - items: [messagesItem], - trialDays: 14, - cardRequired: false, -}); -``` - ---- - -## Test Details - -### `trials-basic.test.ts` (5 tests) - -| # | Test Name | Scenario | Key Assertions | -|---|-----------|----------|----------------| -| 1 | trial: attach product with trial | Attach proTrial | Product status = trialing, trialEndsAt correct | -| 2 | trial: features available during trial | Check entitlements during trial | All features accessible | -| 3 | trial: trial end date calculation | 7-day trial attached today | trialEndsAt = now + 7 days | -| 4 | trial: usage tracking during trial | Track usage during trial | Usage recorded, balance updated | -| 5 | trial: trial with prepaid features | Trial product with prepaid credits | Credits available during trial | - -**Setup:** -```typescript -const proTrial = products.proWithTrial({ - id: "pro-trial", - items: [messagesItem], - trialDays: 7, - cardRequired: true, -}); - -const { customerId, autumnV1 } = await initScenario({ - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [proTrial] }), - ], - actions: [s.attach({ productId: proTrial.id })], -}); - -const customer = await autumnV1.customers.get(customerId); -expectProductTrialing({ - customer, - productId: proTrial.id, - trialEndsAt: addDays(new Date(), 7).getTime(), - toleranceMs: 60_000, // 1 minute tolerance -}); -``` - ---- - -### `trials-conversion.test.ts` (5 tests) - -| # | Test Name | Scenario | Key Assertions | -|---|-----------|----------|----------------| -| 1 | conversion: trial ends naturally | Advance clock past trial end | Status = active, invoice generated | -| 2 | conversion: remove trial early | Call remove trial action | Trial ends immediately, payment charged | -| 3 | conversion: trial ends without PM | No card trial ends | Product removed or checkout required | -| 4 | conversion: trial ends with failed PM | PM fails at conversion | Invoice open, action required | -| 5 | conversion: invoice amount after trial | Trial ends, verify invoice | First invoice = full price (no proration) | - -**Conversion Pattern:** -```typescript -const { customerId, autumnV1, ctx } = await initScenario({ - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [proTrial] }), - ], - actions: [s.attach({ productId: proTrial.id })], -}); - -// Advance past trial (7 days + buffer) -const advancedTo = await advanceToNextInvoice({ - stripeCli: ctx.stripeCli, - testClockId: ctx.testClockId, - currentEpochMs: addDays(new Date(), 7).getTime(), -}); - -// Verify conversion -const customer = await autumnV1.customers.get(customerId); -expectProductNotTrialing({ customer, productId: proTrial.id, nowMs: advancedTo }); -expectProductActive({ customer, productId: proTrial.id }); -``` - ---- - -### `trials-cancel.test.ts` (5 tests) - -| # | Test Name | Scenario | Key Assertions | -|---|-----------|----------|----------------| -| 1 | cancel-trial: immediately | Cancel trial immediately | Product removed, no invoice | -| 2 | cancel-trial: end-of-cycle (trial period) | Cancel during trial | Canceling status, removed at trial end | -| 3 | cancel-trial: uncancel during trial | Cancel then uncancel | Trial restored, same end date | -| 4 | cancel-trial: usage not charged | Cancel trial with usage | No overage charged | -| 5 | cancel-trial: verify no refund | Cancel free trial | No refund invoice (nothing charged) | - -**Cancel Pattern:** -```typescript -// Cancel trial immediately -await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: proTrial.id, - cancel_action: "cancel_immediately", -}); - -const customer = await autumnV1.customers.get(customerId); -expectProductNotPresent({ customer, productId: proTrial.id }); - -// Cancel at end of trial -await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: proTrial.id, - cancel_action: "cancel_end_of_cycle", -}); - -expectProductCanceling({ customer, productId: proTrial.id }); -expectProductTrialing({ customer, productId: proTrial.id }); // Still trialing until end -``` - ---- - -### `trials-upgrade.test.ts` (4 tests) - -| # | Test Name | Scenario | Key Assertions | -|---|-----------|----------|----------------| -| 1 | upgrade-trial: trial to trial | Pro trial → Premium trial | New trial starts, original trial replaced | -| 2 | upgrade-trial: trial to paid | Trial → paid (no trial) | Trial ends, paid immediately | -| 3 | upgrade-trial: trial to free | Trial → free product | Trial ends, free product attached | -| 4 | upgrade-trial: preserve trial days | Upgrade mid-trial | Remaining trial days preserved (if configured) | - -**Upgrade Pattern:** -```typescript -const proTrial = products.proWithTrial({ id: "pro-trial", items: [...], trialDays: 14 }); -const premiumTrial = products.premiumWithTrial({ id: "premium-trial", items: [...], trialDays: 14 }); - -// Attach pro trial -await autumnV1.attach({ customer_id: customerId, product_id: proTrial.id }); - -// Upgrade to premium trial after 7 days -await advanceTestClock({ ... addDays(7) }); -await autumnV1.attach({ customer_id: customerId, product_id: premiumTrial.id }); - -// Verify new trial -const customer = await autumnV1.customers.get(customerId); -expectProductTrialing({ customer, productId: premiumTrial.id }); -expectProductNotPresent({ customer, productId: proTrial.id }); -``` - ---- - -### `trials-entities.test.ts` (4 tests) - -| # | Test Name | Scenario | Key Assertions | -|---|-----------|----------|----------------| -| 1 | entity-trial: new entity starts trial | Entity1 with trial | Entity1 trialing | -| 2 | entity-trial: second entity mid-trial | Entity2 joins while Entity1 in trial | Entity2 starts own trial | -| 3 | entity-trial: entity trial conversion | Advance past entity trial end | Entity-level invoice generated | -| 4 | entity-trial: cancel one entity trial | Cancel Entity1 trial | Entity2 trial unaffected | - -**Entity Pattern:** -```typescript -const { customerId, autumnV1 } = await initScenario({ - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [proTrial] }), - s.entities({ ids: ["entity-1", "entity-2"] }), - ], - actions: [ - s.attach({ productId: proTrial.id, entityId: "entity-1" }), - ], -}); - -// Entity-1 trialing -const entity1 = await autumnV1.entities.get(customerId, "entity-1"); -expectProductTrialing({ customer: entity1, productId: proTrial.id }); - -// Entity-2 not attached yet -const entity2 = await autumnV1.entities.get(customerId, "entity-2"); -expect(entity2.products.length).toBe(0); -``` - ---- - -### `trials-payment-method.test.ts` (4 tests) - -| # | Test Name | Scenario | Key Assertions | -|---|-----------|----------|----------------| -| 1 | pm-trial: card required - no PM | Attach cardRequired trial without PM | Error or checkout required | -| 2 | pm-trial: card required - with PM | Attach cardRequired trial with PM | Trial starts successfully | -| 3 | pm-trial: no card required - start | Attach no-card trial without PM | Trial starts without PM | -| 4 | pm-trial: no card required - conversion | No-card trial ends | Checkout required to continue | - -**Payment Method Pattern:** -```typescript -// Card required - needs PM -const cardRequiredTrial = products.proWithTrial({ - items: [...], - trialDays: 7, - cardRequired: true, -}); - -// No card required - no PM needed -const noCardTrial = products.baseWithTrial({ - items: [...], - trialDays: 7, - cardRequired: false, -}); - -// Without PM - cardRequired fails, noCard succeeds -const { customerId, autumnV1 } = await initScenario({ - setup: [ - s.customer({ testClock: true }), // No payment method - s.products({ list: [cardRequiredTrial, noCardTrial] }), - ], -}); - -// This should require checkout or fail -const result1 = await autumnV1.attach({ - customer_id: customerId, - product_id: cardRequiredTrial.id, -}); -expect(result1.checkout_url).toBeDefined(); - -// This should succeed -await autumnV1.attach({ - customer_id: customerId, - product_id: noCardTrial.id, -}); -``` - ---- - -## Key Utilities - -**Product Fixtures:** -```typescript -products.proWithTrial({ items, trialDays, cardRequired }) -products.premiumWithTrial({ items, trialDays, cardRequired }) -products.baseWithTrial({ items, trialDays, cardRequired }) -products.defaultTrial({ items, trialDays, cardRequired }) -``` - -**Expectation Helpers:** -```typescript -expectProductTrialing({ - customer, - productId, - trialEndsAt, // Expected trial end timestamp - toleranceMs, // Tolerance for date comparison (default 60000) -}); - -expectProductNotTrialing({ - customer, - productId, - nowMs, // Current time to compare against -}); -``` - -**Test Clock Advancement:** -```typescript -// Advance to end of trial -await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addDays(new Date(), trialDays).getTime(), - waitForSeconds: 30, -}); - -// Or use advanceToNextInvoice for full cycle -await advanceToNextInvoice({ - stripeCli, - testClockId, - currentEpochMs, -}); -``` diff --git a/server/tests/merged/mergeUtils/expectSubCorrect.ts b/server/tests/merged/mergeUtils/expectSubCorrect.ts index b5b90d31a..ca8c5a7ab 100644 --- a/server/tests/merged/mergeUtils/expectSubCorrect.ts +++ b/server/tests/merged/mergeUtils/expectSubCorrect.ts @@ -169,6 +169,7 @@ export const expectSubToBeCorrect = async ({ shouldBeTrialing?: boolean; flags?: { checkNotTrialing?: boolean; + checkTrialing?: boolean; }; subId?: string; rewards?: string[]; @@ -429,6 +430,10 @@ export const expectSubToBeCorrect = async ({ expect(sub.status).not.toBe("trialing"); } + if (flags?.checkTrialing) { + expect(sub.status).toBe("trialing"); + } + // Should be canceled const cusSubShouldBeCanceled = cusProducts.every((cp) => { if (cp.subscription_ids?.includes(subId!)) { diff --git a/server/tests/utils/testAttachUtils/testAttachUtils.ts b/server/tests/utils/testAttachUtils/testAttachUtils.ts index e5406e5f6..ea9d0ff9d 100644 --- a/server/tests/utils/testAttachUtils/testAttachUtils.ts +++ b/server/tests/utils/testAttachUtils/testAttachUtils.ts @@ -168,7 +168,7 @@ export const advanceToNextInvoice = async ({ stripeCli, testClockId, advanceTo: addMonths(baseTime, 1).getTime(), - waitForSeconds: 45, + waitForSeconds: 30, }); await advanceTestClock({ diff --git a/shared/api/billing/attachV2/attachParamsV0.ts b/shared/api/billing/attachV2/attachParamsV0.ts index 5198fd49e..ffc5f7d8b 100644 --- a/shared/api/billing/attachV2/attachParamsV0.ts +++ b/shared/api/billing/attachV2/attachParamsV0.ts @@ -1,5 +1,4 @@ import { z } from "zod/v4"; -import { FeatureOptionsSchema } from "../../../models/cusProductModels/cusProductModels.js"; import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels.js"; import { BillingParamsBaseSchema } from "../common/billingParamsBase.js"; @@ -16,8 +15,6 @@ export const ExtAttachParamsV0Schema = BillingParamsBaseSchema.extend({ finalize_invoice: z.boolean().optional(), // Product config - options: z.array(FeatureOptionsSchema).nullish(), - version: z.number().optional(), // Checkout behavior redirect_mode: RedirectModeSchema.default("always"), diff --git a/shared/api/billing/common/billingParamsBase.ts b/shared/api/billing/common/billingParamsBase.ts index de8a1b17d..276412673 100644 --- a/shared/api/billing/common/billingParamsBase.ts +++ b/shared/api/billing/common/billingParamsBase.ts @@ -1,3 +1,6 @@ +import { FreeTrialParamsV0Schema } from "@api/billing/common/freeTrial/freeTrialParamsV0.js"; +import { FeatureOptionsSchema } from "@models/cusProductModels/cusProductModels.js"; +import { ProductItemSchema } from "@models/productV2Models/productItemModels/productItemModels.js"; import { z } from "zod/v4"; import { CustomerDataSchema } from "../../common/customerData.js"; import { EntityDataSchema } from "../../common/entityData.js"; @@ -7,6 +10,12 @@ export const BillingParamsBaseSchema = z.object({ entity_id: z.string().nullish(), customer_data: CustomerDataSchema.optional(), entity_data: EntityDataSchema.optional(), + + // Used for both update and attach + options: z.array(FeatureOptionsSchema).nullish(), + version: z.number().optional(), + free_trial: FreeTrialParamsV0Schema.nullable().optional(), + items: z.array(ProductItemSchema).optional(), }); export type BillingParamsBase = z.infer; diff --git a/shared/api/billing/common/freeTrial/freeTrialParamsV0.ts b/shared/api/billing/common/freeTrial/freeTrialParamsV0.ts new file mode 100644 index 000000000..5ccba662f --- /dev/null +++ b/shared/api/billing/common/freeTrial/freeTrialParamsV0.ts @@ -0,0 +1,10 @@ +import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTrialEnums.js"; +import { z } from "zod/v4"; + +export const FreeTrialParamsV0Schema = z.object({ + length: z.number(), + duration: z.enum(FreeTrialDuration), + card_required: z.boolean().default(true), +}); + +export type FreeTrialParamsV0 = z.infer; diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts index 11c03f445..91a22cf2e 100644 --- a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts +++ b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts @@ -1,9 +1,6 @@ import { RefundBehaviorSchema } from "@api/billing/common/refundBehavior"; -import { CreateFreeTrialSchema } from "@models/productModels/freeTrialModels/freeTrialModels"; import { nullish } from "@utils/utils"; import { z } from "zod/v4"; -import { FeatureOptionsSchema } from "../../../models/cusProductModels/cusProductModels"; -import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels"; import { BillingBehaviorSchema } from "../common/billingBehavior"; import { BillingParamsBaseSchema } from "../common/billingParamsBase"; import { CancelActionSchema } from "../common/cancelAction"; @@ -16,12 +13,8 @@ export const ExtUpdateSubscriptionV0ParamsSchema = invoice: z.boolean().optional(), enable_product_immediately: z.boolean().optional(), finalize_invoice: z.boolean().optional(), - options: z.array(FeatureOptionsSchema).nullish(), // used for update quantity etc (in api - feature_quantities) // New - version: z.number().optional(), - items: z.array(ProductItemSchema).optional(), // used for custom configuration of a plan (in api - plan_override) - free_trial: CreateFreeTrialSchema.nullable().optional(), // Cancel action: 'cancel_immediately' | 'cancel_end_of_cycle' | 'uncancel' cancel_action: CancelActionSchema.optional(),