diff --git a/packages/core/src/billing/polar.ts b/packages/core/src/billing/polar.ts index 5e517115..8c2e09ba 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. 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);