Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<void> {
/**
* 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<BillingSummary> => {
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<void> {
/**
* 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 };
},
);
}