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
+38 -2
View File
@@ -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 }
+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
};
}
);
}