fix(billing): every webhook says what it did, with the provider's ids (#406)

**Problem.** A billing webhook that changes nothing answers `200`
exactly like one that worked.
- A subscription made before checkouts carried the team id (`db43068`)
never matches a team.
- When one was cancelled, the API logged `a paid subscription matched no
team` with only the event type. Nothing said whose subscription it was,
so nobody could act on it.
- Deliveries that did apply were not logged at all.

**Changes.**
- `Polar.receive` returns the provider's `subscriptionId` and
`customerId`, in both camelCase and snake_case, as the rest of the
payload is read.
- For a `subscription.*` event, the event's own id is the
subscription's.
- For anything else, such as `order.refunded`, it is the
`subscription_id` field.
- The webhook route logs one line per delivery: `billing webhook {type,
team, standing, applied, subscription, customer}`.
- The unmatched-paid warning carries those ids and says what to do: find
the customer in the provider dashboard and set the team's plan by hand.
- A delivery naming a team that no longer exists is logged as an error,
not silently applied to nothing.

No behaviour change: the same deliveries apply to the same teams. This
is logging only.

**Tests.**
- `bun test src/billing/polar.test.ts`: 17 pass. One new test covers the
ids in both spellings, on a subscription event and on a non-subscription
one.
- `tsc` has no errors in the changed files. The existing errors are in
`utils/hook.ts` and `utils/validator.ts`, from Zod 4.
This commit is contained in:
Wanjohi
2026-10-15 19:31:36 +03:00
committed by GitHub
parent ebf31b5fe8
commit d565e6d445
3 changed files with 108 additions and 3 deletions
+44
View File
@@ -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 });
+26 -1
View File
@@ -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
};
}
);
}