From 4d0731a418d901dc07f74e45e81560fb40971d5c Mon Sep 17 00:00:00 2001
From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com>
Date: Thu, 30 Jul 2026 07:16:34 +0200
Subject: [PATCH 1/3] docs(adr-012): the merge chains provisioning, and the PR
body says so (#101)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The deploy repo's provision-on-registry-merge.yml makes merging a registry PR
stand up the tenant (SOFRA-ONBOARDING-PLAN §2 option B). Two things here had to
follow it.
ADR-012 first. Its status still read "proposed — no code ships until a mechanism is
chosen" four days after D + A shipped and was proven end to end, so that is
corrected to accepted, and an amendment records the chain. The amendment is
smaller than the plan implied because it is not a reversal: none of the three
invariants ever said a merge cannot provision — invariant 3 asks for a human
review checkpoint, which the merge now is — and the recommendation already read
"a founder (or a `workflow_run`-chained Action) then runs the script". The strong
claim lived only in operating prose (the runbook, a workflow comment), never in
the decision record.
Then the PR body, which is what the founder actually reads at the moment that now
matters. It leads with "merging this PR provisions the tenant" and a pre-merge
checklist, and keeps the two commands as the fallback.
Three fixes the reviewer caught in that body, all real:
- The ⚠️ header was emitted for every box, but the chain is staging-only. A
`box: prod` entry was promised hands-off provisioning that will never happen; it
now gets the opposite header and the commands as a requirement.
- The backend_tag checklist item was inverted. `buildTenantRegistryEntry` pins the
tag FROM the box, so "a staging-box tenant on :latest" cannot be generated —
warning about it was an unfalsifiable checkbox on 100% of real PRs, which trains
the reader to tick without looking. The live risk is the reverse and is the one
judgement no generator can make: every self-serve tenant lands on the staging box
and therefore rides the *develop* build. Right for a showcase, a decision for
someone paying.
- A newline in the tenant name broke the markdown fence around the fallback
command and handed the founder a command with an unterminated quote. `shq()`
escapes apostrophes, and `trim()` only strips the ends.
That last one is fixed at the edge rather than escaped downstream: provisionSchema
now refuses control characters in `name`. It was flowing into three formats where a
newline changes meaning — the registry YAML (already safe via yaml.stringify), this
PR body, and 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. The
body also collapses whitespace itself, so it stays safe as a standalone function.
Tests are behavioural rather than wording-pinned where possible: staging vs prod
headers, which backend_tag risk each box gets, and the newline case asserting the
invariant markdown actually keys on (fence delimiters at line start, not a raw
count of ``` in the document).
---
docs/adr/ADR-012-auto-provisioning-trigger.md | 110 +++++++++++++++---
lib/provisioning-registry.ts | 105 +++++++++++++----
lib/validation.ts | 15 ++-
tests/unit/provisioning-registry.test.ts | 69 +++++++----
tests/unit/validation.test.ts | 14 +++
5 files changed, 250 insertions(+), 63 deletions(-)
diff --git a/docs/adr/ADR-012-auto-provisioning-trigger.md b/docs/adr/ADR-012-auto-provisioning-trigger.md
index 68c22ae..6496659 100644
--- a/docs/adr/ADR-012-auto-provisioning-trigger.md
+++ b/docs/adr/ADR-012-auto-provisioning-trigger.md
@@ -1,7 +1,9 @@
# ADR-012 — Auto-provisioning trigger: how the control plane runs the tenant scripts
-**Status:** proposed 2026-07-18 (owner decision pending — this ADR frames the
-options; no code ships until a mechanism is chosen)
+**Status:** **accepted 2026-07-26** — **D + A** was chosen and shipped, and proven end
+to end (merged registry PR → live HTTPS tenant in ~12 min); **amended 2026-07-30** — the
+second half of the chain is now automatic: merging the registry PR builds the tenant
+image and provisions. See §Amendment.
## Context
@@ -69,7 +71,7 @@ via a contents-scoped GitHub token; `sync-to-*.yml` delivers it; a founder (or a
preserves ADR-003/007 unchanged, is fully auditable/reversible (a reviewable PR),
and adds **no box privilege** to the app — only repo-contents write.
-## Recommendation (proposed)
+## Recommendation — chosen and shipped
**D + A, staged.** The app, on convert/provision, **opens a registry PR** via a
narrowly-scoped GitHub token (contents write on the deploy repo only) — honoring
@@ -81,22 +83,94 @@ most defensible for a solo operator: every provision is a reviewable, revertable
PR; the app holds only a repo-scoped token; and it composes from patterns already
in the repo rather than a new privileged box listener.
-For **staging/demo** tenants the registry PR may auto-merge (trusted, low-stakes);
-**prod** provisioning keeps the human merge. Cross-box prod provisioning from the
-staging control plane stays out of scope (per-box boundary) until a prod-box CI
-leg exists.
+Cross-box prod provisioning from the staging control plane stays out of scope (per-box
+boundary) until a prod-box CI leg exists.
+
+**Auto-merge was not taken**, and the 2026-07-30 amendment is why it is not needed:
+automating the work *after* the merge gets the same hands-off result while keeping the one
+thing worth a human, which is reading the proposed entry. Auto-merging is the *onboarding
+plan's* option C (§2), explicitly rejected there — not to be confused with option C above.
If CI-in-the-loop latency proves unacceptable, fall back to **B** (box listener) —
it keeps the same privilege split without the git round-trip.
-## Decide at implementation
-
-- GitHub token scope + storage for the app (fine-grained, contents-write on the
- deploy repo only; box `.env`, never committed).
-- Whether the registry PR carries the full computed entry (slug/db/domain/
- languages/modules/currency/template from the signup + module choices, ADR-010) —
- the app already reads the registry grammar (`lib/tenant-registry.ts`).
-- Idempotency + status reflection: the script is idempotent; the control plane
- needs to surface provisioning state (the `Client.status='LIVE'` / registry
- `status` flip) back to `/admin`.
-- Deprovision path (same trigger, `deprovision-tenant.sh`) — likely founder-only.
+## Decided at implementation
+
+- **Token scope + storage.** `PROVISION_GITHUB_TOKEN` — fine-grained, `piwas-21/restaurant-app-deploy`
+ only, Contents + Pull requests: write. Lives in `/opt/rumi/deploy/.env` on the box,
+ never committed. It can propose a tenant and nothing else. **Its expiry is silent**
+ (`/admin/provision` degrades to a "not configured" banner rather than erroring), so
+ the expiry is calendared — see the workspace runbook §0.
+- **The PR carries the full computed entry** (`lib/provisioning-registry.ts`):
+ slug-derived `db`/`db_role`/`compose_project`/`domain`/`frontend_tag`, plus
+ languages/modules/currency/template from the signup, `status: provisioning`,
+ `managed: scripts`, and a box-aware `backend_tag`.
+- **Deprovision stays founder-only** over SSH. Unchanged.
+- **Status reflection back to `/admin` is still open** — the registry `status` flip to
+ `active` remains a manual follow-up commit, and nothing automatic reads it.
+
+## Amendment — 2026-07-30: the merge chains build + provision
+
+> **Mind the option letters.** A–D above are this document's, and what shipped is
+> **D + A**. SOFRA-ONBOARDING-PLAN §2 re-uses A/B/C for a *different* question — how much
+> of the post-merge work to automate — and this amendment implements that plan's **option
+> B**. Same letters, different axis; the plan's B is not the box listener described above.
+
+Shipped as the deploy repo's `provision-on-registry-merge.yml`: merging the registry PR
+now chains `build-tenant-image.yml` (frontend repo) → `provision-tenant.sh` on the box.
+The founder merges; nothing else is theirs to do.
+
+**Why the invariants survive** — this is an amendment, not a violation:
+
+| Invariant | Still holds because |
+|---|---|
+| 1. registry stays git-first | the chain only **reads** the registry, after the entry is committed, reviewed and synced. Nothing writes it. |
+| 2. the public container stays unprivileged | unchanged. The box SSH key is still only in Actions secrets; `sofra` gained no capability. The chain's one new credential (`FRONTEND_DISPATCH_TOKEN`, Actions:write on the frontend repo) lives in the **deploy repo's** Actions secrets, not in the app. |
+| 3. a human review checkpoint before first live provisioning | the checkpoint **is the merge**. This ADR's own recommendation already allowed for it: *"a founder (or a `workflow_run`-chained Action) then runs the script."* Its value was a human reading the proposed YAML; it was never improved by that human also copying two `gh workflow run` commands. |
+
+What made this safe now and not at proposal time is **payment gating**
+(`lib/provisioning-payment-gate.ts`, O2): a self-serve tenant gets no proposal at all
+until its first payment settles. Without that, coupling an anonymous form to a merge
+that provisions would put spam one rubber-stamp away from a database.
+
+**Two properties the chain owes, and how it pays them:**
+
+- **Idempotent.** Selection is on state, never on the push diff — a diff-based trigger
+ cannot survive a revert-and-remerge, which reproduces the same diff. A slug is
+ **eligible** when the registry declares intent (`managed: scripts` + `box: staging` +
+ `status: provisioning`) and the chain has not already finished it. Eligible is not the
+ same as provisioned: the run still refuses the whole batch over a cap (2), and refuses
+ everything if `FRONTEND_DISPATCH_TOKEN` is missing — both reported, neither silent.
+
+ The completion marker is one the chain writes itself
+ (`/opt/rumi/tenants//.chain-provisioned`), **not** the tenant's `.env`.
+ `provision-tenant.sh` renders `.env` early and then keeps going through
+ `docker compose pull`, `up -d` and a five-minute health wait, so `.env` means "the
+ script started". Keying on it would make the most likely failure this chain introduces —
+ provisioning against an image the build never published — permanently invisible: the
+ retry the failure notice recommends would find `.env`, skip, and report green. A tenant
+ with `.env` but no marker is therefore **completed**, not skipped.
+ Consequence, deliberate: **first provisioning only.** Re-provisioning a live tenant
+ (a module upsell) stays an explicit `provision-tenant.yml` dispatch, because an
+ unattended trigger that also re-applied would let an unrelated registry edit restart
+ every tenant on the box.
+- **Failure-visible.** Nobody watches a terminal now, so every outcome is reported to
+ where the founder already is: a comment on the registry PR they just merged, plus an
+ issue on the deploy repo when anything fails. A silent automatic chain would be worse
+ than a noisy manual one.
+
+ Two cases are easy to leave silent and are deliberately not. The **upstream registry
+ sync failing** is checked in a *step* rather than the job's `if:` — gating the job would
+ skip the workflow entirely, so the `if: always()` reporter would never run, and a failed
+ sync is exactly when the founder is wondering why their merge did nothing. And an entry
+ the chain **refuses** (a `box: prod` tenant, a malformed field) is reported too, not just
+ dropped: a merged tenant that will never be provisioned is the same silence in a
+ different costume.
+
+**Still founder-operated after this amendment:** credential handover. The generated
+admin password is read off the box by hand. The one-time-reveal replacement (never
+emailed, forced change at first login) is the remaining half of O3.
+
+**Scope unchanged:** staging box only — the chain follows `sync-registry-to-staging.yml`
+and inherits its narrowness. A `box: prod` entry is reported and never provisioned,
+per the per-box boundary above.
diff --git a/lib/provisioning-registry.ts b/lib/provisioning-registry.ts
index 02cc135..33764a7 100644
--- a/lib/provisioning-registry.ts
+++ b/lib/provisioning-registry.ts
@@ -76,46 +76,107 @@ const SHELL_QUOTED_APOSTROPHE = String.raw`'\''`;
const shq = (value: string): string =>
"'" + value.replaceAll("'", SHELL_QUOTED_APOSTROPHE) + "'";
+/** Collapse anything that would break the markdown fence or the shell command this
+ * body embeds. `provisionSchema` already refuses control characters in `name`, so in
+ * practice this changes nothing — it is here so the function is safe on its own,
+ * because a body builder that depends on a caller's validation is one refactor away
+ * from emitting an unbalanced code fence built from public-form input. */
+const oneLine = (value: string): string => value.replace(/\s+/g, " ").trim();
+
/**
- * The PR body for a provisioning proposal: what is being added, then the exact
- * post-merge commands in order. It is a checklist rather than prose because the
- * step that is easy to forget — building the per-tenant frontend image — is a
- * hard prerequisite: `NEXT_PUBLIC_*` are baked per domain, so provisioning
+ * The PR body for a provisioning proposal.
+ *
+ * **For a staging-box tenant, merging this PR provisions it** (SOFRA-ONBOARDING-PLAN §2
+ * option B, ADR-012 amendment 2026-07-30): the deploy repo's
+ * `provision-on-registry-merge.yml` chains the image build and `provision-tenant.sh` off
+ * the registry sync. So the body leads with what to CHECK before merging — the merge is
+ * the last reversible moment.
+ *
+ * That chain is **staging-only** (it follows `sync-registry-to-staging.yml` and inherits
+ * its narrowness), so a `box: prod` entry gets the opposite header: merging does nothing
+ * and the commands are required, not a fallback. Telling a prod entry "merging provisions
+ * this" would leave the founder waiting on a chain that never runs.
+ *
+ * The image-build command stays in the body either way, because that step is the one that
+ * is easy to skip and fatal to skip: `NEXT_PUBLIC_*` are baked per domain, so provisioning
* without it dies at `docker compose pull` on an image that was never published.
*/
export function buildProvisioningPrBody(input: TenantProvisionInput): string {
const { slug } = input;
const domain = `${slug}.sofrapiwas.com`;
+ const box = input.box ?? "staging";
+ const chained = box === "staging";
+
+ // `backend_tag` is pinned by box above, so the risk is NOT "a staging tenant might be
+ // on :latest" — that pairing cannot be generated. It is the reverse, and it is the one
+ // judgement no generator can make: every self-serve tenant lands on the staging box, so
+ // every self-serve tenant rides the DEVELOP build. Right for a showcase; a decision for
+ // someone paying.
+ const tagCheck = chained
+ ? `- [ ] **\`backend_tag: staging\`** is deliberate — a staging-box tenant rides the *develop* build, i.e. unreleased backend code. Correct for a showcase; for a paying customer, pin \`backend_tag: latest\` before merging`
+ : `- [ ] **\`backend_tag: latest\`** (prod box) — released code, which is what a prod tenant should ride`;
+
+ const header = chained
+ ? [
+ "### ⚠️ Merging this PR provisions the tenant",
+ "",
+ "`provision-on-registry-merge.yml` builds the per-tenant frontend image and then runs",
+ "`provision-tenant.sh` on the box — roughly 15 minutes, hands-off. **This is the human",
+ "checkpoint, and it is the last reversible moment.** Before you merge:",
+ ]
+ : [
+ `### Merging this PR does **not** provision — \`box: ${box}\``,
+ "",
+ "The post-merge chain is staging-only. This entry will be reported and skipped, so the",
+ "two commands below are **required**, not a fallback. Still check the entry first:",
+ ];
+
+ const after = chained
+ ? [
+ "The chain provisions **first-time only**, and reports back on this PR when it is done —",
+ "or opens an issue on the deploy repo if any stage fails, including the registry sync it",
+ "waits on. A tenant it has already finished is skipped, so re-merging or",
+ "reverting-and-remerging this PR will not provision twice. One it left part-way through is",
+ "*completed* rather than skipped, so a retry is always safe.",
+ ]
+ : [
+ "Merging still fires `sync-registry-to-staging.yml`, which copies the registry to the",
+ "**staging** box only. A prod-box tenant needs the prod box's own access (ADR-012",
+ "per-box boundary), so run the commands from a machine that has it.",
+ ];
+
return [
`Adds the \`${slug}\` tenant to \`tenants/registry.yml\`, proposed by the control plane (sofra ADR-012).`,
"",
`- **domain** \`${domain}\` · **template** \`${input.template}\` · **currency** \`${input.currency}\``,
`- **languages** \`${input.languages.join(", ")}\` · **modules** \`${input.modules.join(", ")}\``,
- `- **box** \`${input.box ?? "staging"}\` · status starts at \`provisioning\``,
+ `- **box** \`${box}\` · status starts at \`provisioning\``,
+ "",
+ ...header,
"",
- "Review the entry before merging — this is the human checkpoint before any box provisioning.",
+ `- [ ] the **slug** \`${slug}\` is what the customer should live on forever — it is the subdomain, database, role and compose project, and changing it later is a full re-provision`,
+ `- [ ] **modules** \`${input.modules.join(", ")}\` match what they actually paid for — they are enforced at runtime now, so a missing id is a feature they bought and will not get`,
+ tagCheck,
+ `- [ ] **template** \`${input.template}\` and **currency** \`${input.currency}\` are right — the template is baked into the image at build time, so changing it later is a rebuild`,
"",
- "### After merging, in order",
+ ...after,
"",
- "1. **Registry sync** — automatic: merging to `develop` fires `sync-registry-to-staging.yml`. Check it went green; the box reads the registry, so nothing below works until it has.",
- "2. **Build the tenant frontend image** — required *before* provisioning (`NEXT_PUBLIC_*` are baked per domain):",
+ `Afterwards: \`./verify-env.sh https://${domain}\`, hand over the generated admin password from the tenant \`.env\` (and have them change it), then flip this entry's \`status\` to \`active\` in a follow-up commit.`,
"",
- " ```bash",
- " gh workflow run build-tenant-image.yml --repo piwas-21/restaurant-app-frontend \\",
- ` -f tenant_domain=${domain} \\`,
- ` -f image_tag=tenant-${slug} \\`,
- ` -f restaurant_name=${shq(input.name)} \\`,
- ` -f template=${input.template} \\`,
- ` -f currency=${input.currency}`,
- " ```",
+ chained ? "### If the chain fails" : "### Run these after merging",
"",
- "3. **Provision on the box**:",
+ "Both are idempotent and safe to re-run:",
"",
- " ```bash",
- ` gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}`,
- " ```",
+ "```bash",
+ "gh workflow run build-tenant-image.yml --repo piwas-21/restaurant-app-frontend \\",
+ ` -f tenant_domain=${domain} \\`,
+ ` -f image_tag=tenant-${slug} \\`,
+ ` -f restaurant_name=${shq(oneLine(input.name))} \\`,
+ ` -f template=${input.template} \\`,
+ ` -f currency=${input.currency}`,
"",
- `4. **Verify** — \`./verify-env.sh https://${domain}\`, log in with the generated admin password from the tenant \`.env\` and change it, then flip this entry's \`status\` to \`active\` in a follow-up commit.`,
+ `gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}`,
+ "```",
"",
"Full runbook: deploy repo `DEPLOYMENT.md` §Tenant provisioning.",
].join("\n");
diff --git a/lib/validation.ts b/lib/validation.ts
index 5839ba7..92c0733 100644
--- a/lib/validation.ts
+++ b/lib/validation.ts
@@ -137,7 +137,20 @@ export const provisionSchema = z.object({
.string()
.trim()
.regex(/^[a-z0-9][a-z0-9-]{1,30}$/, "lowercase slug, 2-31 chars"),
- name: z.string().trim().min(1).max(200),
+ // 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"),
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/tests/unit/provisioning-registry.test.ts b/tests/unit/provisioning-registry.test.ts
index c334476..39c1491 100644
--- a/tests/unit/provisioning-registry.test.ts
+++ b/tests/unit/provisioning-registry.test.ts
@@ -112,31 +112,56 @@ describe("buildProvisioningPrBody", () => {
city: "Rotterdam",
};
- it("carries the post-merge commands with the tenant's own values", () => {
+ it("tells a STAGING entry that merging provisions it", () => {
const body = buildProvisioningPrBody(input);
- // The image build is the step that is easy to skip and fatal to skip.
- expect(body).toContain(
- "gh workflow run build-tenant-image.yml --repo piwas-21/restaurant-app-frontend",
- );
- expect(body).toContain("-f tenant_domain=bistro-nova.sofrapiwas.com");
- expect(body).toContain("-f image_tag=tenant-bistro-nova");
- expect(body).toContain("-f template=craft");
- expect(body).toContain("-f currency=EUR");
- expect(body).toContain(
- "gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=bistro-nova",
- );
- // Ordering is the point: build the image before provisioning.
- expect(body.indexOf("build-tenant-image.yml")).toBeLessThan(
- body.indexOf("provision-tenant.yml"),
- );
+ expect(body).toContain("Merging this PR provisions the tenant");
+ // The slug is the one field that cannot be renegotiated afterwards, so the
+ // checklist has to name the actual value rather than talk about slugs.
+ expect(body).toContain("`bistro-nova` is what the customer should live on forever");
+ expect(body).toContain("### If the chain fails");
});
- it("summarises the proposed entry", () => {
- const body = buildProvisioningPrBody(input);
- expect(body).toContain("`bistro-nova.sofrapiwas.com`");
- expect(body).toContain("`en, nl`");
- expect(body).toContain("`core, reservations`");
- expect(body).toContain("`staging`"); // default box
+ it("tells a PROD entry the opposite, because the chain is staging-only", () => {
+ // The chain follows sync-registry-to-staging and skips any other box. A prod body
+ // promising hands-off provisioning would leave the founder waiting on nothing.
+ const body = buildProvisioningPrBody({ ...input, box: "prod" });
+ expect(body).toContain("does **not** provision");
+ expect(body).not.toContain("Merging this PR provisions the tenant");
+ expect(body).not.toContain("hands-off");
+ // ...and the commands stop being a fallback.
+ expect(body).toContain("### Run these after merging");
+ });
+
+ it("flags the backend_tag risk that actually exists for each box", () => {
+ // buildTenantRegistryEntry pins backend_tag FROM the box, so "a staging tenant might
+ // be on :latest" is impossible by construction — warning about it would be an
+ // unfalsifiable checkbox on every real PR. The live risk is the reverse: a staging-box
+ // tenant rides the develop build, which is wrong for someone paying.
+ const staging = buildProvisioningPrBody(input);
+ expect(staging).toContain("rides the *develop* build");
+ expect(staging).toContain("unreleased backend code");
+ expect(staging).not.toContain("staging-box tenant on `:latest`");
+
+ const prod = buildProvisioningPrBody({ ...input, box: "prod" });
+ expect(prod).toContain("released code");
+ expect(prod).not.toContain("unreleased backend code");
+ });
+
+ it("keeps a newline in the tenant name from breaking the fence or the command", () => {
+ // provisionSchema refuses control characters, but this body is a pure function that
+ // embeds `name` inside a ``` fence AND a shell command. An interior newline would
+ // close the fence early, render the rest as prose, and hand the founder a command
+ // with an unterminated quote.
+ const body = buildProvisioningPrBody({ ...input, name: "Bistro\n```\n## PWNED" });
+ const lines = body.split("\n");
+ // Markdown only closes a fence at the START of a line, so counting every ``` in the
+ // document would fail on a harmless mid-line one. The invariant that matters is that
+ // the fence delimiters are exactly the two we wrote.
+ expect(lines.filter((l) => l.trimStart().startsWith("```"))).toEqual(["```bash", "```"]);
+ // ...and the whole command stays on one line, so it is still copy-pasteable.
+ expect(lines.filter((l) => l.includes("-f restaurant_name="))).toEqual([
+ " -f restaurant_name='Bistro ``` ## PWNED' \\",
+ ]);
});
it("shell-quotes the tenant name so an apostrophe cannot break the command", () => {
diff --git a/tests/unit/validation.test.ts b/tests/unit/validation.test.ts
index b1124db..d00b1dc 100644
--- a/tests/unit/validation.test.ts
+++ b/tests/unit/validation.test.ts
@@ -292,6 +292,20 @@ describe("provisionSchema (ADR-012 tenant proposal)", () => {
it("still rejects an empty module list", () => {
expect(provisionSchema.safeParse({ ...base, modules: "" }).success).toBe(false);
});
+
+ it("rejects a line break inside the tenant name", () => {
+ // `trim()` only strips the ENDS, so an interior newline used to survive — and the
+ // name is forwarded into build-tenant-image.yml's `build-args:`, which is a
+ // newline-delimited list. A second line there injects a build arg (e.g. a different
+ // NEXT_PUBLIC_API_URL) into the tenant's own bundle.
+ for (const name of ["Bistro\nNova", "Bistro\r\nNova", "Bistro\tNova", "Bistro\u0000Nova"]) {
+ expect(provisionSchema.safeParse({ ...base, name }).success).toBe(false);
+ }
+ // Ordinary names, including non-ASCII and punctuation, are untouched.
+ for (const name of ["Chez L'Ami", "Nova: Café — Bar", "北京饭店"]) {
+ expect(provisionSchema.safeParse({ ...base, name }).success).toBe(true);
+ }
+ });
});
describe("splitCsvLower", () => {
From d662e36bf8da2d6ef252d079e86efdf04d938cd2 Mon Sep 17 00:00:00 2001
From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com>
Date: Thu, 30 Jul 2026 09:22:17 +0200
Subject: [PATCH 2/3] feat(provisioning): a settled first payment opens the
registry PR itself (#102)
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
From 602b0d146b1dfae7d26b875805624ee70acae5d8 Mon Sep 17 00:00:00 2001
From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com>
Date: Thu, 30 Jul 2026 18:33:53 +0200
Subject: [PATCH 3/3] feat(control): show the owner their app and their plan
(O4 part 1) (#103)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(control): show the owner their app and their plan
O4, part 1 — the owner-facing half of first-run setup, on the sofra
dashboard. Two things this page could not say before, in one pass so they
share their 6-locale keys instead of arriving as two lone panels.
**"Your app is ready — set your admin password."** O3 closed the credential
story mechanically: the tenant frontend now has /forgot-password and
/reset-password, so the owner sets their own password and the bootstrap
password never leaves the box. Nothing TOLD them. `TenantReadyPanel` does,
and every claim it makes is earned rather than defaulted:
- `liveSince` is a date the founder typed, and on the self-serve path
nobody types it; the registry's `status: active` is a manual follow-up
commit nothing automatic writes or reads; `provisioningPrUrl` proves a
proposal was opened, not merged, built or booted; a registry ENTRY
proves the merge started a ~15-minute build that can still fail.
- So the only evidence the app is serving is asking it. `tenant-health.ts`
probes the tenant's own /api/health and asserts the body's `service`
field — a bare 200 proves nothing, the same lesson O5 learned from a
bare 404: a wildcard subdomain, a Caddy default and a parked domain all
answer 200.
- Everything short of that degrades to a weaker claim, never a stronger
one. The probe fails CLOSED, the opposite of the tenant frontend's
getTenantModules — there a blip must not take features away; here the
only thing riding on it is a link we tell a paying customer to click.
**Their actual plan.** An owner with an ACTIVE subscription saw one
sentence — "nothing to do here right now" — with no amount, no next-charge
date and no history, because /dashboard/billing is requirePartner(). They
now get their own card. Not by loosening that page: it is the reseller's
book, and an owner needs their plan plus the panel above, which is one card.
Found while building, fixed here:
- **"Next charge" was a date in the past from month two onward.**
`BillingSubscription.startDate` is the FIRST recurring charge, written
once at activation and never advanced — `recordPayment` only inserts
payment rows. Both surfaces printed it as the next charge. `nextChargeDate`
derives it instead; the partner page gets the same fix.
- **Every locale's `activating.stepProvision` still promised "we send your
login details".** False in all six since O3.
- `ownerAllSet` / `notLiveYet` removed — orphaned by the above.
Verified: 258 vitest, 23/23 unmocked Playwright including a real Mollie
first payment on the test_ key, typecheck, eslint, i18n parity 664x6,
file-length, next build.
Co-Authored-By: Claude Opus 5
* fix(billing): drop the redundant getTime() in nextChargeDate
Sonar: Date supports relational comparison directly.
Co-Authored-By: Claude Opus 5
* perf(control): one history query for the owner's plans, not one per plan
The review gate blocked this as an N+1, correctly. The per-row query was
justified as "an owner holds one plan in practice", which is true and is
still not a reason to write a loop that issues a round-trip per row.
Batched into a single `billingId IN (...)` read, grouped in memory and
sliced to the same 10 per plan. `HISTORY_ROW_CAP` exists so the query cannot
grow with the age of an account; the one case where the cap is lossy — an
owner holding several plans, one far busier than the rest — is written down
next to it rather than left to be discovered.
Co-Authored-By: Claude Opus 5
* fix(billing): clone the anchor Date directly
`new Date(d)` clones; the `.getTime()` round-trip was the same unnecessary
call Sonar flagged one line below.
Co-Authored-By: Claude Opus 5
---------
Co-authored-by: Claude Opus 5
---
app/(control)/dashboard/billing/page.tsx | 19 ++-
app/(control)/dashboard/page.tsx | 198 ++++-------------------
components/control/OwnerDashboard.tsx | 163 +++++++++++++++++++
components/control/OwnerPlanCard.tsx | 150 +++++++++++++++++
components/control/PartnerDashboard.tsx | 146 +++++++++++++++++
components/control/TenantReadyPanel.tsx | 91 +++++++++++
lib/billing-display.ts | 76 +++++++++
lib/tenant-health.ts | 64 ++++++++
lib/tenant-liveness.ts | 118 ++++++++++++++
messages/ar.json | 30 +++-
messages/de.json | 30 +++-
messages/en.json | 30 +++-
messages/fr.json | 30 +++-
messages/nl.json | 30 +++-
messages/tr.json | 30 +++-
tests/e2e/billing-mollie.spec.ts | 9 +-
tests/e2e/helpers/db.ts | 92 +++++++++++
tests/e2e/owner-dashboard.spec.ts | 152 +++++++++++++++++
tests/unit/billing-display.test.ts | 89 +++++++++-
tests/unit/tenant-liveness.test.ts | 125 ++++++++++++++
vitest.config.ts | 1 +
21 files changed, 1484 insertions(+), 189 deletions(-)
create mode 100644 components/control/OwnerDashboard.tsx
create mode 100644 components/control/OwnerPlanCard.tsx
create mode 100644 components/control/PartnerDashboard.tsx
create mode 100644 components/control/TenantReadyPanel.tsx
create mode 100644 lib/tenant-health.ts
create mode 100644 lib/tenant-liveness.ts
create mode 100644 tests/e2e/owner-dashboard.spec.ts
create mode 100644 tests/unit/tenant-liveness.test.ts
diff --git a/app/(control)/dashboard/billing/page.tsx b/app/(control)/dashboard/billing/page.tsx
index 1bf184d..e229d4c 100644
--- a/app/(control)/dashboard/billing/page.tsx
+++ b/app/(control)/dashboard/billing/page.tsx
@@ -3,7 +3,12 @@ import { requirePartner } from "@/lib/rbac";
import { controlLocale } from "@/lib/control-locale";
import { db } from "@/lib/db";
import { eur, shortDate } from "@/lib/format";
-import { intervalKeyOf, planState, type PlanState } from "@/lib/billing-display";
+import {
+ intervalKeyOf,
+ nextChargeDate,
+ planState,
+ type PlanState,
+} from "@/lib/billing-display";
import StartPaymentButton from "@/components/control/StartPaymentButton";
export default async function DashboardBillingPage() {
@@ -12,11 +17,17 @@ export default async function DashboardBillingPage() {
const t = await getTranslations({ locale, namespace: "control.plan" });
// Plan-status node via if/else (avoids a nested ternary — Sonar S3358).
- const statusNode = (state: PlanState, startDate: Date | null, billingId: string) => {
+ //
+ // `nextChargeDate` rather than the raw `startDate` this used to print: that column is
+ // the FIRST recurring charge and is never advanced, so from month two onward it named
+ // a date in the past (see lib/billing-display.ts). Same defect, same fix, on both the
+ // reseller's page and the owner's card.
+ const statusNode = (sub: { startDate: Date | null; interval: string }, state: PlanState, billingId: string) => {
if (state === "active") {
+ const next = nextChargeDate(sub.startDate, sub.interval, new Date());
return (
- {startDate ? t("activeNextCharge", { date: shortDate(startDate) }) : t("active")}
+ {next ? t("activeNextCharge", { date: shortDate(next) }) : t("active")}
);
}
@@ -78,7 +89,7 @@ export default async function DashboardBillingPage() {
interval: t(`interval.${intervalKeyOf(sub.interval)}`),
})}
- {statusNode(state, sub.startDate, b.id)}
+ {statusNode(sub, state, b.id)}
>
) : (
{t("noPlan")}
diff --git a/app/(control)/dashboard/page.tsx b/app/(control)/dashboard/page.tsx
index 17214ad..d8e8480 100644
--- a/app/(control)/dashboard/page.tsx
+++ b/app/(control)/dashboard/page.tsx
@@ -1,75 +1,26 @@
-import { getTranslations } from "next-intl/server";
import { requirePartnerOrOwner } from "@/lib/rbac";
import { controlLocale } from "@/lib/control-locale";
import { db } from "@/lib/db";
-import { eur, shortDate } from "@/lib/format";
-import { intervalKeyOf, planState, type PlanState } from "@/lib/billing-display";
-import ClientForm from "@/components/control/ClientForm";
-import ClientStatusBadge from "@/components/control/ClientStatusBadge";
-import StartPaymentButton from "@/components/control/StartPaymentButton";
-import ActivatingPanel from "@/components/control/ActivatingPanel";
+import OwnerDashboard from "@/components/control/OwnerDashboard";
+import PartnerDashboard from "@/components/control/PartnerDashboard";
/**
- * Which "where is my restaurant" line the welcome hero shows — or none.
- * Extracted so the choice reads as a decision instead of a nested ternary
- * (Sonar S3358).
+ * `/dashboard` — shared by the reseller (PARTNER) and the restaurant owner (OWNER),
+ * who want almost nothing in common.
*
- * Returns null for an owner who is already activating: ``
- * says what is happening in full, and the hero's "start your subscription"
- * nudge directly contradicts a panel that opens with "your first payment went
- * through". A line that argues with the line below it is worse than no line.
- */
-function liveSinceLine(
- liveSince: Date | null,
- restaurant: string,
- isOwner: boolean,
- activating: boolean,
- tp: (key: string, values?: Record) => string,
-): string | null {
- if (liveSince) return tp("liveSince", { restaurant, date: shortDate(liveSince) });
- if (!isOwner) return tp("liveSinceUnknown", { restaurant });
- return activating ? null : tp("notLiveYet", { restaurant });
-}
-
-/**
- * What the payer can do about this plan right now. Written as guard clauses
- * rather than a chain of ternaries (Sonar S3358), mirroring `statusNode` in
- * `/dashboard/billing`.
+ * A partner reads a pipeline: many clients, each a row, the plans needing attention
+ * pulled to the top. An owner has exactly one plan and one question — *where is my
+ * restaurant app and how do I get into it?* Until O4 they shared one render, and the
+ * owner's half of it was a single sentence ("nothing to do here right now") with no
+ * amount, no next-charge date and no mention of their app at all.
*
- * `state` is only ever "pay" or "processing" here — the caller's filter admits
- * exactly those two — so "processing" is the mandate-validation window. An owner
- * gets it spelled out; a partner keeps the terse line, because a reseller reads
- * this queue as a pipeline and is not the one who just watched money leave their
- * account. Neither branch renders a pay button in that window: a second payment
- * is the trap it sets.
+ * The two views are now separate components; this page owns only the guard, the
+ * locale, and the one query both need.
*/
-function planAction(args: {
- state: PlanState;
- billingId: string;
- isOwner: boolean;
- locale: string;
- restaurant: string;
- tp: (key: string) => string;
-}) {
- const { state, billingId, isOwner, locale, restaurant, tp } = args;
- if (state === "pay") {
- return (
-
-
- {tp("firstChargeNote")}
-
- );
- }
- if (isOwner) return ;
- return {tp("processing")}
;
-}
-
export default async function DashboardPage() {
const user = await requirePartnerOrOwner();
const isOwner = user.role === "OWNER";
const locale = await controlLocale();
- const t = await getTranslations({ locale, namespace: "control.dashboard" });
- const tp = await getTranslations({ locale, namespace: "control.plan" });
// Billings scoped to the caller: an OWNER pays via payerUserId (ADR-004); a
// PARTNER via their CRM clients.
@@ -78,116 +29,33 @@ export default async function DashboardPage() {
include: {
client: true,
subscriptions: { orderBy: { createdAt: "desc" } },
- // Only first payments distinguish "pay" from "processing" (planState);
- // scope + bound so the unboundedly-growing recurring history is never
- // pulled into this request path.
+ // Only first payments distinguish "pay" from "processing" (planState); scope +
+ // bound so the unboundedly-growing recurring history is never pulled into this
+ // request path. KEEP THIS SCOPED now that an owner also sees a history list:
+ // widening it to "the 10 newest payments" would push the `first` payment out of
+ // the window once a plan has ten recurring charges, and planState would then
+ // read a paid, activating plan as "pay" — a pay button shown to somebody who
+ // already paid. OwnerDashboard fetches the history separately, and bounds it
+ // separately, for exactly that reason.
payments: { where: { sequenceType: "first" }, orderBy: { createdAt: "desc" }, take: 20 },
},
orderBy: { createdAt: "desc" },
});
- // Plans that still need the payer's attention (welcome hero): awaiting a
- // payment, or a payment being processed.
- const awaiting = billings.filter((b) => {
- const st = planState(b.subscriptions[0], b.payments);
- return st === "pay" || st === "processing";
- });
- // Reseller CRM — partners only; an owner has no clients.
- const clients = isOwner
- ? []
- : await db.client.findMany({ where: { partnerId: user.id }, orderBy: { updatedAt: "desc" } });
-
- return (
-
-
-
{isOwner ? t("ownerTitle") : t("title")}
-
{isOwner ? t("ownerIntro") : t("intro")}
-
-
- {awaiting.map((b) => {
- const sub = b.subscriptions[0];
- if (!sub) return null;
- // Owner billings carry no CRM client; the slug identifies the restaurant.
- const restaurant = b.client?.restaurantName ?? b.tenantSlug;
- const state = planState(sub, b.payments);
- // `liveSince` is set by the founder at onboarding, so for a reseller plan
- // (defined AFTER the tenant is live) its absence just means "date
- // unknown" — the tenant is live either way. A self-serve owner is the
- // opposite case: they signed up minutes ago and nothing has been
- // provisioned, so telling them their restaurant "is live" is simply false.
- const whereItStands = liveSinceLine(
- b.liveSince,
- restaurant,
- isOwner,
- state === "processing",
- tp,
- );
- return (
-
-
- {tp("welcomeKicker")}
-
-
- {tp("welcomeTitle", { name: user.name })}
-
- {whereItStands && {whereItStands}
}
-
- {tp("amountLine", {
- amount: eur(sub.amountCents),
- interval: tp(`interval.${intervalKeyOf(sub.interval)}`),
- })}
-
- {planAction({
- state,
- billingId: b.id,
- isOwner,
- locale,
- restaurant,
- tp,
- })}
-
- );
- })}
- {isOwner ? (
- awaiting.length === 0 && (
-
{t("ownerAllSet")}
- )
- ) : (
- <>
-
- {t("addClient")}
-
-
-
-
+ if (isOwner) {
+ return
;
+ }
- {clients.length === 0 ? (
-
{t("empty")}
- ) : (
-
- )}
- >
- )}
-
+ const clients = await db.client.findMany({
+ where: { partnerId: user.id },
+ orderBy: { updatedAt: "desc" },
+ });
+ return (
+
);
}
diff --git a/components/control/OwnerDashboard.tsx b/components/control/OwnerDashboard.tsx
new file mode 100644
index 0000000..cb4bdc6
--- /dev/null
+++ b/components/control/OwnerDashboard.tsx
@@ -0,0 +1,163 @@
+import { getTranslations } from "next-intl/server";
+import { db } from "@/lib/db";
+import { loadTenantRegistry } from "@/lib/tenant-registry";
+import { tenantStage } from "@/lib/tenant-liveness";
+import { probeTenantHealthy } from "@/lib/tenant-health";
+import OwnerPlanCard from "./OwnerPlanCard";
+
+/**
+ * The restaurant owner's dashboard (SOFRA-ONBOARDING-PLAN O4).
+ *
+ * Shows EVERY plan they pay for, not only the ones "awaiting attention" — that filter
+ * is what left an owner with an active subscription reading one sentence and no
+ * numbers. Each plan carries the panel that says where their app is and how to get
+ * into it, which is the piece O3 handed over.
+ */
+
+type PaymentRow = {
+ id: string;
+ billingId: string;
+ createdAt: Date;
+ sequenceType: string;
+ status: string;
+ amountCents: number;
+};
+
+type OwnerBilling = {
+ id: string;
+ tenantSlug: string;
+ liveSince: Date | null;
+ provisioningPrUrl: string | null;
+ client: { restaurantName: string } | null;
+ subscriptions: { status: string; amountCents: number; interval: string; startDate: Date | null }[];
+ payments: { sequenceType: string; status: string }[];
+};
+
+/** Newest payments shown in an owner's history, per plan. */
+const HISTORY_LIMIT = 10;
+
+/**
+ * Ceiling on the rows the history query may read, across every plan the owner holds.
+ *
+ * The cap exists so the query cannot grow with the age of an account, not because the
+ * number is meaningful: an owner holds ONE plan in practice (the self-serve signup
+ * mints exactly one), so 100 rows is over eight years of monthly charges for the
+ * realistic case and the slice below is exact.
+ *
+ * The one case where it is lossy is stated rather than hidden: an owner holding several
+ * plans, one of them far busier, could see the quiet plan's history thinned — the rows
+ * are taken newest-first across all of them. That is display-only history on a page
+ * whose purpose is the CURRENT plan, and the alternative (a query per plan) is the
+ * N+1 this replaced.
+ */
+const HISTORY_ROW_CAP = 100;
+
+/**
+ * The newest payments for every plan in one round trip, grouped by plan.
+ *
+ * Prisma has no per-group limit, so the slice happens in memory. Ordering is done by
+ * the database and preserved by `Map`/array insertion order, so each plan's list stays
+ * newest-first without a second sort.
+ */
+async function paymentHistory(billingIds: string[]) {
+ if (billingIds.length === 0) return new Map();
+ const rows = await db.billingPayment.findMany({
+ where: { billingId: { in: billingIds } },
+ orderBy: { createdAt: "desc" },
+ take: HISTORY_ROW_CAP,
+ });
+ const byBilling = new Map();
+ for (const row of rows) {
+ const list = byBilling.get(row.billingId) ?? [];
+ if (list.length < HISTORY_LIMIT) list.push(row);
+ byBilling.set(row.billingId, list);
+ }
+ return byBilling;
+}
+
+/**
+ * The registry `domain` for each slug, or an empty map when the registry cannot be
+ * read at all.
+ *
+ * An unreadable registry degrading to "no domain" is the fail-closed direction here:
+ * `tenantStage` then cannot reach "ready", so the worst case is a live owner briefly
+ * told their app is still being set up. The alternative — surfacing the registry read
+ * error on a customer's dashboard — reports one of our ops conditions to somebody who
+ * cannot act on it, and the founder already gets it loudly on `/admin/provision`.
+ */
+async function registryDomains(slugs: string[]): Promise