diff --git a/apps/api/app/routes/billing.ts b/apps/api/app/routes/billing.ts index 3c2bfbac..bd1aa1fe 100644 --- a/apps/api/app/routes/billing.ts +++ b/apps/api/app/routes/billing.ts @@ -78,6 +78,10 @@ export namespace BillingApi { }); const delivery = Polar.receive({ body, headers }); + const ids = { + subscription: delivery.subscriptionId, + customer: delivery.customerId + }; // Acknowledged rather than refused. A delivery we cannot act on is // still a delivery that arrived intact, and answering an error @@ -85,10 +89,28 @@ export namespace BillingApi { // 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. + // somebody paying for nothing, and a 200 alone hides it. With + // the provider's ids, so whoever reads it can find the customer + // in the provider's dashboard and set the team's plan by hand: + // a subscription made before checkouts carried the team id + // arrives like this for as long as it lives. if (delivery.standing?.plan === 'paid') { - console.error('a paid subscription matched no team', { type: delivery.type }); + console.error( + 'a paid subscription matched no team; find the customer in the provider dashboard and set the team plan by hand', + { type: delivery.type, team: delivery.teamId, ...ids } + ); } + // Every delivery is said, applied or not. A webhook that + // changes nothing looks exactly like one that worked from the + // outside, and a cancellation on 2026-10-15 was only found to + // have missed by reading the team's row. + console.log('billing webhook', { + type: delivery.type, + team: delivery.teamId, + standing: delivery.standing, + applied: false, + ...ids + }); return c.json({ data: { applied: false, type: delivery.type } }); } @@ -97,6 +119,20 @@ export namespace BillingApi { plan: delivery.standing.plan, subscriptionStatus: delivery.standing.status }); + if (!updated) { + console.error('a subscription names a team that does not exist', { + type: delivery.type, + team: delivery.teamId, + ...ids + }); + } + console.log('billing webhook', { + type: delivery.type, + team: delivery.teamId, + standing: delivery.standing, + applied: Boolean(updated), + ...ids + }); return c.json({ data: { applied: Boolean(updated), type: delivery.type } diff --git a/packages/core/src/billing/polar.test.ts b/packages/core/src/billing/polar.test.ts index 73a28d24..624fb40b 100644 --- a/packages/core/src/billing/polar.test.ts +++ b/packages/core/src/billing/polar.test.ts @@ -235,6 +235,50 @@ describe('Both signing schemes', () => { expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' }); }); + test('the provider\u2019s ids travel with a delivery, in either spelling', async () => { + // They are what a person looks the customer up by when a delivery + // matched no team, as a subscription made before checkouts carried the + // team id does for as long as it lives. + const { Webhook } = await import('standardwebhooks'); + const secret = `whsec_${Buffer.from('d'.repeat(32)).toString('base64')}`; + configure({ POLAR_WEBHOOK_SECRET: secret }); + + const send = (payload: object, id: string) => { + const body = JSON.stringify(payload); + const timestamp = new Date(); + return Polar.receive({ + body, + headers: { + 'webhook-id': id, + 'webhook-timestamp': Math.floor(timestamp.getTime() / 1000).toString(), + 'webhook-signature': new Webhook(secret).sign(id, timestamp, body) + } + }); + }; + + const unmatched = send( + { + type: 'subscription.canceled', + data: { id: 'sub_1', customer_id: 'cus_1', customer: { external_id: null }, product_id: PAID } + }, + 'msg_ids_snake' + ); + expect(unmatched.teamId).toBeNull(); + expect(unmatched.standing).toEqual({ plan: 'paid', status: 'canceled' }); + expect(unmatched.subscriptionId).toBe('sub_1'); + expect(unmatched.customerId).toBe('cus_1'); + + // Not a subscription event: its own id is the order's, and the + // subscription is a field on it. + const order = send( + { type: 'order.refunded', data: { id: 'ord_1', subscriptionId: 'sub_2', customer: { id: 'cus_2' } } }, + 'msg_ids_order' + ); + expect(order.subscriptionId).toBe('sub_2'); + expect(order.customerId).toBe('cus_2'); + expect(order.standing).toBeNull(); + }); + 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 9e71c22e..705c9300 100644 --- a/packages/core/src/billing/polar.ts +++ b/packages/core/src/billing/polar.ts @@ -286,6 +286,14 @@ export namespace Polar { /** The team this is about, from the customer's external id. */ teamId: string | null; standing: Standing | null; + /** + * The provider's own ids for the subscription and the customer. Nothing + * here acts on them; they are what a person searches the provider's + * dashboard for when a delivery matched no team and has to be applied by + * hand. + */ + subscriptionId: string | null; + customerId: string | null; } /** @@ -372,7 +380,24 @@ export namespace Polar { product?.id ?? null; - return { type: event.type, teamId, standing: standingFor(event.type, productId) }; + // A subscription event's own id is the subscription's; anything else + // about one carries it as a field. + const str = (v: unknown) => (typeof v === 'string' ? v : null); + const subscriptionId = event.type.startsWith('subscription.') + ? str(data.id) + : (str(data.subscriptionId) ?? str(data.subscription_id)); + const customerId = + str(data.customerId) ?? + str(data.customer_id) ?? + str((data.customer as { id?: unknown } | undefined)?.id); + + return { + type: event.type, + teamId, + standing: standingFor(event.type, productId), + subscriptionId, + customerId + }; } ); }