diff --git a/packages/core/src/billing/polar.test.ts b/packages/core/src/billing/polar.test.ts index 989460fa..0e65e27b 100644 --- a/packages/core/src/billing/polar.test.ts +++ b/packages/core/src/billing/polar.test.ts @@ -172,6 +172,36 @@ describe('Both signing schemes', () => { 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', () => { const secret = `whsec_${Buffer.from('a'.repeat(32)).toString('base64')}`; configure({ POLAR_WEBHOOK_SECRET: secret }); diff --git a/packages/core/src/billing/polar.ts b/packages/core/src/billing/polar.ts index 5e517115..9cb06b6c 100644 --- a/packages/core/src/billing/polar.ts +++ b/packages/core/src/billing/polar.ts @@ -87,30 +87,79 @@ export namespace Polar { * fixes it. That is also why it is safe to call on a team that already has * one. */ - export const ensureFree = fn(z.object({ teamId: z.string() }), async (input) => { - const { freeProductId } = settings(); - if (!freeProductId) { - return { created: false, reason: 'no free product configured' as const }; - } - - try { - const existing = await client().customers.getStateExternal({ - externalId: input.teamId - }); - if (existing.activeSubscriptions.length > 0) { - return { created: false, reason: 'already subscribed' as const }; + export const ensureFree = fn( + z.object({ teamId: z.string(), email: z.email().optional() }), + async (input) => { + const { freeProductId } = settings(); + if (!freeProductId) { + return { created: false, reason: 'no free product configured' as const }; } - } catch { - // No such customer yet, which is the ordinary case the first time. - // Creating the subscription below makes one. - } - await client().subscriptions.create({ - productId: freeProductId, - externalCustomerId: input.teamId - }); - return { created: true, reason: 'created' as const }; - }); + // Already ours, and already subscribed to something. + try { + const state = await client().customers.getStateExternal({ + externalId: input.teamId + }); + if (state.activeSubscriptions.length > 0) { + 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 { + // No customer carries this team id yet, which is the ordinary + // 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({ + productId: freeProductId, + externalCustomerId: input.teamId + }); + return { created: true, reason: 'created' as const }; + } + ); /** * A checkout for a team, as the customer they already are. @@ -292,18 +341,27 @@ export namespace Polar { 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 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 // without one is about a customer created some other way — by hand in // their dashboard, most likely — and there is nothing here it can // 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 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) }; } diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts index a85d82ca..4c4cc381 100644 --- a/packages/core/src/team/index.ts +++ b/packages/core/src/team/index.ts @@ -7,6 +7,7 @@ import { Database } from '../db/index.js'; import { Examples } from '../examples.js'; import { fn } from '../fn.js'; import { Identifier } from '../id.js'; +import { User } from '../user/index.js'; import { TeamMemberTable } from './member.sql.js'; import { TeamTable } from './team.sql.js'; @@ -91,7 +92,12 @@ export namespace Team { // idempotent. Database.effect(async () => { 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) { // eslint-disable-next-line no-console console.error('could not register team with the payment provider:', error);