From 1301ff080692989a074fe2147fc757231bb055bd Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Tue, 13 Oct 2026 21:37:22 +0300 Subject: [PATCH] fix(billing): a returning customer's subscription reaches their team The provider reuses a customer it already has under the same email and leaves our team id off it, so the subscription arrived with no team, was acknowledged, and changed nothing: a paid plan never applied. The checkout now carries the team in its metadata, which the subscription keeps, and the webhook reads it there when the customer has none. A paid delivery that still matches no team is logged instead of dropped in silence. --- apps/api/app/routes/billing.ts | 5 ++++ packages/core/src/billing/polar.test.ts | 33 +++++++++++++++++++++++++ packages/core/src/billing/polar.ts | 19 ++++++++++---- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/api/app/routes/billing.ts b/apps/api/app/routes/billing.ts index 8a003848..3c2bfbac 100644 --- a/apps/api/app/routes/billing.ts +++ b/apps/api/app/routes/billing.ts @@ -84,6 +84,11 @@ export namespace BillingApi { // would have them retry it for days against a thing that will // never become actionable. if (!delivery.teamId || !delivery.standing) { + // Said, because a paid subscription that matches no team is + // somebody paying for nothing, and a 200 alone hides it. + if (delivery.standing?.plan === 'paid') { + console.error('a paid subscription matched no team', { type: delivery.type }); + } return c.json({ data: { applied: false, type: delivery.type } }); } diff --git a/packages/core/src/billing/polar.test.ts b/packages/core/src/billing/polar.test.ts index 0e65e27b..73a28d24 100644 --- a/packages/core/src/billing/polar.test.ts +++ b/packages/core/src/billing/polar.test.ts @@ -202,6 +202,39 @@ describe('Both signing schemes', () => { expect(delivery.standing).toEqual({ plan: 'free', status: 'revoked' }); }); + test('a customer who existed before checkout is found by the checkout metadata', async () => { + // The provider reuses a customer it already has under the same email and + // does not put our id on it, so the subscription arrives with a customer + // that has no external id. A paid subscription was dropped this way. + const { Webhook } = await import('standardwebhooks'); + const secret = `whsec_${Buffer.from('c'.repeat(32)).toString('base64')}`; + configure({ POLAR_WEBHOOK_SECRET: secret }); + + const body = JSON.stringify({ + type: 'subscription.active', + data: { + customer: { external_id: null }, + metadata: { teamId: 'tem_returning' }, + product_id: PAID + } + }); + const id = 'msg_returning'; + const timestamp = new Date(); + const signature = new Webhook(secret).sign(id, timestamp, body); + + const delivery = Polar.receive({ + body, + headers: { + 'webhook-id': id, + 'webhook-timestamp': Math.floor(timestamp.getTime() / 1000).toString(), + 'webhook-signature': signature + } + }); + + expect(delivery.teamId).toBe('tem_returning'); + expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' }); + }); + test('a tampered body under a valid-looking signature is refused', () => { const secret = `whsec_${Buffer.from('a'.repeat(32)).toString('base64')}`; configure({ POLAR_WEBHOOK_SECRET: secret }); diff --git a/packages/core/src/billing/polar.ts b/packages/core/src/billing/polar.ts index 9cb06b6c..9e71c22e 100644 --- a/packages/core/src/billing/polar.ts +++ b/packages/core/src/billing/polar.ts @@ -187,6 +187,11 @@ export namespace Polar { const created = await client().checkouts.create({ products: [productId], externalCustomerId: input.teamId, + // Also on the checkout itself, which the provider copies onto the + // subscription. A customer who already existed under this email + // keeps the customer record they had, without our id on it, and + // this is then the only place the team survives. + metadata: { teamId: input.teamId }, customerEmail: input.email, successUrl: input.successUrl }); @@ -350,11 +355,15 @@ export namespace Polar { const customer = data.customer as | { externalId?: string | null; external_id?: string | null } | undefined; - // `externalId` is the team id we put on the customer. A delivery - // without one is about a customer created some other way — by hand in - // their dashboard, most likely — and there is nothing here it can - // change. - const teamId = customer?.externalId ?? customer?.external_id ?? null; + // `externalId` is the team id we put on the customer. A customer who + // existed before checkout keeps the record they had, without it, so + // the team then comes from the checkout's metadata, which the + // subscription carries. A delivery with neither was not started here. + const metadata = data.metadata as { teamId?: unknown } | undefined; + const teamId = + customer?.externalId ?? + customer?.external_id ?? + (typeof metadata?.teamId === 'string' ? metadata.teamId : null); const product = data.product as { id?: string } | undefined; const productId =