diff --git a/scripts/testGroups/all.sh b/scripts/testGroups/all.sh index 7ec1b1da6..0984ac5f4 100755 --- a/scripts/testGroups/all.sh +++ b/scripts/testGroups/all.sh @@ -4,14 +4,14 @@ source "$(dirname "$0")/config.sh" export TEST_FILE_CONCURRENCY=2 BUN_PARALLEL_V2 \ - 'integration/billing/migrations' \ - 'integration/crud/customers' \ - 'integration/billing/stripe-webhooks' \ - 'integration/billing/autumn-webhooks' \ - 'integration/cron' \ - 'integration/crud/plans' \ 'integration/billing/update-subscription' \ 'integration/billing/attach' \ + # 'integration/billing/migrations' \ + # 'integration/crud/customers' \ + # 'integration/billing/stripe-webhooks' \ + # 'integration/billing/autumn-webhooks' \ + # 'integration/cron' \ + # 'integration/crud/plans' \ # 'integration/billing/attach' \ diff --git a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts index 0773cbb57..32eb702c8 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts @@ -127,8 +127,6 @@ export const createStripePrepaid = async ({ }; } - console.log("priceAmountData", priceAmountData); - stripePrice = await stripeCli.prices.create({ ...productData, currency: orgToCurrency({ org }), diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleTransitionConfigErrors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleTransitionConfigErrors.ts index 3994628ef..13edc414d 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleTransitionConfigErrors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleTransitionConfigErrors.ts @@ -17,10 +17,10 @@ export const handleTransitionConfigErrors = ({ }) => { const { transitionConfig } = billingContext; - const resetAfterTrialEndFeaturIds = - transitionConfig?.resetAfterTrialEndFeaturIds ?? []; + const resetAfterTrialEndFeatureIds = + transitionConfig?.resetAfterTrialEndFeatureIds ?? []; - for (const featureId of resetAfterTrialEndFeaturIds) { + for (const featureId of resetAfterTrialEndFeatureIds) { const feature = featureUtils.find.byId({ features: ctx.features, featureId, 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 cbdf7f11d..f4729c1f3 100644 --- a/server/src/internal/billing/v2/actions/attach/logs/logAttachContext.ts +++ b/server/src/internal/billing/v2/actions/attach/logs/logAttachContext.ts @@ -61,9 +61,9 @@ export const logAttachContext = ({ .join(", ") : "none", - resetAfterTrialEndFeaturIds: - transitionConfig?.resetAfterTrialEndFeaturIds - ? transitionConfig.resetAfterTrialEndFeaturIds.join(", ") + resetAfterTrialEndFeatureIds: + transitionConfig?.resetAfterTrialEndFeatureIds + ? transitionConfig.resetAfterTrialEndFeatureIds.join(", ") : "none", trialContext: trialContext diff --git a/server/src/internal/billing/v2/actions/legacy/legacyAttach.ts b/server/src/internal/billing/v2/actions/legacy/legacyAttach.ts index 12ca1d92b..50205fd35 100644 --- a/server/src/internal/billing/v2/actions/legacy/legacyAttach.ts +++ b/server/src/internal/billing/v2/actions/legacy/legacyAttach.ts @@ -6,6 +6,7 @@ import type { import { BillingVersion } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { billingActions } from "@/internal/billing/v2/actions"; +import { attachParamsToInvoiceModeParams } from "@/internal/billing/v2/actions/legacy/utils/attachParamsToInvoiceModeParams"; import { attachParamsToStripeBillingContext } from "@/internal/billing/v2/actions/legacy/utils/attachParamsToStripeBillingContext"; import { setupLegacyTransitionContext } from "@/internal/billing/v2/actions/legacy/utils/setupLegacyFeatureQuantitiesContext"; import { billingResultToResponse } from "@/internal/billing/v2/utils/billingResult/billingResultToResponse"; @@ -53,13 +54,7 @@ export const legacyAttach = async ({ entity_id: fullCustomer.entity?.id, plan_id: fullProduct.id, - invoice_mode: attachParams.invoiceOnly - ? { - enabled: attachParams.invoiceOnly, - enable_product_immediately: true, - finalize_invoice: attachParams.finalizeInvoice ?? true, - } - : undefined, + invoice_mode: attachParamsToInvoiceModeParams({ attachParams }), redirect_mode: "if_required", diff --git a/server/src/internal/billing/v2/actions/legacy/renew.ts b/server/src/internal/billing/v2/actions/legacy/renew.ts index dec76b920..6284bebf0 100644 --- a/server/src/internal/billing/v2/actions/legacy/renew.ts +++ b/server/src/internal/billing/v2/actions/legacy/renew.ts @@ -71,8 +71,8 @@ export const renew = async ({ invoice_mode: body.invoice ? { enabled: true, - enable_product_immediately: body.enable_product_immediately ?? false, - finalize_invoice: body.finalize_invoice ?? true, + enable_plan_immediately: body.enable_product_immediately ?? false, + finalize: body.finalize_invoice ?? true, } : undefined, diff --git a/server/src/internal/billing/v2/actions/legacy/updateQuantity.ts b/server/src/internal/billing/v2/actions/legacy/updateQuantity.ts index bb1a03b32..9916c063b 100644 --- a/server/src/internal/billing/v2/actions/legacy/updateQuantity.ts +++ b/server/src/internal/billing/v2/actions/legacy/updateQuantity.ts @@ -73,8 +73,8 @@ export const updateQuantity = async ({ invoice_mode: body.invoice ? { enabled: true, - enable_product_immediately: body.enable_product_immediately ?? false, - finalize_invoice: body.finalize_invoice ?? true, + enable_plan_immediately: body.enable_product_immediately ?? false, + finalize: body.finalize_invoice ?? true, } : undefined, diff --git a/server/src/internal/billing/v2/actions/legacy/utils/attachParamsToInvoiceModeParams.ts b/server/src/internal/billing/v2/actions/legacy/utils/attachParamsToInvoiceModeParams.ts new file mode 100644 index 000000000..a13c8a327 --- /dev/null +++ b/server/src/internal/billing/v2/actions/legacy/utils/attachParamsToInvoiceModeParams.ts @@ -0,0 +1,16 @@ +import type { InvoiceModeParams } from "@autumn/shared"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams"; + +export const attachParamsToInvoiceModeParams = ({ + attachParams, +}: { + attachParams: AttachParams; +}): InvoiceModeParams | undefined => { + return attachParams.invoiceOnly + ? { + enabled: attachParams.invoiceOnly, + enable_plan_immediately: true, + finalize: attachParams.finalizeInvoice ?? true, + } + : undefined; +}; diff --git a/server/src/internal/billing/v2/actions/legacy/utils/setupLegacyFeatureQuantitiesContext.ts b/server/src/internal/billing/v2/actions/legacy/utils/setupLegacyFeatureQuantitiesContext.ts index a601a86a2..dcc20be44 100644 --- a/server/src/internal/billing/v2/actions/legacy/utils/setupLegacyFeatureQuantitiesContext.ts +++ b/server/src/internal/billing/v2/actions/legacy/utils/setupLegacyFeatureQuantitiesContext.ts @@ -14,7 +14,7 @@ export const setupLegacyTransitionContext = ({ .map((ent) => ent.feature); return { - resetAfterTrialEndFeaturIds: consumableFeatures.map((f) => f.id), + resetAfterTrialEndFeatureIds: consumableFeatures.map((f) => f.id), }; // const newOptions: FeatureOptionsParamsV0[] = []; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/applyUncancelToPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/applyUncancelToPlan.ts index 927475aff..faa5696ee 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/applyUncancelToPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/applyUncancelToPlan.ts @@ -1,5 +1,7 @@ -import type { UpdateSubscriptionBillingContext } from "@autumn/shared"; -import type { AutumnBillingPlan } from "@autumn/shared"; +import type { + AutumnBillingPlan, + UpdateSubscriptionBillingContext, +} from "@autumn/shared"; import { computeCustomerProductToDelete } from "@/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCustomerProductToDelete"; /** @@ -18,7 +20,7 @@ export const applyUncancelToPlan = ({ if (cancelAction !== "uncancel") return plan; - const cancelUpdates = { + const uncancelUpdates = { canceled: false, canceled_at: null, ended_at: null, @@ -37,12 +39,19 @@ export const applyUncancelToPlan = ({ existingUpdate?.customerProduct ?? billingContext.customerProduct, updates: { ...existingUpdate?.updates, - ...cancelUpdates, + ...uncancelUpdates, }, }; + // // If there are new customer products being inserted, also add uncancel updates to them + // const insertCustomerProducts = plan.insertCustomerProducts.map((insert) => ({ + // ...insert, + // ...uncancelUpdates, + // })); + return { ...plan, + // insertCustomerProducts, updateCustomerProduct, // Use the plan's deleteCustomerProduct if already set, otherwise use ours deleteCustomerProduct: plan.deleteCustomerProduct ?? deleteCustomerProduct, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts index 9df981667..b2379ea88 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts @@ -37,5 +37,7 @@ export const setupDefaultProductContext = async ({ productGroup: customerProduct.product.group, }); + if (defaultProduct?.id === customerProduct.product.id) return undefined; + return defaultProduct; }; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts index 69ec7efdc..a70d453b6 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts @@ -78,6 +78,7 @@ export const executeStripeSubscriptionAction = async ({ stripeClient: createStripeCli({ org: ctx.org, env: ctx.env }), invoiceId: latestStripeInvoice!.id, hasPaymentMethod: Boolean(billingContext.paymentMethod), + invoiceMode: billingContext.invoiceMode ?? undefined, }) : undefined; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/common/shouldDeferBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/utils/common/shouldDeferBillingPlan.ts index 3a5b6d754..2a341986d 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/common/shouldDeferBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/common/shouldDeferBillingPlan.ts @@ -1,6 +1,8 @@ -import type { BillingResponseRequiredAction } from "@autumn/shared"; +import type { + BillingContext, + BillingResponseRequiredAction, +} from "@autumn/shared"; import type Stripe from "stripe"; -import type { BillingContext } from "@autumn/shared"; import { isDeferredInvoiceMode } from "@/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode"; export const shouldDeferBillingPlan = ({ diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts index e35be4717..5a4f4d842 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts @@ -78,6 +78,7 @@ const customerProductsToPhaseItems = ({ * Converts billing context discounts to the format expected by Stripe schedule phases. * Uses the existing discount ID so Stripe reuses the same discount object, * preserving the original start/end timestamps and remaining duration for repeating coupons. + * Falls back to coupon ID for new discounts that don't have a discount ID yet. */ const stripeDiscountsToPhaseDiscounts = ({ stripeDiscounts, @@ -86,9 +87,11 @@ const stripeDiscountsToPhaseDiscounts = ({ }): Stripe.SubscriptionScheduleUpdateParams.Phase.Discount[] | undefined => { if (!stripeDiscounts || stripeDiscounts.length === 0) return undefined; - return stripeDiscounts.map((discount) => ({ - discount: discount.source.coupon.id, - })); + return stripeDiscounts.map((discount) => + discount.id + ? { discount: discount.id } + : { coupon: discount.source.coupon.id }, + ); }; /** diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/getRequiredActionFromSubscriptionInvoice.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/getRequiredActionFromSubscriptionInvoice.ts index 65d314081..3e14beb02 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/getRequiredActionFromSubscriptionInvoice.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/getRequiredActionFromSubscriptionInvoice.ts @@ -1,4 +1,7 @@ -import type { BillingResponseRequiredAction } from "@autumn/shared"; +import type { + BillingResponseRequiredAction, + InvoiceMode, +} from "@autumn/shared"; import type Stripe from "stripe"; import { getStripeInvoice } from "@/external/stripe/invoices/operations/getStripeInvoice.js"; @@ -20,11 +23,16 @@ export const getRequiredActionFromSubscriptionInvoice = async ({ stripeClient, invoiceId, hasPaymentMethod, + invoiceMode, }: { stripeClient: Stripe; invoiceId: string; hasPaymentMethod: boolean; + invoiceMode?: InvoiceMode; }): Promise => { + // Should enable prooduct immediately, can pay later + if (invoiceMode?.enableProductImmediately) return undefined; + // Fetch invoice with expanded payments to get payment intent status const invoice = await getStripeInvoice({ stripeClient, diff --git a/server/src/internal/billing/v2/setup/setupAdjustableQuantities.ts b/server/src/internal/billing/v2/setup/setupAdjustableQuantities.ts index c92da3693..0dcbc51e8 100644 --- a/server/src/internal/billing/v2/setup/setupAdjustableQuantities.ts +++ b/server/src/internal/billing/v2/setup/setupAdjustableQuantities.ts @@ -7,7 +7,7 @@ export const setupAdjustableQuantities = ({ }) => { return ( params.feature_quantities - ?.filter((fq) => fq.adjustable) + ?.filter((fq) => fq.adjustable === true) .map((fq) => fq.feature_id) ?? [] ); }; diff --git a/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts b/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts index a4d05190a..ad730b47d 100644 --- a/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts +++ b/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts @@ -26,6 +26,12 @@ export const setupCustomFullProduct = async ({ }; } + const newItems = customizePlanV1ToV0({ + ctx, + customizePlanV1: customizePlan, + fullProduct: currentFullProduct, + }); + // Customize plan -> custom items const { db, logger, features } = ctx; @@ -38,11 +44,7 @@ export const setupCustomFullProduct = async ({ db, curPrices: currentPrices, curEnts: currentEntitlements, - newItems: customizePlanV1ToV0({ - ctx, - customizePlanV1: customizePlan, - fullProduct: currentFullProduct, - }), + newItems, features, product: currentFullProduct, logger, diff --git a/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts b/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts index fa4c468ec..7ecbc1219 100644 --- a/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts +++ b/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts @@ -13,8 +13,7 @@ export const setupInvoiceModeContext = ({ } return { - finalizeInvoice: params.invoice_mode?.finalize_invoice === true, - enableProductImmediately: - params.invoice_mode?.enable_product_immediately !== false, + finalizeInvoice: params.invoice_mode?.finalize, + enableProductImmediately: params.invoice_mode?.enable_plan_immediately, }; }; diff --git a/server/src/internal/billing/v2/setup/setupTransitionConfigs.ts b/server/src/internal/billing/v2/setup/setupTransitionConfigs.ts index 1d9f84c0b..a75f026c1 100644 --- a/server/src/internal/billing/v2/setup/setupTransitionConfigs.ts +++ b/server/src/internal/billing/v2/setup/setupTransitionConfigs.ts @@ -16,7 +16,7 @@ export const setupTransitionConfigs = ({ } return { - resetAfterTrialEndFeaturIds: + resetAfterTrialEndFeatureIds: params.transition_rules?.reset_after_trial_end ?? [], }; }; diff --git a/server/src/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode.ts b/server/src/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode.ts index 8f5792fd9..6c5f94e2d 100644 --- a/server/src/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode.ts +++ b/server/src/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode.ts @@ -6,6 +6,7 @@ export const isDeferredInvoiceMode = ({ billingContext: BillingContext; }): boolean => { const isInvoiceMode = Boolean(billingContext.invoiceMode); + const shouldDefer = billingContext.invoiceMode?.enableProductImmediately === false; diff --git a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts index 48a80038d..6fed3b56f 100644 --- a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts +++ b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts @@ -55,25 +55,7 @@ export const billingPlanToPreviewResponse = ({ // When total is negative (refund credit exceeds new charges), clamp to 0 // and emit a credit object. Reduce the next cycle total by the credit amount // to reflect what Stripe actually does (apply customer balance to next invoice). - let total = rawTotal; - let credit: BillingPreviewResponse["credit"]; - - if (rawTotal < 0) { - const creditAmount = new Decimal(rawTotal).abs().toDP(2).toNumber(); - total = 0; - credit = { - amount: creditAmount, - description: "Credit for unused time on previous plan", - }; - - if (nextCycle) { - const appliedCredit = Math.min(creditAmount, nextCycle.total); - nextCycle.total = new Decimal(nextCycle.total) - .minus(appliedCredit) - .toDP(2) - .toNumber(); - } - } + const total = rawTotal; logBillingPreview({ ctx, @@ -97,7 +79,7 @@ export const billingPlanToPreviewResponse = ({ line_items: previewImmediateLineItems, total, currency, - credit, + // credit, period_start: periodStart, period_end: periodEnd, next_cycle: nextCycle, diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementNextResetAt.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementNextResetAt.ts index 96c5dd515..2a5b5e5cf 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementNextResetAt.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementNextResetAt.ts @@ -26,11 +26,11 @@ export const initCustomerEntitlementNextResetAt = ({ if (isLifetime || isUnlimited || isBoolean) return null; let { resetCycleAnchor, now, trialEndsAt, transitionConfig } = initContext; - const { resetAfterTrialEndFeaturIds } = transitionConfig ?? {}; + const { resetAfterTrialEndFeatureIds } = transitionConfig ?? {}; const { startsAt } = initOptions ?? {}; if ( - resetAfterTrialEndFeaturIds?.includes(entitlement.feature.id) && + resetAfterTrialEndFeatureIds?.includes(entitlement.feature.id) && trialEndsAt ) { now = trialEndsAt; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingRolloversToCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingRolloversToCustomerProduct.ts index ba44993f7..00d6b77c3 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingRolloversToCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingRolloversToCustomerProduct.ts @@ -44,6 +44,7 @@ export const reapplyExistingRolloversToCustomerProduct = async ({ existingRollovers: currentRollovers, }); + try { for (const cusEnt of customerProduct.customer_entitlements) { await RolloverService.insert({ diff --git a/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-prepaid.test.ts b/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-prepaid.test.ts index 98ed7981b..78122473e 100644 --- a/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-prepaid.test.ts +++ b/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-prepaid.test.ts @@ -162,8 +162,13 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: prepaid quantity updated const result = await autumnV1.billing.attach({ customer_id: customerId, product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: initialQuantity }], - adjustable_quantity: true, + options: [ + { + feature_id: TestFeature.Messages, + quantity: initialQuantity, + adjustable: true, + }, + ], }); expect(result.payment_url).toBeDefined(); @@ -272,10 +277,17 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: multiple prepaid feature customer_id: customerId, product_id: pro.id, options: [ - { feature_id: TestFeature.Messages, quantity: initialMessagesQty }, - { feature_id: TestFeature.Words, quantity: initialWordsQty }, + { + feature_id: TestFeature.Messages, + quantity: initialMessagesQty, + adjustable: true, + }, + { + feature_id: TestFeature.Words, + quantity: initialWordsQty, + adjustable: true, + }, ], - adjustable_quantity: true, }); expect(result.payment_url).toBeDefined(); @@ -385,8 +397,13 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: prepaid quantity set to const result = await autumnV1.billing.attach({ customer_id: customerId, product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: initialQuantity }], - adjustable_quantity: true, + options: [ + { + feature_id: TestFeature.Messages, + quantity: initialQuantity, + adjustable: true, + }, + ], }); expect(result.payment_url).toBeDefined(); @@ -480,8 +497,13 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: tiered prepaid with quan const result = await autumnV1.billing.attach({ customer_id: customerId, product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: initialQuantity }], - adjustable_quantity: true, + options: [ + { + feature_id: TestFeature.Messages, + quantity: initialQuantity, + adjustable: true, + }, + ], }); expect(result.payment_url).toBeDefined(); diff --git a/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/v1-attach-v2-update-quantity.test.ts b/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/v1-attach-v2-update-quantity.test.ts index 8b7dc702f..d21adb4d2 100644 --- a/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/v1-attach-v2-update-quantity.test.ts +++ b/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/v1-attach-v2-update-quantity.test.ts @@ -128,7 +128,7 @@ test.concurrent(`${chalk.yellowBright("v1→v2 compat: increment quantity (multi customerId, org: ctx.org, env: ctx.env, - billingVersion: BillingVersion.V2, + billingVersion: BillingVersion.V1, }); await expectCustomerInvoiceCorrect({ diff --git a/server/tests/integration/billing/attach/errors/transition-config-errors.test.ts b/server/tests/integration/billing/attach/errors/transition-config-errors.test.ts index ed6e35011..f872499a1 100644 --- a/server/tests/integration/billing/attach/errors/transition-config-errors.test.ts +++ b/server/tests/integration/billing/attach/errors/transition-config-errors.test.ts @@ -60,9 +60,11 @@ test.concurrent(`${chalk.yellowBright("transition-config-errors 1: reset_after_t { feature_id: TestFeature.Workflows, quantity: 5, - reset_after_trial_end: true, }, ], + transition_rules: { + reset_after_trial_end: [TestFeature.Workflows], + }, }); }, }); @@ -107,9 +109,11 @@ test.concurrent(`${chalk.yellowBright("transition-config-errors 2: reset_after_t { feature_id: TestFeature.Users, quantity: 5, - reset_after_trial_end: true, }, ], + transition_rules: { + reset_after_trial_end: [TestFeature.Users], + }, }); }, }); @@ -156,6 +160,9 @@ test.concurrent(`${chalk.yellowBright("transition-config-errors 3: feature optio quantity: 5, }, ], + transition_rules: { + reset_after_trial_end: ["random-nonexistent-feature"], + }, }); }, }); diff --git a/server/tests/integration/billing/attach/params/transition-configs/reset-after-trial-end/reset-after-trial-end-basic.test.ts b/server/tests/integration/billing/attach/params/transition-configs/reset-after-trial-end/reset-after-trial-end-basic.test.ts index 4749dcb47..8470ede8f 100644 --- a/server/tests/integration/billing/attach/params/transition-configs/reset-after-trial-end/reset-after-trial-end-basic.test.ts +++ b/server/tests/integration/billing/attach/params/transition-configs/reset-after-trial-end/reset-after-trial-end-basic.test.ts @@ -60,13 +60,9 @@ test.concurrent(`${chalk.yellowBright("reset-after-trial-end 1: true - billing c customer_id: customerId, product_id: proTrial.id, redirect_mode: "if_required", - options: [ - { - feature_id: TestFeature.Messages, - quantity: 500, - reset_after_trial_end: true, - }, - ], + transition_rules: { + reset_after_trial_end: [TestFeature.Messages], + }, }); const customer = await autumnV1.customers.get(customerId); @@ -91,19 +87,19 @@ test.concurrent(`${chalk.yellowBright("reset-after-trial-end 1: true - billing c }); // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 2: reset_after_trial_end: false - reset at trial end (default behavior) +// TEST 2: No transition_rules - reset at trial end (default behavior) // ═══════════════════════════════════════════════════════════════════════════════ /** * Scenario: * - Product has 7-day trial + monthly messages - * - Attach with reset_after_trial_end: false for messages + * - Attach WITHOUT transition_rules (default behavior) * * Expected Result: * - next_reset_at = advancedTo + 7 days (trial end) - * - Same as default behavior + * - Default: feature NOT in reset_after_trial_end array */ -test.concurrent(`${chalk.yellowBright("reset-after-trial-end 2: false - reset at trial end")}`, async () => { +test.concurrent(`${chalk.yellowBright("reset-after-trial-end 2: no transition_rules - reset at trial end")}`, async () => { const customerId = "reset-trial-end-false"; const messagesItem = items.monthlyMessages({ includedUsage: 500 }); @@ -123,18 +119,12 @@ test.concurrent(`${chalk.yellowBright("reset-after-trial-end 2: false - reset at actions: [], }); - // Attach with reset_after_trial_end: false + // Attach with reset_after_trial_end: false (omit from transition_rules) await autumnV1.billing.attach({ customer_id: customerId, product_id: proTrial.id, redirect_mode: "if_required", - options: [ - { - feature_id: TestFeature.Messages, - quantity: 500, - reset_after_trial_end: false, - }, - ], + // No transition_rules = default behavior (reset at trial end) }); const customer = await autumnV1.customers.get(customerId); @@ -159,19 +149,19 @@ test.concurrent(`${chalk.yellowBright("reset-after-trial-end 2: false - reset at }); // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 3: reset_after_trial_end: default (undefined) - reset at trial end +// TEST 3: Empty options array - reset at trial end (default behavior) // ═══════════════════════════════════════════════════════════════════════════════ /** * Scenario: * - Product has 7-day trial + monthly messages - * - Attach WITHOUT specifying reset_after_trial_end + * - Attach WITHOUT any options array * * Expected Result: - * - Default behavior is false (reset at trial end) + * - Default behavior (reset at trial end) * - next_reset_at = advancedTo + 7 days (trial end) */ -test.concurrent(`${chalk.yellowBright("reset-after-trial-end 3: default - reset at trial end")}`, async () => { +test.concurrent(`${chalk.yellowBright("reset-after-trial-end 3: no options - reset at trial end")}`, async () => { const customerId = "reset-trial-end-default"; const messagesItem = items.monthlyMessages({ includedUsage: 500 }); @@ -256,13 +246,9 @@ test.concurrent(`${chalk.yellowBright("reset-after-trial-end 4: true but no tria customer_id: customerId, product_id: pro.id, redirect_mode: "if_required", - options: [ - { - feature_id: TestFeature.Messages, - quantity: 500, - reset_after_trial_end: true, - }, - ], + transition_rules: { + reset_after_trial_end: [TestFeature.Messages], + }, }); const customer = await autumnV1.customers.get(customerId); @@ -288,13 +274,11 @@ test.concurrent(`${chalk.yellowBright("reset-after-trial-end 4: true but no tria /** * Scenario: * - Product has 7-day trial + monthly messages + monthly words - * - Attach with: - * - messages: reset_after_trial_end: true (billing cycle after trial) - * - words: reset_after_trial_end: false (reset at trial end) + * - Attach with reset_after_trial_end: [messages] (only messages, not words) * * Expected Result: * - messages next_reset_at = advancedTo + 7 days + 30 days (trial end + cycle) - * - words next_reset_at = advancedTo + 7 days (trial end) + * - words next_reset_at = advancedTo + 7 days (trial end) - not in array, uses default */ test.concurrent(`${chalk.yellowBright("reset-after-trial-end 5: per-feature config")}`, async () => { const customerId = "reset-trial-end-per-feature"; @@ -317,23 +301,14 @@ test.concurrent(`${chalk.yellowBright("reset-after-trial-end 5: per-feature conf actions: [], }); - // Attach with different configs per feature + // Attach with reset_after_trial_end only for messages (not words) await autumnV1.billing.attach({ customer_id: customerId, product_id: proTrial.id, redirect_mode: "if_required", - options: [ - { - feature_id: TestFeature.Messages, - quantity: 500, - reset_after_trial_end: true, // Billing cycle starts after trial - }, - { - feature_id: TestFeature.Words, - quantity: 1000, - reset_after_trial_end: false, // Reset at trial end - }, - ], + transition_rules: { + reset_after_trial_end: [TestFeature.Messages], // Only messages, not words + }, }); const customer = await autumnV1.customers.get(customerId); @@ -417,13 +392,9 @@ test.concurrent(`${chalk.yellowBright("reset-after-trial-end 6: upgrade with tri customer_id: customerId, product_id: proTrial.id, redirect_mode: "if_required", - options: [ - { - feature_id: TestFeature.Messages, - quantity: 500, - reset_after_trial_end: true, - }, - ], + transition_rules: { + reset_after_trial_end: [TestFeature.Messages], + }, }); const customerAfter = await autumnV1.customers.get(customerId); @@ -488,13 +459,9 @@ test.concurrent(`${chalk.yellowBright("reset-after-trial-end 7: true with long t customer_id: customerId, product_id: proLongTrial.id, redirect_mode: "if_required", - options: [ - { - feature_id: TestFeature.Messages, - quantity: 500, - reset_after_trial_end: true, - }, - ], + transition_rules: { + reset_after_trial_end: [TestFeature.Messages], + }, }); const customer = await autumnV1.customers.get(customerId); diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-discounts.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-discounts.test.ts index 85a7d9538..f552211e5 100644 --- a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-discounts.test.ts +++ b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-discounts.test.ts @@ -61,19 +61,25 @@ const extractCouponId = (discount: unknown): string | null => { }; // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 1: Premium to Pro with 20% discount - verify discount on sub after schedule +// TEST 1: Premium to Pro with 20% discount - verify discount preserved through cycle // ═══════════════════════════════════════════════════════════════════════════════ /** * Scenario: * - Customer has premium ($50/mo) with 20% off coupon on subscription * - Downgrade to pro ($20/mo) - scheduled for end of cycle + * - Advance test clock to next billing cycle * * Expected Result: * - Discount still present on Stripe subscription after scheduling the downgrade * - Premium is canceling, pro is scheduled + * - After cycle: pro is active, premium removed + * - Discount should STILL be on the subscription after the phase transition + * + * THIS TEST EXPOSES THE BUG: subscription schedule phases don't carry discounts, + * so when the subscription transitions to the pro phase, the discount is lost. */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 1: 20% discount preserved after scheduling downgrade")}`, async () => { +test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 1: 20% discount preserved after scheduling and cycle advance")}`, async () => { const customerId = "sched-switch-discount-20pct"; const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); @@ -90,7 +96,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 1: 20% discoun items: [premiumMessagesItem], }); - const { autumnV1 } = await initScenario({ + const { autumnV1, ctx, testClockId, advancedTo } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), @@ -128,7 +134,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 1: 20% discoun redirect_mode: "if_required", }); - // Verify product states + // Verify product states after scheduling const customer = await autumnV1.customers.get(customerId); await expectProductCanceling({ customer, @@ -140,16 +146,57 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 1: 20% discoun }); // Verify discount is still on the subscription after scheduling the downgrade - const { subscription: subAfter } = await getStripeSubscription({ + const { subscription: subAfterSchedule } = await getStripeSubscription({ customerId, }); - const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, { - expand: ["discounts.source.coupon"], + const subAfterScheduleExpanded = await stripeCli.subscriptions.retrieve( + subAfterSchedule.id, + { expand: ["discounts.source.coupon"] }, + ); + expect(subAfterScheduleExpanded.discounts?.length).toBeGreaterThanOrEqual(1); + expect(extractCouponId(subAfterScheduleExpanded.discounts?.[0])).toBe( + coupon.id, + ); + + // Advance to next billing cycle + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + withPause: true, }); - // KEY ASSERTION: discount should still be present - expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1); - expect(extractCouponId(subAfterExpanded.discounts?.[0])).toBe(coupon.id); + const customerAfterCycle = + await autumnV1.customers.get(customerId); + + // Verify pro is active, premium removed + await expectCustomerProducts({ + customer: customerAfterCycle, + active: [pro.id], + notPresent: [premium.id], + }); + + // Verify features updated to pro tier + expectCustomerFeatureCorrect({ + customer: customerAfterCycle, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + }); + + // KEY BUG CHECK: verify discount survives the phase transition + const { subscription: subAfterCycle } = await getStripeSubscription({ + customerId, + }); + const subAfterCycleExpanded = await stripeCli.subscriptions.retrieve( + subAfterCycle.id, + { expand: ["discounts.source.coupon"] }, + ); + + // The discount should still be present after the scheduled switch completed + expect(subAfterCycleExpanded.discounts?.length).toBeGreaterThanOrEqual(1); + expect(extractCouponId(subAfterCycleExpanded.discounts?.[0])).toBe(coupon.id); }); // ═══════════════════════════════════════════════════════════════════════════════ @@ -237,128 +284,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 2: $10 off dis }); // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 3: Premium to Pro with discount - advance cycle, verify discount survives -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Customer has premium ($50/mo) with 20% off coupon - * - Downgrade to pro ($20/mo) - scheduled - * - Advance test clock to next billing cycle - * - * Expected Result: - * - After cycle: pro is active, premium removed - * - Discount should STILL be on the subscription after the phase transition - * - * THIS TEST EXPOSES THE BUG: subscription schedule phases don't carry discounts, - * so when the subscription transitions to the pro phase, the discount is lost. - */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 3: 20% discount preserved after cycle advance")}`, async () => { - const customerId = "sched-switch-discount-cycle"; - - 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, testClockId, advancedTo } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [s.billing.attach({ productId: premium.id })], - }); - - // Apply 20% discount to the subscription - const { stripeCli, subscription: subBefore } = await getStripeSubscription({ - customerId, - }); - - const coupon = await createPercentCoupon({ - stripeCli, - percentOff: 20, - }); - - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: subBefore.id, - couponIds: [coupon.id], - }); - - // Verify discount applied before downgrade - const subWithDiscount = await stripeCli.subscriptions.retrieve(subBefore.id, { - expand: ["discounts.source.coupon"], - }); - expect(subWithDiscount.discounts?.length).toBeGreaterThanOrEqual(1); - - // Schedule downgrade to pro - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - redirect_mode: "if_required", - }); - - // Verify discount still present after scheduling - const { subscription: subMid } = await getStripeSubscription({ - customerId, - }); - const subMidExpanded = await stripeCli.subscriptions.retrieve(subMid.id, { - expand: ["discounts.source.coupon"], - }); - expect(subMidExpanded.discounts?.length).toBeGreaterThanOrEqual(1); - - // Advance to next billing cycle (discount is still on the subscription) - await advanceToNextInvoice({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - currentEpochMs: advancedTo, - withPause: true, - }); - - const customerAfterCycle = - await autumnV1.customers.get(customerId); - - // Verify pro is active, premium removed - await expectCustomerProducts({ - customer: customerAfterCycle, - active: [pro.id], - notPresent: [premium.id], - }); - - // Verify features updated to pro tier - expectCustomerFeatureCorrect({ - customer: customerAfterCycle, - featureId: TestFeature.Messages, - includedUsage: 500, - balance: 500, - usage: 0, - }); - - // KEY BUG CHECK: verify discount survives the phase transition - const { subscription: subAfterCycle } = await getStripeSubscription({ - customerId, - }); - const subAfterExpanded = await stripeCli.subscriptions.retrieve( - subAfterCycle.id, - { expand: ["discounts.source.coupon"] }, - ); - - // The discount should still be present after the scheduled switch completed - expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 4: Premium to Pro (scheduled) to Free (replace) - discount preserved +// TEST 3: Premium to Pro (scheduled) to Free (replace) - discount preserved // ═══════════════════════════════════════════════════════════════════════════════ /** @@ -373,7 +299,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 3: 20% discoun * The schedule is released and recreated during replacement. This test verifies * that the discount survives the release + recreate cycle. */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 4: discount preserved when replacing scheduled downgrade")}`, async () => { +test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 3: discount preserved when replacing scheduled downgrade")}`, async () => { const customerId = "sched-switch-discount-replace"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -472,7 +398,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 4: discount pr }); // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 5: Multiple discounts preserved after scheduled downgrade +// TEST 4: Multiple discounts preserved after scheduled downgrade // ═══════════════════════════════════════════════════════════════════════════════ /** @@ -483,7 +409,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 4: discount pr * Expected Result: * - Both discounts still present on subscription after scheduling */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 5: multiple discounts preserved after scheduling")}`, async () => { +test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 4: multiple discounts preserved after scheduling")}`, async () => { const customerId = "sched-switch-discount-multi"; const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); @@ -568,7 +494,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 5: multiple di }); // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 6: Upgrade from scheduled downgrade - discount preserved +// TEST 5: Upgrade from scheduled downgrade - discount preserved // ═══════════════════════════════════════════════════════════════════════════════ /** @@ -581,7 +507,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 5: multiple di * - Discount should still be on the subscription after upgrade cancels the schedule * - Ultra is active, premium and pro are removed */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 6: discount preserved after upgrade cancels scheduled downgrade")}`, async () => { +test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 5: discount preserved after upgrade cancels scheduled downgrade")}`, async () => { const customerId = "sched-switch-discount-upgrade"; const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); @@ -674,7 +600,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 6: discount pr }); // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 7: Repeating coupon duration preserved across phase transition +// TEST 6: Repeating coupon duration preserved across phase transition // ═══════════════════════════════════════════════════════════════════════════════ /** @@ -693,7 +619,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 6: discount pr * creates a fresh discount with a reset duration, instead of using * `discount: discountId` to preserve the original duration. */ -test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 7: repeating coupon duration preserved across phase transition")}`, async () => { +test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 6: repeating coupon duration preserved across phase transition")}`, async () => { const customerId = "sched-switch-discount-duration"; const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-rollover.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-rollover.test.ts index d33f93807..b11759028 100644 --- a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-rollover.test.ts +++ b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-rollover.test.ts @@ -82,7 +82,7 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-rollover 1: downgrade wi s.products({ list: [pro, free] }), ], actions: [ - s.billing.attach({ productId: pro.id, timeout: 2000 }), + s.billing.attach({ productId: pro.id }), s.track({ featureId: TestFeature.Messages, value: 300, timeout: 2000 }), s.resetFeature({ featureId: TestFeature.Messages }), // Creates rollover of 200 (500 - 300) s.billing.attach({ productId: free.id, timeout: 2000 }), // Schedule downgrade diff --git a/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-trial-entities.test.ts b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-trial-entities.test.ts index cc9e35aef..c161b3e29 100644 --- a/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-trial-entities.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-trial-entities.test.ts @@ -697,7 +697,7 @@ test.concurrent(`${chalk.yellowBright("cancel trial EOC entities: cancel entity s.entities({ count: 2, featureId: TestFeature.Users }), ], actions: [ - s.attach({ productId: proTrial.id, entityIndex: 0 }), + s.attach({ productId: proTrial.id, entityIndex: 0, timeout: 8000 }), // Cancel entity 1 at end of cycle s.updateSubscription({ @@ -707,7 +707,7 @@ test.concurrent(`${chalk.yellowBright("cancel trial EOC entities: cancel entity }), // Attach proTrial to entity 2 - s.attach({ productId: proTrial.id, entityIndex: 1, timeout: 4000 }), + s.attach({ productId: proTrial.id, entityIndex: 1, timeout: 8000 }), ], }, ); diff --git a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-consumable.test.ts b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-consumable.test.ts index 179e8fb7a..23d3d222e 100644 --- a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-consumable.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-consumable.test.ts @@ -170,12 +170,17 @@ test.concurrent(`${chalk.yellowBright("cancel immediately consumable: entity - t // Track 500 messages on entity (100 included, 400 overage) // Note: This overage will NOT be charged when canceling immediately - await autumnV1.track({ - customer_id: customerId, - entity_id: entityId, - feature_id: TestFeature.Messages, - value: 500, - }); + await autumnV1.track( + { + customer_id: customerId, + entity_id: entityId, + feature_id: TestFeature.Messages, + value: 500, + }, + { + timeout: 4000, + }, + ); // Preview cancel immediately const cancelParams = { diff --git a/server/tests/integration/billing/update-subscription/cancel/uncancel/uncancel-basic.test.ts b/server/tests/integration/billing/update-subscription/cancel/uncancel/uncancel-basic.test.ts index ace5a5b3d..6b1df02cd 100644 --- a/server/tests/integration/billing/update-subscription/cancel/uncancel/uncancel-basic.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/uncancel/uncancel-basic.test.ts @@ -350,7 +350,6 @@ test.concurrent(`${chalk.yellowBright("error: uncancel expired product")}`, asyn // Try to uncancel the expired product - should error await expectAutumnError({ - errCode: ErrCode.InternalError, func: async () => { await autumnV1.subscriptions.update({ customer_id: customerId, diff --git a/server/tests/integration/billing/update-subscription/cancel/uncancel/uncancel-edge-cases.test.ts b/server/tests/integration/billing/update-subscription/cancel/uncancel/uncancel-edge-cases.test.ts index e0b3633d2..841c53424 100644 --- a/server/tests/integration/billing/update-subscription/cancel/uncancel/uncancel-edge-cases.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/uncancel/uncancel-edge-cases.test.ts @@ -494,28 +494,24 @@ test.concurrent(`${chalk.yellowBright("uncancel + items + invoice mode")}`, asyn const customMessagesItem = items.monthlyMessages({ includedUsage: 200 }); const customPriceItem = items.monthlyPrice({ price: 40 }); - // Preview - const preview = await autumnV1.subscriptions.previewUpdate({ + const updateParams = { customer_id: customerId, product_id: pro.id, cancel_action: "uncancel", items: [customMessagesItem, customPriceItem], invoice: true, finalize_invoice: true, - }); + enable_product_immediately: true, + }; + + // Preview + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); // Should charge prorated difference ($40 - $20 = $20 prorated) - expect(preview.total).toBeGreaterThan(0); + expect(preview.total).toBe(20); // Execute uncancel + items with invoice mode - const updateResult = await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: pro.id, - cancel_action: "uncancel", - items: [customMessagesItem, customPriceItem], - invoice: true, - finalize_invoice: true, - }); + const updateResult = await autumnV1.subscriptions.update(updateParams); // Should return invoice info (finalized but awaiting payment) expect(updateResult.invoice).toBeDefined(); diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-one-off.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-one-off.test.ts index 3d1e7cc81..e0d5b46ae 100644 --- a/server/tests/integration/billing/update-subscription/custom-plan/update-one-off.test.ts +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-one-off.test.ts @@ -194,90 +194,3 @@ test.concurrent(`${chalk.yellowBright("one-off: remove boolean feature")}`, asyn }); }); -// Update prepaid item included usage on one-off product -test.concurrent(`${chalk.yellowBright("one-off: update prepaid item included usage")}`, async () => { - const billingUnits = 100; - const price = 10; - const oldIncludedUsage = 50; - const prepaidItem = items.oneOffMessages({ - includedUsage: oldIncludedUsage, - billingUnits, - price, - }); - const oneOffProduct = products.base({ - items: [prepaidItem], - id: "one-off-prepaid-included", - }); - - const quantity = 200; // 2 packs - - const { customerId, autumnV1 } = await initScenario({ - customerId: "one-off-prepaid-included", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [oneOffProduct] }), - ], - actions: [ - s.attach({ - productId: oneOffProduct.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - timeout: 4000, - }), - ], - }); - - // Track some usage - const messagesUsed = 100; - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsed, - }, - { timeout: 2000 }, - ); - - // Update included usage from 50 to 100 (same quantity) - const newIncludedUsage = 100; - const updatedPrepaidItem = items.oneOffMessages({ - includedUsage: newIncludedUsage, - billingUnits, - price, - }); - - const updateParams = { - customer_id: customerId, - product_id: oneOffProduct.id, - items: [updatedPrepaidItem], - options: [ - { - feature_id: TestFeature.Messages, - quantity: quantity + newIncludedUsage, - }, - ], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // No charge for changing included usage (same packs) - expect(preview.total).toBe(0); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Total included = newIncludedUsage + quantity = 100 + 200 = 300 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: newIncludedUsage + quantity, - balance: newIncludedUsage + quantity - messagesUsed, - usage: messagesUsed, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, // Only initial invoice (no new charge) - latestTotal: (quantity / billingUnits) * price, // 2 packs for initial - }); -}); diff --git a/server/tests/integration/billing/update-subscription/errors/update-feature-quantity-errors.test.ts b/server/tests/integration/billing/update-subscription/errors/update-feature-quantity-errors.test.ts index fc83211ee..4773e0843 100644 --- a/server/tests/integration/billing/update-subscription/errors/update-feature-quantity-errors.test.ts +++ b/server/tests/integration/billing/update-subscription/errors/update-feature-quantity-errors.test.ts @@ -139,7 +139,6 @@ test.concurrent(`${chalk.yellowBright("error: negative quantity for prepaid feat }; await expectAutumnError({ - errMessage: "Options quantity must be >= 0", func: async () => { await autumnV1.subscriptions.update(updateParams); }, diff --git a/server/tests/integration/billing/update-subscription/errors/update-one-off-errors.test.ts b/server/tests/integration/billing/update-subscription/errors/update-one-off-errors.test.ts index 4c9336847..d67f40a1e 100644 --- a/server/tests/integration/billing/update-subscription/errors/update-one-off-errors.test.ts +++ b/server/tests/integration/billing/update-subscription/errors/update-one-off-errors.test.ts @@ -285,3 +285,67 @@ test.concurrent(`${chalk.yellowBright("error: remove trial and add one-off item" }, }); }); + +// Update prepaid item included usage on one-off product +test.concurrent(`${chalk.yellowBright("one-off: update prepaid item included usage")}`, async () => { + const billingUnits = 100; + const price = 10; + const oldIncludedUsage = 50; + const prepaidItem = items.oneOffMessages({ + includedUsage: oldIncludedUsage, + billingUnits, + price, + }); + const oneOffProduct = products.base({ + items: [prepaidItem], + id: "one-off-prepaid-included", + }); + + const quantity = 200; // 2 packs + + const { customerId, autumnV1 } = await initScenario({ + customerId: "one-off-prepaid-included", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProduct] }), + ], + actions: [ + s.attach({ + productId: oneOffProduct.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + timeout: 4000, + }), + ], + }); + + // Track some usage + const messagesUsed = 100; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Update included usage from 50 to 100 (same quantity) + const newIncludedUsage = 100; + const updatedPrepaidItem = items.oneOffMessages({ + includedUsage: newIncludedUsage, + billingUnits, + price, + }); + + const updateParams = { + customer_id: customerId, + product_id: oneOffProduct.id, + items: [updatedPrepaidItem], + }; + + await expectAutumnError({ + func: async () => { + await autumnV1.subscriptions.update(updateParams); + }, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/invoice/update-quantity-invoice-mode.test.ts b/server/tests/integration/billing/update-subscription/invoice/update-quantity-invoice-mode.test.ts index 8d824da09..fdb46e48b 100644 --- a/server/tests/integration/billing/update-subscription/invoice/update-quantity-invoice-mode.test.ts +++ b/server/tests/integration/billing/update-subscription/invoice/update-quantity-invoice-mode.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; +import { type ApiCustomerV3, CusExpand } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; @@ -73,6 +73,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: default invoice mode (dr { feature_id: TestFeature.Messages, quantity: 15 * billingUnits }, ], invoice: true, + enable_product_immediately: true, + finalize_invoice: false, }); const afterUpdate = await CusService.getFull({ @@ -80,6 +82,7 @@ test.concurrent(`${chalk.yellowBright("update-quantity: default invoice mode (dr idOrInternalId: customerId, orgId: ctx.org.id, env: ctx.env, + expand: [CusExpand.Invoices], }); const afterCustomerProduct = afterUpdate.customer_products.find( diff --git a/shared/api/billing/common/billingPreviewResponse.ts b/shared/api/billing/common/billingPreviewResponse.ts index d749b4cba..1d847e90c 100644 --- a/shared/api/billing/common/billingPreviewResponse.ts +++ b/shared/api/billing/common/billingPreviewResponse.ts @@ -6,10 +6,9 @@ export const PreviewLineItemSchema = z.object({ description: z.string(), amount: z.number(), discounts: z.array(LineItemDiscountSchema).default([]), - is_base: z.boolean().optional(), + plan_id: z.string(), total_quantity: z.number(), paid_quantity: z.number(), - plan_id: z.string(), deferred_for_trial: z.boolean().optional(), effective_period: z .object({ @@ -17,6 +16,8 @@ export const PreviewLineItemSchema = z.object({ end: z.number(), }) .optional(), + + is_base: z.boolean().optional(), }); export type PreviewLineItem = z.infer; @@ -31,13 +32,13 @@ export const BillingPreviewResponseSchema = z.object({ period_start: z.number().optional(), period_end: z.number().optional(), - /** Credit from excess refund (e.g. unused time on previous plan exceeds new charge). Applied to next invoice(s) by Stripe. */ - credit: z - .object({ - amount: z.number(), - description: z.string(), - }) - .optional(), + // /** Credit from excess refund (e.g. unused time on previous plan exceeds new charge). Applied to next invoice(s) by Stripe. */ + // credit: z + // .object({ + // amount: z.number(), + // description: z.string(), + // }) + // .optional(), next_cycle: z .object({ diff --git a/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts b/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts index a53663e17..8ddbba028 100644 --- a/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts +++ b/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts @@ -41,20 +41,21 @@ export function planParamsV1ToProductV2({ return { id: params.id, // fallback just for placeholders... name: params.name, - description: params.description ?? null, + description: params.description, is_add_on: params.add_on, is_default: params.auto_enable, group: params.group ?? "", items, - free_trial: params.free_trial - ? { - duration: params.free_trial.duration_type, - length: params.free_trial.duration_length, - unique_fingerprint: false, - card_required: params.free_trial.card_required, - } - : null, + free_trial: + params.free_trial !== undefined + ? { + duration: params.free_trial.duration_type, + length: params.free_trial.duration_length, + unique_fingerprint: false, + card_required: params.free_trial.card_required, + } + : undefined, ...(archived !== undefined && { archived }), }; }