import { createHmac, timingSafeEqual } from 'node:crypto'; import type { PlanId, SubscriptionStatus } from '@wellformed/shared'; import { config } from '../config.js'; /** * Taking money. * * Wellformed uses a **merchant of record** — Paddle here — rather than a raw card * processor, because a one-person company selling software across the EU * otherwise inherits VAT registration, rate tables and filing in every country * it sells into. That is not a feature to build; it is a business to run. * * What that leaves on this side is small and is all of it below: a link to a * hosted checkout, and a webhook that says what happened. Wellformed never sees a * card number, never stores one, and has no PCI surface at all. * * With no provider configured the product still works end to end — every * workspace is simply on the free plan, and the operator moves one with * `npm run plan`. That is the self-hosted case, and it is not a degraded mode. */ export interface CheckoutRequest { workspaceId: string; workspaceSlug: string; plan: PlanId; email: string; /** Where the provider should send them back to. */ returnUrl: string; } export function billingEnabled(): boolean { return config.billing.provider !== 'none'; } /** * The hosted checkout to send someone to. * * `workspaceId` rides along as custom data so the webhook can say which * workspace was paid for without trusting anything in the browser. */ export function checkoutUrl(request: CheckoutRequest): string | null { const { provider, checkoutBase, priceIds } = config.billing; if (provider === 'none' || !checkoutBase) return null; const price = priceIds[request.plan]; if (!price) return null; const url = new URL(checkoutBase); url.searchParams.set('items[0][price_id]', price); url.searchParams.set('items[0][quantity]', '1'); url.searchParams.set('customer_email', request.email); url.searchParams.set('custom_data[workspace_id]', request.workspaceId); url.searchParams.set('custom_data[workspace_slug]', request.workspaceSlug); url.searchParams.set('success_url', request.returnUrl); return url.toString(); } /** * Verify a Paddle webhook signature. * * The header is `ts=;h1=`, and the signed payload is * `:`. Two details matter and both are security-relevant: the * comparison is constant-time, and the timestamp is checked so a captured * webhook cannot be replayed a month later to reinstate a cancelled plan. */ export const WEBHOOK_TOLERANCE_MS = 5 * 60_000; export function verifySignature(header: string | undefined, rawBody: string, now = Date.now()): boolean { const secret = config.billing.webhookSecret; if (!secret || !header) return false; const parts = new Map( header .split(';') .map((piece) => piece.split('=', 2)) .filter((pair): pair is [string, string] => pair.length === 2) .map(([key, value]) => [key.trim(), value.trim()]), ); const ts = parts.get('ts'); const signature = parts.get('h1'); if (!ts || !signature) return false; const age = Math.abs(now - Number(ts) * 1000); if (!Number.isFinite(age) || age > WEBHOOK_TOLERANCE_MS) return false; const expected = createHmac('sha256', secret).update(`${ts}:${rawBody}`).digest('hex'); const given = Buffer.from(signature, 'utf8'); const mine = Buffer.from(expected, 'utf8'); return given.length === mine.length && timingSafeEqual(given, mine); } /** The subset of a provider event Wellformed acts on. */ export interface BillingEvent { id: string; kind: string; workspaceId: string | null; subscriptionId: string | null; customerId: string | null; status: SubscriptionStatus | null; plan: PlanId | null; periodEnd: string | null; cancelAtPeriodEnd: boolean; } interface PaddlePayload { event_id?: string; event_type?: string; data?: { id?: string; status?: string; customer_id?: string; scheduled_change?: { action?: string } | null; current_billing_period?: { ends_at?: string } | null; custom_data?: { workspace_id?: string } | null; items?: { price?: { id?: string } }[]; }; } const STATUS_MAP: Record = { active: 'active', trialing: 'trialing', past_due: 'past_due', paused: 'past_due', canceled: 'cancelled', cancelled: 'cancelled', }; /** * Read a provider event into Wellformed's terms. * * The plan comes from the price id rather than from anything the payload calls * a plan: the price is what was actually charged, and it is the only field the * customer cannot influence. */ export function parseEvent(payload: unknown): BillingEvent | null { const body = payload as PaddlePayload; const id = body.event_id; const kind = body.event_type; if (!id || !kind) return null; const data = body.data ?? {}; const priceId = data.items?.[0]?.price?.id ?? null; let plan: PlanId | null = null; for (const [candidate, configured] of Object.entries(config.billing.priceIds)) { if (configured && configured === priceId) plan = candidate as PlanId; } return { id, kind, workspaceId: data.custom_data?.workspace_id ?? null, subscriptionId: data.id ?? null, customerId: data.customer_id ?? null, status: data.status ? (STATUS_MAP[data.status] ?? null) : null, plan, periodEnd: data.current_billing_period?.ends_at ?? null, cancelAtPeriodEnd: data.scheduled_change?.action === 'cancel', }; }