From cb3aa8478cc28ded03badd943df27b3980a3dfbe Mon Sep 17 00:00:00 2001 From: mahmutkaya Date: Thu, 30 Jul 2026 09:16:07 +0200 Subject: [PATCH] feat(provisioning): a settled first payment opens the registry PR itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O3's second half. `recordPayment`'s `first`+`paid` branch now calls `autoProposeProvisioning`; the founder reviews and merges as before, which since the merge chain is what stands the tenant up. This automates the typing, not the judgement. THE PLAN'S PREMISE WAS FALSE, and finding that was most of the work. It said to auto-open "from the stored configurator answers" as though they were reachable from the billing row. They were not: SignupRequest had no FK to TenantBilling, and the only join was desiredSlug = tenantSlug — soft, because every leadOnly outcome writes a lead, so two rows can share a desiredSlug while only one minted the account. Matching on it could hand a paying customer another lead's MODULE LIST. The founder path never hit this because a human passes the id (?from=). Hence the migration: TenantBilling.signupRequestId (+ provisioningPrUrl), written at intake. Shape follows the payment gate's: a pure policy (lib/auto-provision-policy.ts, unit-tested without the mocks §7 forbids) plus a thin shell (lib/auto-provision.ts). slugProvisionVerdict moved to lib/provisioning-facts.ts now that it has two callers — it could not stay in the action, which is "use server". notifyFounder moved to lib/billing-notify.ts so the founder EMAIL stopped growing a file that is already over limit. Load-bearing decisions: - Proposed BEFORE activation. Activation throws MandateNotReadyError to force a webhook 503 during the mandate race, a window of ~80s typically and up to ~26h. The customer has paid and the gate treats a settled first payment as sufficient, so waiting on the mandate would be waiting on the wrong thing. - It cannot throw. The webhook turns an exception into a non-2xx and Mollie then redelivers for ~26h; a GitHub outage must not become a retry loop on a paid customer. - Idempotency is our own row. provisioningPrUrl is checked first, because redelivery is the ORDINARY case, and "already proposed" outranks every other verdict so a redelivery can never be reported as a fresh skip. - It refuses to invent. No template/currency/modules/languages, or a lead slug that disagrees with the billing anchor, hands back to the founder with a note saying what to do. A missing PROVISION_GITHUB_TOKEN is a FAILURE (it expires silently) but is checked last, so an ineligible plan cannot raise a token alarm. Eight reviewer findings fixed, four of which were the interesting kind: 1. The automatic path ROUTED AROUND the control-character guard added last PR — the one that exists because the name reaches build-tenant-image.yml's newline-delimited `build-args:`. provisionSchema had it; signupSchema did not, and the auto path never touches provisionSchema. A crafted restaurantName could have injected a build arg into the tenant's own bundle. Now a shared refinement on both schemas, plus the policy refusing it for rows captured before the guard existed. 2. A partial openProvisioningPr failure wedged the path permanently. It creates the branch before committing and opening the PR, so a death in between left an orphan branch — after which every retry matched "already open" and reported "nothing to do" while a paid customer had no tenant, forever. Now: re-read the row, and only call it a duplicate if a URL was actually recorded. 3. The refusal classifier conflated "this slug is already a LIVE tenant" with "a proposal is open" — so money taken for someone else's subdomain read as benign. Split, and moved into the pure policy: it was a string decision with a correctness bug sitting on the untested side of the very split the module argues for. 4. A failed proposal was reported nowhere if activation then threw. The payment email was the only carrier, and it is sent after activation. Failures now get their own message and every outcome is audited. Also: a 15s timeout on the GitHub calls, which O3 put in the webhook's critical path ahead of activation, where a HANG (not an error) would stall activation on a dependency unrelated to billing; the manual path now records provisioningPrUrl too, so a founder-opened PR is visible to the auto path; and the migration's non-uniqueness rationale was a non-sequitur (tenantSlug @unique prevents two plans per SLUG, not per LEAD) — decision kept, reasoning corrected. Verified: tsc + eslint clean, 235 unit tests, coverage floor 100/98.24/100/100, next build clean, and `prisma migrate deploy` + the real CI drift gate against a throwaway Postgres 16 -> "No difference detected". --- app/api/signup/route.ts | 12 +- lib/actions/provisioning-actions.ts | 35 +--- lib/auto-provision-policy.ts | 135 ++++++++++++ lib/auto-provision.ts | 192 ++++++++++++++++++ lib/billing-notify.ts | 109 ++++++++++ lib/billing.ts | 51 ++--- lib/provisioning-facts.ts | 35 ++++ lib/provisioning.ts | 8 + lib/self-serve-account.ts | 5 + lib/validation.ts | 34 ++-- .../migration.sql | 51 +++++ prisma/schema.prisma | 57 ++++-- scripts/file-length-baseline.txt | 7 +- tests/unit/auto-provision-policy.test.ts | 173 ++++++++++++++++ vitest.config.ts | 1 + 15 files changed, 803 insertions(+), 102 deletions(-) create mode 100644 lib/auto-provision-policy.ts create mode 100644 lib/auto-provision.ts create mode 100644 lib/billing-notify.ts create mode 100644 lib/provisioning-facts.ts create mode 100644 prisma/migrations/20260730060000_billing_signup_link/migration.sql create mode 100644 tests/unit/auto-provision-policy.test.ts 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