diff --git a/backend/__tests__/unit/routes/billing.test.js b/backend/__tests__/unit/routes/billing.test.js index 9e83e13d..6d89f77f 100644 --- a/backend/__tests__/unit/routes/billing.test.js +++ b/backend/__tests__/unit/routes/billing.test.js @@ -157,6 +157,61 @@ describe('/api/billing', () => { expect(arg.subscription_data.metadata.userId).toBe('u-1'); }); + // A customer created under test keys does not exist under live ones. This + // is not hypothetical: the first checkout after the 2026-08-06 test -> live + // cutover named a customer Stripe had never heard of. + describe('a stored customer id Stripe does not recognise', () => { + const missingCustomer = () => Object.assign(new Error("No such customer: 'cus_gone'"), { + code: 'resource_missing', param: 'customer', + }); + + test('is replaced and the checkout succeeds', async () => { + mockSessionsCreate + .mockRejectedValueOnce(missingCustomer()) + .mockResolvedValueOnce({ url: 'https://checkout.stripe.test/s/2' }); + mockCustomersCreate.mockResolvedValue({ id: 'cus_fresh' }); + + const res = await request(app).post('/api/billing/checkout').send({}); + expect(res.status).toBe(200); + expect(res.body.url).toContain('/s/2'); + expect(mockCurrentUser.value.billing.customerId).toBe('cus_fresh'); + // The retry must use the NEW id, or it fails identically. + expect(mockSessionsCreate.mock.calls[1][0].customer).toBe('cus_fresh'); + }); + + test('is retried exactly once, never in a loop', async () => { + mockSessionsCreate.mockRejectedValue(missingCustomer()); + mockCustomersCreate.mockResolvedValue({ id: 'cus_fresh' }); + + const res = await request(app).post('/api/billing/checkout').send({}); + expect(res.status).toBe(500); + expect(mockSessionsCreate).toHaveBeenCalledTimes(2); + expect(mockCustomersCreate).toHaveBeenCalledTimes(1); + }); + + // The dangerous false positive: a half-finished cutover leaves a TEST + // price id against LIVE keys, which also raises resource_missing. Minting + // a new customer for that would churn customers and still fail. + test('a missing PRICE is not mistaken for a stale customer', async () => { + mockSessionsCreate.mockRejectedValue(Object.assign(new Error('No such price'), { + code: 'resource_missing', param: 'line_items[0][price]', + })); + const res = await request(app).post('/api/billing/checkout').send({}); + expect(res.status).toBe(500); + expect(mockCustomersCreate).not.toHaveBeenCalled(); + expect(mockSessionsCreate).toHaveBeenCalledTimes(1); + }); + + test('the portal reports no_subscription rather than 500', async () => { + mockPortalCreate.mockRejectedValue(missingCustomer()); + const res = await request(app).post('/api/billing/portal').send({}); + expect(res.status).toBe(400); + expect(res.body.error).toBe('no_subscription'); + // Never silently mint a customer here — the portal would open empty. + expect(mockCustomersCreate).not.toHaveBeenCalled(); + }); + }); + test('agents cannot subscribe', async () => { mockCurrentUser.value = { _id: 'b-1', isBot: true, save: mockSave }; const res = await request(app).post('/api/billing/checkout').send({}); diff --git a/backend/routes/billing.ts b/backend/routes/billing.ts index b2331f7d..36aa1615 100644 --- a/backend/routes/billing.ts +++ b/backend/routes/billing.ts @@ -20,6 +20,37 @@ const checkoutLimit = rateLimit({ const FRONTEND = () => process.env.FRONTEND_URL || 'https://commonly.me'; +/** + * Did Stripe reject the request because the CUSTOMER we named does not exist? + * + * `billing.customerId` is a cache of a Stripe-side object, not identity — and + * the two can diverge. A customer created under test keys does not exist under + * live ones, so the first checkout after a test -> live cutover names a ghost; + * the same happens if a customer is deleted in the dashboard or the account is + * migrated. Without this the user gets `checkout_failed` forever, with no + * recovery short of a database edit. + * + * `param` is what makes this safe to act on. `resource_missing` also fires for + * a missing PRICE — which is exactly what a half-finished cutover produces — + * and treating that as a stale customer would churn out a new Stripe customer + * on every attempt while still failing. Only `param === 'customer'` is ours. + */ +const isMissingCustomer = (err: any): boolean => { + const e = err?.raw || err; + return e?.code === 'resource_missing' && e?.param === 'customer'; +}; + +/** Create a Stripe customer for this user and persist the id. */ +const attachCustomer = async (stripe: any, user: any): Promise => { + const customer = await stripe.customers.create({ + email: user.email, + metadata: { userId: String(user._id), username: user.username || '' }, + }); + user.billing = { ...(user.billing || {}), customerId: customer.id }; + await user.save(); + return customer.id; +}; + /** * POST /api/billing/checkout — start a subscription. * @@ -43,16 +74,7 @@ router.post('/checkout', checkoutLimit, auth, async (req: any, res: any) => { if (user.isBot) return res.status(400).json({ error: 'Agents do not hold subscriptions' }); const stripe = billingService.stripe(); - let customerId = user.billing?.customerId; - if (!customerId) { - const customer = await stripe.customers.create({ - email: user.email, - metadata: { userId: String(user._id), username: user.username || '' }, - }); - customerId = customer.id; - user.billing = { ...(user.billing || {}), customerId }; - await user.save(); - } + let customerId = user.billing?.customerId || await attachCustomer(stripe, user); // No `automatic_tax` on purpose. Stripe Tax is not activated on the // account, and enabling it here would fail session creation outright. @@ -61,9 +83,9 @@ router.post('/checkout', checkoutLimit, auth, async (req: any, res: any) => { // turning Stripe Tax on later changes what we remit, never the sticker // price. Enabling it also requires `customer_update: { address: 'auto' }`, // since an existing customer needs an address before tax can be computed. - const session = await stripe.checkout.sessions.create({ + const openSession = (customer: string) => stripe.checkout.sessions.create({ mode: 'subscription', - customer: customerId, + customer, line_items: [{ price: priceId, quantity: 1 }], // Both, deliberately: `client_reference_id` survives places metadata does // not, and the webhook reads either. @@ -75,6 +97,18 @@ router.post('/checkout', checkoutLimit, auth, async (req: any, res: any) => { allow_promotion_codes: true, }); + let session; + try { + session = await openSession(customerId); + } catch (err) { + if (!isMissingCustomer(err)) throw err; + // Stale id — re-attach and retry ONCE. A second failure is not staleness, + // and must surface rather than loop. + console.warn('[billing] stored customer id unknown to Stripe; re-creating'); + customerId = await attachCustomer(stripe, user); + session = await openSession(customerId); + } + return res.json({ url: session.url }); } catch (err) { console.error('[billing] checkout failed:', (err as Error).message); @@ -104,6 +138,14 @@ router.post('/portal', checkoutLimit, auth, async (req: any, res: any) => { }); return res.json({ url: session.url }); } catch (err) { + // Unlike checkout, a stale customer is NOT re-created here: a fresh + // customer has no subscription, so the portal would open empty and imply + // the subscription vanished. `no_subscription` is both true and + // actionable — the UI tells them to subscribe. + if (isMissingCustomer(err)) { + console.warn('[billing] portal requested for a customer unknown to Stripe'); + return res.status(400).json({ error: 'no_subscription' }); + } console.error('[billing] portal failed:', (err as Error).message); return res.status(500).json({ error: 'portal_failed' }); }