Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .specs/kiloclaw-billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 36 additions & 6 deletions src/routers/kiloclaw-billing-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
});
Expand Down Expand Up @@ -87,10 +90,11 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
let createCallerForUser: (userId: string) => Promise<any>;

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 }>(
Expand All @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down
30 changes: 21 additions & 9 deletions src/routers/kiloclaw-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading