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> { + if (slugs.length === 0) return new Map(); + const registry = await loadTenantRegistry(); + if (!registry.ok) return new Map(); + const wanted = new Set(slugs); + return new Map(registry.tenants.filter((t) => wanted.has(t.slug)).map((t) => [t.slug, t.domain])); +} + +export default async function OwnerDashboard({ + locale, + ownerName, + billings, +}: { + readonly locale: string; + readonly ownerName: string; + readonly billings: OwnerBilling[]; +}) { + const t = await getTranslations({ locale, namespace: "control.dashboard" }); + const tp = await getTranslations({ locale, namespace: "control.plan" }); + const domains = await registryDomains(billings.map((b) => b.tenantSlug)); + + // ONE history query for every plan, grouped in memory — not one per row. The probe + // is what this render can actually wait on (3s cap, 60s cache per domain), so the + // queries should not add round-trips on top of it. + const historyByBilling = await paymentHistory(billings.map((b) => b.id)); + + const cards = await Promise.all( + billings.map(async (b) => { + const domain = domains.get(b.tenantSlug) ?? null; + return { + billing: b, + domain, + history: historyByBilling.get(b.id) ?? [], + stage: tenantStage({ + // Read from the SAME `first`-scoped payment window planState uses, so the + // panel and the plan status can never disagree about whether money moved. + paid: b.payments.some((p) => p.sequenceType === "first" && p.status === "paid"), + provisioningPrUrl: b.provisioningPrUrl, + registryDomain: domain, + // `tenantSlug` is unique, so no two plans share a domain and this is one + // probe per plan however the loop is written. + healthy: domain ? await probeTenantHealthy(domain) : false, + }), + }; + }), + ); + + return ( +
+
+

{t("ownerTitle")}

+

{t("ownerIntro")}

+
+ + {cards.length === 0 ? ( +

{tp("noPlan")}

+ ) : ( + cards.map(({ billing, domain, history, stage }) => ( + + )) + )} +
+ ); +} diff --git a/components/control/OwnerPlanCard.tsx b/components/control/OwnerPlanCard.tsx new file mode 100644 index 0000000..9678708 --- /dev/null +++ b/components/control/OwnerPlanCard.tsx @@ -0,0 +1,150 @@ +import { getTranslations } from "next-intl/server"; +import { eur, shortDate } from "@/lib/format"; +import { + intervalKeyOf, + nextChargeDate, + paymentStatusKey, + planState, + sequenceKey, + type PlanState, +} from "@/lib/billing-display"; +import { visibleTenantStage, type TenantStage } from "@/lib/tenant-liveness"; +import StartPaymentButton from "./StartPaymentButton"; +import ActivatingPanel from "./ActivatingPanel"; +import TenantReadyPanel from "./TenantReadyPanel"; + +/** + * An owner's single plan, in full (SOFRA-ONBOARDING-PLAN O4 — the gap O2 named and + * deliberately left open). + * + * Until this existed, an owner whose plan went ACTIVE saw one sentence: *"Your + * subscription is active — nothing to do here right now."* No amount, no next-charge + * date, no payment history, no mention of the app they are paying for. Not broken — + * but a defaulted "nothing to do", which is the shape this funnel keeps having to + * remove now that no founder is on the call to fill it in. Someone whose card is + * charged monthly is entitled to see what for and when next. + * + * The fix is NOT to loosen `/dashboard/billing`. That page is `requirePartner()` + * because it is the reseller's book — every plan under their partner id, priced as a + * pipeline. An owner needs their own plan, and the panel that tells them where their + * restaurant is; those are one card, not a page they share with a reseller. + */ +export interface OwnerPlanCardProps { + readonly locale: string; + readonly billingId: string; + readonly restaurant: string; + readonly ownerName: string; + readonly subscription: { readonly status: string; readonly amountCents: number; readonly interval: string; readonly startDate: Date | null } | null; + /** `first`-sequence payments only — what `planState` reads. */ + readonly firstPayments: ReadonlyArray<{ readonly sequenceType: string; readonly status: string }>; + /** Newest-first, bounded: every payment, for the history list. */ + readonly history: ReadonlyArray<{ readonly id: string; readonly createdAt: Date; readonly sequenceType: string; readonly status: string; readonly amountCents: number }>; + readonly liveSince: Date | null; + readonly stage: TenantStage; + readonly tenantDomain: string | null; +} + +/** + * What the owner can do about this plan right now. Guard clauses rather than nested + * ternaries (Sonar S3358), and no pay button in the "processing" window — a second + * payment there is the double-charge trap `ActivatingPanel` exists to prevent. + */ +function planAction(args: { + state: PlanState; + billingId: string; + locale: string; + restaurant: string; + nextCharge: Date | null; + t: (key: string, values?: Record) => string; +}) { + const { state, billingId, locale, restaurant, nextCharge, t } = args; + if (state === "pay") { + return ( +
+ + {t("firstChargeNote")} +
+ ); + } + if (state === "processing") return ; + if (state === "active") { + return ( +

+ {nextCharge ? t("activeNextCharge", { date: shortDate(nextCharge) }) : t("active")} +

+ ); + } + return

{t("inactive")}

; +} + +export default async function OwnerPlanCard(props: OwnerPlanCardProps) { + const { locale, billingId, restaurant, ownerName, subscription, firstPayments, history } = props; + const t = await getTranslations({ locale, namespace: "control.plan" }); + const state = planState(subscription ?? undefined, [...firstPayments]); + + return ( +
+

+ {t("welcomeKicker")} +

+

{t("welcomeTitle", { name: ownerName })}

+ +
+

{restaurant}

+ {props.liveSince && ( + + {t("liveSinceShort", { date: shortDate(props.liveSince) })} + + )} +
+ + {subscription ? ( + <> +

+ {t("amountLine", { + amount: eur(subscription.amountCents), + interval: t(`interval.${intervalKeyOf(subscription.interval)}`), + })} +

+ {planAction({ + state, + billingId, + locale, + restaurant, + nextCharge: nextChargeDate(subscription.startDate, subscription.interval, new Date()), + t, + })} + + ) : ( +

{t("noPlan")}

+ )} + + + + {history.length > 0 && ( +
+

+ {t("history")} +

+
    + {history.map((p) => ( +
  • + + {shortDate(p.createdAt)} · {t(`sequence.${sequenceKey(p.sequenceType)}`)} + + + {eur(p.amountCents)} ·{" "} + {t(`paymentStatus.${paymentStatusKey(p.status)}`, { status: p.status })} + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/components/control/PartnerDashboard.tsx b/components/control/PartnerDashboard.tsx new file mode 100644 index 0000000..d841206 --- /dev/null +++ b/components/control/PartnerDashboard.tsx @@ -0,0 +1,146 @@ +import { getTranslations } from "next-intl/server"; +import { eur, shortDate } from "@/lib/format"; +import { intervalKeyOf, planState, type PlanState } from "@/lib/billing-display"; +import ClientForm from "./ClientForm"; +import ClientStatusBadge from "./ClientStatusBadge"; +import StartPaymentButton from "./StartPaymentButton"; + +/** + * The reseller's dashboard — unchanged behaviour, lifted out of `page.tsx` when the + * owner view stopped being a branch inside it (SOFRA-ONBOARDING-PLAN O4). + */ + +type PartnerBilling = { + id: string; + tenantSlug: string; + liveSince: Date | null; + client: { restaurantName: string } | null; + subscriptions: { amountCents: number; interval: string; status: string }[]; + payments: { sequenceType: string; status: string }[]; +}; + +type PartnerClient = { + id: string; + restaurantName: string; + city: string | null; + contactName: string | null; + status: string; + updatedAt: Date; +}; + +/** + * What the partner can do about a client's plan right now. Guard clauses rather than a + * chain of ternaries (Sonar S3358). + * + * `state` is only ever "pay" or "processing" here — the caller's filter admits exactly + * those two — so "processing" is the mandate-validation window. The reseller keeps the + * terse line: they read this queue as a pipeline and are not the one who just watched + * money leave their own account (the owner gets `` instead). Neither + * branch renders a pay button in that window: a second payment is the trap it sets. + */ +function planAction(args: { state: PlanState; billingId: string; tp: (key: string) => string }) { + const { state, billingId, tp } = args; + if (state === "pay") { + return ( +
+ + {tp("firstChargeNote")} +
+ ); + } + return

{tp("processing")}

; +} + +export default async function PartnerDashboard({ + locale, + partnerName, + billings, + clients, +}: { + readonly locale: string; + readonly partnerName: string; + readonly billings: PartnerBilling[]; + readonly clients: PartnerClient[]; +}) { + const t = await getTranslations({ locale, namespace: "control.dashboard" }); + const tp = await getTranslations({ locale, namespace: "control.plan" }); + + // Plans that still need the payer's attention: 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"; + }); + + return ( +
+
+

{t("title")}

+

{t("intro")}

+
+ + {awaiting.map((b) => { + const sub = b.subscriptions[0]; + if (!sub) return null; + const restaurant = b.client?.restaurantName ?? b.tenantSlug; + // `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. + const whereItStands = b.liveSince + ? tp("liveSince", { restaurant, date: shortDate(b.liveSince) }) + : tp("liveSinceUnknown", { restaurant }); + return ( +
+

+ {tp("welcomeKicker")} +

+

+ {tp("welcomeTitle", { name: partnerName })} +

+

{whereItStands}

+

+ {tp("amountLine", { + amount: eur(sub.amountCents), + interval: tp(`interval.${intervalKeyOf(sub.interval)}`), + })} +

+ {planAction({ state: planState(sub, b.payments), billingId: b.id, tp })} +
+ ); + })} + +
+

{t("addClient")}

+
+ +
+
+ + {clients.length === 0 ? ( +

{t("empty")}

+ ) : ( + + )} +
+ ); +} diff --git a/components/control/TenantReadyPanel.tsx b/components/control/TenantReadyPanel.tsx new file mode 100644 index 0000000..752a9a8 --- /dev/null +++ b/components/control/TenantReadyPanel.tsx @@ -0,0 +1,91 @@ +import { getTranslations } from "next-intl/server"; +import { tenantForgotPasswordUrl, tenantOrigin, type TenantStage } from "@/lib/tenant-liveness"; + +/** + * "Your app is ready — set your admin password" (SOFRA-ONBOARDING-PLAN O4, the piece + * O3 handed over). + * + * 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 generated on the box is never read, never emailed, never spoken. + * The gap left behind was that nobody TELLS the owner. Between paying and being told, + * a self-serve customer has a running restaurant app on their own subdomain and no + * idea it exists — which is the same as not having one. + * + * The stage comes from `tenantStage`, which earns each claim rather than defaulting to + * the friendliest one. Only "ready" — the stage backed by the app answering its own + * health endpoint — renders a link, because only "ready" knows the link works. + * + * Deliberately NOT an email. The address on file is the one that signed up; sending + * "here is where your admin panel lives" unprompted is a phishing shape we would be + * teaching our own customers to trust. They are already logged in here. + */ +export default async function TenantReadyPanel({ + locale, + stage, + domain, +}: { + readonly locale: string; + readonly stage: TenantStage; + readonly domain: string | null; +}) { + const t = await getTranslations({ locale, namespace: "control.tenantReady" }); + if (stage === "none") return null; + + if (stage === "ready" && domain) { + const origin = tenantOrigin(domain); + const resetUrl = tenantForgotPasswordUrl(domain); + // `tenantOrigin` rejecting the domain is the one way a "ready" tenant reaches this + // with no usable link — a malformed registry entry that still somehow answered. + // Fall through to the waiting copy rather than render a broken anchor. + if (origin && resetUrl) { + return ( +
+

+ {t("readyKicker")} +

+

{t("readyTitle")}

+

{t("readyBody", { domain })}

+ +

{t("setPasswordWhy")}

+
+ ); + } + } + + // Everything short of observed-serving. Three distinct waits, because "we are + // building it" and "it is built, waiting on a review" are not the same news to + // someone counting the minutes — and none of the three implies anything is wrong. + // + // A "ready" that fell through the block above (it answered, but its registry domain + // is not a usable host) reads as almostReady, which is precisely what it is: seen + // serving, no link we are willing to hand out. + const key = stage === "ready" ? "almostReady" : stage; + + return ( +
+

+ {t("waitingKicker")} +

+

{t(`${key}Title`)}

+

{t(`${key}Body`)}

+
+ ); +} diff --git a/lib/billing-display.ts b/lib/billing-display.ts index 1daff56..ad40fbd 100644 --- a/lib/billing-display.ts +++ b/lib/billing-display.ts @@ -19,6 +19,82 @@ type PayLike = { sequenceType: string; status: string }; */ export type PlanState = "pay" | "processing" | "active" | "inactive" | "none"; +/** + * When this subscription is charged NEXT, or null when it cannot be stated. + * + * `BillingSubscription.startDate` is NOT the next charge — it is the FIRST recurring + * charge, written once at activation (`lib/billing.ts` `subscriptionStartDate`, = + * activation + one interval) and never advanced again: `recordPayment` inserts a + * `BillingPayment` row for each recurring charge and touches nothing on the + * subscription. So rendering `startDate` as "next charge on {date}" is correct for + * exactly one billing period and then prints a date in the past, forever — which is + * how it read on the partner billing page before O4, and what an owner would otherwise + * have been shown as the answer to the one billing question they actually ask. + * + * Derived rather than fetched: Mollie's subscription resource carries an authoritative + * `nextPaymentDate`, but reading it means a network call on every dashboard render, + * and persisting it means a schema change for a display field. Stepping the anchor + * forward by the interval needs neither and is right whenever charges land on + * schedule. + * + * The caveat, stated rather than hidden: if a charge FAILS, Mollie's own retry + * schedule diverges from this arithmetic and the date shown is optimistic by up to one + * retry window. That is strictly better than a frozen past date, and a failed charge + * shows up in the payment history the owner is now also shown. Month-end anchors also + * carry the same 1–3 day overflow drift `subscriptionStartDate` already documents + * (`setUTCMonth` turns Jan 31 into Mar 3), which settles after the first wrap. + * + * Returns null for a missing anchor or an interval outside the catalog, so the caller + * can fall back to a plain "Active." rather than print a guess. + */ +export function nextChargeDate( + startDate: Date | null, + mollieInterval: string, + now: Date, +): Date | null { + if (!startDate || Number.isNaN(startDate.getTime())) return null; + const months = Object.values(BILLING_INTERVALS).find((i) => i.mollie === mollieInterval)?.months; + if (!months) return null; + + // Step from the anchor rather than from `now`, so the answer stays on the + // subscription's own day-of-month instead of drifting to whenever the page was + // loaded. Bounded by construction: each iteration advances at least one month, and + // the loop stops the moment it passes `now`. + const next = new Date(startDate); + while (next <= now) { + next.setUTCMonth(next.getUTCMonth() + months); + } + return next; +} + +/** + * Mollie's `sequenceType` → a `control.plan.sequence.*` key. + * + * The partner billing page prints the raw value, which is fine for a reseller reading + * their own book. An OWNER's history is customer-facing, and "recurring"/"oneoff" are + * our vendor's vocabulary, not theirs. + */ +export function sequenceKey(sequenceType: string): "first" | "recurring" | "oneoff" { + if (sequenceType === "first") return "first"; + if (sequenceType === "oneoff") return "oneoff"; + return "recurring"; +} + +/** + * Mollie's payment `status` → a `control.plan.paymentStatus.*` key. + * + * Collapses the seven Mollie statuses into the four distinctions a payer acts on: + * it went through, it is in flight, it did not go through, or something we do not + * recognise (kept as a bucket so a status Mollie adds later renders as text rather + * than a missing-key crash — never silently as "paid"). + */ +export function paymentStatusKey(status: string): "paid" | "pending" | "failed" | "other" { + if (status === "paid" || status === "authorized") return "paid"; + if (status === "open" || status === "pending") return "pending"; + if (status === "failed" || status === "canceled" || status === "expired") return "failed"; + return "other"; +} + export function planState(sub: SubLike, payments: PayLike[]): PlanState { if (!sub) return "none"; if (sub.status === "ACTIVE") return "active"; diff --git a/lib/tenant-health.ts b/lib/tenant-health.ts new file mode 100644 index 0000000..b42817f --- /dev/null +++ b/lib/tenant-health.ts @@ -0,0 +1,64 @@ +// Does a tenant's app actually answer? The single piece of EVIDENCE behind the +// owner dashboard's "your app is ready" claim (SOFRA-ONBOARDING-PLAN O4). +// +// Split from `tenant-liveness.ts` because this half touches the network: the pure +// classifier belongs in the coverage floor, this does not (CLAUDE.md §7 — modules with +// network branches stay out of scope rather than acquire mocks). + +import { unstable_cache } from "next/cache"; +import { tenantOrigin } from "@/lib/tenant-liveness"; + +/** + * The `/api/health` payload's own name for itself (backend Program.cs). Asserted on + * because **a bare 200 proves nothing here** — the same lesson O5 learned from a bare + * 404. A wildcard-caught subdomain, a Caddy default page, a parked domain and the + * marketing site all answer 200 to a host that has no tenant behind it, and any of + * them would promote a tenant that does not exist yet to "ready". + */ +const HEALTH_SERVICE_ID = "restaurant-system-api"; + +/** How long a tenant's health answer is reused. Provisioning takes ~15 min, so an + * owner refreshing twice in a minute does not need two round-trips to a box. */ +const HEALTH_TTL_SECONDS = 60; + +/** Budget for the probe — a dashboard render must not hang on an unreachable box. */ +const HEALTH_TIMEOUT_MS = 3000; + +async function fetchHealth(domain: string): Promise { + const origin = tenantOrigin(domain); + if (!origin) return false; + try { + const res = await fetch(`${origin}/api/health`, { + signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS), + redirect: "error", + cache: "no-store", + }); + if (!res.ok) return false; + const body: unknown = await res.json(); + return ( + typeof body === "object" && + body !== null && + (body as { service?: unknown }).service === HEALTH_SERVICE_ID + ); + } catch { + // FAILS CLOSED — the opposite of the tenant frontend's getTenantModules, and the + // difference is worth stating because the two look alike. There, a network blip + // must not take features away from a working app, so unknown means "everything". + // Here, unknown means "we have not seen it serve", and the only thing riding on it + // is a claim we make to a paying customer plus a link we tell them to click. Never + // promote uncertainty to ready. + return false; + } +} + +/** + * Has this tenant's app answered? Cached per domain for {@link HEALTH_TTL_SECONDS}. + * + * `unstable_cache` rather than `fetch`'s own `next.revalidate` because the request + * carries an `AbortSignal`, which opts a fetch out of Next's data cache entirely — the + * timeout and the cache cannot both live on the same call. + */ +export const probeTenantHealthy = unstable_cache(fetchHealth, ["tenant-health"], { + revalidate: HEALTH_TTL_SECONDS, + tags: ["tenant-health"], +}); diff --git a/lib/tenant-liveness.ts b/lib/tenant-liveness.ts new file mode 100644 index 0000000..fca9f8a --- /dev/null +++ b/lib/tenant-liveness.ts @@ -0,0 +1,118 @@ +// "Where is my restaurant app?" — the owner-facing stage of a paid tenant +// (SOFRA-ONBOARDING-PLAN O4, inheriting O3's credential handover). +// +// O3 built the mechanism that means no password ever leaves the box: the tenant +// frontend now has /forgot-password + /reset-password, so the owner sets their own +// admin password. What it did not build is the sentence that tells them so. This +// module answers the one question that sentence depends on — *is their app actually +// up yet?* — and the answer has to be EARNED, because the panel it drives sends the +// owner to a URL. Telling someone "your app is ready" and landing them on a +// connection error is worse than telling them nothing. +// +// There is no clean "is the tenant live" signal in this system today, and this file +// is where that was decided rather than assumed: +// +// - `TenantBilling.liveSince` is admin-entered and display-only. It is a date the +// founder typed, not an observation, and on the self-serve path nobody types it. +// - the registry's `status: active` is a manual follow-up commit that nothing +// automatic writes and nothing automatic reads. `provision-on-registry-merge.yml` +// does not flip it. +// - `provisioningPrUrl` proves a proposal was OPENED, not merged, built or booted. +// - a registry ENTRY proves the founder merged it — which under the O3 merge chain +// does start the build — but not that the build and provision succeeded. +// +// So the only evidence that the app is serving is asking it. `probeTenantHealthy` +// (lib/tenant-health.ts) does, and everything below it degrades to a weaker, honest +// claim. +// +// This file is PURE — no network, no DB — so the precedence above is unit-testable and +// sits inside the coverage floor, the same split `billing-display.ts` keeps from +// `billing.ts`. The probe lives next door. + +export type TenantStage = + /** Not paid yet — the pay button / activating panel owns this moment, say nothing. */ + | "none" + /** Paid, nothing proposed yet. */ + | "preparing" + /** A registry proposal is open, awaiting the founder's merge. */ + | "settingUp" + /** In the registry, but the app has not answered. NEVER claim ready from here. */ + | "almostReady" + /** Observed serving. The only stage that hands out a link. */ + | "ready"; + +export interface TenantStageFacts { + /** A `first` payment has settled. */ + readonly paid: boolean; + /** `TenantBilling.provisioningPrUrl` — the proposal was opened. */ + readonly provisioningPrUrl: string | null; + /** The tenant's registry `domain`, or null when it has no entry *or the registry + * could not be read at all*. Both collapse to "no evidence", deliberately. */ + readonly registryDomain: string | null; + /** `probeTenantHealthy` said yes. */ + readonly healthy: boolean; +} + +/** + * Which claim the evidence supports. First match wins, strongest evidence first. + * + * Every fall-through is a WEAKER claim, never a stronger one: an unreadable registry, + * a timed-out probe and a tenant that genuinely is not built yet all land somewhere + * that promises nothing. Under-claiming shows a live owner "almost ready" for up to a + * minute; over-claiming sends a customer to a dead link and makes the product look + * broken on the first thing they were ever told to do. + * + * `healthy` is guarded by `registryDomain` even though a caller can only obtain one by + * probing the other — so the function is total, and a future caller that keeps a stale + * `healthy` around cannot resurrect "ready" for a tenant with no entry. + */ +export function tenantStage(facts: TenantStageFacts): TenantStage { + if (!facts.paid) return "none"; + if (facts.registryDomain && facts.healthy) return "ready"; + if (facts.registryDomain) return "almostReady"; + if (facts.provisioningPrUrl) return "settingUp"; + return "preparing"; +} + +/** + * Which stage to actually SHOW, given what the plan status is already saying. + * + * In the mandate-lag window (`planState` "processing") `` is on + * screen spelling out the whole wait — paid, bank confirming, then your app is + * prepared. "We are preparing your app" underneath it is the same sentence twice, and + * the argument the old `liveSinceLine` made still holds: a line that restates the line + * above it is worse than no line. + * + * `almostReady` and `ready` survive that window, because they are NEW information — + * the app exists now, which `ActivatingPanel` cannot know and does not claim. + */ +export function visibleTenantStage(stage: TenantStage, planIsProcessing: boolean): TenantStage { + if (!planIsProcessing) return stage; + return stage === "preparing" || stage === "settingUp" ? "none" : stage; +} + +/** + * `https://` for a registry domain, or null if the value is not a bare host. + * + * The registry is founder-reviewed YAML, so this is not the security boundary — but + * the domain ORIGINATES as a customer-typed `desiredSlug`, and the panel turns it into + * a link the owner is told to click and a request this server makes. Rejecting + * anything carrying a scheme, credentials, port, path or query keeps a malformed entry + * from aiming either one somewhere unintended. + * + * The final label must be alphabetic, which is what rules out an IP literal — + * `127.0.0.1` is otherwise a well-formed sequence of dot-separated alphanumeric labels + * and would sail through, pointing a server-side fetch at the loopback interface of + * the container. Single-label names (`localhost`, `backend`) are refused by the same + * rule needing at least one dot. + */ +export function tenantOrigin(domain: string): string | null { + if (!/^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(domain)) return null; + return `https://${domain}`; +} + +/** Where the owner sets their own admin password — the O3 page, on their own app. */ +export function tenantForgotPasswordUrl(domain: string): string | null { + const origin = tenantOrigin(domain); + return origin ? `${origin}/forgot-password` : null; +} diff --git a/messages/ar.json b/messages/ar.json index 26c8416..3985f40 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -494,7 +494,6 @@ "ownerIntro": "خطة SofraPiwas والفوترة الخاصة بك في مكان واحد.", "addClient": "إضافة عميل", "empty": "لا عملاء بعد — أضف أول مطعم أعلاه.", - "ownerAllSet": "اشتراكك نشط — لا شيء لتفعله هنا الآن.", "updated": "آخر تحديث {date}" }, "client": { @@ -837,10 +836,35 @@ "title": "جارٍ إعداد {restaurant}", "stepPaid": "تم استلام دفعتك الأولى — شكرًا لك.", "stepMandate": "يقوم مصرفك بتأكيد الاشتراك الشهري. يستغرق ذلك دقيقة عادةً، وأحيانًا أطول.", - "stepProvision": "بعد ذلك يُجهَّز تطبيق مطعمك ونرسل إليك بيانات الدخول.", + "stepProvision": "بعد ذلك يُجهَّز تطبيق مطعمك، وتوضّح لك هذه الصفحة كيفية تعيين كلمة مرور المدير بنفسك.", "noSecondPayment": "لا شيء آخر عليك فعله، ولا مبلغ إضافي للدفع — تُحدَّث هذه الصفحة تلقائيًا." }, - "notLiveYet": "لم يتم إعداد {restaurant} بعد — ابدأ اشتراكك وسنُجهّز تطبيقك." + "sequence": { + "first": "الدفعة الأولى", + "recurring": "رسوم الاشتراك", + "oneoff": "دفعة لمرة واحدة" + }, + "paymentStatus": { + "paid": "مدفوعة", + "pending": "قيد المعالجة", + "failed": "لم تكتمل", + "other": "{status}" + } + }, + "tenantReady": { + "readyKicker": "تطبيقك جاهز", + "readyTitle": "تطبيق مطعمك يعمل الآن", + "readyBody": "يعمل على {domain}. عيّن كلمة مرور المدير لتسجيل الدخول لأول مرة.", + "setPassword": "عيّن كلمة مرور المدير", + "openApp": "فتح {domain}", + "setPasswordWhy": "لا نرى كلمة مرورك أبدًا ولا نرسلها إليك بالبريد الإلكتروني — أنت من يعيّنها بنفسك، من داخل تطبيقك.", + "waitingKicker": "قيد الإعداد", + "preparingTitle": "نُجهِّز تطبيقك", + "preparingBody": "تم استلام دفعتك. نُجهِّز التطبيق الخاص بمطعمك — وستُعلمك هذه الصفحة فور أن يصبح جاهزًا.", + "settingUpTitle": "جارٍ بناء تطبيقك", + "settingUpBody": "إعداداتك في انتظار مراجعة أخيرة، ثم يُبنى تطبيقك ويُشغَّل. يستغرق ذلك عادةً خمس عشرة دقيقة تقريبًا.", + "almostReadyTitle": "أوشكنا على الانتهاء", + "almostReadyBody": "جارٍ تشغيل تطبيقك. وفور أن يستجيب، ستوضّح لك هذه الصفحة كيفية تسجيل الدخول." } }, "signup": { diff --git a/messages/de.json b/messages/de.json index 396fee7..cddd740 100644 --- a/messages/de.json +++ b/messages/de.json @@ -494,7 +494,6 @@ "ownerIntro": "Ihr SofraPiwas-Tarif und Ihre Abrechnung an einem Ort.", "addClient": "Kunden hinzufügen", "empty": "Noch keine Kunden — fügen Sie oben das erste Restaurant hinzu.", - "ownerAllSet": "Ihr Abonnement ist aktiv — hier gibt es gerade nichts zu tun.", "updated": "aktualisiert am {date}" }, "client": { @@ -837,10 +836,35 @@ "title": "{restaurant} wird eingerichtet", "stepPaid": "Ihre erste Zahlung ist eingegangen — vielen Dank.", "stepMandate": "Ihre Bank bestätigt das monatliche Abonnement. Das dauert meist eine Minute, gelegentlich länger.", - "stepProvision": "Danach wird die App Ihres Restaurants vorbereitet und wir senden Ihre Zugangsdaten.", + "stepProvision": "Danach wird die App Ihres Restaurants vorbereitet, und diese Seite zeigt Ihnen, wie Sie Ihr Administrator-Passwort selbst festlegen.", "noSecondPayment": "Sie müssen nichts weiter tun und nichts weiter zahlen — diese Seite aktualisiert sich selbst." }, - "notLiveYet": "{restaurant} ist noch nicht eingerichtet — starten Sie Ihr Abonnement und wir bereiten Ihre App vor." + "sequence": { + "first": "Erste Zahlung", + "recurring": "Abo-Abbuchung", + "oneoff": "Einmalzahlung" + }, + "paymentStatus": { + "paid": "bezahlt", + "pending": "ausstehend", + "failed": "nicht abgeschlossen", + "other": "{status}" + } + }, + "tenantReady": { + "readyKicker": "Ihre App ist bereit", + "readyTitle": "Die App Ihres Restaurants ist live", + "readyBody": "Sie läuft unter {domain}. Legen Sie Ihr Administrator-Passwort fest, um sich zum ersten Mal anzumelden.", + "setPassword": "Administrator-Passwort festlegen", + "openApp": "{domain} öffnen", + "setPasswordWhy": "Wir sehen Ihr Passwort nie und senden es Ihnen nie per E-Mail — Sie legen es selbst fest, in Ihrer eigenen App.", + "waitingKicker": "Wird eingerichtet", + "preparingTitle": "Wir bereiten Ihre App vor", + "preparingBody": "Ihre Zahlung ist eingegangen. Wir bereiten die eigene App Ihres Restaurants vor — diese Seite meldet sich, sobald sie live ist.", + "settingUpTitle": "Ihre App wird erstellt", + "settingUpBody": "Ihre Konfiguration wartet auf eine letzte Prüfung, danach wird Ihre App erstellt und gestartet. Das dauert in der Regel etwa fünfzehn Minuten.", + "almostReadyTitle": "Fast geschafft", + "almostReadyBody": "Ihre App wird gestartet. Sobald sie antwortet, zeigt Ihnen diese Seite, wie Sie sich anmelden." } }, "signup": { diff --git a/messages/en.json b/messages/en.json index 265230c..fb07a9a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -494,7 +494,6 @@ "ownerIntro": "Your SofraPiwas plan and billing, in one place.", "addClient": "Add a client", "empty": "No clients yet — add the first restaurant above.", - "ownerAllSet": "Your subscription is active — nothing to do here right now.", "updated": "updated {date}" }, "client": { @@ -837,10 +836,35 @@ "title": "{restaurant} is being set up", "stepPaid": "Your first payment went through — thank you.", "stepMandate": "Your bank is confirming the monthly subscription. This usually takes a minute, occasionally longer.", - "stepProvision": "Then your restaurant's app is prepared and we send your login details.", + "stepProvision": "Then your restaurant's app is prepared, and this page shows you how to set your own admin password.", "noSecondPayment": "Nothing more to do, and nothing more to pay — this page updates itself." }, - "notLiveYet": "{restaurant} isn't set up yet — start your subscription and we'll prepare your app." + "sequence": { + "first": "first payment", + "recurring": "subscription charge", + "oneoff": "one-off payment" + }, + "paymentStatus": { + "paid": "paid", + "pending": "pending", + "failed": "not completed", + "other": "{status}" + } + }, + "tenantReady": { + "readyKicker": "Your app is ready", + "readyTitle": "Your restaurant app is live", + "readyBody": "It is running at {domain}. Set your admin password to sign in for the first time.", + "setPassword": "Set your admin password", + "openApp": "Open {domain}", + "setPasswordWhy": "We never see your password and we never email you one — you set it yourself, on your own app.", + "waitingKicker": "Setting up", + "preparingTitle": "We are preparing your app", + "preparingBody": "Your payment is in. We are getting your restaurant's own app ready — this page will tell you the moment it is live.", + "settingUpTitle": "Your app is being built", + "settingUpBody": "Your setup is queued for a final check, then your app is built and started. This usually takes about fifteen minutes.", + "almostReadyTitle": "Almost there", + "almostReadyBody": "Your app is starting up. As soon as it answers, this page will show you how to sign in." } }, "signup": { diff --git a/messages/fr.json b/messages/fr.json index 79a3926..156c68a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -494,7 +494,6 @@ "ownerIntro": "Votre offre SofraPiwas et votre facturation, au même endroit.", "addClient": "Ajouter un client", "empty": "Pas encore de clients — ajoutez le premier restaurant ci-dessus.", - "ownerAllSet": "Votre abonnement est actif — rien à faire ici pour le moment.", "updated": "mis à jour le {date}" }, "client": { @@ -837,10 +836,35 @@ "title": "{restaurant} est en cours d'installation", "stepPaid": "Votre premier paiement a été accepté — merci.", "stepMandate": "Votre banque confirme l'abonnement mensuel. Cela prend généralement une minute, parfois un peu plus.", - "stepProvision": "Ensuite, l'application de votre restaurant est préparée et nous vous envoyons vos identifiants.", + "stepProvision": "Ensuite, l'application de votre restaurant est préparée, et cette page vous indique comment définir vous-même votre mot de passe administrateur.", "noSecondPayment": "Rien d'autre à faire, et rien de plus à payer — cette page se met à jour d'elle-même." }, - "notLiveYet": "{restaurant} n'est pas encore installé — démarrez votre abonnement et nous préparerons votre application." + "sequence": { + "first": "premier paiement", + "recurring": "prélèvement d'abonnement", + "oneoff": "paiement unique" + }, + "paymentStatus": { + "paid": "payé", + "pending": "en attente", + "failed": "non abouti", + "other": "{status}" + } + }, + "tenantReady": { + "readyKicker": "Votre application est prête", + "readyTitle": "L'application de votre restaurant est en ligne", + "readyBody": "Elle fonctionne sur {domain}. Définissez votre mot de passe administrateur pour vous connecter la première fois.", + "setPassword": "Définir votre mot de passe administrateur", + "openApp": "Ouvrir {domain}", + "setPasswordWhy": "Nous ne voyons jamais votre mot de passe et ne vous en envoyons jamais par e-mail — vous le définissez vous-même, depuis votre propre application.", + "waitingKicker": "Installation en cours", + "preparingTitle": "Nous préparons votre application", + "preparingBody": "Votre paiement est bien arrivé. Nous préparons l'application de votre restaurant — cette page vous préviendra dès qu'elle sera en ligne.", + "settingUpTitle": "Votre application est en cours de création", + "settingUpBody": "Votre configuration attend une dernière vérification, puis votre application est créée et démarrée. Cela prend généralement une quinzaine de minutes.", + "almostReadyTitle": "Presque terminé", + "almostReadyBody": "Votre application démarre. Dès qu'elle répond, cette page vous indiquera comment vous connecter." } }, "signup": { diff --git a/messages/nl.json b/messages/nl.json index 50907a2..952db24 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -494,7 +494,6 @@ "ownerIntro": "Je SofraPiwas-abonnement en facturering op één plek.", "addClient": "Klant toevoegen", "empty": "Nog geen klanten — voeg hierboven het eerste restaurant toe.", - "ownerAllSet": "Je abonnement is actief — hier is nu niets te doen.", "updated": "bijgewerkt {date}" }, "client": { @@ -837,10 +836,35 @@ "title": "{restaurant} wordt ingericht", "stepPaid": "Je eerste betaling is gelukt — bedankt.", "stepMandate": "Je bank bevestigt het maandabonnement. Dit duurt meestal een minuut, soms langer.", - "stepProvision": "Daarna wordt de app van je restaurant klaargezet en sturen we je inloggegevens.", + "stepProvision": "Daarna wordt de app van je restaurant klaargezet en laat deze pagina zien hoe je zelf je beheerderswachtwoord instelt.", "noSecondPayment": "Je hoeft niets meer te doen en niets extra te betalen — deze pagina werkt zichzelf bij." }, - "notLiveYet": "{restaurant} is nog niet ingericht — start je abonnement en we zetten je app klaar." + "sequence": { + "first": "eerste betaling", + "recurring": "abonnementskosten", + "oneoff": "eenmalige betaling" + }, + "paymentStatus": { + "paid": "betaald", + "pending": "in behandeling", + "failed": "niet voltooid", + "other": "{status}" + } + }, + "tenantReady": { + "readyKicker": "Je app is klaar", + "readyTitle": "De app van je restaurant is live", + "readyBody": "Hij draait op {domain}. Stel je beheerderswachtwoord in om voor het eerst in te loggen.", + "setPassword": "Stel je beheerderswachtwoord in", + "openApp": "Open {domain}", + "setPasswordWhy": "Wij zien je wachtwoord nooit en mailen het je nooit — je stelt het zelf in, in je eigen app.", + "waitingKicker": "Wordt ingericht", + "preparingTitle": "We maken je app klaar", + "preparingBody": "Je betaling is binnen. We maken de eigen app van je restaurant gereed — deze pagina laat het weten zodra hij live is.", + "settingUpTitle": "Je app wordt gebouwd", + "settingUpBody": "Je configuratie staat klaar voor een laatste controle; daarna wordt je app gebouwd en gestart. Dit duurt meestal zo'n vijftien minuten.", + "almostReadyTitle": "Bijna klaar", + "almostReadyBody": "Je app wordt opgestart. Zodra hij antwoordt, laat deze pagina zien hoe je inlogt." } }, "signup": { diff --git a/messages/tr.json b/messages/tr.json index 8439e6a..e2999bb 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -494,7 +494,6 @@ "ownerIntro": "SofraPiwas planınız ve faturalandırmanız tek bir yerde.", "addClient": "Müşteri ekle", "empty": "Henüz müşteri yok — ilk restoranı yukarıdan ekleyin.", - "ownerAllSet": "Aboneliğiniz etkin — şu anda burada yapılacak bir şey yok.", "updated": "güncelleme: {date}" }, "client": { @@ -837,10 +836,35 @@ "title": "{restaurant} kuruluyor", "stepPaid": "İlk ödemeniz alındı — teşekkürler.", "stepMandate": "Bankanız aylık aboneliği onaylıyor. Bu genellikle bir dakika, bazen daha uzun sürer.", - "stepProvision": "Ardından restoranınızın uygulaması hazırlanır ve giriş bilgilerinizi göndeririz.", + "stepProvision": "Ardından restoranınızın uygulaması hazırlanır ve bu sayfa yönetici parolanızı nasıl kendiniz belirleyeceğinizi gösterir.", "noSecondPayment": "Yapacak başka bir şey yok, ödenecek başka bir tutar da yok — bu sayfa kendini günceller." }, - "notLiveYet": "{restaurant} henüz kurulmadı — aboneliğinizi başlatın, uygulamanızı hazırlayalım." + "sequence": { + "first": "ilk ödeme", + "recurring": "abonelik tahsilatı", + "oneoff": "tek seferlik ödeme" + }, + "paymentStatus": { + "paid": "ödendi", + "pending": "beklemede", + "failed": "tamamlanmadı", + "other": "{status}" + } + }, + "tenantReady": { + "readyKicker": "Uygulamanız hazır", + "readyTitle": "Restoranınızın uygulaması yayında", + "readyBody": "{domain} adresinde çalışıyor. İlk kez giriş yapmak için yönetici parolanızı belirleyin.", + "setPassword": "Yönetici parolanızı belirleyin", + "openApp": "{domain} adresini aç", + "setPasswordWhy": "Parolanızı hiçbir zaman görmeyiz ve size e-postayla göndermeyiz — onu kendi uygulamanızda kendiniz belirlersiniz.", + "waitingKicker": "Kuruluyor", + "preparingTitle": "Uygulamanızı hazırlıyoruz", + "preparingBody": "Ödemeniz alındı. Restoranınıza ait uygulamayı hazırlıyoruz — yayına girdiği anda bu sayfa size haber verecek.", + "settingUpTitle": "Uygulamanız oluşturuluyor", + "settingUpBody": "Yapılandırmanız son bir kontrol için sırada; ardından uygulamanız oluşturulup başlatılır. Bu genellikle on beş dakika kadar sürer.", + "almostReadyTitle": "Neredeyse hazır", + "almostReadyBody": "Uygulamanız başlatılıyor. Yanıt verdiği anda bu sayfa nasıl giriş yapacağınızı gösterecek." } }, "signup": { diff --git a/tests/e2e/billing-mollie.spec.ts b/tests/e2e/billing-mollie.spec.ts index 339f926..aca5f64 100644 --- a/tests/e2e/billing-mollie.spec.ts +++ b/tests/e2e/billing-mollie.spec.ts @@ -181,8 +181,15 @@ test.describe("Mollie first payment and activation", () => { // against this same page while claiming to check a billing view the owner // cannot reach, on a `/active/i` match loose enough to hit "Not active." // and "Activating" too. + // + // Since O4 the owner sees the plan itself rather than the one-line "your + // subscription is active — nothing to do here right now" this used to assert. + // "Active — next charge on " is the stronger check anyway: it can only + // render from an ACTIVE subscription that carries a real startDate, which is + // exactly what a completed Mollie activation writes. await page.goto("/dashboard"); - await expect(page.getByText(/your subscription is active/i)).toBeVisible(); + await expect(page.getByText(/active — next charge on/i)).toBeVisible(); + await expect(page.getByText(/nothing to do here right now/i)).toHaveCount(0); await expect(page.getByRole("button", { name: /start auto-monthly payment/i })).toHaveCount(0); // The mandate-lag panel must be gone now that it really is active. await expect(page.getByText(/is being set up/i)).toHaveCount(0); diff --git a/tests/e2e/helpers/db.ts b/tests/e2e/helpers/db.ts index 5292d41..c1e3619 100644 --- a/tests/e2e/helpers/db.ts +++ b/tests/e2e/helpers/db.ts @@ -220,3 +220,95 @@ export async function findFirstPayment( ? { molliePaymentId: r.mollie_payment_id, status: r.status, checkoutUrl: r.checkout_url } : null; } + +/** + * Put a plan into the steady state a paying customer lives in for years: an ACTIVE + * subscription that has already taken its first recurring charge. + * + * Like `arrangeMandateLag`, this ARRANGES a state rather than faking a behaviour — and + * getting that right matters more here than it looks. `startDate` is deliberately set + * in the PAST, because that is the only thing production can produce once a recurring + * charge exists: `subscriptionStartDate` writes it as activation + one interval and + * nothing ever advances it, so a plan with a `recurring` payment necessarily has a + * `startDate` behind it. An arrangement with a FUTURE `startDate` plus a recurring + * charge is a state no code path can reach, and a "next charge on " assertion + * against it proves only that the helper's own date round-trips to the DOM — the whole + * point is that the app must DERIVE the next charge rather than print that column. + */ +export async function arrangeActivePlan( + tenantSlug: string, + opts: { firstChargeIso: string }, +): Promise { + const updated = await query<{ id: string }>( + `UPDATE "BillingSubscription" s + SET status = 'ACTIVE', + "startDate" = $2::timestamptz, + "mollieSubscriptionId" = 'sub_e2e_' || substr(md5(random()::text), 1, 16) + FROM "TenantBilling" b + WHERE s."billingId" = b.id AND b."tenantSlug" = $1 + RETURNING s.id`, + [tenantSlug, opts.firstChargeIso], + ); + if (updated.length !== 1) { + throw new Error(`arrangeActivePlan: no subscription for slug "${tenantSlug}"`); + } + // A `first` payment (what planState reads) and a `recurring` one — which is what + // makes `startDate` stale, and what makes the history list more than a single row. + for (const [sequence, status] of [ + ["first", "paid"], + ["recurring", "paid"], + ]) { + // Same reason arrangeMandateLag checks: INSERT…SELECT writes zero rows for an + // unknown slug and the caller would then fail on a visibility assertion, sending + // the reader to the dashboard instead of to the arrangement. + const inserted = await query<{ id: string }>( + `INSERT INTO "BillingPayment" + (id, "billingId", "molliePaymentId", "amountCents", currency, description, + status, "sequenceType", method, "paidAt", "createdAt", "updatedAt") + SELECT gen_random_uuid()::text, b.id, 'tr_e2e_' || substr(md5(random()::text), 1, 16), + COALESCE(s."amountCents", 0), 'EUR', 'E2E arranged payment', + $2, $3, 'ideal', now(), now(), now() + FROM "TenantBilling" b + LEFT JOIN "BillingSubscription" s ON s."billingId" = b.id + WHERE b."tenantSlug" = $1 + LIMIT 1 + RETURNING id`, + [tenantSlug, status, sequence], + ); + if (inserted.length !== 1) { + throw new Error(`arrangeActivePlan: no billing row for slug "${tenantSlug}"`); + } + } +} + +/** + * Move a plan onto a slug the registry fixture knows about. + * + * The signup refuses a taken slug (correctly), so a self-serve plan can never START + * life pointing at a registry entry — which is exactly the state the owner dashboard + * has to handle once the founder merges the entry. Repointing the row afterwards is + * the only way to reach it in a suite with no provisioning. + * + * `tenantSlug` is `@unique`, and the target is a fixed fixture slug, so a Playwright + * RETRY would otherwise hit a 23505 from the row its own first attempt left behind — + * a Postgres error masking whatever the original assertion failure was. Clearing the + * target first makes the helper idempotent across attempts. Safe only because this is + * a throwaway database whose rows exist for one test. + */ +export async function repointBillingSlug(fromSlug: string, toSlug: string): Promise { + await query(`DELETE FROM "TenantBilling" WHERE "tenantSlug" = $1`, [toSlug]); + const rows = await query<{ id: string }>( + `UPDATE "TenantBilling" SET "tenantSlug" = $2 WHERE "tenantSlug" = $1 RETURNING id`, + [fromSlug, toSlug], + ); + if (rows.length !== 1) throw new Error(`repointBillingSlug: no billing row for "${fromSlug}"`); +} + +/** Record that a registry proposal was opened, without opening one. */ +export async function arrangeProposalOpened(tenantSlug: string, url: string): Promise { + const rows = await query<{ id: string }>( + `UPDATE "TenantBilling" SET "provisioningPrUrl" = $2 WHERE "tenantSlug" = $1 RETURNING id`, + [tenantSlug, url], + ); + if (rows.length !== 1) throw new Error(`arrangeProposalOpened: no billing row for "${tenantSlug}"`); +} diff --git a/tests/e2e/owner-dashboard.spec.ts b/tests/e2e/owner-dashboard.spec.ts new file mode 100644 index 0000000..4d96406 --- /dev/null +++ b/tests/e2e/owner-dashboard.spec.ts @@ -0,0 +1,152 @@ +import { expect, test } from "./helpers/fixtures"; +import { activateAndLogin, mintInviteLink, submitSignup, uniq } from "./helpers/flows"; +import { + arrangeActivePlan, + arrangeMandateLag, + arrangeProposalOpened, + findPlan, + findUser, + repointBillingSlug, +} from "./helpers/db"; + +// The owner's dashboard (SOFRA-ONBOARDING-PLAN O4): the two things it now has to say +// that it did not before — *where is my app and how do I get in* (inherited from O3), +// and *what am I paying, and when next* (the gap O2 named and left open). +// +// Nothing is mocked. The health probe is real: the registry fixture's domains are +// `.example.test`, which resolves nowhere, so a probed tenant genuinely fails to +// answer. That is the assertion that matters most here — a merged registry entry must +// NOT be enough to tell a customer their app is ready, because the merge only STARTS +// the build. The "ready" branch itself is covered by the unit tests over `tenantStage`, +// which is where it can be exercised without standing up a tenant. + +/** A signed-up, activated, logged-in owner sitting on their dashboard. */ +async function anOwner(page: Parameters[0], name: string) { + const slug = uniq.slug(name); + const email = uniq.email(name); + await submitSignup(page, { slug, email, restaurantName: `Chez ${name}` }); + await expect(page.getByText(/check your email/i)).toBeVisible(); + const user = await findUser(email); + await activateAndLogin(page, { + inviteLink: await mintInviteLink(user!.id), + email, + password: `e2e-${name}-pass-${Date.now()}`, + }); + return { slug, email }; +} + +test.describe("the owner is told where their app is — and only what is true", () => { + test("before paying, the app panel says nothing at all", async ({ page }) => { + await anOwner(page, "quiet"); + // The pay button owns this moment. A "we are preparing your app" line beside + // "start your subscription" would be a promise made before any money moved. + await expect(page.getByRole("button", { name: /start auto-monthly payment/i })).toBeVisible(); + await expect(page.getByText(/preparing your app/i)).toHaveCount(0); + await expect(page.getByRole("link", { name: /set your admin password/i })).toHaveCount(0); + }); + + test("paid with nothing proposed yet says 'preparing', and hands out no link", async ({ + page, + }) => { + const { slug } = await anOwner(page, "prep"); + await arrangeActivePlan(slug, { firstChargeIso: "2026-01-15T00:00:00Z" }); + + await page.goto("/dashboard"); + await expect(page.getByText(/preparing your app/i)).toBeVisible(); + await expect(page.getByRole("link", { name: /set your admin password/i })).toHaveCount(0); + await expect(page.getByText(/your restaurant app is live/i)).toHaveCount(0); + }); + + test("an open proposal says 'being built', and still hands out no link", async ({ page }) => { + const { slug } = await anOwner(page, "propose"); + await arrangeActivePlan(slug, { firstChargeIso: "2026-01-15T00:00:00Z" }); + await arrangeProposalOpened(slug, "https://github.com/piwas-21/restaurant-app-deploy/pull/999"); + + await page.goto("/dashboard"); + await expect(page.getByText(/your app is being built/i)).toBeVisible(); + await expect(page.getByRole("link", { name: /set your admin password/i })).toHaveCount(0); + }); + + test("the mandate-lag window is not told the same thing twice", async ({ page }) => { + // While `` is up it already spells out the whole wait, ending + // with "then your app is prepared". "We are preparing your app" directly beneath it + // is the same sentence again, and a line that restates the line above it is worse + // than no line. `visibleTenantStage` silences exactly those two stages here. + const { slug } = await anOwner(page, "twice"); + await arrangeMandateLag(slug); + + await page.goto("/dashboard"); + await expect(page.getByText(/your first payment went through/i)).toBeVisible(); + await expect(page.getByText(/preparing your app/i)).toHaveCount(0); + await expect(page.getByText(/your app is being built/i)).toHaveCount(0); + }); + + test("a MERGED registry entry is still not 'ready' — the app has to answer", async ({ page }) => { + // The single most important assertion in this file. Under the O3 merge chain the + // founder's merge starts a build that takes ~15 minutes and can fail; a dashboard + // that read the entry as "live" would send a customer to a connection error on the + // very first thing the product ever asked them to do. + // + // Arranged in the mandate-lag window on purpose: `almostReady` is one of the two + // stages that must SURVIVE the suppression above, because unlike "preparing" it is + // information `ActivatingPanel` does not have. + const { slug } = await anOwner(page, "merged"); + await arrangeMandateLag(slug); + await repointBillingSlug(slug, "e2e-occupied"); + + await page.goto("/dashboard"); + await expect(page.getByText(/almost there/i)).toBeVisible(); + await expect(page.getByRole("link", { name: /set your admin password/i })).toHaveCount(0); + await expect(page.getByText(/your restaurant app is live/i)).toHaveCount(0); + // And no half-built link leaks into the page either. + await expect(page.locator('a[href*="e2e-occupied.example.test"]')).toHaveCount(0); + }); +}); + +test.describe("an owner with an active plan is shown their plan", () => { + test("amount, next charge and payment history — not 'nothing to do here'", async ({ page }) => { + const { slug } = await anOwner(page, "active"); + // A plan whose FIRST recurring charge was 15 Jan 2026 and which has billed monthly + // since — i.e. `startDate` is in the past, the only shape production can produce + // once a recurring payment exists. + await arrangeActivePlan(slug, { firstChargeIso: "2026-01-15T00:00:00Z" }); + + await page.goto("/dashboard"); + + // What they pay, re-read from their own subscription row. Matched with the + // interval attached: the bare amount now also appears on every history row, and a + // loose match would pass on a page that had lost the plan line entirely. + await expect(page.getByText("€ 43,00 / month")).toBeVisible(); + // When it is taken next — the single most-asked billing question, and the one this + // page could not answer at all before O4. It must be DERIVED: `startDate` is the + // FIRST recurring charge and is never advanced, so printing that column puts a date + // months in the past in front of a paying customer. The suite runs well after + // 15 Jan 2026, so the right answer is a 15th still ahead of today — and the stale + // one is precisely what the second assertion refuses. + await expect(page.getByText(/next charge on 15 /i)).toBeVisible(); + await expect(page.getByText(/next charge on 15 Jan 2026/i)).toHaveCount(0); + // Their history, in their words rather than Mollie's: `first`/`recurring` and + // `paid` are our vendor's vocabulary, not a restaurant owner's. + await expect(page.getByText(/payments/i).first()).toBeVisible(); + await expect(page.getByText(/first payment/i)).toBeVisible(); + await expect(page.getByText(/subscription charge/i)).toBeVisible(); + + // The defaulted "nothing to do" this replaced must be gone, not merely pushed + // below the fold. + await expect(page.getByText(/nothing to do here right now/i)).toHaveCount(0); + // An owner is still not a reseller. + await expect(page.getByText(/add a client/i)).toHaveCount(0); + + expect((await findPlan(slug))!.subStatus).toBe("ACTIVE"); + }); + + test("no pay button once the plan is active — that is the double-charge trap", async ({ + page, + }) => { + const { slug } = await anOwner(page, "nopay"); + await arrangeActivePlan(slug, { firstChargeIso: "2026-02-01T00:00:00Z" }); + + await page.goto("/dashboard"); + await expect(page.getByRole("button", { name: /start auto-monthly payment/i })).toHaveCount(0); + }); +}); diff --git a/tests/unit/billing-display.test.ts b/tests/unit/billing-display.test.ts index d97b979..fd96bbb 100644 --- a/tests/unit/billing-display.test.ts +++ b/tests/unit/billing-display.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { intervalKeyOf, planState } from "@/lib/billing-display"; +import { + intervalKeyOf, + nextChargeDate, + paymentStatusKey, + planState, + sequenceKey, +} from "@/lib/billing-display"; const first = (status: string) => ({ sequenceType: "first", status }); const recurring = (status: string) => ({ sequenceType: "recurring", status }); @@ -44,3 +50,84 @@ describe("planState (partner billing view / double-charge guard)", () => { expect(planState({ status: "SUSPENDED" }, [])).toBe("inactive"); }); }); + +describe("sequenceKey", () => { + it("maps Mollie's sequence vocabulary to owner-facing keys", () => { + expect(sequenceKey("first")).toBe("first"); + expect(sequenceKey("recurring")).toBe("recurring"); + expect(sequenceKey("oneoff")).toBe("oneoff"); + }); + + it("buckets anything unrecognised as recurring, never as `first`", () => { + // `first` is the one value the payer reads as "this is the charge that started my + // subscription". An unknown sequence type must not borrow that meaning. + expect(sequenceKey("")).toBe("recurring"); + expect(sequenceKey("something-mollie-added")).toBe("recurring"); + }); +}); + +describe("paymentStatusKey", () => { + it("collapses the seven Mollie statuses into what a payer acts on", () => { + expect(paymentStatusKey("paid")).toBe("paid"); + expect(paymentStatusKey("authorized")).toBe("paid"); + expect(paymentStatusKey("open")).toBe("pending"); + expect(paymentStatusKey("pending")).toBe("pending"); + expect(paymentStatusKey("failed")).toBe("failed"); + expect(paymentStatusKey("canceled")).toBe("failed"); + expect(paymentStatusKey("expired")).toBe("failed"); + }); + + it("never promotes an unknown status to paid", () => { + // A status Mollie adds later renders as its own literal text via the `other` + // bucket. Reading it as "paid" would tell an owner money arrived when it did not. + expect(paymentStatusKey("chargeback")).toBe("other"); + expect(paymentStatusKey("")).toBe("other"); + }); +}); + +describe("nextChargeDate", () => { + const NOW = new Date("2026-07-30T12:00:00Z"); + + it("returns the first recurring charge while it is still ahead", () => { + // Month one: `startDate` IS the next charge, and no stepping should happen. + expect(nextChargeDate(new Date("2026-08-15T00:00:00Z"), "1 month", NOW)).toEqual( + new Date("2026-08-15T00:00:00Z"), + ); + }); + + it("steps a stale anchor forward instead of reporting a date in the past", () => { + // The defect this exists for: `startDate` is written once at activation and never + // advanced, so from month two on it names a charge that already happened. + const next = nextChargeDate(new Date("2026-01-15T00:00:00Z"), "1 month", NOW); + expect(next).toEqual(new Date("2026-08-15T00:00:00Z")); + expect(next!.getTime()).toBeGreaterThan(NOW.getTime()); + }); + + it("keeps the subscription's own day of month rather than drifting to today", () => { + // Stepping from `now` instead of from the anchor would answer "30 Aug" here. + expect(nextChargeDate(new Date("2025-03-02T00:00:00Z"), "1 month", NOW)).toEqual( + new Date("2026-08-02T00:00:00Z"), + ); + }); + + it("honours quarterly and yearly intervals", () => { + expect(nextChargeDate(new Date("2026-01-15T00:00:00Z"), "3 months", NOW)).toEqual( + new Date("2026-10-15T00:00:00Z"), + ); + expect(nextChargeDate(new Date("2024-05-20T00:00:00Z"), "12 months", NOW)).toEqual( + new Date("2027-05-20T00:00:00Z"), + ); + }); + + it("returns null rather than a guess when it cannot compute one", () => { + // The caller falls back to a plain "Active." — better than inventing a date. + expect(nextChargeDate(null, "1 month", NOW)).toBeNull(); + expect(nextChargeDate(new Date("nope"), "1 month", NOW)).toBeNull(); + expect(nextChargeDate(new Date("2026-01-15T00:00:00Z"), "2 weeks", NOW)).toBeNull(); + }); + + it("advances past an anchor exactly equal to now", () => { + // A charge due this instant is not the NEXT one — `<=` in the loop, not `<`. + expect(nextChargeDate(NOW, "1 month", NOW)).toEqual(new Date("2026-08-30T12:00:00Z")); + }); +}); diff --git a/tests/unit/tenant-liveness.test.ts b/tests/unit/tenant-liveness.test.ts new file mode 100644 index 0000000..22df6e0 --- /dev/null +++ b/tests/unit/tenant-liveness.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { + tenantForgotPasswordUrl, + tenantOrigin, + tenantStage, + visibleTenantStage, + type TenantStageFacts, +} from "@/lib/tenant-liveness"; + +const facts = (over: Partial = {}): TenantStageFacts => ({ + paid: true, + provisioningPrUrl: null, + registryDomain: null, + healthy: false, + ...over, +}); + +describe("tenantStage", () => { + it("says nothing before the first payment settles", () => { + // The pay button and ActivatingPanel own this moment; a third voice about the app + // next to "pay now" is noise. + expect(tenantStage(facts({ paid: false }))).toBe("none"); + expect( + tenantStage(facts({ paid: false, registryDomain: "x.sofrapiwas.com", healthy: true })), + ).toBe("none"); + }); + + it("is 'ready' only when the app was observed serving", () => { + expect(tenantStage(facts({ registryDomain: "x.sofrapiwas.com", healthy: true }))).toBe("ready"); + }); + + it("does NOT claim ready from a registry entry alone", () => { + // A merged entry means the chain started, not that the build and provision + // finished. This is the whole reason the probe exists. + expect(tenantStage(facts({ registryDomain: "x.sofrapiwas.com", healthy: false }))).toBe( + "almostReady", + ); + }); + + it("does NOT claim ready from a stale `healthy` with no registry entry", () => { + // Guards against a caller that keeps a health answer around after the entry is + // gone. `healthy` alone must never be sufficient. + expect(tenantStage(facts({ healthy: true }))).toBe("preparing"); + expect(tenantStage(facts({ healthy: true, provisioningPrUrl: "https://gh/pr/1" }))).toBe( + "settingUp", + ); + }); + + it("degrades to 'settingUp' on an open proposal, 'preparing' on nothing", () => { + expect(tenantStage(facts({ provisioningPrUrl: "https://gh/pr/1" }))).toBe("settingUp"); + expect(tenantStage(facts())).toBe("preparing"); + }); + + it("treats an unreadable registry exactly like no entry — never like a live tenant", () => { + // registryDomains() collapses a registry read failure to `null`, so this is the + // shape that reaches the classifier during an ops outage. It must fall BACKWARD. + expect( + tenantStage(facts({ registryDomain: null, provisioningPrUrl: "https://gh/pr/1", healthy: true })), + ).toBe("settingUp"); + }); +}); + +describe("tenantOrigin", () => { + it("accepts a bare host", () => { + expect(tenantOrigin("demo.sofrapiwas.com")).toBe("https://demo.sofrapiwas.com"); + expect(tenantOrigin("www.rumirestaurant.ch")).toBe("https://www.rumirestaurant.ch"); + }); + + it("rejects anything that is not a bare host", () => { + // The value reaches here from a registry entry whose slug half was typed by a + // customer. It becomes both a link we tell the owner to click and a request this + // server makes, so a scheme, credentials, port, path or query is refused outright + // rather than normalised into something that still resolves somewhere. + for (const bad of [ + "https://demo.sofrapiwas.com", + "evil.example.com/path", + "user:pass@evil.example.com", + "demo.sofrapiwas.com:8080", + "demo.sofrapiwas.com?x=1", + "localhost", + "127.0.0.1", + "10.0.0.5", + "backend", + "-leading.example.com", + "trailing-.example.com", + "", + " demo.sofrapiwas.com", + ]) { + expect(tenantOrigin(bad), bad).toBeNull(); + } + }); +}); + +describe("tenantForgotPasswordUrl", () => { + it("points at the tenant's own reset page — the O3 mechanism, on their box", () => { + expect(tenantForgotPasswordUrl("demo.sofrapiwas.com")).toBe( + "https://demo.sofrapiwas.com/forgot-password", + ); + }); + + it("is null when the origin is refused, so the panel cannot render a broken link", () => { + expect(tenantForgotPasswordUrl("evil.example.com/path")).toBeNull(); + }); +}); + +describe("visibleTenantStage", () => { + it("is a pass-through outside the mandate-lag window", () => { + for (const s of ["none", "preparing", "settingUp", "almostReady", "ready"] as const) { + expect(visibleTenantStage(s, false)).toBe(s); + } + }); + + it("silences the waiting copy that ActivatingPanel is already saying", () => { + // "We are preparing your app" under a panel that opens with "your first payment + // went through … then your app is prepared" is the same sentence twice. + expect(visibleTenantStage("preparing", true)).toBe("none"); + expect(visibleTenantStage("settingUp", true)).toBe("none"); + }); + + it("keeps the stages that are NEW information mid-activation", () => { + // ActivatingPanel cannot know the app exists; these two do, so they still speak. + expect(visibleTenantStage("almostReady", true)).toBe("almostReady"); + expect(visibleTenantStage("ready", true)).toBe("ready"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 06593be..9b51351 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -42,6 +42,7 @@ export default defineConfig({ "lib/self-serve-signup.ts", "lib/provisioning-payment-gate.ts", "lib/auto-provision-policy.ts", + "lib/tenant-liveness.ts", ], reporter: ["text-summary", "text"], // Floors sit a few points under the current 100/95/100/100 so a trivial