diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e1511f2a7..705d52b91 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ env: # staging repo (autumn-staging) -> us-east-1 # Branches allowed to deploy to staging via workflow_dispatch with tag=deploy-staging. # Add short-lived PR branches here when you need staging without merging to dev. - STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup fix/analytics-tz-bucket-offset uw-1-storage fix/usage-limit-ai-credit-dimension feat/analytics-week-bin + STAGING_DEPLOY_BRANCH_ALLOWLIST: feat/past-due-cancel-immediate-void jobs: checks: diff --git a/server/src/external/stripe/invoices/operations/voidOpenInvoicesForStripeSubscription.ts b/server/src/external/stripe/invoices/operations/voidOpenInvoicesForStripeSubscription.ts new file mode 100644 index 000000000..7d3129992 --- /dev/null +++ b/server/src/external/stripe/invoices/operations/voidOpenInvoicesForStripeSubscription.ts @@ -0,0 +1,73 @@ +import type { Stripe } from "stripe"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { invoiceActions } from "@/internal/invoices/actions"; + +const VOIDABLE_STATUSES: Stripe.Invoice.Status[] = ["open", "uncollectible"]; + +/** + * Void every open/uncollectible invoice on a Stripe subscription, then mirror the new status + * into Autumn's invoice records. Per-invoice failures are tolerated (the subscription.deleted + * webhook can race this for the same invoices, and Stripe rejects voiding an already-voided one) + * but are counted and returned so the caller can surface a partial failure. + */ +export const voidOpenInvoicesForStripeSubscription = async ({ + ctx, + stripeCli, + customerId, + stripeCustomerId, + subscriptionId, +}: { + ctx: AutumnContext; + stripeCli: Stripe; + customerId: string; + stripeCustomerId: string; + subscriptionId: string; +}): Promise<{ voided: number; failed: number }> => { + const { logger } = ctx; + + const voidableInvoices: Stripe.Invoice[] = []; + let startingAfter: string | undefined; + + while (true) { + const page = await stripeCli.invoices.list({ + customer: stripeCustomerId, + subscription: subscriptionId, + limit: 100, + starting_after: startingAfter, + }); + + for (const invoice of page.data) { + if (VOIDABLE_STATUSES.includes(invoice.status ?? "draft")) { + voidableInvoices.push(invoice); + } + } + + if (!page.has_more) break; + startingAfter = page.data[page.data.length - 1]?.id; + if (!startingAfter) break; + } + + let failed = 0; + await Promise.all( + voidableInvoices.map(async (invoice) => { + try { + const voidedInvoice = await stripeCli.invoices.voidInvoice(invoice.id); + await invoiceActions.updateFromStripe({ + ctx, + customerId, + stripeInvoice: voidedInvoice, + }); + logger.info( + `[voidOpenInvoicesForStripeSubscription] Voided invoice ${invoice.id}`, + ); + } catch (error) { + failed++; + logger.warn( + `[voidOpenInvoicesForStripeSubscription] Failed to void invoice ${invoice.id}: ${error}`, + ); + } + }), + ); + + return { voided: voidableInvoices.length - failed, failed }; +}; 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 90754fea0..97531fa26 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -15,7 +15,10 @@ import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v import { setupAdjustableQuantities } from "@/internal/billing/v2/setup/setupAdjustableQuantities"; import { setupAnchorResetRefund } from "@/internal/billing/v2/setup/setupAnchorResetRefund"; import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor"; -import { setupCancelAction } from "@/internal/billing/v2/setup/setupCancelMode"; +import { + setupCancelAction, + shouldForcePastDueImmediateCancel, +} from "@/internal/billing/v2/setup/setupCancelMode"; import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext"; import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext"; import { setupIgnoreProrationBehavior } from "@/internal/billing/v2/setup/setupIgnoreProrationBehavior"; @@ -171,7 +174,18 @@ export const setupUpdateSubscriptionBillingContext = async ({ customerProduct, }); - const cancelAction = setupCancelAction({ params }); + const cancelAction = setupCancelAction({ + params, + org: ctx.org, + customerProduct, + }); + + // A past_due cancel forced to immediate must not refund the unpaid cycle. + const forcePastDueImmediate = shouldForcePastDueImmediateCancel({ + params, + org: ctx.org, + customerProduct, + }); let checkoutMode = setupAttachCheckoutMode({ paymentMethod, @@ -221,9 +235,11 @@ export const setupUpdateSubscriptionBillingContext = async ({ billingCycleAnchorMs, resetCycleAnchorMs, requestedBillingCycleAnchor: params.billing_cycle_anchor, - requestedProrationBehavior: setupIgnoreProrationBehavior({ intent }) - ? undefined - : params.proration_behavior, + requestedProrationBehavior: forcePastDueImmediate + ? "none" + : setupIgnoreProrationBehavior({ intent }) + ? undefined + : params.proration_behavior, invoiceMode, featureQuantities, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts index 7ea07a791..a2f8e3475 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts @@ -10,6 +10,7 @@ import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/actions/up import { logUpdateSubscriptionContext } from "@/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext"; import { setupUpdateSubscriptionBillingContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext"; import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan"; +import { voidInvoicesOnImmediateCancel } from "@/internal/billing/v2/execute/voidInvoicesOnImmediateCancel"; import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan"; import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan"; import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult"; @@ -114,6 +115,13 @@ export async function updateSubscription({ billingPlan, }); + await voidInvoicesOnImmediateCancel({ + ctx, + billingContext, + billingPlan, + billingResult, + }); + logStripeBillingResult({ ctx, result: billingResult.stripe }); return { diff --git a/server/src/internal/billing/v2/execute/voidInvoicesOnImmediateCancel.ts b/server/src/internal/billing/v2/execute/voidInvoicesOnImmediateCancel.ts new file mode 100644 index 000000000..f2e188e19 --- /dev/null +++ b/server/src/internal/billing/v2/execute/voidInvoicesOnImmediateCancel.ts @@ -0,0 +1,49 @@ +import type { + BillingPlan, + BillingResult, + UpdateSubscriptionBillingContext, +} from "@autumn/shared"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { voidOpenInvoicesForStripeSubscription } from "@/external/stripe/invoices/operations/voidOpenInvoicesForStripeSubscription"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; + +// Void-on-cancel must run inline for Autumn-initiated immediate cancels: the sub: lock makes +// the subscription.deleted webhook (which normally does the voiding) skip itself for our cancels. +export const voidInvoicesOnImmediateCancel = async ({ + ctx, + billingContext, + billingPlan, + billingResult, +}: { + ctx: AutumnContext; + billingContext: UpdateSubscriptionBillingContext; + billingPlan: BillingPlan; + billingResult: BillingResult; +}): Promise => { + if (!ctx.org.config.void_invoices_on_subscription_deletion) return; + if (billingContext.cancelAction !== "cancel_immediately") return; + if (billingResult.stripe.deferred) return; + + // Only void when the whole Stripe subscription was actually cancelled — a partial item + // removal (e.g. cancelling one product while an add-on stays live on the same subscription) + // produces an "update" action, and voiding the surviving product's open invoices would be wrong. + if (billingPlan.stripe.subscriptionAction?.type !== "cancel") return; + + const { stripeSubscription, stripeCustomer, fullCustomer } = billingContext; + if (!stripeSubscription || !stripeCustomer) return; + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const { failed } = await voidOpenInvoicesForStripeSubscription({ + ctx, + stripeCli, + customerId: fullCustomer.id ?? fullCustomer.internal_id, + stripeCustomerId: stripeCustomer.id, + subscriptionId: stripeSubscription.id, + }); + + if (failed > 0) { + ctx.logger.error( + `[voidInvoicesOnImmediateCancel] ${failed} invoice(s) failed to void for subscription ${stripeSubscription.id}; they may still be open in Stripe`, + ); + } +}; diff --git a/server/src/internal/billing/v2/setup/setupCancelMode.ts b/server/src/internal/billing/v2/setup/setupCancelMode.ts index ba3a7db0b..1708deee3 100644 --- a/server/src/internal/billing/v2/setup/setupCancelMode.ts +++ b/server/src/internal/billing/v2/setup/setupCancelMode.ts @@ -1,19 +1,37 @@ -import type { CancelAction, UpdateSubscriptionV1Params } from "@autumn/shared"; +import type { + CancelAction, + FullCusProduct, + Organization, + UpdateSubscriptionV1Params, +} from "@autumn/shared"; +import { CusProductStatus } from "@autumn/shared"; -/** - * Setup cancel action from params - * @param params - The params - * cancel_action param maps directly to internal cancel action - * - cancel_action: "cancel_immediately" means cancel immediately - * - cancel_action: "cancel_end_of_cycle" means cancel at end of cycle - * - cancel_action: "uncancel" means remove scheduled cancellation - * - cancel_action: undefined means no cancel operation - * @returns The cancel action - */ -export const setupCancelAction = ({ +// Void-on-cancel orgs cancel a past_due end-of-cycle request immediately: the customer is in a +// cycle they never paid for, so there is no paid period to honor. The unpaid invoice is voided +// downstream (voidInvoicesOnImmediateCancel) and proration credits are suppressed by the caller +// (no refund is owed for a cycle that was never paid). +export const shouldForcePastDueImmediateCancel = ({ params, + org, + customerProduct, }: { params: UpdateSubscriptionV1Params; -}): CancelAction | undefined => { - return params.cancel_action; -}; + org: Organization; + customerProduct?: FullCusProduct; +}): boolean => + params.cancel_action === "cancel_end_of_cycle" && + org.config.void_invoices_on_subscription_deletion && + customerProduct?.status === CusProductStatus.PastDue; + +export const setupCancelAction = ({ + params, + org, + customerProduct, +}: { + params: UpdateSubscriptionV1Params; + org: Organization; + customerProduct?: FullCusProduct; +}): CancelAction | undefined => + shouldForcePastDueImmediateCancel({ params, org, customerProduct }) + ? "cancel_immediately" + : params.cancel_action; diff --git a/server/src/internal/customers/cancel/handleCancelV2.ts b/server/src/internal/customers/cancel/handleCancelV2.ts index 8290cb0dd..733079e0b 100644 --- a/server/src/internal/customers/cancel/handleCancelV2.ts +++ b/server/src/internal/customers/cancel/handleCancelV2.ts @@ -1,11 +1,12 @@ -import { Scopes } from "@autumn/shared"; import type { UpdateSubscriptionV1Params } from "@autumn/shared"; +import { Scopes } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler"; import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan"; import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors"; import { logUpdateSubscriptionContext } from "@/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext"; import { setupUpdateSubscriptionBillingContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext"; import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan"; +import { voidInvoicesOnImmediateCancel } from "@/internal/billing/v2/execute/voidInvoicesOnImmediateCancel"; import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan"; import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan"; import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult"; @@ -98,6 +99,13 @@ export const handleCancelV2 = createRoute({ }, }); + await voidInvoicesOnImmediateCancel({ + ctx, + billingContext, + billingPlan, + billingResult, + }); + logStripeBillingResult({ ctx, result: billingResult.stripe }); return c.json({ diff --git a/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-past-due-void.test.ts b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-past-due-void.test.ts new file mode 100644 index 000000000..014b9fbe9 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-past-due-void.test.ts @@ -0,0 +1,400 @@ +/** + * TDD (feature) for: void-on-cancel orgs cancel past_due customers immediately. + * + * Contract under test: + * New behavior: + * - flag void_invoices_on_subscription_deletion ON + product past_due + cancel_action "cancel_end_of_cycle" + * -> resolves to an IMMEDIATE cancel: product removed now, default/free active now (not scheduled). + * Side effects: + * - that immediate cancel voids the subscription's open/uncollectible invoices INLINE + * (the Autumn cancel sets the sub: lock, so the subscription.deleted webhook is skipped). + * Gating (unchanged behavior, control cases): + * - flag OFF + past_due + cancel_end_of_cycle -> still scheduled-cancel, open invoice NOT voided. + * - flag ON + NOT past_due + cancel_end_of_cycle -> normal end-of-cycle (canceling + free scheduled). + * + * Pre-impl red: the PRIMARY test fails because cancel_end_of_cycle on a past_due sub today + * schedules the cancel (pro stays, free not active) and never voids the open invoice on the + * Autumn-initiated path. + * + * past_due setup: attach with a good card, swap the SUBSCRIPTION's default_payment_method to a + * failing card and advance the test clock so the renewal charge fails (produces a real open + * invoice in Stripe). The customer_product.status is then flipped to past_due directly in + * Postgres (+ cache bust) rather than waiting on the subscription.updated webhook -- there is no + * public API to set past_due, and the webhook sync is the flakiest part of a local run. This + * mirrors balances/cron/past-due-reset.test.ts. What we're actually testing is the cancel + * behavior, not the past_due sync. + * + * These tests mutate shared org.config, so they run serially (plain `test`) and restore in `finally`. + */ + +import { expect, test } from "bun:test"; +import type { Stripe } from "stripe"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { CusProductStatus, customerProducts } from "@autumn/shared"; +import { + expectCustomerProducts, + expectProductActive, + expectProductCanceling, + expectProductPastDue, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription"; +import { getSubscriptionId } from "@tests/integration/billing/utils/stripe/getSubscriptionId"; +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 { eq } from "drizzle-orm"; +import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils"; +import { CusService } from "@/internal/customers/CusService"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; +import { OrgService } from "@/internal/orgs/OrgService"; +import { timeout } from "@/utils/genUtils"; + +/** + * Fail the renewal to create a real open invoice, then force the product into past_due + * directly in Postgres (no webhook dependency). Returns the Stripe customer + subscription ids. + */ +const driveProductPastDue = async ({ + ctx, + testClockId, + customerId, + productId, +}: { + ctx: Awaited>["ctx"]; + testClockId: string; + customerId: string; + productId: string; +}) => { + const subscriptionId = await getSubscriptionId({ + ctx, + customerId, + productId, + }); + + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + await attachFailedPaymentMethod({ + stripeCli: ctx.stripeCli, + customer: customer!, + }); + + const paymentMethods = await ctx.stripeCli.paymentMethods.list({ + customer: customer!.processor?.id, + }); + await ctx.stripeCli.subscriptions.update(subscriptionId, { + default_payment_method: paymentMethods.data[0].id, + }); + + await advanceToNextInvoice({ stripeCli: ctx.stripeCli, testClockId }); + await timeout(4000); + + // Flip the customer_product to past_due directly (no public API; webhook sync is unreliable + // locally), then bust the cache so the HTTP server re-reads it from Postgres. + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const customerProduct = fullCustomer.customer_products.find( + (cp) => cp.product.id === productId, + ); + await ctx.db + .update(customerProducts) + .set({ status: CusProductStatus.PastDue }) + .where(eq(customerProducts.id, customerProduct!.id)); + await deleteCachedFullCustomer({ ctx, customerId }); + + return { subscriptionId, stripeCustomerId: customer!.processor?.id }; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// PRIMARY (pre-impl RED): flag on + past_due + cancel_end_of_cycle -> immediate + void +// ═══════════════════════════════════════════════════════════════════════════════ + +test(`${chalk.yellowBright("cancel eoc past_due: flag on -> cancels immediately and voids open invoice")}`, async () => { + const customerId = "cancel-pastdue-void-immediate"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + const { autumnV1, ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [free, pro], customerIdsToDelete: [customerId] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const originalOrgConfig = ctx.org.config; + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { + config: { + ...ctx.org.config, + void_invoices_on_subscription_deletion: true, + }, + }, + }); + + try { + const { subscriptionId, stripeCustomerId } = await driveProductPastDue({ + ctx, + testClockId: testClockId!, + customerId, + productId: pro.id, + }); + + // Precondition: product is past_due with an open (unpaid) invoice + const customerBeforeCancel = + await autumnV1.customers.get(customerId); + await expectProductPastDue({ + customer: customerBeforeCancel, + productId: pro.id, + }); + + const invoicesBeforeCancel = await ctx.stripeCli.invoices.list({ + customer: stripeCustomerId, + subscription: subscriptionId, + }); + expect( + invoicesBeforeCancel.data.filter((inv) => inv.status === "open").length, + ).toBeGreaterThan(0); + + // ── Action: cancel_end_of_cycle on a past_due sub with the flag on ── + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_end_of_cycle", + }); + + // Read-consistency wait for the inline void (NOT a webhook wait). + await timeout(3000); + + // ── Contract assertion 1: immediate downgrade (not scheduled) ── + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfterCancel, + notPresent: [pro.id], + active: [free.id], + }); + + // ── Contract assertion 2: Stripe subscription cancelled now ── + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + + // ── Contract assertion 3: open invoice voided inline ── + const invoicesAfterCancel = await ctx.stripeCli.invoices.list({ + customer: stripeCustomerId, + subscription: subscriptionId, + }); + expect( + invoicesAfterCancel.data.filter((inv) => inv.status === "open").length, + ).toBe(0); + expect( + invoicesAfterCancel.data.filter((inv) => inv.status === "void").length, + ).toBeGreaterThan(0); + + // ── Contract assertion 4: no proration credit issued for the unpaid cycle ── + // The customer never paid this cycle, so cancelling must not refund/credit them. + const allInvoices = await ctx.stripeCli.invoices.list({ + customer: stripeCustomerId, + }); + expect( + allInvoices.data.filter((inv) => (inv.total ?? 0) < 0).length, + ).toBe(0); + const stripeCustomer = (await ctx.stripeCli.customers.retrieve( + stripeCustomerId!, + )) as Stripe.Customer; + expect(stripeCustomer.balance).toBe(0); + } finally { + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { config: originalOrgConfig }, + }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CONTROL A: flag OFF + past_due + cancel_end_of_cycle -> unchanged (no immediate, no void) +// ═══════════════════════════════════════════════════════════════════════════════ + +test(`${chalk.yellowBright("cancel eoc past_due: flag off -> stays scheduled, invoice not voided")}`, async () => { + const customerId = "cancel-pastdue-void-flag-off"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + const { autumnV1, ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [free, pro], customerIdsToDelete: [customerId] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const originalOrgConfig = ctx.org.config; + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { + config: { + ...ctx.org.config, + void_invoices_on_subscription_deletion: false, + }, + }, + }); + + try { + const { subscriptionId, stripeCustomerId } = await driveProductPastDue({ + ctx, + testClockId: testClockId!, + customerId, + productId: pro.id, + }); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_end_of_cycle", + }); + + await timeout(3000); + + // Not immediately removed: a Stripe subscription still exists (cancel scheduled at period end) + const subsAfterCancel = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId, + }); + expect(subsAfterCancel.data.length).toBeGreaterThan(0); + + // Open invoice is NOT voided when the flag is off + const invoicesAfterCancel = await ctx.stripeCli.invoices.list({ + customer: stripeCustomerId, + subscription: subscriptionId, + }); + expect( + invoicesAfterCancel.data.filter((inv) => inv.status === "open").length, + ).toBeGreaterThan(0); + expect( + invoicesAfterCancel.data.filter((inv) => inv.status === "void").length, + ).toBe(0); + } finally { + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { config: originalOrgConfig }, + }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CONTROL B: flag ON + NOT past_due + cancel_end_of_cycle -> normal end-of-cycle +// ═══════════════════════════════════════════════════════════════════════════════ + +test(`${chalk.yellowBright("cancel eoc not past_due: flag on -> normal end-of-cycle (no immediate)")}`, async () => { + const customerId = "cancel-pastdue-void-not-pastdue"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free, pro], customerIdsToDelete: [customerId] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const originalOrgConfig = ctx.org.config; + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { + config: { + ...ctx.org.config, + void_invoices_on_subscription_deletion: true, + }, + }, + }); + + try { + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterAttach, + productId: pro.id, + }); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_end_of_cycle", + }); + + // Active (paid) sub: flag must NOT force immediate; normal end-of-cycle applies. + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductScheduled({ + customer: customerAfterCancel, + productId: free.id, + }); + + // Side-effect contract: a paid, non-past_due cancel must NOT void any invoice. + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + const subscriptionId = await getSubscriptionId({ + ctx, + customerId, + productId: pro.id, + }); + const invoices = await ctx.stripeCli.invoices.list({ + customer: customer!.processor?.id, + subscription: subscriptionId, + }); + expect(invoices.data.filter((inv) => inv.status === "void").length).toBe(0); + } finally { + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { config: originalOrgConfig }, + }); + } +});