mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 14:12: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:
@@ -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 }
|
||||
|
||||
@@ -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