mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
Fix webhook payload field names (#345)
Two fixes, both found by testing against the provider rather than in isolation. **Deliveries applied to nobody.** Verifying the signature directly means the body is exactly what was sent — snake_case — while the reader was written against the SDK parser's camelCase. So every delivery arrived, verified, matched no customer, and was acknowledged. Every visible signal was healthy: 200s, no retries, no errors, and a plan that never changed. Both spellings are now read. **A customer may already exist.** Registering a team assumed creating a subscription would create the customer it names; it does not, and creating one fails when the address is taken. Now: use the customer already carrying this team id, else adopt one found by address, else make one. A customer carrying a *different* team's id is left alone — taking it would move where that subscription is billed, and the team that lost it would go quiet rather than fail. All four paths checked against the live sandbox: new, repeated, taken by another team, and no address. 346 tests pass.
This commit is contained in:
@@ -172,6 +172,36 @@ describe('Both signing schemes', () => {
|
|||||||
expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' });
|
expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a raw snake_case payload is read, not just the SDK\u2019s camelCase', async () => {
|
||||||
|
// Verifying the signature ourselves hands back exactly what was sent,
|
||||||
|
// which is snake_case; the SDK's parser renames fields on the way
|
||||||
|
// through. Reading one spelling made every delivery verify correctly and
|
||||||
|
// then apply to nobody, which looks identical to working.
|
||||||
|
const { Webhook } = await import('standardwebhooks');
|
||||||
|
const secret = `whsec_${Buffer.from('b'.repeat(32)).toString('base64')}`;
|
||||||
|
configure({ POLAR_WEBHOOK_SECRET: secret });
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'subscription.revoked',
|
||||||
|
data: { customer: { external_id: 'tem_snake' }, product_id: PAID }
|
||||||
|
});
|
||||||
|
const id = 'msg_snake';
|
||||||
|
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_snake');
|
||||||
|
expect(delivery.standing).toEqual({ plan: 'free', status: 'revoked' });
|
||||||
|
});
|
||||||
|
|
||||||
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 });
|
||||||
|
|||||||
@@ -87,22 +87,70 @@ export namespace Polar {
|
|||||||
* fixes it. That is also why it is safe to call on a team that already has
|
* fixes it. That is also why it is safe to call on a team that already has
|
||||||
* one.
|
* one.
|
||||||
*/
|
*/
|
||||||
export const ensureFree = fn(z.object({ teamId: z.string() }), async (input) => {
|
export const ensureFree = fn(
|
||||||
|
z.object({ teamId: z.string(), email: z.email().optional() }),
|
||||||
|
async (input) => {
|
||||||
const { freeProductId } = settings();
|
const { freeProductId } = settings();
|
||||||
if (!freeProductId) {
|
if (!freeProductId) {
|
||||||
return { created: false, reason: 'no free product configured' as const };
|
return { created: false, reason: 'no free product configured' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Already ours, and already subscribed to something.
|
||||||
try {
|
try {
|
||||||
const existing = await client().customers.getStateExternal({
|
const state = await client().customers.getStateExternal({
|
||||||
externalId: input.teamId
|
externalId: input.teamId
|
||||||
});
|
});
|
||||||
if (existing.activeSubscriptions.length > 0) {
|
if (state.activeSubscriptions.length > 0) {
|
||||||
return { created: false, reason: 'already subscribed' as const };
|
return { created: false, reason: 'already subscribed' as const };
|
||||||
}
|
}
|
||||||
|
await client().subscriptions.create({
|
||||||
|
productId: freeProductId,
|
||||||
|
externalCustomerId: input.teamId
|
||||||
|
});
|
||||||
|
return { created: true, reason: 'subscribed an existing customer' as const };
|
||||||
} catch {
|
} catch {
|
||||||
// No such customer yet, which is the ordinary case the first time.
|
// No customer carries this team id yet, which is the ordinary
|
||||||
// Creating the subscription below makes one.
|
// case the first time. Fall through and find or make one.
|
||||||
|
}
|
||||||
|
|
||||||
|
// A customer may already exist under this address without being
|
||||||
|
// linked to anything of ours — made by hand, or left behind by a
|
||||||
|
// checkout taken before the team existed. Their addresses are unique,
|
||||||
|
// so creating a second one is refused rather than allowed, and the
|
||||||
|
// only way forward is to adopt the one that is there.
|
||||||
|
let customerId: string | null = null;
|
||||||
|
if (input.email) {
|
||||||
|
const found = await client().customers.list({ email: input.email, limit: 2 });
|
||||||
|
const existing = found.result.items.at(0);
|
||||||
|
if (existing) {
|
||||||
|
// **Never take one that belongs to another team.** Moving an
|
||||||
|
// external id would move where a subscription is billed, and
|
||||||
|
// the team losing it would go quiet rather than error.
|
||||||
|
if (existing.externalId && existing.externalId !== input.teamId) {
|
||||||
|
return { created: false, reason: 'address belongs to another team' as const };
|
||||||
|
}
|
||||||
|
if (!existing.externalId) {
|
||||||
|
await client().customers.update({
|
||||||
|
id: existing.id,
|
||||||
|
customerUpdate: { externalId: input.teamId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
customerId = existing.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
if (!input.email) {
|
||||||
|
// Without an address there is nothing to look up and nothing
|
||||||
|
// to create with, and guessing one would make a customer
|
||||||
|
// nobody can be reached at.
|
||||||
|
return { created: false, reason: 'no email to create a customer with' as const };
|
||||||
|
}
|
||||||
|
const made = await client().customers.create({
|
||||||
|
email: input.email,
|
||||||
|
externalId: input.teamId
|
||||||
|
});
|
||||||
|
customerId = made.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await client().subscriptions.create({
|
await client().subscriptions.create({
|
||||||
@@ -110,7 +158,8 @@ export namespace Polar {
|
|||||||
externalCustomerId: input.teamId
|
externalCustomerId: input.teamId
|
||||||
});
|
});
|
||||||
return { created: true, reason: 'created' as const };
|
return { created: true, reason: 'created' as const };
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A checkout for a team, as the customer they already are.
|
* A checkout for a team, as the customer they already are.
|
||||||
@@ -292,18 +341,27 @@ export namespace Polar {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both spellings, for both paths. The SDK's parser renames fields to
|
||||||
|
// camelCase on the way through; verifying the signature ourselves
|
||||||
|
// hands back exactly what was sent, which is snake_case. Reading only
|
||||||
|
// one spelling makes every delivery arrive intact, verify correctly,
|
||||||
|
// and then quietly apply to nobody.
|
||||||
const data = event.data ?? {};
|
const data = event.data ?? {};
|
||||||
const customer = data.customer as { externalId?: string | null } | undefined;
|
const customer = data.customer as
|
||||||
|
| { externalId?: string | null; external_id?: string | null }
|
||||||
|
| undefined;
|
||||||
// `externalId` is the team id we put on the customer. A delivery
|
// `externalId` is the team id we put on the customer. A delivery
|
||||||
// without one is about a customer created some other way — by hand in
|
// without one is about a customer created some other way — by hand in
|
||||||
// their dashboard, most likely — and there is nothing here it can
|
// their dashboard, most likely — and there is nothing here it can
|
||||||
// change.
|
// change.
|
||||||
const teamId = customer?.externalId ?? null;
|
const teamId = customer?.externalId ?? customer?.external_id ?? null;
|
||||||
|
|
||||||
// Both spellings, because which one a payload carries depends on
|
|
||||||
// whether the product was expanded into it.
|
|
||||||
const product = data.product as { id?: string } | undefined;
|
const product = data.product as { id?: string } | undefined;
|
||||||
const productId = (data.productId as string | undefined) ?? product?.id ?? null;
|
const productId =
|
||||||
|
(data.productId as string | undefined) ??
|
||||||
|
(data.product_id as string | undefined) ??
|
||||||
|
product?.id ??
|
||||||
|
null;
|
||||||
|
|
||||||
return { type: event.type, teamId, standing: standingFor(event.type, productId) };
|
return { type: event.type, teamId, standing: standingFor(event.type, productId) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Database } from '../db/index.js';
|
|||||||
import { Examples } from '../examples.js';
|
import { Examples } from '../examples.js';
|
||||||
import { fn } from '../fn.js';
|
import { fn } from '../fn.js';
|
||||||
import { Identifier } from '../id.js';
|
import { Identifier } from '../id.js';
|
||||||
|
import { User } from '../user/index.js';
|
||||||
import { TeamMemberTable } from './member.sql.js';
|
import { TeamMemberTable } from './member.sql.js';
|
||||||
import { TeamTable } from './team.sql.js';
|
import { TeamTable } from './team.sql.js';
|
||||||
|
|
||||||
@@ -91,7 +92,12 @@ export namespace Team {
|
|||||||
// idempotent.
|
// idempotent.
|
||||||
Database.effect(async () => {
|
Database.effect(async () => {
|
||||||
try {
|
try {
|
||||||
await Polar.ensureFree({ teamId: input.id });
|
// The owner's address, so a customer can be found or made. A team
|
||||||
|
// created by somebody with no verified address gets no customer
|
||||||
|
// yet, which is a state `ensureFree` reports rather than guesses
|
||||||
|
// its way out of.
|
||||||
|
const owner = await User.fromID(ownerId);
|
||||||
|
await Polar.ensureFree({ teamId: input.id, email: owner?.email ?? undefined });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('could not register team with the payment provider:', error);
|
console.error('could not register team with the payment provider:', error);
|
||||||
|
|||||||
Reference in New Issue
Block a user