From e307c44f06cd014d43067997c050e930b3be33ee Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 3 Nov 2026 12:25:18 -0800 Subject: [PATCH] Billing: read a customer portal cancellation as not renewing The Stripe customer portal schedules a period-end cancellation through `cancel_at` and leaves `cancel_at_period_end` false, so a subscriber who cancelled there still read as renewing. Store either form in the `cancel_at_period_end` column, and let StripeDev make the portal's form. Co-Authored-By: Claude Opus 5.5 --- packages/stripe/src/index.ts | 6 +++++- packages/stripe/src/testing.ts | 10 +++++++++- tests/integration/billing.test.ts | 20 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index 6401c5a..3c8c8f3 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -699,7 +699,11 @@ export class Billing

{ ended_at: sub.ended_at === null ? null : instant(sub.ended_at), period_end: instant(item.current_period_end), trial_end: sub.trial_end === null ? null : instant(sub.trial_end), - cancel_at_period_end: sub.cancel_at_period_end, + // The customer portal schedules its period-end cancellation through + // `cancel_at`, leaving `cancel_at_period_end` false. + cancel_at_period_end: + sub.cancel_at_period_end || + (sub.cancel_at !== null && sub.cancel_at <= item.current_period_end), updated_at: now, }; }); diff --git a/packages/stripe/src/testing.ts b/packages/stripe/src/testing.ts index 6cc509f..e0dea2b 100644 --- a/packages/stripe/src/testing.ts +++ b/packages/stripe/src/testing.ts @@ -125,6 +125,7 @@ export async function createStripeDev( ended_at: null, canceled_at: null, trial_end: days ? seconds() + days * 86400 : null, + cancel_at: null, cancel_at_period_end: false, metadata: Object.fromEntries( [...p] @@ -207,7 +208,12 @@ export async function createStripeDev( } function transition( subscriptionId: string, - action: 'renew' | 'payment-failed' | 'cancel' | 'cancel-at-period-end', + action: + | 'renew' + | 'payment-failed' + | 'cancel' + | 'cancel-at-period-end' + | 'portal-cancel', ) { const sub = subscriptions.get(subscriptionId); if (!sub) throw new Error('Subscription not found'); @@ -215,6 +221,8 @@ export async function createStripeDev( if (action === 'cancel') result = cancel(sub); else { if (action === 'cancel-at-period-end') sub.cancel_at_period_end = true; + else if (action === 'portal-cancel') + sub.cancel_at = sub.items.data[0]!.current_period_end; else { sub.status = action === 'renew' ? 'active' : 'past_due'; sub.items.data[0]!.current_period_end = periodEnd( diff --git a/tests/integration/billing.test.ts b/tests/integration/billing.test.ts index 4c8dc22..4794f33 100644 --- a/tests/integration/billing.test.ts +++ b/tests/integration/billing.test.ts @@ -203,6 +203,26 @@ billingTest( }, ); +billingTest( + 'a period-end cancellation from the customer portal reads as not renewing', + async ({ f }) => { + const { session } = await f.start('monthly'); + const sub = f.dev.completeCheckout(session.id); + await f.deliver(); + const row = () => + f.billing.db + .selectFrom('subscriptions') + .select('cancel_at_period_end') + .where('id', '=', sub.id) + .executeTakeFirstOrThrow(); + expect((await row()).cancel_at_period_end).toBe(false); + f.dev.transition(sub.id, 'portal-cancel'); + await f.deliver(); + expect((await row()).cancel_at_period_end).toBe(true); + expect((await f.billing.status('alice')).access).toBe(true); + }, +); + billingTest( 'yearly discount uses the configured price; canceled accounts cannot repeat a trial', async ({ f }) => {