mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
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:
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user