import type { FastifyInstance } from 'fastify'; import { planOf, type BillingSummary, type PlanId } from '@wellformed/shared'; import { badRequest, unauthorized } from '../errors.js'; import { config } from '../config.js'; import { effectivePlan, findWorkspaceBySubscription, getBillingEmail, getSubscription, recordBillingEvent, setBillingEmail, setSubscription, } from '../control/billing.js'; import { transactControl } from '../control/db.js'; import { getWorkspaceById } from '../control/workspaces.js'; import { countRows } from '../billing/limits.js'; import { licensedTo } from '../licence/index.js'; import { billingEnabled, checkoutUrl, parseEvent, verifySignature } from '../billing/provider.js'; import { recordAudit, SYSTEM_ACTOR } from '../engine/audit.js'; import { billingContactSchema, checkoutSchema } from './schemas.js'; /** Billing for one workspace. Registered inside the tenant scope. */ export async function billingRoutes(app: FastifyInstance): Promise { /** * The plan, and what it is being used for. * * Admin-only, not because usage is a secret but because the answer is about * money and the only person who can act on it is an admin. */ app.get('/billing', async (request): Promise => { request.ws.require('admin'); const { workspace } = request.ws; const subscription = getSubscription(app.control, workspace.id) ?? { plan: 'free' as PlanId, status: 'active' as const, trialEndsAt: null, periodEnd: null, cancelAtPeriodEnd: false, provider: null, }; const meta = request.ws.meta(); return { subscription, effectivePlan: effectivePlan(subscription), usage: { rows: countRows(request.ws.db, meta), tables: meta.tableOrder.length }, checkoutAvailable: billingEnabled(), metered: config.metered, licensedTo: licensedTo(config.licence), billingEmail: getBillingEmail(app.control, workspace.id), }; }); /** * Change where the invoices go. * * Audited, because it is a change to who the workspace owes money to and the * trail is where an admin will look when an invoice stops arriving. Note what * this does *not* do: the provider holds its own copy of the address from the * checkout, and Wellformed has no write access to it. Saying so is the route's * job — see the note the client shows. */ app.patch('/billing/contact', async (request) => { const { workspace } = request.ws; request.ws.require('admin'); const { billingEmail } = billingContactSchema.parse(request.body); const previous = getBillingEmail(app.control, workspace.id); setBillingEmail(app.control, workspace.id, billingEmail); recordAudit(request.ws.db, request.ws.actor(), { kind: 'auth', old: previous, nw: `invoices now addressed to ${billingEmail.trim().toLowerCase()}`, }); return { billingEmail: getBillingEmail(app.control, workspace.id) }; }); /** * Start a checkout. * * Wellformed hands back a URL and stops. The provider is the merchant of record, * so the card, the VAT and the invoice are all theirs — and the plan does not * change here. It changes when the webhook says the money arrived, which is * the only statement about payment worth trusting. */ app.post('/billing/checkout', async (request) => { request.ws.require('admin'); const { plan, billingEmail } = checkoutSchema.parse(request.body); const { workspace } = request.ws; if (!billingEnabled()) { throw badRequest( 'This installation is not set up to take payments. Whoever runs it can move a workspace with `npm run plan`.', ); } if (planOf(plan).priceEur === 0) throw badRequest('That plan is free; there is nothing to buy.'); /* * The address is asked for, never inferred. Using the clicking admin's * would hand the provider a person as the customer, and the provider's copy * is the one that authenticates the portal and receives the invoices — so * that guess would outlive the guesser's employment. */ if (billingEmail) setBillingEmail(app.control, workspace.id, billingEmail); const invoiceTo = getBillingEmail(app.control, workspace.id); if (!invoiceTo) { throw badRequest('Say where the invoices should go before starting a checkout.'); } const url = checkoutUrl({ workspaceId: workspace.id, workspaceSlug: workspace.slug, plan, email: invoiceTo, returnUrl: `${config.publicUrl}/w/${workspace.slug}`, }); if (!url) throw badRequest('No price is configured for that plan.'); return { url }; }); /** * Where to cancel or change a card. * * A link, not a form. Cancelling inside Wellformed would mean holding a provider * API key with write access to subscriptions, and then reimplementing dunning, * proration and refunds around it — for one flat price, on the provider's own * page, none of that is worth owning. */ app.get('/billing/portal', async (request) => { request.ws.require('admin'); if (!billingEnabled() || !config.billing.portalUrl) { throw badRequest('There is no billing portal on this install.'); } return { url: config.billing.portalUrl }; }); } /** * The webhook. * * Outside the tenant scope, and correctly so: the caller is a payment provider * with no account and no session. Its authorisation is the signature, and the * workspace it names comes from the custom data Wellformed itself put on the * checkout — never from anything the browser could have set. */ export async function billingWebhookRoutes(app: FastifyInstance): Promise { /** * Keep the body as the exact bytes that were signed. * * A parsed-and-restringified object is a *different string* — key order and * whitespace change — and would fail verification for a reason that has * nothing to do with the signature being wrong. Scoped to this plugin, so * every other route still gets a parsed body. */ app.addContentTypeParser('application/json', { parseAs: 'string' }, (_request, body, done) => { done(null, body); }); app.post( '/api/billing/webhook', async (request, reply) => { if (!billingEnabled()) throw badRequest('Billing is not configured on this install.'); const raw = typeof request.body === 'string' ? request.body : ''; if (!verifySignature(request.headers['paddle-signature'] as string | undefined, raw)) { throw unauthorized('That webhook is not signed by the configured provider.'); } let payload: unknown; try { payload = JSON.parse(raw); } catch { throw badRequest('That webhook body is not JSON.'); } const event = parseEvent(payload); if (!event) throw badRequest('That webhook names no event.'); // The workspace is either stamped on the checkout or already known from // an earlier event for the same subscription. const workspaceId = event.workspaceId ?? (event.subscriptionId ? findWorkspaceBySubscription(app.control, config.billing.provider, event.subscriptionId) : null); const fresh = recordBillingEvent(app.control, { id: event.id, provider: config.billing.provider, kind: event.kind, workspaceId, payload: raw, }); // Providers retry. A repeat is a success, not an error, and must change // nothing — otherwise a retried "cancelled" could undo a later upgrade. if (!fresh) return { ok: true, repeated: true }; if (!workspaceId || !getWorkspaceById(app.control, workspaceId)) { // Logged and accepted: answering 4xx would make the provider retry an // event that can never apply, forever. request.log.warn({ event: event.kind }, 'billing webhook names no known workspace'); return { ok: true, applied: false }; } transactControl(app.control, () => { setSubscription(app.control, workspaceId, { ...(event.plan ? { plan: event.plan } : {}), ...(event.status ? { status: event.status } : {}), periodEnd: event.periodEnd, cancelAtPeriodEnd: event.cancelAtPeriodEnd, provider: config.billing.provider, ...(event.customerId ? { providerCustomerId: event.customerId } : {}), ...(event.subscriptionId ? { providerSubscriptionId: event.subscriptionId } : {}), }); }); // Written where the workspace's admins will look for it. const workspace = getWorkspaceById(app.control, workspaceId)!; const db = app.registry.acquire(workspace); try { recordAudit(db, { ...SYSTEM_ACTOR, id: 'billing' }, { kind: 'auth', nw: `billing: ${event.kind}${event.plan ? ` → ${event.plan}` : ''}`, }); } finally { app.registry.release(workspace.id); } reply.code(200); return { ok: true, applied: true }; }, ); }