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
55 changes: 55 additions & 0 deletions backend/__tests__/unit/routes/billing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down
66 changes: 54 additions & 12 deletions backend/routes/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
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.
*
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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' });
}
Expand Down
Loading