mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
fix(billing): a returning customer's subscription reaches their team
The provider reuses a customer it already has under the same email and
leaves our team id off it, so the subscription arrived with no team,
was acknowledged, and changed nothing: a paid plan never applied. The
checkout now carries the team in its metadata, which the subscription
keeps, and the webhook reads it there when the customer has none. A
paid delivery that still matches no team is logged instead of dropped
in silence.
(cherry picked from commit 1301ff0806)
This commit is contained in:
@@ -84,6 +84,11 @@ export namespace BillingApi {
|
|||||||
// would have them retry it for days against a thing that will
|
// would have them retry it for days against a thing that will
|
||||||
// never become actionable.
|
// never become actionable.
|
||||||
if (!delivery.teamId || !delivery.standing) {
|
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.
|
||||||
|
if (delivery.standing?.plan === 'paid') {
|
||||||
|
console.error('a paid subscription matched no team', { type: delivery.type });
|
||||||
|
}
|
||||||
return c.json({ data: { applied: false, type: delivery.type } });
|
return c.json({ data: { applied: false, type: delivery.type } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,39 @@ describe('Both signing schemes', () => {
|
|||||||
expect(delivery.standing).toEqual({ plan: 'free', status: 'revoked' });
|
expect(delivery.standing).toEqual({ plan: 'free', status: 'revoked' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a customer who existed before checkout is found by the checkout metadata', async () => {
|
||||||
|
// The provider reuses a customer it already has under the same email and
|
||||||
|
// does not put our id on it, so the subscription arrives with a customer
|
||||||
|
// that has no external id. A paid subscription was dropped this way.
|
||||||
|
const { Webhook } = await import('standardwebhooks');
|
||||||
|
const secret = `whsec_${Buffer.from('c'.repeat(32)).toString('base64')}`;
|
||||||
|
configure({ POLAR_WEBHOOK_SECRET: secret });
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'subscription.active',
|
||||||
|
data: {
|
||||||
|
customer: { external_id: null },
|
||||||
|
metadata: { teamId: 'tem_returning' },
|
||||||
|
product_id: PAID
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const id = 'msg_returning';
|
||||||
|
const timestamp = new Date();
|
||||||
|
const signature = new Webhook(secret).sign(id, timestamp, body);
|
||||||
|
|
||||||
|
const delivery = Polar.receive({
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
'webhook-id': id,
|
||||||
|
'webhook-timestamp': Math.floor(timestamp.getTime() / 1000).toString(),
|
||||||
|
'webhook-signature': signature
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(delivery.teamId).toBe('tem_returning');
|
||||||
|
expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' });
|
||||||
|
});
|
||||||
|
|
||||||
test('a tampered body under a valid-looking signature is refused', () => {
|
test('a tampered body under a valid-looking signature is refused', () => {
|
||||||
const secret = `whsec_${Buffer.from('a'.repeat(32)).toString('base64')}`;
|
const secret = `whsec_${Buffer.from('a'.repeat(32)).toString('base64')}`;
|
||||||
configure({ POLAR_WEBHOOK_SECRET: secret });
|
configure({ POLAR_WEBHOOK_SECRET: secret });
|
||||||
|
|||||||
@@ -187,6 +187,11 @@ export namespace Polar {
|
|||||||
const created = await client().checkouts.create({
|
const created = await client().checkouts.create({
|
||||||
products: [productId],
|
products: [productId],
|
||||||
externalCustomerId: input.teamId,
|
externalCustomerId: input.teamId,
|
||||||
|
// Also on the checkout itself, which the provider copies onto the
|
||||||
|
// subscription. A customer who already existed under this email
|
||||||
|
// keeps the customer record they had, without our id on it, and
|
||||||
|
// this is then the only place the team survives.
|
||||||
|
metadata: { teamId: input.teamId },
|
||||||
customerEmail: input.email,
|
customerEmail: input.email,
|
||||||
successUrl: input.successUrl
|
successUrl: input.successUrl
|
||||||
});
|
});
|
||||||
@@ -350,11 +355,15 @@ export namespace Polar {
|
|||||||
const customer = data.customer as
|
const customer = data.customer as
|
||||||
| { externalId?: string | null; external_id?: string | null }
|
| { externalId?: string | null; external_id?: string | null }
|
||||||
| undefined;
|
| undefined;
|
||||||
// `externalId` is the team id we put on the customer. A delivery
|
// `externalId` is the team id we put on the customer. A customer who
|
||||||
// without one is about a customer created some other way — by hand in
|
// existed before checkout keeps the record they had, without it, so
|
||||||
// their dashboard, most likely — and there is nothing here it can
|
// the team then comes from the checkout's metadata, which the
|
||||||
// change.
|
// subscription carries. A delivery with neither was not started here.
|
||||||
const teamId = customer?.externalId ?? customer?.external_id ?? null;
|
const metadata = data.metadata as { teamId?: unknown } | undefined;
|
||||||
|
const teamId =
|
||||||
|
customer?.externalId ??
|
||||||
|
customer?.external_id ??
|
||||||
|
(typeof metadata?.teamId === 'string' ? metadata.teamId : null);
|
||||||
|
|
||||||
const product = data.product as { id?: string } | undefined;
|
const product = data.product as { id?: string } | undefined;
|
||||||
const productId =
|
const productId =
|
||||||
|
|||||||
Reference in New Issue
Block a user