From c7d9c7746f456f22b6563516c3070ac66da87aff Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Fri, 8 May 2026 10:39:04 +0100 Subject: [PATCH] chore: handle cancel with future starts_at --- .../compute/cancel/computeCancelPlan.ts | 78 +++++++++++++ .../handleCurrentCustomerProductErrors.ts | 5 +- .../errors/handleUpdateSubscriptionErrors.ts | 2 +- .../buildStripeSubscriptionScheduleAction.ts | 35 +++++- .../errors/handleStripeBillingPlanErrors.ts | 31 ++--- ...executeStripeSubscriptionScheduleAction.ts | 9 ++ .../logSubscriptionScheduleAction.ts | 11 +- .../start-date/starts-at-scheduling.test.ts | 110 ++++++++++++++++++ .../errors/cancel-errors.test.ts | 45 ++++--- .../stripeSubscriptionScheduleAction.ts | 4 + 10 files changed, 294 insertions(+), 36 deletions(-) diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCancelPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCancelPlan.ts index e648f0773..9d1344596 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCancelPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCancelPlan.ts @@ -1,7 +1,11 @@ import { type AutumnBillingPlan, + CusProductStatus, cp, type FullCusProduct, + findMainActiveCustomerProductByGroup, + isCustomerProductCanceling, + isFutureStartDate, type UpdateSubscriptionBillingContext, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; @@ -39,6 +43,68 @@ const computeScheduledAddOnsToDelete = ({ }); }; +const shouldDeleteCustomerProductBeforeBillingStarts = ({ + customerProduct, + currentEpochMs, +}: { + customerProduct: FullCusProduct; + currentEpochMs: number; +}): boolean => { + if (customerProduct.status === CusProductStatus.Scheduled) return true; + + const hasStripeSchedule = (customerProduct.scheduled_ids?.length ?? 0) > 0; + const hasStripeSubscription = + (customerProduct.subscription_ids?.length ?? 0) > 0; + + return ( + hasStripeSchedule && + !hasStripeSubscription && + isFutureStartDate(customerProduct.starts_at, currentEpochMs) + ); +}; + +const computeScheduledCancelPlan = ({ + billingContext, + plan, +}: { + billingContext: UpdateSubscriptionBillingContext; + plan: AutumnBillingPlan; +}): AutumnBillingPlan => { + const { customerProduct, fullCustomer } = billingContext; + + const activeCustomerProduct = findMainActiveCustomerProductByGroup({ + fullCus: fullCustomer, + productGroup: customerProduct.product.group, + internalEntityId: customerProduct.internal_entity_id ?? undefined, + }); + + const scheduledCancelPlan: AutumnBillingPlan = { + ...plan, + updateCustomerProduct: undefined, + deleteCustomerProduct: customerProduct, + }; + + if ( + !activeCustomerProduct || + activeCustomerProduct.id === customerProduct.id || + !isCustomerProductCanceling(activeCustomerProduct) + ) { + return scheduledCancelPlan; + } + + return { + ...scheduledCancelPlan, + updateCustomerProduct: { + customerProduct: activeCustomerProduct, + updates: { + canceled: false, + canceled_at: null, + ended_at: null, + }, + }, + }; +}; + /** * Computes and applies the cancel plan for a subscription. * @@ -64,6 +130,18 @@ export const computeCancelPlan = ({ }); } + if ( + shouldDeleteCustomerProductBeforeBillingStarts({ + customerProduct: billingContext.customerProduct, + currentEpochMs: billingContext.currentEpochMs, + }) + ) { + return computeScheduledCancelPlan({ + billingContext, + plan, + }); + } + // Step 1: Calculate when the subscription ends const endOfCycleMs = computeEndOfCycleMs({ billingContext }); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleCurrentCustomerProductErrors.ts b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleCurrentCustomerProductErrors.ts index acf4a4a4e..3b11ee082 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleCurrentCustomerProductErrors.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleCurrentCustomerProductErrors.ts @@ -12,7 +12,10 @@ export const handleCurrentCustomerProductErrors = ({ }) => { const { customerProduct } = billingContext; - if (isCustomerProductScheduled(customerProduct)) { + if ( + isCustomerProductScheduled(customerProduct) && + !billingContext.cancelAction + ) { throw new RecaseError({ message: `Cannot update subscription for '${customerProduct.product.name}' because it is scheduled and not yet active`, }); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors.ts b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors.ts index a271a5e8d..097a86887 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors.ts @@ -80,5 +80,5 @@ export const handleUpdateSubscriptionErrors = async ({ handleUpdateCheckoutErrors({ billingContext }); // 12. Stripe billing plan errors (validate Stripe resources) - handleStripeBillingPlanErrors({ billingContext }); + handleStripeBillingPlanErrors({ billingContext, billingPlan }); }; diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts index 9e7d082bc..1595c8571 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts @@ -91,12 +91,40 @@ const getScheduleScenario = ({ return "multi_phase"; }; +const buildNoPhasesAction = ({ + hasSubscription, + scheduleId, +}: { + hasSubscription: boolean; + scheduleId: string | undefined; +}): StripeSubscriptionScheduleResult => { + if (!scheduleId) return {}; + + if (hasSubscription) { + return { + scheduleAction: { + type: "release", + stripeSubscriptionScheduleId: scheduleId, + }, + subscriptionCancelAt: null, + }; + } + + return { + scheduleAction: { + type: "cancel", + stripeSubscriptionScheduleId: scheduleId, + }, + }; +}; + /** * Builds the appropriate action for each scenario. */ const buildActionForScenario = ({ scenario, hasSchedule, + hasSubscription, scheduleId, scheduledPhases, cancelAtSeconds, @@ -105,6 +133,7 @@ const buildActionForScenario = ({ }: { scenario: ScheduleScenario; hasSchedule: boolean; + hasSubscription: boolean; scheduleId: string | undefined; scheduledPhases: Stripe.SubscriptionScheduleUpdateParams.Phase[]; cancelAtSeconds: number | undefined; @@ -113,7 +142,10 @@ const buildActionForScenario = ({ }): StripeSubscriptionScheduleResult => { switch (scenario) { case "no_phases": - return {}; + return buildNoPhasesAction({ + hasSubscription, + scheduleId, + }); case "single_indefinite": // Product continues indefinitely: release schedule if exists, clear any cancel_at @@ -285,6 +317,7 @@ export const buildStripeSubscriptionScheduleAction = ({ return buildActionForScenario({ scenario, hasSchedule: !!stripeSubscriptionSchedule, + hasSubscription: !!stripeSubscription, scheduleId: stripeSubscriptionSchedule?.id, scheduledPhases, cancelAtSeconds, diff --git a/server/src/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors.ts b/server/src/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors.ts index 270e0c77a..c992a013f 100644 --- a/server/src/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors.ts +++ b/server/src/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors.ts @@ -1,5 +1,8 @@ +import type { + BillingPlan, + UpdateSubscriptionBillingContext, +} from "@autumn/shared"; import { ErrCode, InternalError } from "@autumn/shared"; -import type { UpdateSubscriptionBillingContext } from "@autumn/shared"; /** * Validates Stripe-specific billing context requirements before executing billing plan. @@ -7,22 +10,24 @@ import type { UpdateSubscriptionBillingContext } from "@autumn/shared"; */ export const handleStripeBillingPlanErrors = ({ billingContext, + billingPlan, }: { billingContext: UpdateSubscriptionBillingContext; + billingPlan: BillingPlan; }) => { - // If there's an existing subscription schedule, validate it has current_phase.start_date - // This is required for schedule updates (Stripe requires anchoring phases to the current phase start) + const { stripeSubscriptionSchedule } = billingContext; + const { subscriptionScheduleAction } = billingPlan.stripe; - if (billingContext.stripeSubscriptionSchedule) { - const currentPhaseStart = - billingContext.stripeSubscriptionSchedule.current_phase?.start_date; + if (subscriptionScheduleAction?.type !== "update") return; + if (!stripeSubscriptionSchedule?.subscription) return; - if (!currentPhaseStart) { - throw new InternalError({ - message: - "Cannot update subscription schedule: missing current phase start_date", - code: ErrCode.InternalError, - }); - } + const currentPhaseStart = + stripeSubscriptionSchedule.current_phase?.start_date; + if (!currentPhaseStart) { + throw new InternalError({ + message: + "Cannot update subscription schedule: missing current phase start_date", + code: ErrCode.InternalError, + }); } }; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts index b9bb44de7..617852e06 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts @@ -257,5 +257,14 @@ export const executeStripeSubscriptionScheduleAction = async ({ subscriptionScheduleAction.stripeSubscriptionScheduleId, ); return null; + + case "cancel": + ctx.logger.debug( + `[executeStripeSubscriptionScheduleAction] Canceling schedule: ${subscriptionScheduleAction.stripeSubscriptionScheduleId}`, + ); + await stripeCli.subscriptionSchedules.cancel( + subscriptionScheduleAction.stripeSubscriptionScheduleId, + ); + return null; } }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction.ts index eeb068548..9249c9df4 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction.ts @@ -1,7 +1,9 @@ +import type { + BillingContext, + StripeSubscriptionScheduleAction, +} from "@autumn/shared"; import { formatSecondsToDate } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; -import type { BillingContext } from "@autumn/shared"; -import type { StripeSubscriptionScheduleAction } from "@autumn/shared"; import type Stripe from "stripe"; import { billingContextFormatPriceByStripePriceId } from "@/internal/billing/v2/utils/billingContextPriceLookup"; @@ -45,7 +47,10 @@ export const logSubscriptionScheduleAction = ({ billingContext: BillingContext; subscriptionScheduleAction: StripeSubscriptionScheduleAction; }): void => { - if (subscriptionScheduleAction.type === "release") { + if ( + subscriptionScheduleAction.type === "release" || + subscriptionScheduleAction.type === "cancel" + ) { ctx.logger.debug( `[logSubscriptionScheduleAction] Action type: ${subscriptionScheduleAction.type}`, ); diff --git a/server/tests/integration/billing/attach/params/start-date/starts-at-scheduling.test.ts b/server/tests/integration/billing/attach/params/start-date/starts-at-scheduling.test.ts index 148d06fef..ef885d16e 100644 --- a/server/tests/integration/billing/attach/params/start-date/starts-at-scheduling.test.ts +++ b/server/tests/integration/billing/attach/params/start-date/starts-at-scheduling.test.ts @@ -101,6 +101,116 @@ test.concurrent(`${chalk.yellowBright("starts_at: future attach creates schedule await expectCustomerInvoiceCorrect({ customerId, count: 0 }); }); +test.concurrent(`${chalk.yellowBright("starts_at: scheduled subscription can be canceled by customer_product_id")}`, async () => { + const customerId = "attach-start-date-cancel-scheduled"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const startDate = addDays(advancedTo, 1).getTime(); + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: startDate, + }); + + const scheduledCustomerProduct = await getCustomerProduct({ + ctx, + customerId, + productId: pro.id, + }); + const scheduleId = scheduledCustomerProduct.scheduled_ids?.[0]; + if (!scheduleId) + throw new Error("Expected scheduled product to have schedule"); + + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + customer_product_id: scheduledCustomerProduct.id, + cancel_action: "cancel_immediately", + }); + expect(preview.total).toBe(0); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + customer_product_id: scheduledCustomerProduct.id, + cancel_action: "cancel_immediately", + }); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ + customer, + notPresent: [pro.id], + }); + + const stripeSchedule = + await ctx.stripeCli.subscriptionSchedules.retrieve(scheduleId); + expect(stripeSchedule.status).toBe("canceled"); + await expectCustomerInvoiceCorrect({ customerId, count: 0 }); +}); + +test.concurrent(`${chalk.yellowBright("starts_at: immediate-access future subscription cancels by deleting schedule")}`, async () => { + const customerId = "attach-start-date-cancel-immediate-access-v2"; + const pro = products.pro({ + id: "pro-immediate-access-cancel", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const startDate = addDays(advancedTo, 1).getTime(); + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: startDate, + enable_plan_immediately: true, + }); + + const customerProduct = await getCustomerProduct({ + ctx, + customerId, + productId: pro.id, + }); + const scheduleId = customerProduct.scheduled_ids?.[0]; + if (!scheduleId) + throw new Error("Expected immediate-access product to have schedule"); + expect(customerProduct.status).toBe(CusProductStatus.Active); + expect(customerProduct.subscription_ids ?? []).toEqual([]); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + customer_product_id: customerProduct.id, + cancel_action: "cancel_end_of_cycle", + }); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ + customer, + notPresent: [pro.id], + }); + + const stripeSchedule = + await ctx.stripeCli.subscriptionSchedules.retrieve(scheduleId); + expect(stripeSchedule.status).toBe("canceled"); + await expectCustomerInvoiceCorrect({ customerId, count: 0 }); +}); + test.concurrent(`${chalk.yellowBright("starts_at: future attach without payment method creates invoice schedule")}`, async () => { const customerId = "attach-start-date-future-invoice"; const pro = products.pro({ diff --git a/server/tests/integration/billing/update-subscription/errors/cancel-errors.test.ts b/server/tests/integration/billing/update-subscription/errors/cancel-errors.test.ts index 7b4c6cc8f..9f1b5ba4f 100644 --- a/server/tests/integration/billing/update-subscription/errors/cancel-errors.test.ts +++ b/server/tests/integration/billing/update-subscription/errors/cancel-errors.test.ts @@ -5,12 +5,14 @@ */ import { expect, test } from "bun:test"; -import { type ApiCustomerV3, ErrCode, FreeTrialDuration } from "@autumn/shared"; +import { type ApiCustomerV3, FreeTrialDuration } from "@autumn/shared"; import { + expectCustomerProducts, expectProductCanceling, expectProductNotPresent, expectProductScheduled, } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; @@ -18,20 +20,21 @@ import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 1: Cannot cancel a scheduled product +// TEST 1: Cancel a scheduled product // ═══════════════════════════════════════════════════════════════════════════════ /** * Scenario: * - User is on Premium ($50/mo) * - User downgrades to Pro ($20/mo) → Premium is canceling, Pro is scheduled - * - User tries to cancel Pro (the scheduled product) + * - User cancels Pro (the scheduled product) * * Expected Result: - * - Should return an error - cannot cancel a scheduled product + * - Pro scheduled attachment is removed + * - Premium is uncanceled and remains active */ -test.concurrent(`${chalk.yellowBright("error: cannot cancel scheduled product")}`, async () => { - const customerId = "err-cancel-scheduled"; +test.concurrent(`${chalk.yellowBright("cancel: scheduled product removes pending schedule")}`, async () => { + const customerId = "cancel-scheduled-product"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -46,7 +49,7 @@ test.concurrent(`${chalk.yellowBright("error: cannot cancel scheduled product")} items: [messagesItem, premiumPriceItem], }); - const { autumnV1 } = await initScenario({ + const { autumnV1, ctx } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), @@ -70,16 +73,24 @@ test.concurrent(`${chalk.yellowBright("error: cannot cancel scheduled product")} productId: pro.id, }); - // Try to cancel the scheduled product - should fail - await expectAutumnError({ - errCode: ErrCode.InvalidRequest, - func: async () => { - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: pro.id, - cancel_action: "cancel_immediately", - }); - }, + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_immediately", + }); + + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfterCancel, + active: [premium.id], + notPresent: [pro.id], + }); + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, }); }); diff --git a/shared/models/billingModels/stripe/stripeSubscriptionScheduleAction.ts b/shared/models/billingModels/stripe/stripeSubscriptionScheduleAction.ts index d676822f2..481111615 100644 --- a/shared/models/billingModels/stripe/stripeSubscriptionScheduleAction.ts +++ b/shared/models/billingModels/stripe/stripeSubscriptionScheduleAction.ts @@ -17,6 +17,10 @@ export const StripeSubscriptionScheduleActionSchema = z.discriminatedUnion( type: z.literal("release"), stripeSubscriptionScheduleId: z.string(), }), + z.object({ + type: z.literal("cancel"), + stripeSubscriptionScheduleId: z.string(), + }), ], );