diff --git a/.specs/kiloclaw-billing.md b/.specs/kiloclaw-billing.md index b16c3cd6a4..72641fa72b 100644 --- a/.specs/kiloclaw-billing.md +++ b/.specs/kiloclaw-billing.md @@ -93,6 +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. 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 90cd7ce814..2cf6d29a60 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() } }, + checkout: { sessions: { create: jest.fn(), list: jest.fn(), expire: jest.fn() } }, billingPortal: { sessions: { create: jest.fn() } }, + errors, }; return { client: stripeMock, __stripeMock: stripeMock }; }); @@ -87,10 +90,11 @@ 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 }; + errors: Stripe['errors']; }; const stripeMock = jest.requireMock<{ __stripeMock: StripeMockShape }>( @@ -116,6 +120,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 +794,40 @@ 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('swallows expire errors (session already expired or completed)', async () => { + stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); + 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', + }); + + const caller = await createCallerForUser(user.id); + const result = await caller.kiloclaw.createSubscriptionCheckout({ plan: 'standard' }); + + expect(stripeMock.checkout.sessions.expire).toHaveBeenCalledWith('cs_gone'); + 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 63499399c3..97df2a85c5 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -1155,16 +1155,28 @@ export const kiloclawRouter = createTRPCRouter({ message: 'You already have an active subscription.', }); } - const hasPendingKiloClawCheckout = openSessions.data.some( - s => s.metadata?.type === 'kiloclaw' + // 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(() => { + // Swallow — session is already expired, completed, or otherwise + // no longer open. The goal (clearing stale sessions) is met. + }) + ) ); - if (hasPendingKiloClawCheckout) { - 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);