Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

8.9 kB Ruw bestand
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 };
    },
  );
}