From afabcdcee6d1e9ecfab18bcc5cbfc72b429c83be Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Wed, 18 Mar 2026 15:07:56 +0200 Subject: [PATCH 1/5] fix(kiloclaw): expire stale checkout sessions instead of rejecting Previously, users who abandoned a KiloClaw checkout were blocked from starting a new one until the old session expired naturally. Now we expire stale open checkout sessions before creating a fresh one, letting users retry without manual intervention. --- .specs/kiloclaw-billing.md | 4 ++++ src/routers/kiloclaw-billing-router.test.ts | 20 ++++++++++++++------ src/routers/kiloclaw-router.ts | 15 +++++---------- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/.specs/kiloclaw-billing.md b/.specs/kiloclaw-billing.md index b16c3cd6a4..48fc28674c 100644 --- a/.specs/kiloclaw-billing.md +++ b/.specs/kiloclaw-billing.md @@ -93,6 +93,10 @@ lapses, with email notifications at each stage. MUST start billing immediately with no delayed period. 8. The system SHOULD include referral tracking data in checkout sessions when a referral cookie is present. +9. When open checkout sessions tagged as KiloClaw already exist for the + user, the system MUST expire them before creating a new checkout + session. This allows users who abandoned a previous checkout to start + fresh without manual intervention. ### Commit Plan Lifecycle diff --git a/src/routers/kiloclaw-billing-router.test.ts b/src/routers/kiloclaw-billing-router.test.ts index 90cd7ce814..459eef5212 100644 --- a/src/routers/kiloclaw-billing-router.test.ts +++ b/src/routers/kiloclaw-billing-router.test.ts @@ -33,7 +33,7 @@ jest.mock('@/lib/stripe-client', () => { const stripeMock = { subscriptions: { retrieve: jest.fn(), update: jest.fn(), list: jest.fn() }, subscriptionSchedules: { create: jest.fn(), update: jest.fn(), release: jest.fn() }, - checkout: { sessions: { create: jest.fn(), list: jest.fn() } }, + checkout: { sessions: { create: jest.fn(), list: jest.fn(), expire: jest.fn() } }, billingPortal: { sessions: { create: jest.fn() } }, }; return { client: stripeMock, __stripeMock: stripeMock }; @@ -87,7 +87,7 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => { let createCallerForUser: (userId: string) => Promise; type StripeMockShape = { - checkout: { sessions: { create: AnyMock; list: AnyMock } }; + checkout: { sessions: { create: AnyMock; list: AnyMock; expire: AnyMock } }; billingPortal: { sessions: { create: AnyMock } }; subscriptions: { retrieve: AnyMock; update: AnyMock; list: AnyMock }; subscriptionSchedules: { create: AnyMock; update: AnyMock; release: AnyMock }; @@ -116,6 +116,8 @@ beforeEach(async () => { stripeMock.checkout.sessions.create.mockReset(); stripeMock.checkout.sessions.list.mockReset(); stripeMock.checkout.sessions.list.mockResolvedValue({ data: [] }); + stripeMock.checkout.sessions.expire.mockReset(); + stripeMock.checkout.sessions.expire.mockResolvedValue({}); stripeMock.billingPortal.sessions.create.mockReset(); stripeMock.subscriptions.retrieve.mockReset(); stripeMock.subscriptions.update.mockReset(); @@ -788,16 +790,22 @@ describe('reactivateSubscription', () => { }); describe('createSubscriptionCheckout — concurrent checkout guard', () => { - it('rejects when an open checkout session already exists', async () => { + it('expires stale open checkout sessions and creates a new one', async () => { stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); stripeMock.checkout.sessions.list.mockResolvedValue({ data: [{ id: 'cs_existing', metadata: { type: 'kiloclaw' } }], }); + stripeMock.checkout.sessions.create.mockResolvedValue({ + id: 'cs_new', + url: 'https://checkout.stripe.com/new', + }); const caller = await createCallerForUser(user.id); - await expect(caller.kiloclaw.createSubscriptionCheckout({ plan: 'standard' })).rejects.toThrow( - 'A checkout is already in progress' - ); + const result = await caller.kiloclaw.createSubscriptionCheckout({ plan: 'standard' }); + + expect(stripeMock.checkout.sessions.expire).toHaveBeenCalledWith('cs_existing'); + expect(stripeMock.checkout.sessions.create).toHaveBeenCalled(); + expect(result).toEqual({ url: 'https://checkout.stripe.com/new' }); }); it('rejects when an active Stripe subscription already exists', async () => { diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index bcce36b9e1..8e4a732cb7 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -1147,16 +1147,11 @@ export const kiloclawRouter = createTRPCRouter({ message: 'You already have an active subscription.', }); } - const hasPendingKiloClawCheckout = openSessions.data.some( - s => s.metadata?.type === 'kiloclaw' - ); - if (hasPendingKiloClawCheckout) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: - 'A checkout is already in progress. Please complete or cancel the existing checkout.', - }); - } + // Expire any stale open checkout sessions so the user can start fresh. + // This handles the case where a user started checkout, closed the tab, + // and came back to try again. + const staleKiloClawSessions = openSessions.data.filter(s => s.metadata?.type === 'kiloclaw'); + await Promise.all(staleKiloClawSessions.map(s => stripe.checkout.sessions.expire(s.id))); const priceId = getStripePriceIdForClawPlan(input.plan); From 3703e15f2889dda5c1c5946ea84b7a1926b75f03 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Wed, 18 Mar 2026 15:23:37 +0200 Subject: [PATCH 2/5] fix(kiloclaw): tolerate already-expired sessions in concurrent expire calls Catch StripeInvalidRequestError when expiring stale checkout sessions so concurrent retry requests don't fail if another request already expired the same session. --- src/routers/kiloclaw-billing-router.test.ts | 27 +++++++++++++++++++++ src/routers/kiloclaw-router.ts | 12 ++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/routers/kiloclaw-billing-router.test.ts b/src/routers/kiloclaw-billing-router.test.ts index 459eef5212..a03e88acd3 100644 --- a/src/routers/kiloclaw-billing-router.test.ts +++ b/src/routers/kiloclaw-billing-router.test.ts @@ -30,11 +30,14 @@ type AnyMock = jest.Mock<(...args: any[]) => any>; // ── Mocks ────────────────────────────────────────────────────────────────── jest.mock('@/lib/stripe-client', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const { errors } = require('stripe').default ?? require('stripe'); const stripeMock = { subscriptions: { retrieve: jest.fn(), update: jest.fn(), list: jest.fn() }, subscriptionSchedules: { create: jest.fn(), update: jest.fn(), release: jest.fn() }, checkout: { sessions: { create: jest.fn(), list: jest.fn(), expire: jest.fn() } }, billingPortal: { sessions: { create: jest.fn() } }, + errors, }; return { client: stripeMock, __stripeMock: stripeMock }; }); @@ -91,6 +94,7 @@ type StripeMockShape = { billingPortal: { sessions: { create: AnyMock } }; subscriptions: { retrieve: AnyMock; update: AnyMock; list: AnyMock }; subscriptionSchedules: { create: AnyMock; update: AnyMock; release: AnyMock }; + errors: Stripe['errors']; }; const stripeMock = jest.requireMock<{ __stripeMock: StripeMockShape }>( @@ -808,6 +812,29 @@ describe('createSubscriptionCheckout — concurrent checkout guard', () => { expect(result).toEqual({ url: 'https://checkout.stripe.com/new' }); }); + it('tolerates already-expired sessions from concurrent expire calls', async () => { + stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); + stripeMock.checkout.sessions.list.mockResolvedValue({ + data: [{ id: 'cs_racy', metadata: { type: 'kiloclaw' } }], + }); + stripeMock.checkout.sessions.expire.mockRejectedValue( + new stripeMock.errors.StripeInvalidRequestError({ + type: 'invalid_request_error', + message: 'This Session has already expired.', + }) + ); + stripeMock.checkout.sessions.create.mockResolvedValue({ + id: 'cs_new', + url: 'https://checkout.stripe.com/new', + }); + + const caller = await createCallerForUser(user.id); + const result = await caller.kiloclaw.createSubscriptionCheckout({ plan: 'standard' }); + + expect(stripeMock.checkout.sessions.expire).toHaveBeenCalledWith('cs_racy'); + expect(result).toEqual({ url: 'https://checkout.stripe.com/new' }); + }); + it('rejects when an active Stripe subscription already exists', async () => { const activeSub = makeStripeSubscription({ id: 'sub_stripe_active', diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 5b71da3a7e..f30e91fcfe 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -1158,8 +1158,18 @@ export const kiloclawRouter = createTRPCRouter({ // Expire any stale open checkout sessions so the user can start fresh. // This handles the case where a user started checkout, closed the tab, // and came back to try again. + // Concurrent requests may race: two requests list the same session, + // the first expires it, and the second gets an error from Stripe. + // We treat "already expired" as success to keep this idempotent. const staleKiloClawSessions = openSessions.data.filter(s => s.metadata?.type === 'kiloclaw'); - await Promise.all(staleKiloClawSessions.map(s => stripe.checkout.sessions.expire(s.id))); + await Promise.all( + staleKiloClawSessions.map(s => + stripe.checkout.sessions.expire(s.id).catch(err => { + if (err instanceof stripe.errors.StripeInvalidRequestError) return; + throw err; + }) + ) + ); const priceId = getStripePriceIdForClawPlan(input.plan); From 368230c4dc16dd54aafb3f329ad7fd502b347640 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Wed, 18 Mar 2026 15:57:20 +0200 Subject: [PATCH 3/5] fix(kiloclaw): narrow expire catch to only swallow already-expired errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catching all StripeInvalidRequestError was too broad — a session completed between list() and expire() would also throw this error type, silently allowing a duplicate checkout. Now only errors matching 'already expired' are swallowed; other reasons (e.g. session completed concurrently) propagate to prevent the duplicate-subscription race. --- src/routers/kiloclaw-billing-router.test.ts | 18 ++++++++++++++++++ src/routers/kiloclaw-router.ts | 11 +++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/routers/kiloclaw-billing-router.test.ts b/src/routers/kiloclaw-billing-router.test.ts index a03e88acd3..d7adbd6da5 100644 --- a/src/routers/kiloclaw-billing-router.test.ts +++ b/src/routers/kiloclaw-billing-router.test.ts @@ -835,6 +835,24 @@ describe('createSubscriptionCheckout — concurrent checkout guard', () => { expect(result).toEqual({ url: 'https://checkout.stripe.com/new' }); }); + it('rejects when expire fails for a non-expired reason (e.g. session completed concurrently)', async () => { + stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); + stripeMock.checkout.sessions.list.mockResolvedValue({ + data: [{ id: 'cs_completed', metadata: { type: 'kiloclaw' } }], + }); + stripeMock.checkout.sessions.expire.mockRejectedValue( + new stripeMock.errors.StripeInvalidRequestError({ + type: 'invalid_request_error', + message: 'This Session is not in an expirable state.', + }) + ); + + const caller = await createCallerForUser(user.id); + await expect(caller.kiloclaw.createSubscriptionCheckout({ plan: 'standard' })).rejects.toThrow( + 'not in an expirable state' + ); + }); + it('rejects when an active Stripe subscription already exists', async () => { const activeSub = makeStripeSubscription({ id: 'sub_stripe_active', diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index f30e91fcfe..450665896e 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -1160,12 +1160,19 @@ export const kiloclawRouter = createTRPCRouter({ // and came back to try again. // Concurrent requests may race: two requests list the same session, // the first expires it, and the second gets an error from Stripe. - // We treat "already expired" as success to keep this idempotent. + // We only swallow "already expired" errors to stay idempotent — other + // StripeInvalidRequestError reasons (e.g. a session that was completed + // between list() and expire()) must propagate so we don't silently + // create a duplicate checkout. const staleKiloClawSessions = openSessions.data.filter(s => s.metadata?.type === 'kiloclaw'); await Promise.all( staleKiloClawSessions.map(s => stripe.checkout.sessions.expire(s.id).catch(err => { - if (err instanceof stripe.errors.StripeInvalidRequestError) return; + if ( + err instanceof stripe.errors.StripeInvalidRequestError && + /already expired/i.test(err.message) + ) + return; throw err; }) ) From 51838990444bfbf3c66c764480b2229bc8df5a5a Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Wed, 18 Mar 2026 16:06:01 +0200 Subject: [PATCH 4/5] fix(kiloclaw): re-check for concurrent sessions after expiring stale ones After expiring stale checkout sessions, re-list open sessions to detect if a concurrent request created a new kiloclaw session in the interim. This closes the race where two requests both expire the same stale session and both proceed to create new checkout sessions. --- src/routers/kiloclaw-billing-router.test.ts | 28 ++++++++++++++++----- src/routers/kiloclaw-router.ts | 21 ++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/routers/kiloclaw-billing-router.test.ts b/src/routers/kiloclaw-billing-router.test.ts index d7adbd6da5..1712ddd214 100644 --- a/src/routers/kiloclaw-billing-router.test.ts +++ b/src/routers/kiloclaw-billing-router.test.ts @@ -796,9 +796,9 @@ describe('reactivateSubscription', () => { describe('createSubscriptionCheckout — concurrent checkout guard', () => { it('expires stale open checkout sessions and creates a new one', async () => { stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); - stripeMock.checkout.sessions.list.mockResolvedValue({ - data: [{ id: 'cs_existing', metadata: { type: 'kiloclaw' } }], - }); + stripeMock.checkout.sessions.list + .mockResolvedValueOnce({ data: [{ id: 'cs_existing', metadata: { type: 'kiloclaw' } }] }) + .mockResolvedValueOnce({ data: [] }); // re-check after expire stripeMock.checkout.sessions.create.mockResolvedValue({ id: 'cs_new', url: 'https://checkout.stripe.com/new', @@ -814,9 +814,9 @@ describe('createSubscriptionCheckout — concurrent checkout guard', () => { it('tolerates already-expired sessions from concurrent expire calls', async () => { stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); - stripeMock.checkout.sessions.list.mockResolvedValue({ - data: [{ id: 'cs_racy', metadata: { type: 'kiloclaw' } }], - }); + stripeMock.checkout.sessions.list + .mockResolvedValueOnce({ data: [{ id: 'cs_racy', metadata: { type: 'kiloclaw' } }] }) + .mockResolvedValueOnce({ data: [] }); // re-check after expire stripeMock.checkout.sessions.expire.mockRejectedValue( new stripeMock.errors.StripeInvalidRequestError({ type: 'invalid_request_error', @@ -835,6 +835,22 @@ describe('createSubscriptionCheckout — concurrent checkout guard', () => { expect(result).toEqual({ url: 'https://checkout.stripe.com/new' }); }); + it('rejects when a concurrent request created a new session between expire and create', async () => { + stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); + stripeMock.checkout.sessions.list + .mockResolvedValueOnce({ data: [{ id: 'cs_stale', metadata: { type: 'kiloclaw' } }] }) + .mockResolvedValueOnce({ + data: [{ id: 'cs_concurrent', metadata: { type: 'kiloclaw' } }], + }); // re-check shows a new session from a concurrent request + + const caller = await createCallerForUser(user.id); + await expect(caller.kiloclaw.createSubscriptionCheckout({ plan: 'standard' })).rejects.toThrow( + 'A checkout is already in progress' + ); + expect(stripeMock.checkout.sessions.expire).toHaveBeenCalledWith('cs_stale'); + expect(stripeMock.checkout.sessions.create).not.toHaveBeenCalled(); + }); + it('rejects when expire fails for a non-expired reason (e.g. session completed concurrently)', async () => { stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); stripeMock.checkout.sessions.list.mockResolvedValue({ diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 450665896e..90463e2a37 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -1178,6 +1178,27 @@ export const kiloclawRouter = createTRPCRouter({ ) ); + // Re-check for open kiloclaw sessions after expiring stale ones. + // If a concurrent request created a new session between our initial + // list() and now, we'll see it here and reject to prevent duplicates. + if (staleKiloClawSessions.length > 0) { + const recheckSessions = await stripe.checkout.sessions.list({ + customer: stripeCustomerId, + status: 'open', + limit: 10, + }); + const hasConcurrentKiloClawCheckout = recheckSessions.data.some( + s => s.metadata?.type === 'kiloclaw' + ); + if (hasConcurrentKiloClawCheckout) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'A checkout is already in progress. Please complete or cancel the existing checkout.', + }); + } + } + const priceId = getStripePriceIdForClawPlan(input.plan); const rewardfulReferral = await getRewardfulReferral(); From fbb4919d6fdb3dea15dfc2f4e57d37d137b55fa3 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Wed, 18 Mar 2026 16:25:16 +0200 Subject: [PATCH 5/5] simplify checkout session cleanup to best-effort expire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the re-check list() and narrow error matching — they added complexity without closing the TOCTOU race inherent in read-then-write against the Stripe API. Instead, swallow all expire() errors (the session is gone either way) and document that duplicate open sessions are tolerable since each requires independent user action and the subscription-level guard prevents real harm. --- .specs/kiloclaw-billing.md | 12 +++-- src/routers/kiloclaw-billing-router.test.ts | 57 ++++----------------- src/routers/kiloclaw-router.ts | 53 ++++++------------- 3 files changed, 33 insertions(+), 89 deletions(-) diff --git a/.specs/kiloclaw-billing.md b/.specs/kiloclaw-billing.md index 48fc28674c..72641fa72b 100644 --- a/.specs/kiloclaw-billing.md +++ b/.specs/kiloclaw-billing.md @@ -93,10 +93,14 @@ lapses, with email notifications at each stage. MUST start billing immediately with no delayed period. 8. The system SHOULD include referral tracking data in checkout sessions when a referral cookie is present. -9. When open checkout sessions tagged as KiloClaw already exist for the - user, the system MUST expire them before creating a new checkout - session. This allows users who abandoned a previous checkout to start - fresh without manual intervention. +9. The system SHOULD attempt to expire open checkout sessions tagged as + KiloClaw before creating a new checkout session, so users who + abandoned a previous checkout can start fresh. Expiration is + best-effort: errors from the payment provider (e.g. the session was + already expired or completed) MUST be swallowed. Duplicate open + sessions from concurrent requests are tolerable because each requires + independent user action to complete, and rule 3 prevents duplicate + subscriptions. ### Commit Plan Lifecycle diff --git a/src/routers/kiloclaw-billing-router.test.ts b/src/routers/kiloclaw-billing-router.test.ts index 1712ddd214..2cf6d29a60 100644 --- a/src/routers/kiloclaw-billing-router.test.ts +++ b/src/routers/kiloclaw-billing-router.test.ts @@ -796,9 +796,9 @@ describe('reactivateSubscription', () => { describe('createSubscriptionCheckout — concurrent checkout guard', () => { it('expires stale open checkout sessions and creates a new one', async () => { stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); - stripeMock.checkout.sessions.list - .mockResolvedValueOnce({ data: [{ id: 'cs_existing', metadata: { type: 'kiloclaw' } }] }) - .mockResolvedValueOnce({ data: [] }); // re-check after expire + stripeMock.checkout.sessions.list.mockResolvedValue({ + data: [{ id: 'cs_existing', metadata: { type: 'kiloclaw' } }], + }); stripeMock.checkout.sessions.create.mockResolvedValue({ id: 'cs_new', url: 'https://checkout.stripe.com/new', @@ -812,17 +812,12 @@ describe('createSubscriptionCheckout — concurrent checkout guard', () => { expect(result).toEqual({ url: 'https://checkout.stripe.com/new' }); }); - it('tolerates already-expired sessions from concurrent expire calls', async () => { + it('swallows expire errors (session already expired or completed)', async () => { stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); - stripeMock.checkout.sessions.list - .mockResolvedValueOnce({ data: [{ id: 'cs_racy', metadata: { type: 'kiloclaw' } }] }) - .mockResolvedValueOnce({ data: [] }); // re-check after expire - stripeMock.checkout.sessions.expire.mockRejectedValue( - new stripeMock.errors.StripeInvalidRequestError({ - type: 'invalid_request_error', - message: 'This Session has already expired.', - }) - ); + stripeMock.checkout.sessions.list.mockResolvedValue({ + data: [{ id: 'cs_gone', metadata: { type: 'kiloclaw' } }], + }); + stripeMock.checkout.sessions.expire.mockRejectedValue(new Error('session no longer open')); stripeMock.checkout.sessions.create.mockResolvedValue({ id: 'cs_new', url: 'https://checkout.stripe.com/new', @@ -831,44 +826,10 @@ describe('createSubscriptionCheckout — concurrent checkout guard', () => { const caller = await createCallerForUser(user.id); const result = await caller.kiloclaw.createSubscriptionCheckout({ plan: 'standard' }); - expect(stripeMock.checkout.sessions.expire).toHaveBeenCalledWith('cs_racy'); + expect(stripeMock.checkout.sessions.expire).toHaveBeenCalledWith('cs_gone'); expect(result).toEqual({ url: 'https://checkout.stripe.com/new' }); }); - it('rejects when a concurrent request created a new session between expire and create', async () => { - stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); - stripeMock.checkout.sessions.list - .mockResolvedValueOnce({ data: [{ id: 'cs_stale', metadata: { type: 'kiloclaw' } }] }) - .mockResolvedValueOnce({ - data: [{ id: 'cs_concurrent', metadata: { type: 'kiloclaw' } }], - }); // re-check shows a new session from a concurrent request - - const caller = await createCallerForUser(user.id); - await expect(caller.kiloclaw.createSubscriptionCheckout({ plan: 'standard' })).rejects.toThrow( - 'A checkout is already in progress' - ); - expect(stripeMock.checkout.sessions.expire).toHaveBeenCalledWith('cs_stale'); - expect(stripeMock.checkout.sessions.create).not.toHaveBeenCalled(); - }); - - it('rejects when expire fails for a non-expired reason (e.g. session completed concurrently)', async () => { - stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); - stripeMock.checkout.sessions.list.mockResolvedValue({ - data: [{ id: 'cs_completed', metadata: { type: 'kiloclaw' } }], - }); - stripeMock.checkout.sessions.expire.mockRejectedValue( - new stripeMock.errors.StripeInvalidRequestError({ - type: 'invalid_request_error', - message: 'This Session is not in an expirable state.', - }) - ); - - const caller = await createCallerForUser(user.id); - await expect(caller.kiloclaw.createSubscriptionCheckout({ plan: 'standard' })).rejects.toThrow( - 'not in an expirable state' - ); - }); - it('rejects when an active Stripe subscription already exists', async () => { const activeSub = makeStripeSubscription({ id: 'sub_stripe_active', diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 90463e2a37..97df2a85c5 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -1155,50 +1155,29 @@ export const kiloclawRouter = createTRPCRouter({ message: 'You already have an active subscription.', }); } - // Expire any stale open checkout sessions so the user can start fresh. - // This handles the case where a user started checkout, closed the tab, - // and came back to try again. - // Concurrent requests may race: two requests list the same session, - // the first expires it, and the second gets an error from Stripe. - // We only swallow "already expired" errors to stay idempotent — other - // StripeInvalidRequestError reasons (e.g. a session that was completed - // between list() and expire()) must propagate so we don't silently - // create a duplicate checkout. + // Best-effort cleanup: expire stale open checkout sessions so the user + // can start fresh (e.g. they started checkout, closed the tab, and came + // back to retry). Errors are swallowed because a session may already be + // expired or completed by the time we call expire() — either way the + // stale session is no longer open, which is the goal. + // + // NOTE: This does not prevent two concurrent requests from both reaching + // sessions.create(). That race is inherent to any read-then-write + // pattern against the Stripe API and cannot be closed without an + // external lock. Duplicate open checkout sessions are tolerable: each + // requires independent user action to complete, and the subscription- + // level guard above (hasActiveKiloClawSub) prevents the real harm — + // duplicate subscriptions. const staleKiloClawSessions = openSessions.data.filter(s => s.metadata?.type === 'kiloclaw'); await Promise.all( staleKiloClawSessions.map(s => - stripe.checkout.sessions.expire(s.id).catch(err => { - if ( - err instanceof stripe.errors.StripeInvalidRequestError && - /already expired/i.test(err.message) - ) - return; - throw err; + stripe.checkout.sessions.expire(s.id).catch(() => { + // Swallow — session is already expired, completed, or otherwise + // no longer open. The goal (clearing stale sessions) is met. }) ) ); - // Re-check for open kiloclaw sessions after expiring stale ones. - // If a concurrent request created a new session between our initial - // list() and now, we'll see it here and reject to prevent duplicates. - if (staleKiloClawSessions.length > 0) { - const recheckSessions = await stripe.checkout.sessions.list({ - customer: stripeCustomerId, - status: 'open', - limit: 10, - }); - const hasConcurrentKiloClawCheckout = recheckSessions.data.some( - s => s.metadata?.type === 'kiloclaw' - ); - if (hasConcurrentKiloClawCheckout) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: - 'A checkout is already in progress. Please complete or cancel the existing checkout.', - }); - } - } - const priceId = getStripePriceIdForClawPlan(input.plan); const rewardfulReferral = await getRewardfulReferral();