diff --git a/app/api/signup/route.ts b/app/api/signup/route.ts index 3c0bee6..0268437 100644 --- a/app/api/signup/route.ts +++ b/app/api/signup/route.ts @@ -46,6 +46,7 @@ const FOUNDER_FALLBACK_NOTES: Record = { async function mintAccount( outcome: Extract, who: { email: string; contactName: string; restaurantName: string }, + signupRequestId: string, ): Promise<{ account: boolean; founderOutcome: string }> { let minted; try { @@ -53,6 +54,7 @@ async function mintAccount( ...who, slug: outcome.slug, amountCents: outcome.amountCents, + signupRequestId, }); } catch (e) { if (!(e instanceof SlugRaceLostError)) throw e; @@ -189,11 +191,11 @@ export async function POST(request: Request) { // ── Mint the account when the decision says so ────────────────────────── const { account, founderOutcome } = outcome.kind === "account" - ? await mintAccount(outcome, { - email, - contactName: data.contactName, - restaurantName: data.restaurantName, - }) + ? await mintAccount( + outcome, + { email, contactName: data.contactName, restaurantName: data.restaurantName }, + signup.id, + ) : { account: false, founderOutcome: FOUNDER_FALLBACK_NOTES[outcome.reason] }; // ── Tell the founder what happened ───────────────────────────────────── diff --git a/lib/actions/provisioning-actions.ts b/lib/actions/provisioning-actions.ts index 104d360..9a351ff 100644 --- a/lib/actions/provisioning-actions.ts +++ b/lib/actions/provisioning-actions.ts @@ -5,9 +5,9 @@ // the change syncs to the box, then the provision-tenant Action runs the script. import { requireAdmin } from "@/lib/rbac"; -import { db } from "@/lib/db"; import { audit } from "@/lib/audit"; -import { provisionGate, type ProvisionGateVerdict } from "@/lib/provisioning-payment-gate"; +import { db } from "@/lib/db"; +import { slugProvisionVerdict } from "@/lib/provisioning-facts"; import { provisionSchema, splitCsvLower } from "@/lib/validation"; import { loadTenantRegistry } from "@/lib/tenant-registry"; import { checkSlug } from "@/lib/slug-availability"; @@ -22,30 +22,6 @@ import { * GitHub API errors pass through raw. `prUrl` on success. */ export type ProvisionActionState = { error?: string; ok?: boolean; prUrl?: string }; -/** - * Read this slug's billing facts and run the O2 payment gate over them. - * - * Kept next to its only caller rather than in the pure gate module, so the - * policy stays unit-testable without a database. Only `first` payments are - * fetched: a settled first payment is what the gate asks about, and the - * recurring history grows without bound. - */ -async function slugProvisionVerdict(slug: string): Promise { - const billing = await db.tenantBilling.findUnique({ - where: { tenantSlug: slug }, - include: { - subscriptions: { select: { status: true } }, - payments: { where: { sequenceType: "first" }, select: { status: true }, take: 20 }, - }, - }); - if (!billing) return provisionGate(null); - return provisionGate({ - selfServe: billing.payerUserId !== null, - firstPaymentSettled: billing.payments.some((p) => p.status === "paid"), - subscriptionActive: billing.subscriptions.some((s) => s.status === "ACTIVE"), - }); -} - /** Collapse a repeated (checkbox-group) form field into the comma list the * schema validates, dropping any non-string entry. */ const csvField = (formData: FormData, name: string): string => @@ -126,6 +102,13 @@ export async function openProvisioningPrAction( modules, city: input.city || undefined, }); + // Record it on the billing row when there is one. The auto path reads this as its + // idempotency marker, so a founder proposing by hand must populate it too — otherwise + // a later payment webhook sees no record, tries again, and has to infer the truth from + // GitHub refusing a duplicate branch. + await db.tenantBilling + .update({ where: { tenantSlug: input.slug }, data: { provisioningPrUrl: prUrl } }) + .catch(() => undefined); // no plan for this slug: founder-proposed, nothing to record await audit(admin.id, "tenant.provision.proposed", "Tenant", input.slug, { prUrl }); return { ok: true, prUrl }; } catch (e) { diff --git a/lib/auto-provision-policy.ts b/lib/auto-provision-policy.ts new file mode 100644 index 0000000..0f1cf37 --- /dev/null +++ b/lib/auto-provision-policy.ts @@ -0,0 +1,135 @@ +// Should the payment-triggered registry proposal be opened, and if not, what should the +// founder be told? (SOFRA-ONBOARDING-PLAN O3, second half.) +// +// Pure — no DB, no network, no GitHub — for the same reason +// lib/provisioning-payment-gate.ts is: the policy is the part worth pinning in tests, +// and the repo forbids the mocks that testing it through Prisma and fetch would need +// (CLAUDE.md §7). lib/auto-provision.ts is the shell that feeds this and performs the +// one side effect it authorises. + +/** Why an automatic proposal was not opened. None of these is an error. */ +export type AutoProposeSkip = + | "notSelfServe" + | "awaitingPayment" + | "incompleteConfiguration" + | "slugMismatch" + | "unsafeName" + | "proposalExists"; + +/** The plan: either do the one side effect, or report without doing it. */ +export type AutoProposePlan = + | { kind: "propose" } + | { kind: "alreadyProposed"; prUrl: string } + | { kind: "skipped"; reason: AutoProposeSkip } + | { kind: "failed"; detail: string }; + +export type AutoProposeOutcome = Exclude | { kind: "opened"; prUrl: string }; + +/** The already-validated configuration a lead recorded, plus the slug it must match. */ +export type AutoProposeConfig = { + /** The lead's requested slug, after re-validation. */ + slug: string; + /** The slug this plan bills against — the immutable anchor. */ + billingSlug: string; + /** Becomes the registry `name:`, and from there a Docker build arg. */ + name: string; + template?: string; + currency?: string; + modules: string[]; + languages: string[]; +}; + +export type AutoProposeFacts = { + /** A proposal already recorded on this plan. */ + existingPrUrl: string | null; + /** The configuration from the linked lead; null when there is no lead at all. */ + config: AutoProposeConfig | null; + /** The O2 payment gate said this slug may be proposed. */ + settled: boolean; + /** PROVISION_GITHUB_TOKEN is present. */ + provisioningConfigured: boolean; +}; + +/** + * Order matters, and each position is a decision: + * + * 1. **Already proposed wins over everything.** Mollie redelivers webhooks, so this is + * the ordinary repeat case, not an edge one — and answering it first means a + * redelivery cannot be reported as a fresh skip or failure. + * 2. **No lead ⇒ not self-serve.** The same signal the payment gate keys on + * (/admin/onboard, the reseller flow, and RUMI have no lead). Not our business to + * automate, and silence would be wrong: the founder should read why. + * 3. **The gate outranks the configuration.** An unpaid plan is refused before we look + * at what it asked for, so a badly configured unpaid plan is reported as unpaid. + * 4. **Slug mismatch is its own answer, not "incomplete".** If the lead's slug and the + * billing anchor disagree, something upstream is wrong; conflating it with a missing + * template would send the founder to fill in a form instead of investigating. + * 5. **Missing configuration is a skip, never a guess.** Template and currency have no + * safe default for someone paying — the theme is baked into their image and the + * currency prices their menu. + * 6. **A missing token is a FAILURE, not a skip.** PROVISION_GITHUB_TOKEN expires + * silently (trap 4); on this path nobody is looking at the "not configured" banner, + * so it has to be loud. Checked last so a plan that was never eligible does not + * raise a token alarm. + */ +export function decideAutoPropose(facts: AutoProposeFacts): AutoProposePlan { + if (facts.existingPrUrl) return { kind: "alreadyProposed", prUrl: facts.existingPrUrl }; + if (!facts.config) return { kind: "skipped", reason: "notSelfServe" }; + if (!facts.settled) return { kind: "skipped", reason: "awaitingPayment" }; + + const c = facts.config; + if (c.slug !== c.billingSlug) return { kind: "skipped", reason: "slugMismatch" }; + if (!c.template || !c.currency || c.modules.length === 0 || c.languages.length === 0) { + return { kind: "skipped", reason: "incompleteConfiguration" }; + } + // Defence in depth behind `signupSchema`. That guard is new (O3), so rows captured + // before it can still hold a newline — and this name travels into + // build-tenant-image.yml's newline-delimited `build-args:`. The deploy chain rejects + // it too, but only AFTER the entry is merged, which would leave a paying customer + // with a merged registry entry that never provisions. + if (/[\u0000-\u001f\u007f]/.test(c.name)) return { kind: "skipped", reason: "unsafeName" }; + if (!facts.provisioningConfigured) { + return { kind: "failed", detail: "PROVISION_GITHUB_TOKEN is unset or expired" }; + } + return { kind: "propose" }; +} + +/** Founder-facing one-liners. Deliberately say what to DO, not just what happened. */ +export const AUTO_PROPOSE_NOTES: Record = { + notSelfServe: + "No automatic proposal: this plan was created by hand (no signup lead attached), so provisioning stays manual as before.", + awaitingPayment: + "No automatic proposal: the payment gate does not consider this plan settled yet. Nothing to do — the next webhook delivery retries.", + incompleteConfiguration: + "No automatic proposal: the lead did not record a full configuration (template, currency, modules and languages are all required). Open /admin/provision?from= and choose.", + slugMismatch: + "No automatic proposal: the lead's requested web address does not match the slug this plan bills against. Someone should look at that before a tenant is created.", + unsafeName: + "No automatic proposal: the restaurant name holds a line break or control character, which cannot go into a tenant image build. Fix the name on the lead, then open /admin/provision?from=.", + proposalExists: + "No automatic proposal opened: a proposal for this slug already exists and is recorded. Nothing to do.", +}; + +/** + * What `openProvisioningPr` meant by refusing. Pure, and here rather than in the shell, + * because the first version of this lived in the shell as an untested string test and got + * it wrong: it matched BOTH refusals and called them both "a proposal already exists". + * + * - `slugLive` — the slug is already MERGED into the registry, i.e. a live tenant. + * Reported as a benign "already proposed" this becomes: money taken + * for a subdomain that belongs to someone else, and an email saying + * there is nothing to do. + * - `proposalOpen` — the `provision/` branch exists. Usually a concurrent webhook + * delivery; but `openProvisioningPr` creates the branch BEFORE it + * commits and opens the PR, so it is also what an interrupted attempt + * leaves behind. The caller has to tell those apart by whether a PR + * URL was actually recorded — an orphan branch with no PR is a wedge, + * not a duplicate. + */ +export type ProvisioningRefusal = "slugLive" | "proposalOpen" | "other"; + +export function classifyProvisioningRefusal(message: string): ProvisioningRefusal { + if (/already has/i.test(message)) return "slugLive"; + if (/already open/i.test(message)) return "proposalOpen"; + return "other"; +} diff --git a/lib/auto-provision.ts b/lib/auto-provision.ts new file mode 100644 index 0000000..f9bbfc8 --- /dev/null +++ b/lib/auto-provision.ts @@ -0,0 +1,192 @@ +// Payment-triggered provisioning (SOFRA-ONBOARDING-PLAN O3, second half) — the shell. +// +// When a SELF-SERVE tenant's first payment settles, propose its registry entry without +// waiting for the founder to open /admin/provision. The founder still reviews and merges +// — that is the human checkpoint, and under the merge chain it is the merge that stands +// the tenant up — so this automates the typing, not the judgement. +// +// The decision lives in lib/auto-provision-policy.ts (pure, unit-tested). This file only +// gathers facts, performs the single side effect the policy authorises, and translates +// GitHub's refusals. Two rules it must keep: +// +// 1. **It never throws.** Its caller is the Mollie webhook, where an exception means a +// non-2xx, which means Mollie redelivers, which means a paid customer's activation +// retries for up to ~26h because a GitHub call failed. Every failure becomes a +// returned outcome. +// 2. **The gate is still the authority.** `slugProvisionVerdict` runs here even though +// the only caller reaches this from the `first`+`paid` branch. A second path that +// decides for itself when money counts is how the two drift apart (trap 7). + +import { db } from "@/lib/db"; +import { audit } from "@/lib/audit"; +import { slugProvisionVerdict } from "@/lib/provisioning-facts"; +import { toProvisionPrefill } from "@/lib/provision-prefill"; +import { + classifyProvisioningRefusal, + decideAutoPropose, + type AutoProposeOutcome, +} from "@/lib/auto-provision-policy"; +import { reportFailedProposal } from "@/lib/billing-notify"; +import { + openProvisioningPr, + provisioningConfigured, + ProvisioningApiError, + ProvisioningNotConfiguredError, +} from "@/lib/provisioning"; + +export { + AUTO_PROPOSE_NOTES, + type AutoProposeOutcome, + type AutoProposeSkip, +} from "@/lib/auto-provision-policy"; + +/** + * Try to open the registry PR for this billing row. Safe to call repeatedly — that is the + * ordinary case, since Mollie redelivers webhooks. + * + * Idempotency is `provisioningPrUrl` on our own row, read by the policy and written here. + * GitHub refusing a duplicate `provision/` branch is the backstop, not the + * mechanism: two concurrent deliveries can both read null, and the loser is recognised + * rather than reported as a failure. + */ +export async function autoProposeProvisioning(billingId: string): Promise { + try { + const billing = await db.tenantBilling.findUnique({ + where: { id: billingId }, + include: { signupRequest: true }, + }); + // Only reachable if the row vanished between recordPayment's read and this one. + // A `skipped` here would email the founder "this plan was created by hand", which + // would be a confident falsehood about a plan that no longer exists. + if (!billing) return { kind: "failed", detail: `billing row ${billingId} disappeared mid-delivery` }; + + // Re-validates every stored answer and DROPS whatever the catalog no longer + // recognises, so a months-old lead cannot carry a retired module id into a registry + // entry that `provision-tenant.sh` would then reject at the box, far from its source. + const lead = billing.signupRequest ? toProvisionPrefill(billing.signupRequest) : null; + + const plan = decideAutoPropose({ + existingPrUrl: billing.provisioningPrUrl, + config: lead + ? { ...lead, billingSlug: billing.tenantSlug } + : null, + // Only asked when it can matter — the gate is a query. + settled: lead ? (await slugProvisionVerdict(billing.tenantSlug)) === "allowed" : false, + provisioningConfigured: provisioningConfigured(), + }); + if (plan.kind !== "propose") return finish(billing.tenantSlug, plan); + + // Non-null by the policy's own checks; asserted so a future policy edit that drops a + // guard fails here loudly instead of proposing a tenant with no theme. + if (!lead?.template || !lead.currency) { + return finish(billing.tenantSlug, { + kind: "failed", + detail: "policy authorised an incomplete configuration", + }); + } + + const { prUrl } = await openProvisioningPr({ + slug: billing.tenantSlug, + name: lead.name, + adminEmail: lead.adminEmail, + template: lead.template, + currency: lead.currency, + languages: lead.languages, + modules: lead.modules, + city: lead.city || undefined, + }); + await db.tenantBilling.update({ + where: { id: billing.id }, + data: { provisioningPrUrl: prUrl }, + }); + return finish(billing.tenantSlug, { kind: "opened", prUrl }); + } catch (e) { + return finish(slugFor(billingId), await translate(billingId, e)); + } +} + +/** Best-effort slug for the failure path, where the row read may itself have failed. */ +async function slugFor(billingId: string): Promise { + try { + const b = await db.tenantBilling.findUnique({ + where: { id: billingId }, + select: { tenantSlug: true }, + }); + return b?.tenantSlug ?? billingId; + } catch { + return billingId; + } +} + +/** + * Turn a thrown error into an outcome. The subtle case is `proposalOpen`: the branch + * exists, which is *usually* a concurrent delivery whose winner has by now recorded the + * PR URL — but `openProvisioningPr` creates the branch before it commits and opens the + * PR, so an attempt that died in between leaves an orphan branch and no PR. Reporting + * that as a benign duplicate is a permanent wedge: every retry would say "already + * exists, nothing to do" while a paid customer has no tenant. So re-read the row, and + * only call it a duplicate if a URL was actually recorded. + */ +async function translate(billingId: string, e: unknown): Promise { + if (e instanceof ProvisioningNotConfiguredError) { + return { kind: "failed", detail: "PROVISION_GITHUB_TOKEN is unset or expired" }; + } + if (e instanceof ProvisioningApiError) { + switch (classifyProvisioningRefusal(e.message)) { + case "slugLive": + return { + kind: "failed", + detail: + "this slug is already a live tenant in the registry — a payment was taken for a subdomain that is not available. Needs a human.", + }; + case "proposalOpen": { + const recorded = await db.tenantBilling + .findUnique({ where: { id: billingId }, select: { provisioningPrUrl: true } }) + .catch(() => null); + if (recorded?.provisioningPrUrl) { + return { kind: "alreadyProposed", prUrl: recorded.provisioningPrUrl }; + } + return { + kind: "failed", + detail: + "a provision/ branch exists but no PR is recorded — an earlier attempt probably died between creating the branch and opening the PR. Delete the orphan branch on the deploy repo, then retry.", + }; + } + default: + return { kind: "failed", detail: e.message }; + } + } + // Never rethrow: see rule 1 in the header. + console.error("autoProposeProvisioning failed", billingId, e); + return { kind: "failed", detail: "unexpected error — see the control-plane logs" }; +} + +/** + * Record every outcome, and email the founder about a failure immediately. + * + * Both halves exist because the payment email is NOT a reliable carrier for this: it is + * sent after `activatePendingSubscriptions`, which deliberately throws to force a webhook + * 503 during the mandate race. A token that expired silently plus a mandate that lags + * would otherwise be reported nowhere at all — the exact trap the policy makes loud. + */ +async function finish( + slugOrPromise: string | Promise, + outcome: AutoProposeOutcome, +): Promise { + // Fully guarded: this also runs from inside the catch block, so a throw here would + // escape the module and break rule 1 — and it would do so while reporting a failure, + // i.e. at the worst possible moment. + try { + const slug = await slugOrPromise; + // actor null: this was a payment, not a person. + await audit(null, `tenant.provision.auto.${outcome.kind}`, "Tenant", slug, { + ...("prUrl" in outcome ? { prUrl: outcome.prUrl } : {}), + ...("reason" in outcome ? { reason: outcome.reason } : {}), + ...("detail" in outcome ? { detail: outcome.detail } : {}), + }); + if (outcome.kind === "failed") await reportFailedProposal(slug, outcome.detail); + } catch (e) { + console.error("autoProposeProvisioning: could not record outcome", e); + } + return outcome; +} diff --git a/lib/billing-notify.ts b/lib/billing-notify.ts new file mode 100644 index 0000000..40fb5b0 --- /dev/null +++ b/lib/billing-notify.ts @@ -0,0 +1,109 @@ +// Founder-facing notification for a payment (S9), split out of lib/billing.ts so the +// billing STATE MACHINE and the prose about it stay separate concerns. The split earned +// itself when O3 added the automatic-proposal outcome: billing.ts is a grandfathered +// over-limit file (scripts/file-length-baseline.txt), and email formatting is the part +// that had no business growing it. + +import { sendEmail, founderInbox } from "@/lib/email"; +import { craftEmail, detailRows } from "@/lib/email-templates"; +import { eur } from "@/lib/format"; +import { AUTO_PROPOSE_NOTES, type AutoProposeOutcome } from "@/lib/auto-provision-policy"; +import type { MolliePayment } from "@/lib/mollie"; + +/** One row for the payment email. `detailRows` escapes both columns itself. */ +function proposalLine(proposal: AutoProposeOutcome): string { + switch (proposal.kind) { + case "opened": + return `registry PR opened automatically — ${proposal.prUrl}`; + case "alreadyProposed": + return `already proposed — ${proposal.prUrl}`; + case "skipped": + return AUTO_PROPOSE_NOTES[proposal.reason]; + case "failed": + return `AUTOMATIC PROPOSAL FAILED — open it by hand at /admin/provision. Reason: ${proposal.detail}`; + } +} + +/** A failed auto-open is the one case where the footer has to ask for action. */ +function proposalFooter(proposal: AutoProposeOutcome | null): string { + if (proposal?.kind === "failed") { + return "The payment is fine — the automatic registry proposal is not. Open it by hand; nothing else is owed."; + } + if (proposal?.kind === "opened") { + return "Review and merge the registry PR to stand the tenant up. Merging provisions it."; + } + return "Mirrored into the control plane automatically."; +} + +export async function notifyFounder( + tenantSlug: string, + payment: MolliePayment, + amountCents: number, + proposal: AutoProposeOutcome | null, +) { + const interesting = + payment.status === "paid" || + payment.status === "failed" || + payment.status === "expired" || + payment.status === "canceled"; + if (!interesting) return; + const inbox = founderInbox(); + if (!inbox) return; + const ok = payment.status === "paid"; + await sendEmail({ + to: inbox, + subject: `[SofraPiwas billing] ${tenantSlug}: ${payment.sequenceType} payment ${payment.status} (${eur(amountCents)})`, + html: craftEmail({ + kicker: "Billing", + title: ok ? "Payment received" : `Payment ${payment.status}`, + // detailRows escapes both columns itself. + bodyHtml: detailRows([ + ["Tenant", tenantSlug], + ["Amount", eur(amountCents)], + ["Type", payment.sequenceType], + ["Status", payment.status], + ["Mollie id", payment.id], + // The automatic proposal's outcome rides the email the founder already opens + // for a payment, rather than a second message. It is the ONLY place a failed + // auto-open surfaces: the webhook must answer 2xx, so it cannot signal there. + ...(proposal ? [["Provisioning", proposalLine(proposal)] as [string, string]] : []), + ]), + footerNote: ok + ? proposalFooter(proposal) + : "Check the Mollie dashboard — a failed recurring charge may need dunning.", + }), + }); +} + +/** + * A failed automatic proposal gets its OWN message rather than a line in the payment + * email, because that email is sent after `activatePendingSubscriptions` — which + * deliberately throws during the mandate race to force a webhook 503. A silently expired + * PROVISION_GITHUB_TOKEN plus a lagging mandate would otherwise be reported nowhere. + * + * Never throws: `sendEmail` swallows a non-2xx into `{sent:false}`, but `fetch` itself + * REJECTS on a DNS/connect failure, and letting that escape would turn a reporting + * problem into a webhook 500 and a Mollie retry loop (the O2 lesson, one layer along). + */ +export async function reportFailedProposal(tenantSlug: string, detail: string): Promise { + try { + const inbox = founderInbox(); + if (!inbox) return; + await sendEmail({ + to: inbox, + subject: `[SofraPiwas] ${tenantSlug}: automatic registry proposal FAILED`, + html: craftEmail({ + kicker: "Provisioning", + title: "Automatic proposal failed", + bodyHtml: detailRows([ + ["Tenant", tenantSlug], + ["Reason", detail], + ]), + footerNote: + "The payment itself is fine. Open the registry PR by hand at /admin/provision — nothing else is owed.", + }), + }); + } catch (e) { + console.error("reportFailedProposal: could not notify", tenantSlug, e); + } +} diff --git a/lib/billing.ts b/lib/billing.ts index 71891b6..73c3fe1 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -14,9 +14,10 @@ import { db } from "@/lib/db"; import { audit } from "@/lib/audit"; -import { sendEmail, founderInbox, siteUrl } from "@/lib/email"; -import { craftEmail, detailRows } from "@/lib/email-templates"; -import { eur } from "@/lib/format"; +import { siteUrl } from "@/lib/email"; +import { autoProposeProvisioning } from "@/lib/auto-provision"; +import type { AutoProposeOutcome } from "@/lib/auto-provision-policy"; +import { notifyFounder } from "@/lib/billing-notify"; import { createCustomer, createFirstPayment, @@ -211,14 +212,25 @@ export async function recordPayment(payment: MolliePayment) { sequenceType: payment.sequenceType, }); + let proposal: AutoProposeOutcome | null = null; if (payment.sequenceType === "first" && payment.status === "paid") { + // O3: propose the registry entry BEFORE activation, deliberately. Activation can + // throw MandateNotReadyError (-> webhook 503 -> Mollie retry) and that window runs + // ~80s typically but up to ~26h in the worst case. The customer has paid; making + // their tenant wait on a mandate would be waiting on the wrong thing. The payment + // gate treats a settled first payment as sufficient, so this agrees with it. + // + // It cannot throw (see lib/auto-provision.ts rule 1) — a GitHub outage must not turn + // a successful payment into a retry loop. + proposal = await autoProposeProvisioning(billing.id); + // billing was located BY this customerId (guarded non-null above), so it is // the customer to activate against — pass it directly (the column is now // nullable for plans defined before their first payment). await activatePendingSubscriptions(billing.id, payment.customerId); } - await notifyFounder(billing.tenantSlug, payment, amountCents); + await notifyFounder(billing.tenantSlug, payment, amountCents, proposal); } /** Create the real Mollie subscription for every PENDING plan (idempotent). */ @@ -290,34 +302,3 @@ async function activatePendingSubscriptions(billingId: string, mollieCustomerId: } } -/** Founder notification on money events — paid, or anything gone wrong. */ -async function notifyFounder(tenantSlug: string, payment: MolliePayment, amountCents: number) { - const interesting = - payment.status === "paid" || - payment.status === "failed" || - payment.status === "expired" || - payment.status === "canceled"; - if (!interesting) return; - const inbox = founderInbox(); - if (!inbox) return; - const ok = payment.status === "paid"; - await sendEmail({ - to: inbox, - subject: `[SofraPiwas billing] ${tenantSlug}: ${payment.sequenceType} payment ${payment.status} (${eur(amountCents)})`, - html: craftEmail({ - kicker: "Billing", - title: ok ? "Payment received" : `Payment ${payment.status}`, - // detailRows escapes both columns itself. - bodyHtml: detailRows([ - ["Tenant", tenantSlug], - ["Amount", eur(amountCents)], - ["Type", payment.sequenceType], - ["Status", payment.status], - ["Mollie id", payment.id], - ]), - footerNote: ok - ? "Mirrored into the control plane automatically." - : "Check the Mollie dashboard — a failed recurring charge may need dunning.", - }), - }); -} diff --git a/lib/provisioning-facts.ts b/lib/provisioning-facts.ts new file mode 100644 index 0000000..efa9d4c --- /dev/null +++ b/lib/provisioning-facts.ts @@ -0,0 +1,35 @@ +// The database half of the O2 payment gate: read a slug's billing facts and run the +// pure policy (lib/provisioning-payment-gate.ts) over them. +// +// This lived inside `openProvisioningPrAction` while that action was its only caller, +// with a comment explaining that keeping it there left the policy unit-testable without +// a database. That reasoning still holds for the POLICY — which is why it stays in the +// pure module — but the query now has a second caller (the payment-triggered proposal, +// O3), and a second copy of "what counts as settled" is exactly the kind of drift that +// would let one path provision what the other refuses. +// +// It cannot live in the action file: that is `"use server"`, where every export must be +// an async server action. + +import { db } from "@/lib/db"; +import { provisionGate, type ProvisionGateVerdict } from "@/lib/provisioning-payment-gate"; + +/** + * Only `first` payments are fetched: a settled first payment is what the gate asks + * about, and the recurring history grows without bound. + */ +export async function slugProvisionVerdict(slug: string): Promise { + const billing = await db.tenantBilling.findUnique({ + where: { tenantSlug: slug }, + include: { + subscriptions: { select: { status: true } }, + payments: { where: { sequenceType: "first" }, select: { status: true }, take: 20 }, + }, + }); + if (!billing) return provisionGate(null); + return provisionGate({ + selfServe: billing.payerUserId !== null, + firstPaymentSettled: billing.payments.some((p) => p.status === "paid"), + subscriptionActive: billing.subscriptions.some((s) => s.status === "ACTIVE"), + }); +} diff --git a/lib/provisioning.ts b/lib/provisioning.ts index a8dd53e..2ace22c 100644 --- a/lib/provisioning.ts +++ b/lib/provisioning.ts @@ -33,10 +33,18 @@ export function provisioningConfigured(): boolean { return Boolean(process.env.PROVISION_GITHUB_TOKEN); } +/** Per-call ceiling. Since O3 these calls sit in the Mollie webhook's critical path, + * ahead of subscription activation — and a HANG there (not an error, a hang) would stall + * activation on a dependency that has nothing to do with billing, until Mollie times the + * delivery out and redelivers on top of the one still in flight. `fetch` has no default + * timeout, so it needs an explicit one. */ +const GH_TIMEOUT_MS = 15_000; + async function gh(token: string, path: string, init?: RequestInit): Promise { const res = await fetch(`${API}${path}`, { // Never serve a cached registry/ref read — a stale sha would 409 the commit. cache: "no-store", + signal: AbortSignal.timeout(GH_TIMEOUT_MS), ...init, headers: { Authorization: `Bearer ${token}`, diff --git a/lib/self-serve-account.ts b/lib/self-serve-account.ts index e72c3b8..e3b82bc 100644 --- a/lib/self-serve-account.ts +++ b/lib/self-serve-account.ts @@ -71,6 +71,10 @@ export async function createSelfServeAccount(input: { restaurantName: string; slug: string; amountCents: number; + /** The lead this plan is minted from. Carries the configurator answers that the + * payment-triggered proposal reads (O3) — without it the only join back to them + * is `desiredSlug`, which several leads can share. */ + signupRequestId: string; }): Promise { try { const minted = await db.$transaction(async (tx) => { @@ -103,6 +107,7 @@ export async function createSelfServeAccount(input: { // Owner flow: the payer IS the user, and there is no reseller Client // (the clientId XOR payerUserId shape `defineTenantPlan` asserts). payerUserId: user.id, + signupRequestId: input.signupRequestId, }, }); diff --git a/lib/validation.ts b/lib/validation.ts index 92c0733..1cef40a 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -9,6 +9,22 @@ export const splitCsvLower = (raw: string): string[] => .map((s) => s.trim().toLowerCase()) .filter(Boolean); +/** No line breaks or control characters. + * + * A tenant's display name reaches three formats where a newline changes meaning: the + * registry YAML (safe on its own — `yaml.stringify` quotes it), the provisioning PR body + * (a newline breaks the fence around the fallback shell command), and — through the + * registry — `build-tenant-image.yml`'s `build-args:`, which is a NEWLINE-DELIMITED list, + * so a second line there injects a build arg into the tenant's own bundle. + * + * It lived only on `provisionSchema` while the founder form was the only way in. O3's + * payment-triggered proposal reaches `openProvisioningPr` from the PUBLIC intake without + * passing through that form, so the guard has to be at the intake edge too or the + * unattended path is the one place nothing checks. `trim()` is not enough — it strips + * only the ends. */ +const noControlChars = >(schema: T) => + schema.refine((v) => !/[\u0000-\u001f\u007f]/.test(v), "no line breaks or control characters"); + export const applySchema = z.object({ name: z.string().trim().min(1).max(200), email: z.string().trim().max(200).email(), @@ -23,7 +39,8 @@ export const applySchema = z.object({ // given, must match the registry grammar (same as billing/onboard) so we don't // capture garbage the founder then has to clean up. export const signupSchema = z.object({ - restaurantName: z.string().trim().min(1).max(200), + // Guarded because this becomes the registry `name:` for a self-serve tenant (O3). + restaurantName: noControlChars(z.string().trim().min(1).max(200)), contactName: z.string().trim().min(1).max(200), email: z.string().trim().max(200).email(), phone: z.string().trim().max(50).optional().or(z.literal("")), @@ -137,20 +154,7 @@ export const provisionSchema = z.object({ .string() .trim() .regex(/^[a-z0-9][a-z0-9-]{1,30}$/, "lowercase slug, 2-31 chars"), - // No control characters. `trim()` strips only LEADING/TRAILING whitespace, so an - // interior newline survived — and the tenant name is free text that then flows into - // three formats where a newline changes meaning: the registry YAML (safe, via - // `yaml.stringify`), the provisioning PR body (a newline breaks the markdown fence - // around the fallback shell command), and — via that entry — build-tenant-image.yml's - // `build-args:`, which is a NEWLINE-DELIMITED list, so a second line there injects a - // build arg into the tenant's own bundle. Rejected at the edge rather than escaped - // three times downstream. - name: z - .string() - .trim() - .min(1) - .max(200) - .refine((v) => !/[\u0000-\u001f\u007f]/.test(v), "no line breaks or control characters"), + name: noControlChars(z.string().trim().min(1).max(200)), adminEmail: z.string().trim().max(200).email(), template: z.enum(["classic", "craft"]), currency: z.string().trim().regex(/^[A-Z]{3}$/, "3-letter ISO code, e.g. EUR"), diff --git a/prisma/migrations/20260730060000_billing_signup_link/migration.sql b/prisma/migrations/20260730060000_billing_signup_link/migration.sql new file mode 100644 index 0000000..0953f67 --- /dev/null +++ b/prisma/migrations/20260730060000_billing_signup_link/migration.sql @@ -0,0 +1,51 @@ +-- Payment-triggered provisioning (workspace docs/plans/SOFRA-ONBOARDING-PLAN.md, O3). +-- +-- O3's remaining half is: when a self-serve tenant's first payment settles, open the +-- registry PR automatically instead of waiting for the founder to click +-- /admin/provision. That needs the configurator answers (modules/template/currency/ +-- languages, on SignupRequest since O1) reachable FROM the billing row — and until now +-- they were not, in either direction. +-- +-- The plan assumed they were. What actually existed was a SOFT join, +-- SignupRequest.desiredSlug = TenantBilling.tenantSlug, and it is not safe to provision +-- from: leads accumulate (every leadOnly outcome writes one), so two SignupRequest rows +-- can carry the same desiredSlug while only one of them minted the account. Matching on +-- it could hand a paying customer another lead's MODULE LIST. Today a human supplies the +-- id explicitly (/admin/provision?from=); an unattended path has no human. +-- +-- signupRequestId is therefore the durable link, written at intake by +-- createSelfServeAccount. NULL for every founder-created plan (/admin/onboard, the +-- reseller flow, and RUMI, which predates all of this), which is exactly the same +-- signal the payment gate already keys on: no lead ⇒ not self-serve ⇒ not our business +-- to automate. +-- +-- Deliberately NOT unique, and the honest reason is the second one below, not the first: +-- * "tenantSlug is already @unique" does NOT cover this. That prevents two plans per +-- SLUG; a unique signupRequestId would prevent two plans per LEAD, which is a +-- different claim. +-- * What actually decides it: the state is unreachable (the id comes from a row created +-- in the same request), and adding a unique constraint on the signup path would add a +-- fresh P2002 surface inside the money-adjacent transaction — where the existing catch +-- is narrowed on `tenantSlug` (O2 fix #5), so a different violation would fall through +-- as an unexplained 500 mid-signup. A constraint whose only effect is a worse failure +-- mode for an impossible state is not worth having. +-- +-- ON DELETE SET NULL: nothing prunes SignupRequest today (retention does not cover it), +-- but a dangling FK would be a worse way to find that out than a null. +-- +-- provisioningPrUrl records the proposal that was opened, and doubles as the auto-open's +-- idempotency record: Mollie redelivers webhooks, so "have I already proposed this +-- tenant?" has to be answerable from our own rows and not only from GitHub refusing a +-- duplicate branch. +-- +-- Both columns additive and nullable ⇒ safe on existing rows. + +ALTER TABLE "TenantBilling" ADD COLUMN "signupRequestId" TEXT; +ALTER TABLE "TenantBilling" ADD COLUMN "provisioningPrUrl" TEXT; + +CREATE INDEX "TenantBilling_signupRequestId_idx" ON "TenantBilling"("signupRequestId"); + +ALTER TABLE "TenantBilling" + ADD CONSTRAINT "TenantBilling_signupRequestId_fkey" + FOREIGN KEY ("signupRequestId") REFERENCES "SignupRequest"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d05e37d..d030c55 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,15 +56,15 @@ model User { status UserStatus @default(INVITED) createdAt DateTime @default(now()) - profile PartnerProfile? - clients Client[] - notes ClientNote[] - commissions CommissionEntry[] @relation("PartnerCommissions") + profile PartnerProfile? + clients Client[] + notes ClientNote[] + commissions CommissionEntry[] @relation("PartnerCommissions") createdEntries CommissionEntry[] @relation("EntryAuthor") - inviteTokens InviteToken[] - auditLogs AuditLog[] + inviteTokens InviteToken[] + auditLogs AuditLog[] // Tenants this user pays for directly (OWNER self-serve; ADR-004). - billingsPaid TenantBilling[] @relation("BillingPayer") + billingsPaid TenantBilling[] @relation("BillingPayer") } model PartnerApplication { @@ -107,13 +107,17 @@ model SignupRequest { // queried by element, and every consumer on the path speaks CSV. // All nullable — leads captured before the configurator shipped have none, and a // null here means "the founder still chooses", exactly as before. - modules String? - languages String? - template String? - currency String? + modules String? + languages String? + template String? + currency String? /// Monthly total in EUR integer cents as quoted at signup. A record of what they /// were shown, NOT a price that binds — always re-quote at onboarding. - quotedCents Int? + quotedCents Int? + + // Back-reference for the O3 link. A list because Prisma requires it on the + // non-owning side; in practice at most one plan is minted per lead. + billing TenantBilling[] @@index([status, createdAt]) } @@ -198,33 +202,48 @@ enum SubscriptionStatus { } model TenantBilling { - id String @id @default(cuid()) + id String @id @default(cuid()) // Registry slug (deploy repo tenants/registry.yml) — the billing anchor; // not a FK on purpose (the registry graduates to a table only at >3 // tenants, ADR-007). - tenantSlug String @unique + tenantSlug String @unique name String email String // Null until the payer starts the first payment: an admin can define a // PENDING plan (partner onboarding) before any Mollie customer exists. The // @unique still holds — Postgres allows multiple NULLs. - mollieCustomerId String? @unique - clientId String? @unique - client Client? @relation(fields: [clientId], references: [id], onDelete: SetNull) + mollieCustomerId String? @unique + clientId String? @unique + client Client? @relation(fields: [clientId], references: [id], onDelete: SetNull) // Explicit payer for the direct-owner flow (ADR-004): set when there is no // reseller Client. The reseller flow leaves this null and derives the payer // from client.partner. Exactly one of clientId / payerUserId is set in practice. payerUserId String? - payer User? @relation("BillingPayer", fields: [payerUserId], references: [id], onDelete: SetNull) + payer User? @relation("BillingPayer", fields: [payerUserId], references: [id], onDelete: SetNull) // Display-only: when the tenant's app went live (admin-entered at // onboarding), shown on the partner's welcome panel. liveSince DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) + + // The lead this plan was minted from (O3). Set only on the SELF-SERVE path, so + // null means founder-created — the same signal the payment gate already keys on. + // It exists because the configurator answers live on SignupRequest and the + // payment-triggered proposal needs them; the only alternative join + // (desiredSlug = tenantSlug) is soft and can select another lead's module list. + // Not unique on purpose — the state is unreachable and the constraint would only add + // a P2002 surface to the signup transaction. See the migration for the full reasoning. + signupRequestId String? + signupRequest SignupRequest? @relation(fields: [signupRequestId], references: [id], onDelete: SetNull) + // The registry proposal opened for this tenant. Also the auto-open's idempotency + // record: Mollie redelivers, so "already proposed?" must be answerable from our + // own rows, not only from GitHub refusing a duplicate branch. + provisioningPrUrl String? subscriptions BillingSubscription[] payments BillingPayment[] @@index([payerUserId]) + @@index([signupRequestId]) } model BillingSubscription { diff --git a/scripts/file-length-baseline.txt b/scripts/file-length-baseline.txt index ca8261c..c1d85b8 100644 --- a/scripts/file-length-baseline.txt +++ b/scripts/file-length-baseline.txt @@ -3,6 +3,9 @@ # Remove a line once its file is refactored under the limit. # # lib/billing.ts — Mollie subscription state machine (PENDING→ACTIVATING→ACTIVE, -# atomic claim, idempotency, mandate-race 503). 285 LOC; live-billing code, -# splitting it is its own risk-managed PR, not this test-infra one. +# atomic claim, idempotency, mandate-race 503). ~304 LOC; live-billing code, +# splitting the state machine is its own risk-managed PR. +# O3 (2026-07-30) added the payment-triggered proposal here and took the founder +# EMAIL out (-> lib/billing-notify.ts) so the growth stayed in the machine rather +# than in prose about it. Keep new concerns out of this file. lib/billing.ts diff --git a/tests/unit/auto-provision-policy.test.ts b/tests/unit/auto-provision-policy.test.ts new file mode 100644 index 0000000..7b59e3b --- /dev/null +++ b/tests/unit/auto-provision-policy.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { + AUTO_PROPOSE_NOTES, + classifyProvisioningRefusal, + decideAutoPropose, + type AutoProposeConfig, + type AutoProposeFacts, + type AutoProposeSkip, +} from "@/lib/auto-provision-policy"; + +const config = (over: Partial = {}): AutoProposeConfig => ({ + slug: "bistro-nova", + billingSlug: "bistro-nova", + name: "Bistro Nova", + template: "craft", + currency: "EUR", + modules: ["core", "reservations"], + languages: ["en", "nl"], + ...over, +}); + +const facts = (over: Partial = {}): AutoProposeFacts => ({ + existingPrUrl: null, + config: config(), + settled: true, + provisioningConfigured: true, + ...over, +}); + +describe("decideAutoPropose", () => { + it("proposes for a settled self-serve plan with a full configuration", () => { + expect(decideAutoPropose(facts())).toEqual({ kind: "propose" }); + }); + + it("reports an existing proposal instead of opening a second one", () => { + // The ordinary repeat case, not an edge one: Mollie redelivers webhooks. The URL + // rides along so a redelivery still tells the founder something useful. + const url = "https://github.com/piwas-21/restaurant-app-deploy/pull/99"; + expect(decideAutoPropose(facts({ existingPrUrl: url }))).toEqual({ + kind: "alreadyProposed", + prUrl: url, + }); + }); + + it("lets an existing proposal outrank every other verdict", () => { + // If this ordering ever inverts, a redelivery for an unpaid or badly configured plan + // would report a fresh skip about a tenant that has already been proposed. + const url = "https://example.test/pr/1"; + for (const over of [ + { config: null }, + { settled: false }, + { provisioningConfigured: false }, + { config: config({ template: undefined }) }, + ] as Partial[]) { + expect(decideAutoPropose(facts({ ...over, existingPrUrl: url })).kind).toBe("alreadyProposed"); + } + }); + + it("skips a plan with no lead — that is the founder path, not a failure", () => { + // No lead is exactly the signal the payment gate keys on: /admin/onboard, the + // reseller flow, and RUMI all have none. + expect(decideAutoPropose(facts({ config: null }))).toEqual({ + kind: "skipped", + reason: "notSelfServe", + }); + }); + + it("refuses an unpaid plan before it looks at the configuration", () => { + // A badly configured unpaid plan must report as unpaid: the gate is the security + // property, and "fix your template" would be the wrong instruction. + expect( + decideAutoPropose(facts({ settled: false, config: config({ currency: undefined }) })), + ).toEqual({ kind: "skipped", reason: "awaitingPayment" }); + }); + + it("treats a slug mismatch as its own answer, not as incomplete", () => { + // Conflating the two would send the founder to fill in a form when what they need to + // do is find out why a plan bills against a slug its lead never asked for. + expect(decideAutoPropose(facts({ config: config({ slug: "someone-else" }) }))).toEqual({ + kind: "skipped", + reason: "slugMismatch", + }); + }); + + it("never guesses a missing choice", () => { + // Template is baked into the tenant's image and currency prices their menu; neither + // has a safe default for someone who has paid. + for (const over of [ + { template: undefined }, + { currency: undefined }, + { modules: [] }, + { languages: [] }, + ] as Partial[]) { + expect(decideAutoPropose(facts({ config: config(over) }))).toEqual({ + kind: "skipped", + reason: "incompleteConfiguration", + }); + } + }); + + it("FAILS on a missing token rather than skipping — and only once eligible", () => { + // PROVISION_GITHUB_TOKEN expires silently and /admin/provision degrades to a banner + // nobody is looking at on this path, so it has to be loud. + expect(decideAutoPropose(facts({ provisioningConfigured: false }))).toEqual({ + kind: "failed", + detail: "PROVISION_GITHUB_TOKEN is unset or expired", + }); + // ...but a plan that was never eligible must not raise a token alarm. + expect( + decideAutoPropose(facts({ provisioningConfigured: false, settled: false })).kind, + ).toBe("skipped"); + expect(decideAutoPropose(facts({ provisioningConfigured: false, config: null })).kind).toBe( + "skipped", + ); + }); + + it("refuses a name that cannot survive a Docker build arg", () => { + // `signupSchema` guards this at intake now, but that guard is new — rows captured + // before it can still hold a newline, and this name reaches + // build-tenant-image.yml's NEWLINE-DELIMITED `build-args:`. The deploy chain also + // rejects it, but only after the entry is merged, which would leave a paying + // customer with a merged registry entry that never provisions. + for (const name of ["Bistro\nNEXT_PUBLIC_API_URL=https://evil.test", "A\tB", "A\u0000B"]) { + expect(decideAutoPropose(facts({ config: config({ name }) }))).toEqual({ + kind: "skipped", + reason: "unsafeName", + }); + } + // An ordinary name with punctuation and non-ASCII is untouched. + for (const name of ["Chez L'Ami", "Nova: Café — Bar", "北京饭店"]) { + expect(decideAutoPropose(facts({ config: config({ name }) })).kind).toBe("propose"); + } + }); + + it("has a founder-facing note for EVERY skip reason, enumerated explicitly", () => { + // Iterating AUTO_PROPOSE_NOTES would be vacuous — it can only contain what it + // contains. Listing the union members is what makes adding a reason without a note + // fail, here and at compile time. + const reasons: AutoProposeSkip[] = [ + "notSelfServe", + "awaitingPayment", + "incompleteConfiguration", + "slugMismatch", + "unsafeName", + "proposalExists", + ]; + expect(Object.keys(AUTO_PROPOSE_NOTES).sort()).toEqual([...reasons].sort()); + for (const reason of reasons) { + expect(AUTO_PROPOSE_NOTES[reason], reason).toMatch(/^No automatic proposal/); + expect(AUTO_PROPOSE_NOTES[reason].length, reason).toBeGreaterThan(20); + } + }); +}); + +describe("classifyProvisioningRefusal", () => { + it("tells a LIVE tenant apart from an open proposal", () => { + // The first version of this lived in the shell, untested, and matched both with one + // regex — so "that slug is already a live tenant" (money taken for a subdomain + // someone else owns) was reported to the founder as "nothing to do". + expect(classifyProvisioningRefusal("registry already has a 'demo' entry")).toBe("slugLive"); + expect( + classifyProvisioningRefusal( + "a provisioning proposal for 'demo' is already open (branch provision/demo exists)", + ), + ).toBe("proposalOpen"); + }); + + it("does not guess at anything else", () => { + for (const msg of ["GitHub POST /repos/x/y → 401: Bad credentials", "", "already"]) { + expect(classifyProvisioningRefusal(msg)).toBe("other"); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index e5e9edf..06593be 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ "lib/billing-display.ts", "lib/self-serve-signup.ts", "lib/provisioning-payment-gate.ts", + "lib/auto-provision-policy.ts", ], reporter: ["text-summary", "text"], // Floors sit a few points under the current 100/95/100/100 so a trivial