From 2a40cb8dc3577734d250b34df3e5ace49a15317c Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:05:57 +0200 Subject: [PATCH 1/8] feat(payments): persist the connected account we mint, before the registry PR (E1) (#227) --- lib/connect-account-store.ts | 126 ++++++++++++++++++ .../migration.sql | 85 ++++++++++++ prisma/schema.prisma | 64 +++++++++ tests/unit/connect-account-store.test.ts | 114 ++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 lib/connect-account-store.ts create mode 100644 prisma/migrations/20260905190000_stripe_connect_account/migration.sql create mode 100644 tests/unit/connect-account-store.test.ts diff --git a/lib/connect-account-store.ts b/lib/connect-account-store.ts new file mode 100644 index 0000000..ac2ee7f --- /dev/null +++ b/lib/connect-account-store.ts @@ -0,0 +1,126 @@ +// Where a MINTED Stripe connected account lives between the Stripe call and the +// registry PR that records it (ADR-011 amendment, slice E1). +// +// The gap this closes: minting is a live, billable side effect at Stripe, and +// `stripe_account:` only becomes durable when a human merges the registry PR +// that carries it. Until this module there was no Prisma column anywhere holding +// an `acct_` — the registry YAML was the only tenant -> account map — so a crash +// in that window lost a real account with nothing naming it. The write here +// happens IMMEDIATELY after the mint and BEFORE the PR is composed. +// +// Two guards, layered, because neither is enough alone: +// +// 1. The Stripe `Idempotency-Key`, derived from the slug. MEASURED 2026-09-05 +// on the test platform (acct_1TpwTNCAHTt6eZ8i, every account deleted after): +// the same key with the same body returns the SAME account, a key differing +// by one character returns a DIFFERENT one (the negative control), and the +// same key with a changed body is a hard 400. So a replay after a crash +// RECOVERS the account instead of minting a second one. +// 2. `StripeConnectAccount.tenantSlug @unique`. Stripe expires an idempotency +// key after about 24 hours, so guard 1 has a fuse; this one does not. +// +// Split from the code that CALLS Stripe (lib/stripe-connect-accounts.ts) for the +// reason lib/stripe-fee-earned.ts splits its own pure halves out: the two +// decisions that are easy to get wrong — what the key is, and what the write is +// keyed on — are then decidable by a unit test with no DB and no network. + +import { db } from "@/lib/db"; + +/** + * The registry slug grammar, restated from `provisionSchema` rather than + * imported, because this is a REFUSAL and not a form check: the schema validates + * what a founder typed, while this validates what we are about to send to Stripe + * as an idempotency key. A key built from an empty or exotic string still looks + * like a key, and Stripe would accept it happily. + */ +const SLUG = /^[a-z0-9][a-z0-9-]{1,30}$/; + +/** + * The account-minting convention this key belongs to. Bumping it is how a + * DELIBERATE second account is asked for; nothing else may change the key, + * because a changed key is a new live account (measured — see the header). + */ +export const CONNECT_KEY_SUFFIX = "connect-express-v1"; + +/** + * The `Idempotency-Key` for minting this tenant's connected account. + * + * Derived from the slug and from nothing else — not from a timestamp, not from a + * random id, not from the payload — because the whole point is that a SECOND + * attempt, in a later process, after a crash, computes the same string. It + * mirrors the convention the runbook already used by hand + * (`-connect-standard-v1`), one account type over. + * + * @throws if the slug is not registry-shaped. Refusing is the only safe answer: + * a key built from a blank slug is `-connect-express-v1`, which is a perfectly + * valid key that every blank-slug caller would then share — one Stripe account + * handed to whichever tenant asked second. + */ +export function connectExpressIdempotencyKey(slug: string): string { + if (!SLUG.test(slug)) { + throw new Error(`refusing to mint a connected account for a non-registry slug: ${JSON.stringify(slug)}`); + } + return `${slug}-${CONNECT_KEY_SUFFIX}`; +} + +/** One row of `StripeConnectAccount`, exactly as the database takes it. */ +export type ConnectAccountRow = { + tenantSlug: string; + stripeAccountId: string; + idempotencyKey: string; + country: string; +}; + +/** + * The WRITE, described as data so its anchor is assertable without a database. + * + * `where` is keyed on `stripeAccountId`, so replaying a mint (which returns the + * same account) updates nothing rather than inserting a twin. The OTHER unique + * column, `tenantSlug`, is deliberately left to fail: if this row's slug already + * has a DIFFERENT account, that means a second live account was minted for one + * restaurant, and the correct behaviour is a loud P2002 that reaches a human — + * not an upsert that quietly repoints the tenant and abandons the first account. + * + * `update` is empty ON PURPOSE, the same as `feeEarnedUpsert`: a replay is the + * same account with the same country under the same key, so it has nothing new + * to say and must not be able to restate the row. + */ +export type ConnectAccountUpsert = { + where: { stripeAccountId: string }; + create: ConnectAccountRow; + update: Record; +}; + +export function connectAccountUpsert(row: ConnectAccountRow): ConnectAccountUpsert { + return { where: { stripeAccountId: row.stripeAccountId }, create: row, update: {} }; +} + +/** + * The account already minted for this slug, or null. + * + * Asked BEFORE Stripe on the mint path, so the ordinary re-run costs one indexed + * read instead of a network call — and so the answer stays right after the + * 24-hour idempotency window has closed, which is the only window where Stripe + * itself would answer wrongly. + */ +export async function findConnectAccountForSlug(slug: string): Promise { + const row = await db.stripeConnectAccount.findUnique({ + where: { tenantSlug: slug }, + select: { tenantSlug: true, stripeAccountId: true, idempotencyKey: true, country: true }, + }); + return row; +} + +/** + * Record a minted account. Called immediately after `POST /v1/accounts` returns + * and before the registry PR is composed. + * + * Deliberately NOT swallowing errors. Its callers already own the + * "never throw at a webhook" rule and turn a failure into a reported outcome; a + * store that hid a failed write would leave the caller believing the account is + * recorded when it is not, which is the exact state this table exists to prevent. + */ +export async function recordConnectAccount(row: ConnectAccountRow): Promise { + await db.stripeConnectAccount.upsert(connectAccountUpsert(row)); + return row; +} diff --git a/prisma/migrations/20260905190000_stripe_connect_account/migration.sql b/prisma/migrations/20260905190000_stripe_connect_account/migration.sql new file mode 100644 index 0000000..cba1653 --- /dev/null +++ b/prisma/migrations/20260905190000_stripe_connect_account/migration.sql @@ -0,0 +1,85 @@ +-- The Stripe connected account this platform MINTED for a tenant (ADR-011 +-- amendment, slice E1). ADR-011 always said each restaurant becomes a connected +-- account via Express onboarding; the implementation shipped Standard accounts +-- created by hand with `curl` (docs/runbooks/signup-to-live-tenant.md §2b.1) and +-- hand-typed into a registry PR. The control plane now mints them, and this +-- table is what makes that safe to do at all. +-- +-- WHY IT EXISTS. Minting is a live, billable, externally-visible side effect at +-- Stripe. The registry PR that records `stripe_account:` is composed AFTERWARDS +-- and merged by a human minutes or days later. Until this table there was no +-- Prisma column anywhere holding an `acct_` — the registry YAML was the only +-- tenant -> account map — so a crash in that window lost a real, possibly +-- undeletable Stripe account with nothing anywhere naming it. This row is +-- written IMMEDIATELY after the mint and BEFORE the PR is composed. +-- +-- ADDITIVE ONLY, and safe on a DB with live rows: one new table and nothing +-- else. No existing column is retyped, no constraint is dropped, no row changes +-- meaning, and nothing is backfilled — there is nothing to backfill, because no +-- tenant has a `stripe_account` today (`grep -n "^ *stripe_account:" +-- deploy/tenants/registry.yml` returns nothing, exit 1, while the same-shaped +-- `grep -c "^ *modules:"` on the same file returns 6, so the instrument +-- discriminates and the zero is real). The migration cost of the account-type +-- change is exactly zero. +-- +-- A DEDICATED TABLE, not a column on "TenantBilling", for three reasons: +-- 1. "TenantBilling" exists only for a tenant that has a BILLING PLAN. The +-- founder path (/admin/provision) stands a tenant up with no such row at +-- all and it mints accounts too, so a column there could not hold them. +-- 2. This is the third table of a shape this app already has twice — +-- "StripeFeeRefund" (20260904120000) and "StripeApplicationFee" +-- (20260905000000): keyed on a Stripe id, joined back to a tenant at READ +-- time, never by a foreign key (ADR-007, the registry is the source of +-- truth and this app never writes to it). +-- 3. An account has its own lifecycle facts to record later (onboarding +-- progress, capabilities) which have nothing to do with a subscription. +-- +-- THREE UNIQUE COLUMNS, and they are not redundant. +-- +-- "tenantSlug" is the DURABLE one-account-per-tenant guard. It is what still +-- holds tomorrow: Stripe expires an idempotency key after about 24 hours +-- (documented by Stripe, not measurable from here), so a replay a day later +-- would otherwise mint a second live account for the same restaurant. +-- +-- "stripeAccountId" is the idempotency anchor for everything written ABOUT the +-- account afterwards, the same role "StripeApplicationFee"."applicationFeeId" +-- plays: a repeated delivery of the same fact upserts on it and so can restate +-- the row but never split it in two. +-- +-- "idempotencyKey" records the `Idempotency-Key` the mint was actually sent +-- with (`-connect-express-v1`), rather than leaving it merely derivable. +-- MEASURED 2026-09-05 against the test platform acct_1TpwTNCAHTt6eZ8i, with a +-- negative control, all accounts deleted afterwards (GET -> 403): +-- * same key + same body -> the SAME account (acct_1UCOkhCSPiP2JWOQ, twice) +-- * key changed by one char -> a DIFFERENT account (acct_1UCOkpFoEuSsk6t2) +-- * same key + changed body -> HTTP 400 "Keys for idempotent requests can only +-- be used with the same parameters they were first used with" +-- So the key IS the mechanism, the prefill payload is part of it, and a change +-- of convention has to be visible in the data rather than only in the code that +-- happens to be deployed. A future `-v2` is then readable as a `-v2`. +-- +-- "country" is recorded rather than re-read because Stripe fixes it at creation +-- (as it fixes the account type): it is the field a wrong tenant address would +-- have silently baked into a live account. +-- +-- No index beyond the three unique constraints. Every read is by one of them — +-- by slug when provisioning asks "does this tenant already have an account", +-- by `acct_` when Stripe tells us something about one. + +CREATE TABLE "StripeConnectAccount" ( + "id" TEXT NOT NULL, + "tenantSlug" TEXT NOT NULL, + "stripeAccountId" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "country" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StripeConnectAccount_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "StripeConnectAccount_tenantSlug_key" + ON "StripeConnectAccount"("tenantSlug"); +CREATE UNIQUE INDEX "StripeConnectAccount_stripeAccountId_key" + ON "StripeConnectAccount"("stripeAccountId"); +CREATE UNIQUE INDEX "StripeConnectAccount_idempotencyKey_key" + ON "StripeConnectAccount"("idempotencyKey"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 784420e..ecfb2a7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -916,3 +916,67 @@ model StripeApplicationFee { @@index([connectedAccountId, feeCreatedAt]) @@index([chargeId]) } + +/// The Stripe CONNECTED ACCOUNT this platform minted for a tenant (ADR-011 +/// amendment: tenants get Express connected accounts, which the control plane +/// creates — the founder no longer `curl`s one by hand). +/// +/// It exists because of a gap with no other floor under it. Minting is a live, +/// billable, externally-visible side effect; the registry PR that records +/// `stripe_account:` is composed AFTERWARDS and merged by a human minutes or +/// days later. A crash in between used to lose a real Stripe account with +/// nothing anywhere naming it. This row is written IMMEDIATELY after the mint, +/// before the PR is composed, so the account is ours again the moment it exists. +/// +/// A dedicated model rather than a column on TenantBilling, for three reasons: +/// 1. TenantBilling exists only for a tenant with a BILLING PLAN. The founder +/// path (/admin/provision) stands a tenant up with no TenantBilling row at +/// all, and it mints accounts too — a column there could not hold them. +/// 2. It is the third table of a shape this app already has twice +/// (StripeFeeRefund, StripeApplicationFee): keyed on a Stripe id, joined +/// back to a tenant at READ time, never an FK (ADR-007). +/// 3. An account has its own lifecycle facts to record (onboarding progress, +/// capabilities) that have nothing to do with a Mollie subscription. +model StripeConnectAccount { + id String @id @default(cuid()) + + /// The registry slug this account was minted FOR. @unique is the durable + /// "one account per tenant" guard — the one that still holds after Stripe's + /// idempotency key has expired (see `idempotencyKey`). NOT a foreign key, same + /// seam as TenantBilling.tenantSlug and StripeApplicationFee.connectedAccountId + /// (ADR-007): the registry is the source of truth and this app never writes it. + tenantSlug String @unique + + /// Stripe's own account id (`acct_...`). @unique for the reason + /// StripeApplicationFee.applicationFeeId is: it is the anchor a repeated + /// delivery of the same fact upserts on, so a second write about one account + /// can restate the row but can never split it in two. + stripeAccountId String @unique + + /// The `Idempotency-Key` the mint was sent with (`-connect-express-v1`), + /// recorded rather than only derived. Two facts make it worth a column. + /// + /// MEASURED 2026-09-05, test platform acct_1TpwTNCAHTt6eZ8i: the same key with + /// the same body returns the SAME account (acct_1UCOkh… twice); a key differing + /// in one character returns a DIFFERENT one (acct_1UCOkp…, the negative + /// control, both since deleted); and the same key with a CHANGED body is a + /// hard `400 "Keys for idempotent requests can only be used with the same + /// parameters they were first used with"`. So the key is the mechanism, and + /// the prefill payload is part of it. + /// + /// Stripe expires an idempotency key after ~24h (documented, not measurable + /// here), which is exactly why it is not the only guard: after that window + /// `tenantSlug @unique` above is what stops a second account, and this column + /// says which convention minted the row if the key ever needs a `-v2`. + idempotencyKey String @unique + + /// ISO-3166-1 alpha-2, as sent to Stripe. Immutable at Stripe after creation + /// (as is the account TYPE), which is why it is recorded rather than re-read: + /// it is the field a wrong tenant address would have silently baked in. + country String + + /// When WE recorded it. Deliberately not "when Stripe created it": the two are + /// seconds apart and nothing here periodises on this, so a second clock would + /// be a field that can only ever disagree. + createdAt DateTime @default(now()) +} diff --git a/tests/unit/connect-account-store.test.ts b/tests/unit/connect-account-store.test.ts new file mode 100644 index 0000000..03481f7 --- /dev/null +++ b/tests/unit/connect-account-store.test.ts @@ -0,0 +1,114 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + connectAccountUpsert, + connectExpressIdempotencyKey, + type ConnectAccountRow, +} from "@/lib/connect-account-store"; + +// The two decisions this slice is FOR: what the idempotency key is, and what the +// write is keyed on. Both are pure, so both are decidable here — which is the +// point of splitting them out of the module that calls Stripe. + +describe("connectExpressIdempotencyKey", () => { + it("is exactly `-connect-express-v1`", () => { + // Pinned as a literal, not composed from the same constant the code uses: a + // test that rebuilds the key the way the source does cannot notice the key + // changing. MEASURED consequence of it changing (2026-09-05, test platform): + // one altered character minted a SECOND live Stripe account. + expect(connectExpressIdempotencyKey("rumi")).toBe("rumi-connect-express-v1"); + }); + + it("depends on the slug and on nothing else, so a retry after a crash recomputes it", () => { + // No clock, no random, no payload. This is the whole recovery mechanism: a + // later process, in a later container, must arrive at the same string. + expect(connectExpressIdempotencyKey("obresse")).toBe(connectExpressIdempotencyKey("obresse")); + expect(connectExpressIdempotencyKey("obresse")).not.toBe(connectExpressIdempotencyKey("obress")); + }); + + it("refuses a slug that is not registry-shaped", () => { + // A blank slug yields `-connect-express-v1`, a perfectly valid key that every + // blank-slug caller would share — i.e. one Stripe account handed to whichever + // tenant asked second. Each of these is a different way to arrive there. + for (const bad of ["", " ", "a", "RUMI", "rumi restaurant", "-rumi", "../rumi", "rumi/../x"]) { + expect(() => connectExpressIdempotencyKey(bad)).toThrow(/non-registry slug/); + } + }); + + it("accepts what the registry actually contains", () => { + // The positive control for the refusal above: a rule that refuses everything + // would pass every assertion in the previous test. + for (const ok of ["rumi", "obresse", "cafe-du-nord", "a1", "x".repeat(31)]) { + expect(connectExpressIdempotencyKey(ok)).toBe(`${ok}-connect-express-v1`); + } + expect(() => connectExpressIdempotencyKey("x".repeat(32))).toThrow(); + }); +}); + +describe("connectAccountUpsert — the anchor", () => { + const row = (over: Partial = {}): ConnectAccountRow => ({ + tenantSlug: "rumi", + stripeAccountId: "acct_1UCOkhCSPiP2JWOQ", + idempotencyKey: "rumi-connect-express-v1", + country: "CH", + ...over, + }); + + it("keys the write on stripeAccountId and on nothing else", () => { + // A replay of the mint returns the SAME account, so the write must collide + // on the account id. Keyed on the slug instead, a second account minted by + // mistake would silently REPOINT the tenant and abandon the first one — the + // worst outcome available here, because the abandoned account is live. + expect(connectAccountUpsert(row()).where).toEqual({ stripeAccountId: "acct_1UCOkhCSPiP2JWOQ" }); + }); + + it("says nothing on the replay branch", () => { + expect(connectAccountUpsert(row()).update).toEqual({}); + expect(Object.keys(connectAccountUpsert(row()).update)).toHaveLength(0); + }); + + it("creates the row it was handed, unaltered", () => { + expect(connectAccountUpsert(row()).create).toEqual(row()); + }); +}); + +// The constraints live in SQL and in schema.prisma, not in TypeScript, so no test +// over the pure functions above can see them — and the upsert is only idempotent +// BECAUSE the columns are unique. These read the declarations off disk (no DB, no +// network, in keeping with CLAUDE.md §7) so that deleting an anchor is a red test +// rather than a silent behaviour change, exactly as the StripeApplicationFee +// tests do for theirs. +const read = (path: string) => + readFileSync(fileURLToPath(new URL(`../../${path}`, import.meta.url)), "utf8"); + +describe("the StripeConnectAccount guards are declared", () => { + const model = () => + /model StripeConnectAccount \{[\s\S]*?\n\}/.exec(read("prisma/schema.prisma"))?.[0]; + + it("schema.prisma makes tenantSlug unique — the guard that outlives the idempotency key", () => { + // Stripe expires an idempotency key after ~24h. After that, this constraint + // is the only thing standing between a replay and a second live account. + expect(model()).toBeDefined(); + expect(model()).toMatch(/tenantSlug\s+String\s+@unique/); + }); + + it("schema.prisma makes stripeAccountId and idempotencyKey unique too", () => { + expect(model()).toMatch(/stripeAccountId\s+String\s+@unique/); + expect(model()).toMatch(/idempotencyKey\s+String\s+@unique/); + }); + + it("the migration creates the three unique indexes Prisma expects", () => { + // The index NAMES matter: Prisma derives `__key` and CI's drift + // check compares the two, so a differently-named index enforces the same rule + // and still fails the build. + const sql = read("prisma/migrations/20260905190000_stripe_connect_account/migration.sql"); + for (const col of ["tenantSlug", "stripeAccountId", "idempotencyKey"]) { + expect(sql).toMatch( + new RegExp( + `CREATE UNIQUE INDEX "StripeConnectAccount_${col}_key"\\s*\\n?\\s*ON "StripeConnectAccount"\\("${col}"\\)`, + ), + ); + } + }); +}); From 7e1cb32ed70d4c0f2d9a1f1656c4a573c95bf269 Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:19:30 +0200 Subject: [PATCH 2/8] feat(payments): mint a tenant Express connected account, prefilled (E2) (#228) * feat(payments): mint a tenant Express connected account, prefilled (E2) * docs(env): the platform key now needs Connect -> write, and only in the control plane (E2) * chore(payments): NON_IBAN_COUNTRIES is a Set (SonarCloud S7776) --- .env.example | 9 +- lib/connect-account-request.ts | 177 ++++++++++++++++++ lib/stripe-connect-accounts.ts | 74 ++++++++ tests/unit/connect-account-request.test.ts | 198 +++++++++++++++++++++ vitest.config.ts | 9 + 5 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 lib/connect-account-request.ts create mode 100644 lib/stripe-connect-accounts.ts create mode 100644 tests/unit/connect-account-request.test.ts diff --git a/.env.example b/.env.example index ee717b5..bfc709b 100644 --- a/.env.example +++ b/.env.example @@ -34,8 +34,15 @@ MOLLIE_API_KEY= # --- Stripe platform webhooks (POST /api/webhooks/stripe) --- # ADR-011 amendment. This app never creates Stripe charges (that's the backend, # Job B); it returns an application fee when a connected account refunds a -# charge, and it records the fees it earns. +# charge, it records the fees it earns, and since the Express migration it MINTS +# each tenant's connected account (lib/stripe-connect-accounts.ts). # Platform secret key (sk_test_/sk_live_). Unset -> the webhook 503s. +# +# SCOPES: `Application fees -> write` (the refund rail) plus, for minting, +# `Connect -> write`. That second scope is the reason this key stays in the +# CONTROL PLANE and is never the box's: `provision-tenant.sh` reads +# STRIPE_PLATFORM_API_KEY on the box, and a key that can mint connected accounts +# is a higher-value target than any box. Do NOT add `Connect -> write` there. STRIPE_API_KEY= # ONE url, TWO Stripe endpoints, one handler — because the two event scopes are # ORTHOGONAL and Stripe will not merge them. Each endpoint has its own whsec_; diff --git a/lib/connect-account-request.ts b/lib/connect-account-request.ts new file mode 100644 index 0000000..ccd2ed3 --- /dev/null +++ b/lib/connect-account-request.ts @@ -0,0 +1,177 @@ +// What we send Stripe to MINT a tenant's Express connected account, decided +// without calling anything (ADR-011 amendment, slice E2). +// +// Split from the module that performs the call (lib/stripe-connect-accounts.ts) +// for the reason vies-result.ts is split from vies.ts: the part that is easy to +// get wrong here is the PAYLOAD, and a payload is decidable by a unit test while +// a network call is not. Three of its decisions are load-bearing enough that +// each has a measurement behind it. +// +// MEASURED 2026-09-05, Stripe TEST mode, platform acct_1TpwTNCAHTt6eZ8i. Every +// account created was deleted afterwards (GET -> 403). +// +// 1. **The three capabilities go in ONE create call.** `card_payments`, +// `transfers` and `twint_payments` requested together -> 200 with all three +// `inactive` (i.e. requested and pending onboarding). The runbook already +// records why this is not a preference: omitting them fails QUIETLY (§2b.1), +// and `card_payments` is refused without `transfers`. Asking on a non-CH +// account is harmless — an FR Express account took `twint_payments` and +// reported it `inactive` / `requirements.fields_needed`, NOT a refusal — so +// the list does not need to be country-conditional, and a conditional list +// is one more thing that can silently omit a capability. +// 2. **Prefill is create-only.** The same fields are refused on UPDATE with +// `403 oauth_not_supported`, so this call is the only chance. A bare CH +// Express account has 16 `currently_due`; with business_profile + email + +// IBAN and no business type it has 13; with `business_type=individual` plus +// the person's name and address it has 6 (dob x3, phone, tos.date, tos.ip). +// 3. **An address is not a neutral prefill.** `individual[...]` without +// `business_type` is a hard `400 "The business_type must be provided when +// sending either of individual or company parameters"`, and `business_type` +// is one of the fields UPDATE refuses. So the address lives NESTED under +// `individual` here rather than as a free-standing field: passing it is a +// commitment that the account holder is a natural person, and a restaurant +// that turns out to be a company cannot be corrected through the API. + +/** + * The countries this platform will mint a connected account for, mapped to the + * currency an external bank account in that country is denominated in. + * + * The list is exactly the one `docs/runbooks/signup-to-live-tenant.md` §2b.1 + * already offered the founder, one account type over. It is an ALLOWLIST rather + * than a "not TR" denylist on purpose: a country we have not thought about + * announces itself as a refusal a founder can read, whereas a denylist admits it + * silently and discovers the problem at Stripe, or later at the restaurant. + * + * MEASURED: Stripe refuses TR itself — `400 country_unsupported "TR is not + * currently supported by Stripe"`. That is the backstop, not the gate: we refuse + * BEFORE the call so the message names the tenant and no half-built account can + * exist. + */ +export const CONNECT_ONBOARDABLE_COUNTRIES: Readonly> = { + CH: "chf", + FR: "eur", + DE: "eur", + NL: "eur", + IT: "eur", + ES: "eur", + BE: "eur", + AT: "eur", + GB: "gbp", + US: "usd", + AE: "aed", +}; + +/** Countries in the list above whose bank accounts are NOT identified by an IBAN. */ +const NON_IBAN_COUNTRIES = new Set(["US"]); + +/** Eating places / restaurants. Every tenant of this platform is one. */ +export const RESTAURANT_MCC = "5812"; + +export class UnsupportedConnectCountryError extends Error { + readonly country: string; + constructor(country: string) { + super( + `Stripe Connect onboarding is not offered for country ${JSON.stringify(country)} — ` + + `supported: ${Object.keys(CONNECT_ONBOARDABLE_COUNTRIES).join(", ")}. ` + + `TR in particular is refused by Stripe itself (country_unsupported).`, + ); + this.country = country; + } +} + +export type ConnectPostalAddress = { + line1: string; + city: string; + postalCode: string; +}; + +export type ExpressAccountInput = { + /** Registry slug — the metadata tag and the idempotency-key seed. */ + slug: string; + /** ISO-3166-1 alpha-2, upper case. Fixed at Stripe forever after this call. */ + country: string; + /** The RESTAURANT's own address, never a Sofra one: Stripe mails it. */ + email: string; + /** The restaurant's trading name -> `business_profile[name]`. */ + name: string; + /** The tenant's public URL -> `business_profile[url]`. */ + url: string; + /** + * The natural person who owns the account. Nested rather than flat because + * sending ANY `individual[...]` field commits `business_type=individual` + * (measured: a 400 otherwise), and `business_type` is refused on update + * (403 `oauth_not_supported`). So this is a claim, not a hint. + */ + individual?: { + firstName?: string; + lastName?: string; + address?: ConnectPostalAddress; + }; + /** The restaurant's IBAN, when we hold one -> `external_account`. */ + iban?: string; +}; + +/** + * Build the `POST /v1/accounts` form. Pure: no clock, no network, no env. + * + * Everything optional is OMITTED when absent rather than sent empty — Stripe + * treats an empty string as a value, and a blank `business_profile[url]` is a + * field the restaurant then has to clear by hand in the hosted flow. + * + * @throws UnsupportedConnectCountryError for a country we do not onboard, and a + * plain Error for an IBAN in a country that does not use one (US). Both are + * refusals rather than silent drops, because a dropped prefill is invisible + * until the restaurant is asked for a field we already had. + */ +export function expressAccountForm(input: ExpressAccountInput): Record { + const country = input.country.trim().toUpperCase(); + const currency = CONNECT_ONBOARDABLE_COUNTRIES[country]; + if (!currency) throw new UnsupportedConnectCountryError(input.country); + + const form: Record = { + // The account TYPE, and it is immutable: Stripe offers no conversion, which + // is why this migration is cheap only while no tenant has an account. + type: "express", + country, + email: input.email.trim(), + "business_profile[name]": input.name.trim(), + "business_profile[mcc]": RESTAURANT_MCC, + "business_profile[url]": input.url.trim(), + // All three, unconditionally. See the header: omitting one fails quietly, + // `card_payments` is refused without `transfers`, and asking for TWINT + // outside CH is answered `inactive`, not refused. + "capabilities[card_payments][requested]": "true", + "capabilities[transfers][requested]": "true", + "capabilities[twint_payments][requested]": "true", + // The one field that lets a live account be traced back to a tenant when the + // registry PR was never merged — i.e. exactly the crash this slice's sibling + // table (StripeConnectAccount) exists for. Belt and braces, and free. + "metadata[sofra_tenant]": input.slug, + }; + + const person = input.individual; + if (person && (person.firstName || person.lastName || person.address)) { + form.business_type = "individual"; + if (person.firstName) form["individual[first_name]"] = person.firstName.trim(); + if (person.lastName) form["individual[last_name]"] = person.lastName.trim(); + if (person.address) { + form["individual[address][line1]"] = person.address.line1.trim(); + form["individual[address][city]"] = person.address.city.trim(); + form["individual[address][postal_code]"] = person.address.postalCode.trim(); + form["individual[address][country]"] = country; + } + } + + const iban = input.iban?.replace(/\s+/g, "").toUpperCase(); + if (iban) { + if (NON_IBAN_COUNTRIES.has(country)) { + throw new Error(`an IBAN was supplied for ${country}, whose bank accounts Stripe identifies by routing number`); + } + form["external_account[object]"] = "bank_account"; + form["external_account[country]"] = country; + form["external_account[currency]"] = currency; + form["external_account[account_number]"] = iban; + } + + return form; +} diff --git a/lib/stripe-connect-accounts.ts b/lib/stripe-connect-accounts.ts new file mode 100644 index 0000000..ddc09f7 --- /dev/null +++ b/lib/stripe-connect-accounts.ts @@ -0,0 +1,74 @@ +// Minting a tenant's Express connected account (ADR-011 amendment, slice E2). +// +// This is the code that replaces the founder's hand-run `curl` in +// docs/runbooks/signup-to-live-tenant.md §2b.1. It runs in the CONTROL PLANE and +// nowhere else: `provision-tenant.sh` keeps reading the registry as the source of +// truth (ADR-003/ADR-007) and needs no new capability, and the box key +// (STRIPE_PLATFORM_API_KEY) must never gain `Connect -> write` — a control plane +// that can mint connected accounts is a higher-value target than any box, and +// that is an argument for keeping the power in one place, not for spreading it. +// +// The whole file is orchestration. Both decisions worth testing live next door +// and are pure: the payload (lib/connect-account-request.ts) and the idempotency +// key (lib/connect-account-store.ts). What is left here is the ORDER, and the +// order is the safety property: +// +// 1. derive the idempotency key -> refuses a non-registry slug, offline +// 2. build the form -> refuses an unsupported country, offline +// 3. read our own row -> a tenant already minted for is answered +// without a network call, and stays answered +// after Stripe's ~24h key window has closed +// 4. POST /v1/accounts -> the only side effect +// 5. record the row -> immediately, BEFORE any registry PR is +// composed, because that is the window a +// crash used to lose a live account in +// +// There is deliberately NO update path. Measured: `business_type`, +// `individual[...]`, `external_account[...]` and `email` are all refused on +// `POST /v1/accounts/{id}` with `403 oauth_not_supported`. Prefill is one-shot. + +import { + connectExpressIdempotencyKey, + findConnectAccountForSlug, + recordConnectAccount, +} from "@/lib/connect-account-store"; +import { expressAccountForm, type ExpressAccountInput } from "@/lib/connect-account-request"; +import { stripePost } from "@/lib/stripe"; + +/** The slice of Stripe's Account object this module reads. */ +type StripeAccountCreated = { id: string }; + +export type MintedConnectAccount = { + /** `acct_...` */ + stripeAccountId: string; + /** + * True when the account already existed for this slug and no Stripe call was + * made. Reported rather than hidden because the caller's audit line should say + * which of the two happened — "minted" and "already had one" are different + * facts about a live payment account. + */ + reused: boolean; +}; + +export async function createExpressAccount(input: ExpressAccountInput): Promise { + const idempotencyKey = connectExpressIdempotencyKey(input.slug); + const form = expressAccountForm(input); + + const existing = await findConnectAccountForSlug(input.slug); + if (existing) return { stripeAccountId: existing.stripeAccountId, reused: true }; + + const account = await stripePost("/v1/accounts", form, { idempotencyKey }); + + // Not in a transaction with the call above, because there is no such thing: the + // account exists at Stripe the moment it returns. What makes the gap survivable + // is that the key is derived from the slug, so a replay recovers THIS account + // rather than minting a second one (measured 2026-09-05, with a negative control). + await recordConnectAccount({ + tenantSlug: input.slug, + stripeAccountId: account.id, + idempotencyKey, + country: form.country, + }); + + return { stripeAccountId: account.id, reused: false }; +} diff --git a/tests/unit/connect-account-request.test.ts b/tests/unit/connect-account-request.test.ts new file mode 100644 index 0000000..a250901 --- /dev/null +++ b/tests/unit/connect-account-request.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { + CONNECT_ONBOARDABLE_COUNTRIES, + RESTAURANT_MCC, + UnsupportedConnectCountryError, + expressAccountForm, + type ExpressAccountInput, +} from "@/lib/connect-account-request"; +import { createExpressAccount } from "@/lib/stripe-connect-accounts"; + +// Every expectation below is pinned to a MEASUREMENT against Stripe TEST mode on +// 2026-09-05 (platform acct_1TpwTNCAHTt6eZ8i; every probe account deleted after, +// GET -> 403), not to the documentation. + +const input = (over: Partial = {}): ExpressAccountInput => ({ + slug: "rumi", + country: "CH", + email: "owner@example.com", + name: "RUMI Restaurant", + url: "https://rumi.sofrapiwas.com", + ...over, +}); + +describe("expressAccountForm — the capabilities", () => { + it("requests card_payments, transfers AND twint_payments in the one create call", () => { + // The runbook's §2b.1 warning, and the reason this is three unconditional + // lines: omitting a capability fails QUIETLY — a 200 carrying an account + // that can never take a card. `card_payments` is additionally refused + // without `transfers`, and prefill is create-only, so there is no second + // chance to ask. + const form = expressAccountForm(input()); + expect(form["capabilities[card_payments][requested]"]).toBe("true"); + expect(form["capabilities[transfers][requested]"]).toBe("true"); + expect(form["capabilities[twint_payments][requested]"]).toBe("true"); + }); + + it("asks for TWINT outside Switzerland too", () => { + // MEASURED: an FR Express account created with twint_payments requested + // returned 200 and reported that capability `inactive` / + // `requirements.fields_needed` — NOT a refusal. So the list stays + // unconditional, and no country branch can silently drop a capability. + expect(expressAccountForm(input({ country: "FR" }))["capabilities[twint_payments][requested]"]).toBe("true"); + }); +}); + +describe("expressAccountForm — the account itself", () => { + it("is an EXPRESS account and normalises the country", () => { + // The type is immutable at Stripe — there is no conversion — which is what + // makes this migration cheap only while no tenant has an account. + const form = expressAccountForm(input({ country: "ch" })); + expect(form.type).toBe("express"); + expect(form.country).toBe("CH"); + }); + + it("tags the account with the tenant slug", () => { + // The only field that can trace a LIVE account back to a tenant when the + // registry PR was never merged. + expect(expressAccountForm(input())["metadata[sofra_tenant]"]).toBe("rumi"); + }); + + it("prefills the business profile every restaurant has", () => { + const form = expressAccountForm(input()); + expect(form["business_profile[name]"]).toBe("RUMI Restaurant"); + expect(form["business_profile[mcc]"]).toBe(RESTAURANT_MCC); + expect(form["business_profile[mcc]"]).toBe("5812"); + expect(form["business_profile[url]"]).toBe("https://rumi.sofrapiwas.com"); + expect(form.email).toBe("owner@example.com"); + }); +}); + +describe("expressAccountForm — the country boundary", () => { + it("refuses TR, which Stripe itself refuses", () => { + // MEASURED: `POST /v1/accounts country=TR` -> 400 `country_unsupported`, + // "TR is not currently supported by Stripe". We refuse BEFORE the call so + // the message names the tenant and no half-built account can exist. + expect(() => expressAccountForm(input({ country: "TR" }))).toThrow(UnsupportedConnectCountryError); + expect(() => expressAccountForm(input({ country: "tr" }))).toThrow(/TR/); + }); + + it("refuses anything else it has not been told about", () => { + // An allowlist, not a "not TR" denylist: a country nobody thought about + // announces itself as a readable refusal instead of being discovered at + // Stripe, or later at the restaurant. + for (const bad of ["", "CHE", "XX", "SW", "JP"]) { + expect(() => expressAccountForm(input({ country: bad }))).toThrow(UnsupportedConnectCountryError); + } + }); + + it("accepts every country the runbook offers", () => { + // The positive control for the two refusals above: a rule that refused + // everything would pass both of them. + for (const country of Object.keys(CONNECT_ONBOARDABLE_COUNTRIES)) { + expect(expressAccountForm(input({ country })).country).toBe(country); + } + expect(Object.keys(CONNECT_ONBOARDABLE_COUNTRIES)).toContain("CH"); + }); +}); + +describe("expressAccountForm — business_type is a commitment, not a hint", () => { + it("sends no business_type and no individual fields when we know no person", () => { + // MEASURED both ways. Without a business type a prefilled CH account has 13 + // `currently_due`; with `business_type=individual` plus name and address it + // has 6. But `business_type` is refused on UPDATE (403 oauth_not_supported), + // so guessing "individual" for what turns out to be a company is a live + // account that cannot be corrected through the API. 13 beats wrong. + const form = expressAccountForm(input()); + expect(form.business_type).toBeUndefined(); + expect(Object.keys(form).some((k) => k.startsWith("individual["))).toBe(false); + }); + + it("sends business_type=individual as soon as any individual field is given", () => { + // MEASURED: `individual[...]` without `business_type` is a hard 400 — "The + // business_type must be provided when sending either of individual or + // company parameters". So the two travel together or not at all. + const form = expressAccountForm( + input({ + individual: { + firstName: "Ada", + lastName: "Lovelace", + address: { line1: "Rue du Test 1", city: "Geneve", postalCode: "1201" }, + }, + }), + ); + expect(form.business_type).toBe("individual"); + expect(form["individual[first_name]"]).toBe("Ada"); + expect(form["individual[last_name]"]).toBe("Lovelace"); + expect(form["individual[address][line1]"]).toBe("Rue du Test 1"); + expect(form["individual[address][city]"]).toBe("Geneve"); + expect(form["individual[address][postal_code]"]).toBe("1201"); + }); + + it("puts the ACCOUNT's country on the individual address", () => { + // The address has no country of its own in the input on purpose: a person + // resident in a different country from the account is a case Stripe asks + // about in its own flow, and inventing an answer here would prefill a wrong + // one into a field that cannot be updated. + const form = expressAccountForm( + input({ country: "fr", individual: { address: { line1: "1 rue", city: "Lyon", postalCode: "69001" } } }), + ); + expect(form["individual[address][country]"]).toBe("FR"); + expect(form.business_type).toBe("individual"); + }); + + it("does not commit a business type for an empty individual block", () => { + expect(expressAccountForm(input({ individual: {} })).business_type).toBeUndefined(); + }); +}); + +describe("expressAccountForm — the IBAN", () => { + it("attaches it as the external account, in the country's own currency", () => { + // MEASURED: an IBAN passed as `external_account` on create returns + // `external_accounts.total_count: 1` — the payout destination is prefilled + // and the restaurant never types it. This is the answer to "can we just + // collect the IBAN": yes, and only here. + const form = expressAccountForm(input({ iban: "CH93 0076 2011 6238 5295 7" })); + expect(form["external_account[object]"]).toBe("bank_account"); + expect(form["external_account[country]"]).toBe("CH"); + expect(form["external_account[currency]"]).toBe("chf"); + expect(form["external_account[account_number]"]).toBe("CH9300762011623852957"); + }); + + it("denominates the account in the country's currency, not always CHF", () => { + expect(expressAccountForm(input({ country: "FR", iban: "FR7630006000011234567890189" }))["external_account[currency]"]).toBe("eur"); + expect(expressAccountForm(input({ country: "GB", iban: "GB33BUKB20201555555555" }))["external_account[currency]"]).toBe("gbp"); + }); + + it("sends no external_account at all when we hold no IBAN", () => { + // Not an empty one: Stripe reads an empty string as a value, and a blank + // payout destination is a field the restaurant then has to clear by hand. + const form = expressAccountForm(input()); + expect(Object.keys(form).some((k) => k.startsWith("external_account"))).toBe(false); + }); + + it("refuses an IBAN for a country that does not use one", () => { + // Dropping it silently is the failure mode this whole module is written + // against: an invisible non-prefill, discovered when the restaurant is asked + // for something we already had. + expect(() => expressAccountForm(input({ country: "US", iban: "CH9300762011623852957" }))).toThrow(/routing number/); + }); +}); + +describe("createExpressAccount — the order of its refusals", () => { + // These assert something no pure test can: that the offline refusals happen + // BEFORE the database read and before the Stripe call. If either refusal moved + // below them, these tests would reach a real network/DB in a suite that has + // neither and would fail — which is the point. + it("refuses a non-registry slug before anything else", async () => { + await expect(createExpressAccount({ slug: "", country: "CH", email: "a@example.com", name: "n", url: "https://x" })).rejects.toThrow( + /non-registry slug/, + ); + }); + + it("refuses an unsupported country before anything else", async () => { + await expect( + createExpressAccount({ slug: "rumi", country: "TR", email: "a@example.com", name: "n", url: "https://x" }), + ).rejects.toThrow(UnsupportedConnectCountryError); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index e1e7593..18afa47 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -198,6 +198,15 @@ export default defineConfig({ // file rather than by this list. "lib/commission-earnings.ts", "lib/stripe-webhook-secrets.ts", + // ADR-011 amendment E2 — what we send Stripe to MINT a connected account. + // Pure by construction (no clock, no env, no network), and in scope because + // every branch in it is a field that cannot be corrected afterwards: the + // capability list fails QUIETLY when short, and `business_type`, + // `individual[...]`, `external_account[...]` and `email` are all refused on + // update with 403 oauth_not_supported. Its Stripe-calling sibling + // `lib/stripe-connect-accounts.ts` stays OUT, the same split as + // vies/vies-result and stripe-fee-refund's. + "lib/connect-account-request.ts", ], reporter: ["text-summary", "text"], // Floors sit a few points under the current 100/95/100/100 so a trivial From 855d8a809da84fc0a4ddee4dcf5201c714b07f65 Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:34:32 +0200 Subject: [PATCH 3/8] feat(payments): the platform mints the connected account, so one registry PR carries both halves (E3) (#229) --- components/control/ProvisionForm.tsx | 28 +++---- lib/actions/provisioning-actions.ts | 8 +- lib/auto-provision-policy.ts | 12 ++- lib/auto-provision.ts | 63 +++++++-------- lib/connect-account-country.ts | 62 ++++++++++++++ lib/provision-form-input.ts | 9 ++- lib/provisioning-mint.ts | 94 ++++++++++++++++++++++ lib/provisioning-module-pairing.ts | 40 ++++----- lib/provisioning-pr-blocks.ts | 43 ++++++---- lib/provisioning-pr-body.ts | 2 +- lib/provisioning-registry.ts | 15 +++- lib/provisioning.ts | 52 ++++++++++-- lib/validation-provision.ts | 34 +++++--- messages/ar.json | 9 +-- messages/de.json | 9 +-- messages/en.json | 9 +-- messages/fr.json | 9 +-- messages/nl.json | 9 +-- messages/tr.json | 9 +-- tests/unit/connect-account-country.test.ts | 51 ++++++++++++ tests/unit/provision-form-input.test.ts | 41 +++++++--- tests/unit/provisioning-mint.test.ts | 57 +++++++++++++ tests/unit/provisioning-registry.test.ts | 14 +++- tests/unit/validation.test.ts | 34 ++++---- vitest.config.ts | 5 ++ 25 files changed, 545 insertions(+), 173 deletions(-) create mode 100644 lib/connect-account-country.ts create mode 100644 lib/provisioning-mint.ts create mode 100644 tests/unit/connect-account-country.test.ts create mode 100644 tests/unit/provisioning-mint.test.ts diff --git a/components/control/ProvisionForm.tsx b/components/control/ProvisionForm.tsx index 2206dca..3a92c5b 100644 --- a/components/control/ProvisionForm.tsx +++ b/components/control/ProvisionForm.tsx @@ -103,23 +103,17 @@ export default function ProvisionForm({ aria-label={t("provision.currency")} className="input-primary" /> - {/* Optional, and deliberately NOT prefilled from a signup: a lead has no connected - account (only the restaurant can create one, via Stripe's hosted onboarding). - It is here for the founder path, where runbook §2b creates the account BEFORE - proposing — with it the entry carries `online-payments` in one shot, without it - the generator holds the module back rather than proposing an entry that - provision-tenant.sh refuses. */} - + {/* NOT an input any more (ADR-011 amendment). The premise this field rested on — + "only the restaurant can create a connected account, and it cannot be + pre-filled" — was measured false at CREATE time, so the control plane mints the + account itself before this proposal is composed and the entry carries + `online-payments` AND `stripe_account:` in one commit. Left as a read-only + sentence rather than deleted: the founder is about to review a PR whose diff + contains an `acct_` nobody typed, and this is where they learn where it came + from. If the mint fails, the PR body says so and says what to do. */} +

+ {t("provision.stripeAccountNote")} +

{/* A partner's own zone (SOFRA-PARTNER-FLEXIBILITY-PLAN D1). The default option is empty and emits exactly the entry this form emitted before the field existed: `.sofrapiwas.com`, no `base_domain:` key. Picked, the entry's domain is diff --git a/lib/actions/provisioning-actions.ts b/lib/actions/provisioning-actions.ts index ffdd199..8e7a02b 100644 --- a/lib/actions/provisioning-actions.ts +++ b/lib/actions/provisioning-actions.ts @@ -76,7 +76,7 @@ export async function openProvisioningPrAction( } try { - const { prUrl, deferred } = await openProvisioningPr(input); + const { prUrl, deferred, stripeAccount, mintNote } = await openProvisioningPr(input); // 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 @@ -89,6 +89,12 @@ export async function openProvisioningPrAction( await audit(admin.id, "tenant.provision.proposed", "Tenant", input.slug, { prUrl, ...(deferred.length ? { deferred } : {}), + // The `acct_` the control plane minted for this proposal, or why it did not + // (ADR-011 amendment, E3). An `acct_` is an identifier and not a secret — the + // fleet list already renders one — and this is the only durable record of a LIVE + // Stripe account created on the founder's behalf while they filled in a form. + ...(stripeAccount ? { stripeAccount } : {}), + ...(mintNote ? { mintNote } : {}), }); return { ok: true, prUrl }; } catch (e) { diff --git a/lib/auto-provision-policy.ts b/lib/auto-provision-policy.ts index dd87423..0831810 100644 --- a/lib/auto-provision-policy.ts +++ b/lib/auto-provision-policy.ts @@ -26,10 +26,14 @@ export type AutoProposePlan = export type AutoProposeOutcome = | Exclude // `deferred` = modules the buyer PAID for that the proposed entry withholds, because - // provisioning refuses them without a Stripe account the self-serve buyer cannot have - // yet. Carried on the outcome so it reaches the audit trail: this is the only durable - // record that someone is being billed for a module their tenant does not yet have. - | { kind: "opened"; prUrl: string; deferred?: string[] }; + // provisioning refuses them without a Stripe account. Carried on the outcome so it + // reaches the audit trail: this is the only durable record that someone is being + // billed for a module their tenant does not yet have. + // + // Since the ADR-011 amendment this is the EXCEPTIONAL path — the control plane mints + // the account — so `mintNote` travels beside it saying why the mint did not happen. + // Absent when it worked, and absent when no account was needed at all. + | { kind: "opened"; prUrl: string; deferred?: string[]; mintNote?: string }; /** The already-validated configuration a lead recorded, plus the slug it must match. */ export type AutoProposeConfig = { diff --git a/lib/auto-provision.ts b/lib/auto-provision.ts index f860dcb..49fcc13 100644 --- a/lib/auto-provision.ts +++ b/lib/auto-provision.ts @@ -2,30 +2,23 @@ // // 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 human checkpoint, and under the merge chain the merge is what 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 +// gathers facts, performs the side effects 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. +// non-2xx — a paid customer's activation retried for ~26h. Failures become outcomes. // 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). +// the only caller reaches this from `first`+`paid`: a second path deciding 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 { classifyProvisioningRefusal, decideAutoPropose, type AutoProposeOutcome } from "@/lib/auto-provision-policy"; import { reportFailedProposal } from "@/lib/billing-notify"; import { openProvisioningPr, @@ -34,11 +27,7 @@ import { ProvisioningNotConfiguredError, } from "@/lib/provisioning"; -export { - AUTO_PROPOSE_NOTES, - type AutoProposeOutcome, - type AutoProposeSkip, -} from "@/lib/auto-provision-policy"; +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 @@ -47,7 +36,8 @@ export { * 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. + * rather than reported as a failure. (The MINT has its own, separate idempotency — a + * slug-derived Stripe key plus a unique row; see lib/connect-account-store.ts.) */ export async function autoProposeProvisioning(billingId: string): Promise { try { @@ -55,14 +45,12 @@ export async function autoProposeProvisioning(billingId: string): Promise { /** * 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. + * PR URL — but `openProvisioningPr` creates the branch before it 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) { @@ -172,8 +164,8 @@ async function translate(billingId: string, e: unknown): Promise, @@ -188,6 +180,7 @@ async function finish( await audit(null, `tenant.provision.auto.${outcome.kind}`, "Tenant", slug, { ...("prUrl" in outcome ? { prUrl: outcome.prUrl } : {}), ...("deferred" in outcome && outcome.deferred?.length ? { deferred: outcome.deferred } : {}), + ...("mintNote" in outcome && outcome.mintNote ? { mintNote: outcome.mintNote } : {}), ...("reason" in outcome ? { reason: outcome.reason } : {}), ...("detail" in outcome ? { detail: outcome.detail } : {}), }); diff --git a/lib/connect-account-country.ts b/lib/connect-account-country.ts new file mode 100644 index 0000000..68d31cd --- /dev/null +++ b/lib/connect-account-country.ts @@ -0,0 +1,62 @@ +// Which country a tenant's connected account is created in (ADR-011 amendment, +// slice E3). +// +// This module exists because of a gap nobody planned for: sofra holds a +// restaurant's name, email, city, currency and modules — and NO country. The +// registry has no `country:` key either (`provision-tenant.sh`'s field whitelist +// does not contain one), and neither does `SignupRequest`. But an Express +// account MUST be created in a country, and Stripe fixes that country forever at +// creation — it is refused on update, like the account type and `business_type`. +// +// So the country is DERIVED from the one fact we do hold, the tenant's trading +// currency, and the derivation refuses to guess whenever the answer is not +// unique. EUR is the case that matters: it is spoken by FR, DE, NL, IT, ES, BE +// and AT, and picking one would create a live, uncorrectable account in the +// wrong country for a real restaurant. A refusal costs the founder one hand-edit +// before merging the registry PR (which the PR body spells out); a wrong guess +// costs a Stripe support case and, possibly, a dead account. +// +// This is deliberately narrow rather than clever. The proper fix is to ASK — a +// country on the lead or on the provision form — and it is recorded in +// docs/plans/BACKLOG.md rather than smuggled in here, because it is a form +// change with six locales attached and this slice is already the wide one. + +/** + * Currencies whose country is unambiguous among the countries we onboard + * (`CONNECT_ONBOARDABLE_COUNTRIES`). + * + * CHF is the one that matters today: Switzerland is the first market, RUMI is a + * CH tenant, and every tenant in the registry trades in CHF or EUR. + */ +const UNAMBIGUOUS: Readonly> = { + CHF: "CH", + GBP: "GB", + USD: "US", + AED: "AE", +}; + +export type ConnectCountryVerdict = + | { ok: true; country: string } + | { ok: false; reason: string }; + +/** + * The account country for a tenant trading in this currency, or a refusal that + * says why — in words a founder reading a PR body can act on. + * + * @param currency ISO-4217, as the registry carries it (`currency:`). + */ +export function connectCountryForCurrency(currency: string | undefined): ConnectCountryVerdict { + const code = (currency ?? "").trim().toUpperCase(); + if (!code) return { ok: false, reason: "this entry records no currency, so no account country can be derived" }; + const country = UNAMBIGUOUS[code]; + if (country) return { ok: true, country }; + if (code === "EUR") { + return { + ok: false, + reason: + "EUR does not name one country (FR, DE, NL, IT, ES, BE and AT all use it) and Stripe fixes " + + "an account's country permanently at creation, so this is not a guess worth making", + }; + } + return { ok: false, reason: `no Stripe Connect country is mapped to currency ${code}` }; +} diff --git a/lib/provision-form-input.ts b/lib/provision-form-input.ts index 07d6833..00ae302 100644 --- a/lib/provision-form-input.ts +++ b/lib/provision-form-input.ts @@ -72,8 +72,8 @@ export function readProvisionForm(formData: FormData): ProvisionFormResult { city: optionalField(formData, "city"), // NOT optional to remember. Every field the form posts is read here, and this file // is the one place where "the form has it and the action does not" can be seen at a - // glance — which is precisely what went wrong with `stripeAccount`. - stripeAccount: optionalField(formData, "stripeAccount"), + // glance — which is precisely what went wrong with `stripeAccount`, back when that + // was a field at all. baseDomain: optionalField(formData, "baseDomain"), }); if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "invalidInput" }; @@ -97,7 +97,10 @@ export function readProvisionForm(formData: FormData): ProvisionFormResult { currency: data.currency, languages, modules, - stripeAccount: data.stripeAccount || undefined, + // No `stripeAccount`: it is not a form field any more. Under the ADR-011 + // amendment the control plane MINTS the tenant's connected account + // (lib/provisioning-mint.ts) and the ACTION attaches the result, so the + // mapping from a browser's fields cannot carry it and cannot drop it. // Re-normalized rather than passed through: the schema only ASKED whether the // value is a usable base domain, and the answer it validated is a different // string from the one it was handed (a pasted scheme, a trailing dot). The diff --git a/lib/provisioning-mint.ts b/lib/provisioning-mint.ts new file mode 100644 index 0000000..f7cd7fc --- /dev/null +++ b/lib/provisioning-mint.ts @@ -0,0 +1,94 @@ +// The mint, placed where a registry proposal is composed (ADR-011 amendment, E3). +// +// This is the provenance flip. Until now `stripe_account:` could only come from a +// founder typing an `acct_` they had created by hand with `curl`, because of a +// premise written into five files: "only the restaurant can create one, through +// Stripe's hosted onboarding, which cannot be pre-filled". MEASURED 2026-09-05, +// that premise is false in the direction that matters — prefill is refused on +// UPDATE (403 `oauth_not_supported`) and works on CREATE. So the platform mints +// the account, and the entry carries `online-payments` AND `stripe_account:` in +// ONE commit, which is exactly what `provision-tenant.sh:117` asks for. +// +// THREE RULES, and each is here rather than in the callers because both callers +// need all three: +// +// 1. **It never throws.** The self-serve caller is reached from the Mollie +// webhook, where an exception means a non-2xx, which means Mollie redelivers +// a paid customer's activation for ~26h. Every failure becomes a `note`. +// 2. **A failure does not cancel the tenant.** A restaurant whose Stripe account +// could not be minted still gets its restaurant — provisioned without +// `online-payments`, trading on cash, exactly as before this slice. The +// module is then withheld by the pairing rule rather than proposed into an +// entry `provision-tenant.sh` would refuse *before the database*, i.e. into +// no tenant at all. That guard stays the last-resort assertion and stays +// satisfiable; what changes is that it now essentially never fires. +// 3. **It mints only what was bought.** No `online-payments` in the modules, no +// account: a live Stripe account for a restaurant that never asked for card +// payments is a real object with a real compliance obligation attached. + +import { createExpressAccount } from "@/lib/stripe-connect-accounts"; +import { connectCountryForCurrency } from "@/lib/connect-account-country"; +import { isStripeAccountId } from "@/lib/validation-provision"; +import { stripeConfigured } from "@/lib/stripe"; +import { ACCOUNT_PAIRED_MODULE_IDS } from "@/lib/provisioning-module-pairing"; + +export type MintForProposalInput = { + slug: string; + name: string; + adminEmail: string; + currency: string; + modules: string[]; + /** The tenant's own hostname — `business_profile[url]`. */ + url: string; +}; + +export type MintForProposalResult = { + /** The minted (or already-recorded) `acct_…`, when there is one. */ + stripeAccount?: string; + /** + * Why there is none — founder-facing, and written into the PR body. Absent when + * nothing was attempted (the tenant did not buy the module) or when it worked. + */ + note?: string; +}; + +/** + * Mint this tenant's connected account, if the proposal needs one. + * + * Returns `{}` when the tenant bought no account-paired module — the ordinary case + * for most entries, and not a failure to report. + */ +export async function mintForProposal(input: MintForProposalInput): Promise { + const buysPaired = input.modules.some((id) => (ACCOUNT_PAIRED_MODULE_IDS as readonly string[]).includes(id)); + if (!buysPaired) return {}; + + if (!stripeConfigured()) { + return { note: "STRIPE_API_KEY is not configured in the control plane, so no account could be created" }; + } + + const country = connectCountryForCurrency(input.currency); + if (!country.ok) return { note: country.reason }; + + try { + const minted = await createExpressAccount({ + slug: input.slug, + country: country.country, + email: input.adminEmail, + name: input.name, + url: input.url, + }); + // The grammar check that used to guard a founder's typing, now applied to what + // Stripe returned: this value reaches the tenant's Stripe env, where a wrong + // string means charges addressed to an account that does not exist. It has + // never once failed here — which is the point of asserting it. + if (!isStripeAccountId(minted.stripeAccountId)) { + return { note: `Stripe returned an account id in an unexpected shape and it was not recorded in the entry` }; + } + return { stripeAccount: minted.stripeAccountId }; + } catch (e) { + // No PII: a slug and Stripe's own message (CLAUDE.md §5.8). + console.error("mintForProposal failed", input.slug, e); + const detail = e instanceof Error ? e.message : "unknown error"; + return { note: `creating the Stripe connected account failed — ${detail}` }; + } +} diff --git a/lib/provisioning-module-pairing.ts b/lib/provisioning-module-pairing.ts index cb16c7c..9e5ad42 100644 --- a/lib/provisioning-module-pairing.ts +++ b/lib/provisioning-module-pairing.ts @@ -19,30 +19,34 @@ import type { ModuleId } from "./module-catalog"; * lacking card payment — it yields no tenant at all. * * Hence the pairing rule below: the module ships only alongside an account, never on - * its own. Which half is missing depends on the path in, and BOTH paths reach this one - * generator: + * its own. * - * - **Self-serve.** The buyer has no `acct_` and cannot be given one — only the - * restaurant can create it, through Stripe's hosted onboarding, which cannot be - * pre-filled (`oauth_not_supported` on a Standard account, SOFRA-PAYMENTS-PLAN §3). - * So the module is deferred to a second registry PR and the PR body says so. - * - **Founder.** `docs/runbooks/signup-to-live-tenant.md` §2b has the founder create - * the account BEFORE proposing, precisely because of this guard — so they arrive - * holding the `acct_`, and the entry carries both halves in one shot. + * WHAT CHANGED (ADR-011 amendment — Express). This rule used to fire on nearly every + * self-serve entry, because of a premise stated here and in four other files: "the + * buyer has no `acct_` and cannot be given one — only the restaurant can create it, + * through Stripe's hosted onboarding, which cannot be pre-filled". MEASURED 2026-09-05: + * `oauth_not_supported` is the answer to an UPDATE, not to a CREATE. Prefill at create + * time works, so the control plane mints the account itself (lib/provisioning-mint.ts) + * BEFORE the proposal is composed, and both paths — self-serve and founder — now arrive + * holding an `acct_`. * - * Deferring unconditionally would have been wrong for the second path: it would make - * the founder's documented order pointless and tell them, falsely, that no account can - * exist yet. + * So this function is no longer the normal path; it is the LAST-RESORT one, and it is + * kept for exactly that. `provision-tenant.sh:117` still refuses the module without an + * account, before the database, and that guard must stay SATISFIABLE: when a mint fails + * (Stripe down, a currency whose country we cannot derive, a key without + * `Connect -> write`), the entry must still be one a merge can stand up. Withholding the + * module gives the restaurant everything else and a working cash tenant; proposing the + * unpaired module would give them no tenant at all. */ -const ACCOUNT_PAIRED_MODULE_IDS: readonly ModuleId[] = ["online-payments"]; +export const ACCOUNT_PAIRED_MODULE_IDS: readonly ModuleId[] = ["online-payments"]; /** - * Split a purchased module list into what this entry may carry now and what must wait - * for a second registry PR. Pure and shared, so the entry and the PR body describing it - * cannot disagree about which is which. + * Split a purchased module list into what this entry may carry now and what must wait. + * Pure and shared, so the entry and the PR body describing it cannot disagree about + * which is which. * - * `stripeAccount` is the whole hinge: with one, nothing is deferred; without one, the - * account-paired ids are held back. + * `stripeAccount` is the whole hinge: with one — which is now the ordinary case, because + * we mint it — nothing is deferred; without one, the account-paired ids are held back. */ export function splitDeferredModules( modules: string[], diff --git a/lib/provisioning-pr-blocks.ts b/lib/provisioning-pr-blocks.ts index 1f9f2cb..08ce2ee 100644 --- a/lib/provisioning-pr-blocks.ts +++ b/lib/provisioning-pr-blocks.ts @@ -20,12 +20,19 @@ import { COMMISSION_FLOOR_CENTS } from "./payments-pricing"; * Named, not silent. The entry omits a module they PAID for, so the body has to say * so where the founder is already reading — otherwise the checklist quietly * contradicts the receipt, and the gap is discovered by a customer asking why card - * payment does not work. Empty for every tenant that bought nothing deferred. + * payment does not work. + * + * WHAT THIS SECTION NOW MEANS (ADR-011 amendment). It used to be the ordinary + * self-serve outcome, on the premise that only the restaurant could create a Stripe + * account. The control plane MINTS the account now, so reaching this section means the + * mint did not happen — and `note` says why. The instruction is therefore no longer + * "wait for the restaurant"; it is "fix the reason, or supply an account yourself". */ export function deferredSection( slug: string, granted: string[], deferred: string[], + note?: string, ): string[] { if (!deferred.length) return []; return [ @@ -38,15 +45,21 @@ export function deferredSection( "the database, the compose project or the image — so proposing both here would not give", "them a restaurant lacking card payment, it would give them **no restaurant at all**.", "", - "No account was supplied with this proposal. If you are the founder and you already", - "hold their `acct_` — runbook §2b has you create it *before* proposing, exactly so", - "this does not happen — the fix is one shot, not two: add both fields in **Files", - "changed** before merging and delete this section's premise. Otherwise the account", - "genuinely cannot exist yet, because only the restaurant can create it, through", - "Stripe's hosted onboarding, which cannot be pre-filled. In that case provision them", - "now on everything else, then, once they have finished Stripe and you have their id, open a", - "**second** registry PR that adds BOTH halves together — one without the other trips", - "the same guard. Only these two fields change; the rest of the entry stays as merged:", + // The reason, when the mint reported one. Without it this section would say only + // that something is missing, which is the state this whole slice was written to end. + ...(note + ? [ + `**Why there is no account:** ${note}.`, + "", + "Sofra creates each tenant's Stripe **Express** account itself, before opening this", + "PR, so this is a failure rather than the normal course of things.", + "", + ] + : []), + "**Two ways forward, and the first is usually right.** Fix the cause above and re-propose,", + "so the entry carries both halves in one commit as it is meant to. Or, if you already hold", + "an `acct_` for them, add both fields in **Files changed** before merging — one shot, not", + "two. Only these two lines change; the rest of the entry stays as merged:", "", "```yaml", ` ${slug}:`, @@ -54,10 +67,12 @@ export function deferredSection( ` modules: [${[...granted, ...deferred].join(", ")}]`, "```", "", - `…then re-run provisioning (\`gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}\`)`, - "and restart the tenant so it picks up the Stripe env. Full recipe — account creation,", - "the KYC sitting, TWINT, the box env — workspace `docs/runbooks/signup-to-live-tenant.md`", - "**§2b**, which is written to be followed BEFORE this second PR.", + "Failing both, provision them now on everything else — they trade on cash from day one —", + "then add both fields together in a follow-up registry PR and re-run provisioning", + `(\`gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}\`),`, + "and restart the tenant so it picks up the Stripe env. Background — what the account is,", + "what the restaurant still has to finish, TWINT, the box env — workspace", + "`docs/runbooks/signup-to-live-tenant.md` **§2b**.", ]; } diff --git a/lib/provisioning-pr-body.ts b/lib/provisioning-pr-body.ts index f29dc84..7dd2baa 100644 --- a/lib/provisioning-pr-body.ts +++ b/lib/provisioning-pr-body.ts @@ -147,7 +147,7 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string { : `- [ ] **modules** \`${granted.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`, - ...deferredSection(slug, granted, deferred), + ...deferredSection(slug, granted, deferred, input.stripeAccountNote), ...commissionSection(slug, input.paymentsCommissionBps, grantsOnlinePayments), ...baseDomainSection(input.baseDomain, domain), // Only when a publishable partner brand reached the entry — `renderableBrand` diff --git a/lib/provisioning-registry.ts b/lib/provisioning-registry.ts index 9120183..ba29d7c 100644 --- a/lib/provisioning-registry.ts +++ b/lib/provisioning-registry.ts @@ -41,9 +41,20 @@ export interface TenantProvisionInput { currency: string; languages: string[]; modules: string[]; - /** The tenant's Stripe connected account (`acct_…`), when they already have one. - * Absent on the self-serve path; present when the founder followed runbook §2b. */ + /** + * The tenant's Stripe connected account (`acct_…`) — SERVER-DERIVED since the + * ADR-011 amendment: the control plane mints it (lib/provisioning-mint.ts) before + * this entry is composed, on BOTH paths, so it is no longer something anyone types. + * Absent only when the mint could not happen, which `stripeAccountNote` explains. + */ stripeAccount?: string; + /** + * Why there is no `stripeAccount`, in founder-facing words. NOT a registry field — + * `buildTenantRegistryEntry` ignores it entirely; it exists so the PR body can say + * what went wrong at the one moment someone is reading the diff. Absent when an + * account was minted, and when none was needed. + */ + stripeAccountNote?: string; /** * The tenant's per-transaction commission rate, in basis points * (SOFRA-PAYMENTS-PRICING-MODE-PLAN S1; range governed by `lib/payments-pricing.ts`, diff --git a/lib/provisioning.ts b/lib/provisioning.ts index 6df7ea6..b2681c0 100644 --- a/lib/provisioning.ts +++ b/lib/provisioning.ts @@ -8,7 +8,12 @@ import { buildProvisioningPrBody } from "@/lib/provisioning-pr-body"; import { tenantPartnerBrand } from "@/lib/partner-brand-lookup"; -import { buildTenantRegistryEntry, type TenantProvisionInput } from "@/lib/provisioning-registry"; +import { mintForProposal } from "@/lib/provisioning-mint"; +import { + buildTenantRegistryEntry, + tenantDomain, + type TenantProvisionInput, +} from "@/lib/provisioning-registry"; // Exported so lib/registry-commission-pr.ts (the amendment counterpart to // openProvisioningPr below, split into its own file for CLAUDE.md §4's line @@ -74,7 +79,7 @@ export async function gh(token: string, path: string, init?: RequestInit): Pr */ export async function openProvisioningPr( input: TenantProvisionInput, -): Promise<{ prUrl: string; deferred: string[] }> { +): Promise<{ prUrl: string; deferred: string[]; stripeAccount?: string; mintNote?: string }> { const token = process.env.PROVISION_GITHUB_TOKEN; if (!token) throw new ProvisioningNotConfiguredError(); @@ -96,11 +101,36 @@ export async function openProvisioningPr( // allowed to decide that a name may be published (D-B1/D-B1a). const partnerBrand = await tenantPartnerBrand(input.slug); + // The tenant's Stripe connected account, minted HERE and for the same reason the + // partner credit is resolved here rather than accepted from a caller: this is the one + // place that writes a registry entry, so no caller can forget it, no caller can inject + // one, and a third path added later inherits it (ADR-011 amendment, E3). + // + // AFTER the "slug already merged" refusal above, deliberately — minting for a slug + // that cannot be proposed would create a live Stripe account for nothing. And it never + // throws: a Stripe failure must not cost a paying customer their whole tenant, so it + // degrades to the pre-existing behaviour (the module is withheld by the pairing rule) + // and the PR body says why. + const mint = await mintForProposal({ + slug: input.slug, + name: input.name, + adminEmail: input.adminEmail, + currency: input.currency, + modules: input.modules, + url: `https://${tenantDomain(input)}`, + }); + const withAccount: TenantProvisionInput = { + ...input, + partnerBrand, + ...(mint.stripeAccount ? { stripeAccount: mint.stripeAccount } : {}), + ...(mint.note ? { stripeAccountNote: mint.note } : {}), + }; + // `deferred` is returned to the caller rather than only rendered into the PR body: a // deferral means a customer is being BILLED for a module their tenant will not have // until a second registry PR lands, and a prose section in one PR is not a record // anyone can query later. The callers put it in the audit trail. - const { entry, deferred } = buildTenantRegistryEntry({ ...input, partnerBrand }); + const { entry, deferred } = buildTenantRegistryEntry(withAccount); // trimEnd() (no regex) drops any trailing whitespace/newlines, then we re-add // exactly two — avoids the ReDoS-prone `\n*$`, and the blank line keeps the // new tenant from butting against the previous one's trailing comment, which @@ -146,11 +176,17 @@ export async function openProvisioningPr( title: `Provision tenant: ${input.slug}`, head: branch, base: BASE, - // The SAME resolved credit the entry carries: a body that describes a partner - // the diff does not name (or omits one it does) is worse than no body — the - // founder ticks the checklist against it. - body: buildProvisioningPrBody({ ...input, partnerBrand }), + // The SAME object the entry was built from — resolved credit, minted account and + // all. A body that describes a partner the diff does not name (or omits one it + // does), or that calls a module deferred when the entry carries it, is worse than + // no body: the founder ticks the checklist against it. + body: buildProvisioningPrBody(withAccount), }), }); - return { prUrl: pr.html_url, deferred }; + return { + prUrl: pr.html_url, + deferred, + ...(mint.stripeAccount ? { stripeAccount: mint.stripeAccount } : {}), + ...(mint.note ? { mintNote: mint.note } : {}), + }; } diff --git a/lib/validation-provision.ts b/lib/validation-provision.ts index b9fec45..f5590cb 100644 --- a/lib/validation-provision.ts +++ b/lib/validation-provision.ts @@ -49,18 +49,6 @@ export const provisionSchema = z.object({ message: `unknown module — allowed: ${MODULE_IDS.join(", ")}`, }), city: z.string().trim().max(200).optional().or(z.literal("")), - // The tenant's Stripe connected account, when the founder already has it (runbook - // §2b creates it BEFORE proposing, precisely because provision-tenant.sh refuses the - // module without it). Optional: left empty, the generator defers `online-payments` to - // a second registry PR rather than emitting an entry that would be refused. - // Grammar-pinned rather than free text — this value reaches the tenant's Stripe env, - // where a typo means charges addressed to an account that does not exist. - stripeAccount: z - .string() - .trim() - .regex(/^acct_[A-Za-z0-9]{8,32}$/, "Stripe account id, e.g. acct_1AbCdEfGhIjKlMnO") - .optional() - .or(z.literal("")), // A PARTNER'S own zone, when the tenant should live under it rather than ours // (SOFRA-PARTNER-FLEXIBILITY-PLAN D1). Left empty, the generator emits exactly what // it emitted before this field existed — that is the whole contract, and every @@ -81,3 +69,25 @@ export const provisionSchema = z.object({ .optional() .or(z.literal("")), }); + +/** + * Does this string have the grammar of a Stripe connected-account id? + * + * It used to be a FIELD on the schema above, because the founder typed the value + * by hand (runbook §2b had them create the account with `curl` first). Under the + * ADR-011 amendment the control plane MINTS the account, so `stripeAccount` is + * server-derived and no longer an operator input — the field is gone from the + * form, from `readProvisionForm` and from this schema. + * + * The check itself stays, moved to where the value now comes from + * (`lib/provisioning-mint.ts`, applied to what Stripe returned). The reason it + * existed has not changed: this value reaches the tenant's Stripe env, where a + * wrong string means charges addressed to an account that does not exist. What + * changed is only who could get it wrong — and an assertion that has never fired + * is exactly the kind worth keeping when the thing it guards is money. + */ +const STRIPE_ACCOUNT_ID = /^acct_[A-Za-z0-9]{8,32}$/; + +export function isStripeAccountId(value: string): boolean { + return STRIPE_ACCOUNT_ID.test(value); +} diff --git a/messages/ar.json b/messages/ar.json index 906834c..b972c8f 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -701,7 +701,7 @@ "modules": "الوحدات: {list}", "template": "القالب: {template}", "stripeAccount": "Stripe: {account}", - "stripeAccountMissing": "يشتري وحدة المدفوعات عبر الإنترنت، لكن هذا السجل لا يتضمّن stripe_account — التجهيز يرفض هذا الاقتران ويتوقّف قبل قاعدة البيانات، فإعادة تجهيز هذا المستأجر لا تفعل شيئًا على الإطلاق. أضف الحساب إلى السجل، أو احذف الوحدة.", + "stripeAccountMissing": "يشتري المدفوعات بالبطاقة، لكن هذا السجل لا يحمل stripe_account — لذلك يرفض التزويد هذا الاقتران ويتوقف قبل قاعدة البيانات، وإعادة التزويد لا تفعل شيئًا. صرنا ننشئ هذا الحساب بأنفسنا، ما يعني أن الإنشاء فشل: اقرأ سبب ذلك في طلب الدمج، ثم أضف الحساب إلى السجل أو أزل الوحدة.", "paymentsModeFlat": "المدفوعات: سعر ثابت", "paymentsModeCommission": "المدفوعات: عمولة ({percent})", "paymentsModePending": "(معلّق — لم يُحدَّث السجل بعد)" @@ -775,8 +775,6 @@ "currency": "العملة (مثل EUR)", "languages": "اللغات", "modules": "الوحدات", - "stripeAccount": "حساب Stripe (acct_…، اختياري)", - "stripeAccountHint": "فقط إذا اشتروا online-payments وكنت قد أنشأت حسابهم المرتبط بالفعل (دليل التشغيل §2b). إذا تُرك فارغًا، يُؤجَّل استخدام الوحدة ويشرح طلب الدمج كيفية إضافتها لاحقًا — إذ يرفض التزويد الوحدة بدون حساب.", "create": "فتح طلب سحب للسجل", "creating": "جارٍ الفتح…", "created": "تم فتح طلب سحب للسجل — راجعه وادمجه، ثم زوّد:", @@ -792,7 +790,8 @@ "baseDomainOurs": "sofrapiwas.com — نطاقنا (الافتراضي)", "baseDomainPreview": "سيستجيب هذا المستأجر على", "baseDomainDnsFirst": "منطقة الشريك الخاصة: يحمل المُدخل base_domain: ويجب أن يستجيب سجل A قبل الدمج، وإلا تعذّر إصدار الشهادة.", - "baseDomainSlugPlaceholder": "" + "baseDomainSlugPlaceholder": "", + "stripeAccountNote": "حساب Stripe: نحن ننشئه. عندما يشتري هذا المطعم المدفوعات بالبطاقة، تنشئ لوحة التحكم حساب Stripe Express الخاص به قبل فتح هذا الاقتراح، بحيث يحمل سجل registry الحساب والوحدة معًا. لا شيء تكتبه هنا — وإذا فشل الإنشاء، يوضح طلب الدمج السبب والخطوة التالية." }, "fleet": { "title": "أسطول الطابعات", @@ -1028,7 +1027,7 @@ "net": "الصافي", "fees": "{count} رسوم", "empty": "هذا الحساب تحت المراقبة ولم يحصّل شيئًا في هذه الفترة.", - "noAccount": "لا يحمل سجل هذا المستأجر أي stripe_account، لذا لا يوجد حساب تُعرض رسومه. هذه هي الحالة الطبيعية إلى أن يُكمل المطعم تسجيله لدى Stripe.", + "noAccount": "لا يحمل سجل هذا المطعم أي stripe_account، لذلك لا يوجد حساب تُعرض رسومه. وبما أننا ننشئ هذا الحساب بأنفسنا، فهذا يشير إلى سجل أُنشئ قبل ذلك أو إلى إنشاء فشل — لا إلى مطعم لم يكمل تسجيله بعد.", "registryUnavailable": "تعذّرت قراءة سجل المستأجرين، لذا فإن الحساب المرتبط غير معروف الآن. لا يُعرض أي رقم بدلًا من عرض رقم خاطئ.", "unmatchedRefunds": "{count} من عمليات الاسترداد في هذه الفترة ليس لها رسوم مسجَّلة — فرسومها سابقة لبدء التسجيل، لذا فالصافي أعلاه أقل من الحقيقة." }, diff --git a/messages/de.json b/messages/de.json index 0109d40..8280b7b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -701,7 +701,7 @@ "modules": "Module: {list}", "template": "Template: {template}", "stripeAccount": "Stripe: {account}", - "stripeAccountMissing": "Kauft Online-Zahlungen, aber dieser Eintrag enthält kein stripe_account — die Provisionierung lehnt das Paar ab und bricht vor der Datenbank ab, ein erneutes Provisionieren dieses Tenants bewirkt also gar nichts. Konto zum Eintrag hinzufügen oder das Modul entfernen.", + "stripeAccountMissing": "Kauft Kartenzahlungen, aber dieser Eintrag führt kein stripe_account — das Provisioning verweigert das Paar und stoppt vor der Datenbank, ein erneutes Provisionieren bewirkt also gar nichts. Dieses Konto legen jetzt wir an, das heißt: das Anlegen ist fehlgeschlagen. Den Grund nennt der Vorschlags-PR; danach das Konto eintragen oder das Modul entfernen.", "paymentsModeFlat": "Zahlungen: pauschal", "paymentsModeCommission": "Zahlungen: Provision ({percent})", "paymentsModePending": "(ausstehend — Registry noch nicht aktualisiert)" @@ -775,8 +775,6 @@ "currency": "Währung (z. B. EUR)", "languages": "Sprachen", "modules": "Module", - "stripeAccount": "Stripe-Konto (acct_…, optional)", - "stripeAccountHint": "Nur wenn online-payments gekauft wurde UND das verbundene Konto bereits angelegt ist (Runbook §2b). Leer gelassen, wird das Modul zurückgestellt und die PR erklärt, wie es später ergänzt wird — die Provisionierung lehnt das Modul ohne Konto ab.", "create": "Registry-PR öffnen", "creating": "Wird geöffnet…", "created": "Registry-PR geöffnet — prüfen + zusammenführen, dann bereitstellen:", @@ -792,7 +790,8 @@ "baseDomainOurs": "sofrapiwas.com — unsere (Standard)", "baseDomainPreview": "Dieser Mandant wird erreichbar sein unter", "baseDomainDnsFirst": "Eigene Zone des Partners: der Eintrag führt base_domain: und der A-Eintrag muss vor dem Merge bereits auflösen, sonst kann kein Zertifikat ausgestellt werden.", - "baseDomainSlugPlaceholder": "" + "baseDomainSlugPlaceholder": "", + "stripeAccountNote": "Stripe-Konto: wir erstellen es. Kauft dieser Betrieb Kartenzahlungen, legt die Steuerungsebene sein Stripe-Express-Konto an, bevor dieser Vorschlag geöffnet wird — der Registry-Eintrag trägt Konto und Modul gemeinsam. Hier ist nichts einzutragen — und schlägt die Erstellung fehl, nennt der PR Grund und nächsten Schritt." }, "fleet": { "title": "Drucker-Flotte", @@ -1028,7 +1027,7 @@ "net": "Netto", "fees": "{count} Gebühren", "empty": "Dieses Konto wird beobachtet und hat in diesem Zeitraum nichts eingenommen.", - "noAccount": "Der Registry-Eintrag dieses Mandanten trägt kein stripe_account, es gibt also kein Konto, für das Gebühren gemeldet werden könnten. Das ist der normale Zustand, solange das Restaurant das Stripe-Onboarding nicht abgeschlossen hat.", + "noAccount": "Der Registry-Eintrag dieses Betriebs führt kein stripe_account, es gibt also kein Konto, für das Gebühren berichtet werden könnten. Da wir dieses Konto selbst anlegen, deutet das auf einen älteren Eintrag oder ein fehlgeschlagenes Anlegen hin — nicht auf ein Restaurant, das seine Anmeldung noch nicht beendet hat.", "registryUnavailable": "Die Mandanten-Registry konnte nicht gelesen werden, das verbundene Konto ist daher derzeit unbekannt. Es wird keine Zahl angezeigt statt einer falschen.", "unmatchedRefunds": "{count} Rückerstattungen in diesem Zeitraum haben keine erfasste Gebühr — ihre Gebühren liegen vor der Erfassung, das Netto oben ist also niedriger als die Wahrheit." }, diff --git a/messages/en.json b/messages/en.json index b71e39f..78ac0fb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -701,7 +701,7 @@ "modules": "modules: {list}", "template": "template: {template}", "stripeAccount": "Stripe: {account}", - "stripeAccountMissing": "Buys online payments, but this entry records no stripe_account — provisioning refuses the pair and stops before the database, so re-provisioning this tenant does nothing at all. Add the account to the entry, or drop the module.", + "stripeAccountMissing": "Buys online payments, but this entry records no stripe_account — provisioning refuses the pair and stops before the database, so re-provisioning this tenant does nothing at all. We create that account ourselves now, so this means creating it failed: read the proposal PR for the reason, then add the account to the entry or drop the module.", "paymentsModeFlat": "payments: flat", "paymentsModeCommission": "payments: commission ({percent})", "paymentsModePending": "(pending — registry not yet updated)" @@ -775,8 +775,6 @@ "currency": "Currency (e.g. EUR)", "languages": "Languages", "modules": "Modules", - "stripeAccount": "Stripe account (acct_…, optional)", - "stripeAccountHint": "Only if they bought online-payments AND you already created their connected account (runbook §2b). Left empty, the module is held back and the PR says how to add it later — provisioning refuses the module without an account.", "create": "Open registry PR", "creating": "Opening…", "created": "Registry PR opened — review + merge it, then provision:", @@ -792,7 +790,8 @@ "baseDomainOurs": "sofrapiwas.com — ours (default)", "baseDomainPreview": "This tenant will answer on", "baseDomainDnsFirst": "A partner's own zone: the entry carries base_domain: and the A record must already resolve before you merge, or the certificate cannot be issued.", - "baseDomainSlugPlaceholder": "" + "baseDomainSlugPlaceholder": "", + "stripeAccountNote": "Stripe account: we create it. When this tenant buys online payments, the control plane creates their Stripe Express account before this proposal is opened, so the registry entry carries the account and the module together. Nothing to type here — and if creating it fails, the PR says why and what to do." }, "fleet": { "title": "Printer fleet", @@ -1028,7 +1027,7 @@ "net": "Net", "fees": "{count} fees", "empty": "This account is being watched and has collected nothing in this period.", - "noAccount": "This tenant's registry entry carries no stripe_account, so there is no account to report fees for. That is the normal state until the restaurant has completed Stripe onboarding.", + "noAccount": "This tenant's registry entry carries no stripe_account, so there is no account to report fees for. Since we create that account ourselves, this points at an entry made before we did, or at a creation that failed — not at a restaurant that has yet to finish signing up.", "registryUnavailable": "The tenant registry could not be read, so this tenant's connected account is unknown right now. No figure is shown rather than a wrong one.", "unmatchedRefunds": "{count} refunds in this period have no recorded fee — their fees predate fee recording, so the net above is lower than the truth." }, diff --git a/messages/fr.json b/messages/fr.json index 365d9fb..a1607d8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -701,7 +701,7 @@ "modules": "modules : {list}", "template": "modèle : {template}", "stripeAccount": "Stripe : {account}", - "stripeAccountMissing": "Achète les paiements en ligne, mais cette entrée n'indique aucun stripe_account — le provisionnement refuse ce couple et s'arrête avant la base de données : reprovisionner ce tenant ne fait donc rien du tout. Ajoutez le compte à l'entrée, ou retirez le module.", + "stripeAccountMissing": "Achète les paiements par carte, mais cette entrée ne porte aucun stripe_account — le provisioning refuse la paire et s'arrête avant la base de données, donc re-provisionner ce restaurant ne fait rien du tout. C'est nous qui créons ce compte désormais : cela signifie donc que la création a échoué. Lisez la PR de proposition pour la raison, puis ajoutez le compte à l'entrée ou retirez le module.", "paymentsModeFlat": "paiements : forfait", "paymentsModeCommission": "paiements : commission ({percent})", "paymentsModePending": "(en attente — registre pas encore mis à jour)" @@ -775,8 +775,6 @@ "currency": "Devise (ex. EUR)", "languages": "Langues", "modules": "Modules", - "stripeAccount": "Compte Stripe (acct_…, facultatif)", - "stripeAccountHint": "Uniquement s’ils ont acheté online-payments ET que vous avez déjà créé leur compte connecté (runbook §2b). Laissé vide, le module est mis de côté et la PR explique comment l’ajouter ensuite — le provisionnement refuse le module sans compte.", "create": "Ouvrir la PR de registre", "creating": "Ouverture…", "created": "PR de registre ouverte — révisez + fusionnez, puis provisionnez :", @@ -792,7 +790,8 @@ "baseDomainOurs": "sofrapiwas.com — le nôtre (par défaut)", "baseDomainPreview": "Ce locataire répondra sur", "baseDomainDnsFirst": "Zone propre au partenaire : l'entrée porte base_domain: et l'enregistrement A doit déjà résoudre avant la fusion, sinon le certificat ne peut pas être émis.", - "baseDomainSlugPlaceholder": "" + "baseDomainSlugPlaceholder": "", + "stripeAccountNote": "Compte Stripe : nous le créons. Si ce restaurant achète les paiements par carte, la console crée son compte Stripe Express avant l'ouverture de la proposition, afin que l'entrée du registre porte le compte et le module ensemble. Rien à saisir ici — et si la création échoue, la PR explique pourquoi et quoi faire." }, "fleet": { "title": "Parc d'imprimantes", @@ -1028,7 +1027,7 @@ "net": "Net", "fees": "{count} frais", "empty": "Ce compte est surveillé et n'a rien encaissé sur cette période.", - "noAccount": "L'entrée de registre de ce client ne porte aucun stripe_account : il n'y a donc aucun compte pour lequel rapporter des frais. C'est l'état normal tant que le restaurant n'a pas terminé son inscription Stripe.", + "noAccount": "L'entrée de ce restaurant ne porte aucun stripe_account : il n'y a donc aucun compte pour lequel rapporter des commissions. Comme c'est nous qui créons ce compte, cela indique une entrée antérieure à ce changement, ou une création qui a échoué — pas un restaurant qui n'a pas encore terminé son inscription.", "registryUnavailable": "Le registre des clients n'a pas pu être lu : le compte connecté de ce client est donc inconnu pour l'instant. Aucun chiffre n'est affiché plutôt qu'un chiffre faux.", "unmatchedRefunds": "{count} remboursements de cette période n'ont aucun frais enregistré — leurs frais sont antérieurs à l'enregistrement, le net ci-dessus est donc inférieur à la réalité." }, diff --git a/messages/nl.json b/messages/nl.json index 9af9927..1a436aa 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -701,7 +701,7 @@ "modules": "modules: {list}", "template": "sjabloon: {template}", "stripeAccount": "Stripe: {account}", - "stripeAccountMissing": "Koopt online betalingen, maar deze entry bevat geen stripe_account — provisionering weigert dit paar en stopt vóór de database, dus dit tenant opnieuw provisioneren doet helemaal niets. Voeg het account toe aan de entry, of haal de module eruit.", + "stripeAccountMissing": "Koopt kaartbetalingen, maar deze regel bevat geen stripe_account — provisioning weigert het paar en stopt vóór de database, dus opnieuw provisioneren doet helemaal niets. Wij maken dat account nu zelf aan, dus dit betekent dat het aanmaken is mislukt: lees de voorstel-PR voor de reden en voeg dan het account toe of haal de module weg.", "paymentsModeFlat": "betalingen: vast", "paymentsModeCommission": "betalingen: commissie ({percent})", "paymentsModePending": "(in behandeling — register nog niet bijgewerkt)" @@ -775,8 +775,6 @@ "currency": "Valuta (bijv. EUR)", "languages": "Talen", "modules": "Modules", - "stripeAccount": "Stripe-account (acct_…, optioneel)", - "stripeAccountHint": "Alleen als ze online-payments hebben gekocht ÉN je hun gekoppelde account al hebt aangemaakt (runbook §2b). Leeg gelaten wordt de module achtergehouden en legt de PR uit hoe je die later toevoegt — provisioning weigert de module zonder account.", "create": "Registry-PR openen", "creating": "Bezig met openen…", "created": "Registry-PR geopend — beoordeel + voeg samen, richt dan in:", @@ -792,7 +790,8 @@ "baseDomainOurs": "sofrapiwas.com — het onze (standaard)", "baseDomainPreview": "Deze tenant komt te staan op", "baseDomainDnsFirst": "Eigen zone van de partner: de entry krijgt base_domain: en het A-record moet al resolven vóór de merge, anders kan het certificaat niet worden uitgegeven.", - "baseDomainSlugPlaceholder": "" + "baseDomainSlugPlaceholder": "", + "stripeAccountNote": "Stripe-account: wij maken hem aan. Koopt deze zaak kaartbetalingen, dan maakt het control plane hun Stripe Express-account aan vóórdat dit voorstel wordt geopend, zodat de registry-regel het account en de module samen draagt. Hier hoef je niets te typen — en mislukt het aanmaken, dan zegt de PR waarom en wat te doen." }, "fleet": { "title": "Printervloot", @@ -1028,7 +1027,7 @@ "net": "Netto", "fees": "{count} kosten", "empty": "Dit account wordt gevolgd en heeft in deze periode niets geïnd.", - "noAccount": "De registryvermelding van deze klant bevat geen stripe_account, dus er is geen account om kosten voor te rapporteren. Dat is de normale toestand zolang het restaurant de Stripe-onboarding niet heeft afgerond.", + "noAccount": "De registry-regel van deze zaak bevat geen stripe_account, dus er is geen account om kosten voor te rapporteren. Omdat wij dat account zelf aanmaken, wijst dit op een regel van vóór die verandering of op een mislukte aanmaak — niet op een restaurant dat zijn aanmelding nog moet afronden.", "registryUnavailable": "Het klantenregister kon niet worden gelezen, dus het gekoppelde account is nu onbekend. Er wordt geen bedrag getoond in plaats van een verkeerd bedrag.", "unmatchedRefunds": "{count} terugbetalingen in deze periode hebben geen geregistreerde kosten — hun kosten dateren van vóór de registratie, dus het netto hierboven is lager dan de waarheid." }, diff --git a/messages/tr.json b/messages/tr.json index c399763..b831187 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -701,7 +701,7 @@ "modules": "modüller: {list}", "template": "şablon: {template}", "stripeAccount": "Stripe: {account}", - "stripeAccountMissing": "Çevrimiçi ödeme satın alınmış ama bu kayıtta stripe_account yok — provizyon bu ikiliyi reddeder ve veritabanından önce durur, dolayısıyla bu tenant'ı yeniden provizyonlamak hiçbir şey yapmaz. Hesabı kayda ekleyin ya da modülü çıkarın.", + "stripeAccountMissing": "Kartlı ödeme satın alıyor ama bu kayıtta stripe_account yok — provisioning bu çifti reddeder ve veritabanından önce durur; yani bu kiracıyı yeniden provision etmek hiçbir şey yapmaz. Bu hesabı artık biz oluşturuyoruz, dolayısıyla bu, oluşturmanın başarısız olduğu anlamına gelir: nedeni öneri PR'ında yazar, sonra hesabı kayda ekleyin ya da modülü kaldırın.", "paymentsModeFlat": "ödemeler: sabit ücret", "paymentsModeCommission": "ödemeler: komisyon ({percent})", "paymentsModePending": "(beklemede — kayıt henüz güncellenmedi)" @@ -775,8 +775,6 @@ "currency": "Para birimi (ör. EUR)", "languages": "Diller", "modules": "Modüller", - "stripeAccount": "Stripe hesabı (acct_…, isteğe bağlı)", - "stripeAccountHint": "Yalnızca online-payments satın aldılarsa VE bağlı hesaplarını zaten oluşturduysanız (runbook §2b). Boş bırakılırsa modül beklemeye alınır ve PR sonradan nasıl ekleneceğini anlatır — hesap olmadan provisioning modülü reddeder.", "create": "Kayıt PR’si aç", "creating": "Açılıyor…", "created": "Kayıt PR’si açıldı — inceleyip birleştirin, sonra sağlayın:", @@ -792,7 +790,8 @@ "baseDomainOurs": "sofrapiwas.com — bizimki (varsayılan)", "baseDomainPreview": "Bu kiracı şu adreste yanıt verecek:", "baseDomainDnsFirst": "Partnerin kendi bölgesi: girdi base_domain: taşır ve A kaydı birleştirmeden önce çözümlenmelidir; aksi hâlde sertifika verilemez.", - "baseDomainSlugPlaceholder": "" + "baseDomainSlugPlaceholder": "", + "stripeAccountNote": "Stripe hesabı: hesabı biz oluşturuyoruz. Bu restoran kartlı ödeme satın alırsa, kontrol düzlemi bu öneri açılmadan önce Stripe Express hesabını oluşturur; böylece registry kaydı hesabı ve modülü birlikte taşır. Buraya bir şey yazmanıza gerek yok — oluşturma başarısız olursa PR nedenini ve ne yapılacağını yazar." }, "fleet": { "title": "Yazıcı filosu", @@ -1028,7 +1027,7 @@ "net": "Net", "fees": "{count} ücret", "empty": "Bu hesap izleniyor ve bu dönemde hiçbir tutar toplamadı.", - "noAccount": "Bu kiracının kayıt girdisinde stripe_account yok, dolayısıyla ücret raporlanacak bir hesap da yok. Restoran Stripe kaydını tamamlayana kadar bu normal durumdur.", + "noAccount": "Bu kiracının registry kaydında stripe_account yok, dolayısıyla ücret raporlanacak bir hesap da yok. Bu hesabı artık biz oluşturduğumuza göre bu, ya bu değişiklikten önceki bir kaydı ya da başarısız bir oluşturmayı gösterir — kaydını henüz tamamlamamış bir restoranı değil.", "registryUnavailable": "Kiracı kaydı okunamadı, bu yüzden bağlı hesap şu an bilinmiyor. Yanlış bir rakam yerine hiçbir rakam gösterilmiyor.", "unmatchedRefunds": "Bu dönemdeki {count} iadenin kayıtlı ücreti yok — ücretleri kayıt tutulmadan öncesine ait, bu nedenle yukarıdaki net gerçekte olduğundan düşüktür." }, diff --git a/tests/unit/connect-account-country.test.ts b/tests/unit/connect-account-country.test.ts new file mode 100644 index 0000000..9fef594 --- /dev/null +++ b/tests/unit/connect-account-country.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { connectCountryForCurrency } from "@/lib/connect-account-country"; +import { CONNECT_ONBOARDABLE_COUNTRIES } from "@/lib/connect-account-request"; + +// The country an Express account is created in is FIXED FOREVER at creation — Stripe +// refuses it on update, like the account type. sofra holds no country for a tenant +// (neither `SignupRequest` nor the registry has one), so it is derived from the +// currency, and the whole value of this module is in what it REFUSES to derive. + +describe("connectCountryForCurrency", () => { + it("answers CHF, the currency of the first market", () => { + expect(connectCountryForCurrency("CHF")).toEqual({ ok: true, country: "CH" }); + expect(connectCountryForCurrency("chf")).toEqual({ ok: true, country: "CH" }); + expect(connectCountryForCurrency(" CHF ")).toEqual({ ok: true, country: "CH" }); + }); + + it("refuses EUR, and says why in words a founder can act on", () => { + // The one that matters. EUR is spoken by FR, DE, NL, IT, ES, BE and AT; picking one + // would create a LIVE account in the wrong country for a real restaurant, and it + // cannot be corrected through the API. A refusal costs one hand-edit before merging. + const verdict = connectCountryForCurrency("EUR"); + expect(verdict.ok).toBe(false); + expect(!verdict.ok && verdict.reason).toMatch(/EUR does not name one country/); + expect(!verdict.ok && verdict.reason).toMatch(/FR, DE, NL, IT, ES, BE and AT/); + }); + + it("refuses an absent currency rather than defaulting", () => { + expect(connectCountryForCurrency(undefined).ok).toBe(false); + expect(connectCountryForCurrency("").ok).toBe(false); + expect(connectCountryForCurrency(" ").ok).toBe(false); + }); + + it("refuses a currency it has never been told about", () => { + for (const c of ["JPY", "SEK", "TRY", "XXX"]) { + const v = connectCountryForCurrency(c); + expect(v.ok).toBe(false); + expect(!v.ok && v.reason).toContain(c); + } + }); + + it("only ever answers with a country we can actually onboard", () => { + // The link between the two modules: a derived country that `expressAccountForm` + // would refuse would be a mint that fails at the boundary for a reason nobody can + // read. Checked over every currency this module knows, not over a sample. + for (const currency of ["CHF", "GBP", "USD", "AED"]) { + const v = connectCountryForCurrency(currency); + expect(v.ok).toBe(true); + expect(v.ok && Object.keys(CONNECT_ONBOARDABLE_COUNTRIES)).toContain(v.ok ? v.country : ""); + } + }); +}); diff --git a/tests/unit/provision-form-input.test.ts b/tests/unit/provision-form-input.test.ts index fe60944..aeacdde 100644 --- a/tests/unit/provision-form-input.test.ts +++ b/tests/unit/provision-form-input.test.ts @@ -49,22 +49,40 @@ const entryFrom = (fields: Record) => { return { tenant, deferred: built.deferred, input: read.input }; }; -describe("a Stripe account typed on the form reaches the registry entry", () => { - it("round-trips the acct_ AND grants the module it is paired with", () => { - const { tenant, deferred } = entryFrom({ +describe("the Stripe account is SERVER-DERIVED, and the form cannot supply one", () => { + // The provenance flip (ADR-011 amendment, E3). The account is minted by + // `openProvisioningPr` and attached to the input it builds the entry from; the + // browser has no say. The original regression this file was written for — a posted + // field silently never read — is therefore now the REQUIRED behaviour for this one + // field, and it is asserted as such rather than deleted. + it("ignores a posted acct_ instead of forwarding it to the entry", () => { + const { tenant, deferred, input } = entryFrom({ ...base, modules: ["core", "online-payments"], stripeAccount: "acct_1AbCdEfGhIjKlMnO", }); - // The regression: this was `undefined` all the way down. - expect(tenant.stripe_account).toBe("acct_1AbCdEfGhIjKlMnO"); - // …and the consequence that made it invisible-but-expensive: the module the founder - // had already earned the right to ship was held back for a second registry PR. - expect(deferred).toEqual([]); + expect(input.stripeAccount).toBeUndefined(); + expect("stripe_account" in tenant).toBe(false); + // And the pairing rule still holds the module back rather than emitting an entry + // `provision-tenant.sh` would refuse before the database. + expect(deferred).toEqual(["online-payments"]); + }); + + it("carries BOTH halves in one entry once the mint has attached an account", () => { + // What the action actually does: map the form, then hand the builder the input plus + // the minted id. This is the shape that makes `provision-tenant.sh:117` never fire. + const read = readProvisionForm(form({ ...base, modules: ["core", "online-payments"] })); + if (!read.ok) throw new Error(read.error); + const built = buildTenantRegistryEntry({ ...read.input, stripeAccount: "acct_1UCOkhCSPiP2JWOQ" }); + const tenant = ( + parse(`version: 1\ntenants:\n${built.entry}`) as { tenants: Record> } + ).tenants[read.input.slug]; + expect(tenant.stripe_account).toBe("acct_1UCOkhCSPiP2JWOQ"); expect(tenant.modules).toEqual(["core", "online-payments"]); + expect(built.deferred).toEqual([]); }); - it("still defers when the founder genuinely has no account — the other half of the pair", () => { + it("still defers when no account could be minted — the last-resort path", () => { const { tenant, deferred } = entryFrom({ ...base, modules: ["core", "online-payments"] }); expect(deferred).toEqual(["online-payments"]); expect(tenant.modules).toEqual(["core"]); @@ -110,12 +128,11 @@ describe("readProvisionForm — the rest of the mapping", () => { // and `z.string().optional()` accepts `undefined`, not `null` — so without the // coercion in `optionalField` adding `baseDomain` would have made every such POST // fail the WHOLE form with "Invalid input", naming nothing. - const fd = form(base); // no city, no stripeAccount, no baseDomain + const fd = form(base); // no city, no baseDomain const read = readProvisionForm(fd); expect(read.ok).toBe(true); if (read.ok) { expect(read.input.baseDomain).toBeUndefined(); - expect(read.input.stripeAccount).toBeUndefined(); expect(read.input.city).toBeUndefined(); } }); @@ -148,7 +165,7 @@ describe("readProvisionForm — the rest of the mapping", () => { // Vacuity guard: an empty (or shape-less) key list would make the loop below pass by // iterating nothing, which is the same false green this test exists to end. expect(keys.length).toBeGreaterThan(5); - expect(keys).toContain("stripeAccount"); + expect(keys).toContain("baseDomain"); for (const key of keys) { expect(seen, `provisionSchema validates "${key}" but the form is never asked for it`).toContain( key, diff --git a/tests/unit/provisioning-mint.test.ts b/tests/unit/provisioning-mint.test.ts new file mode 100644 index 0000000..50e83eb --- /dev/null +++ b/tests/unit/provisioning-mint.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mintForProposal } from "@/lib/provisioning-mint"; + +// The seam where the ADR-011 amendment's provenance flip actually happens. These do not +// mock Stripe (CLAUDE.md §7 forbids it) — they exercise the branches that DECIDE not to +// call it. If any of those decisions moved below the call, these tests would reach the +// real API from a suite that has no key, which is a loud failure rather than a quiet one. + +const KEY = "STRIPE_API_KEY"; +const originalKey = process.env[KEY]; + +afterEach(() => { + if (originalKey === undefined) delete process.env[KEY]; + else process.env[KEY] = originalKey; +}); + +const input = { + slug: "bistro-nova", + name: "Bistro Nova", + adminEmail: "owner@example.com", + currency: "CHF", + modules: ["core", "reservations"], + url: "https://bistro-nova.sofrapiwas.com", +}; + +describe("mintForProposal", () => { + it("mints nothing for a tenant that did not buy online payments", async () => { + // A live Stripe connected account is a real object with a real compliance + // obligation attached. Creating one for a restaurant that never asked for card + // payments is not a harmless default. + process.env[KEY] = "sk_test_not_used_by_this_branch"; + await expect(mintForProposal(input)).resolves.toEqual({}); + }); + + it("says so, and does not throw, when the control plane has no Stripe key", async () => { + // Its caller chain ends at the Mollie webhook, where a throw means a non-2xx, which + // means a paid customer's activation is redelivered for ~26h. + delete process.env[KEY]; + const result = await mintForProposal({ ...input, modules: ["core", "online-payments"] }); + expect(result.stripeAccount).toBeUndefined(); + expect(result.note).toMatch(/STRIPE_API_KEY is not configured/); + }); + + it("refuses to guess a country from EUR — before any network call", async () => { + // Reached with a key present, so the ONLY thing that can stop a network call here is + // the country decision itself. Stripe fixes an account's country permanently at + // creation, so this refusal is the difference between a hand-edit and a dead account. + process.env[KEY] = "sk_test_never_reaches_the_wire"; + const result = await mintForProposal({ + ...input, + currency: "EUR", + modules: ["core", "online-payments"], + }); + expect(result.stripeAccount).toBeUndefined(); + expect(result.note).toMatch(/EUR does not name one country/); + }); +}); diff --git a/tests/unit/provisioning-registry.test.ts b/tests/unit/provisioning-registry.test.ts index 8114346..35d6ded 100644 --- a/tests/unit/provisioning-registry.test.ts +++ b/tests/unit/provisioning-registry.test.ts @@ -338,8 +338,18 @@ describe("deferring online-payments out of the generated entry", () => { // Named as bought, not silently absent. expect(body).toContain("Bought but deliberately NOT in this entry: `online-payments`"); expect(body).toContain("no restaurant at all"); - // The reason the founder cannot just add it now. - expect(body).toContain("only the restaurant can create it"); + // The reason there is no account. Under the ADR-011 amendment this is a FAILURE + // report — the control plane mints the account — so the body must carry the reason + // the mint gave rather than the retired premise that only the restaurant could + // create one. + expect(body).not.toContain("only the restaurant can create it"); + const withNote = buildProvisioningPrBody({ + ...base, + modules: bought, + stripeAccountNote: "EUR does not name one country", + }); + expect(withNote).toContain("**Why there is no account:** EUR does not name one country"); + expect(withNote).toContain("Sofra creates each tenant's Stripe **Express** account itself"); // The follow-up, with BOTH halves — an entry adding one without the other is the // same landmine re-armed by hand. expect(body).toContain("stripe_account: acct_"); diff --git a/tests/unit/validation.test.ts b/tests/unit/validation.test.ts index fb87c43..a0df2cc 100644 --- a/tests/unit/validation.test.ts +++ b/tests/unit/validation.test.ts @@ -14,7 +14,7 @@ import { signupStatusSchema, SIGNUP_STATUSES, } from "@/lib/validation"; -import { provisionSchema } from "@/lib/validation-provision"; +import { isStripeAccountId, provisionSchema } from "@/lib/validation-provision"; describe("applySchema (partner application)", () => { const valid = { name: "Ada", email: "ada@example.com", message: "Hi there" }; @@ -307,13 +307,14 @@ describe("provisionSchema (ADR-012 tenant proposal)", () => { expect(provisionSchema.safeParse({ ...base, modules: "" }).success).toBe(false); }); - it("accepts a Stripe account id, and treats absent/empty as 'not supplied'", () => { - // Optional by design: the self-serve path never has one, and the founder path only - // sometimes does. Empty must parse, because empty is the signal that makes the - // generator hold `online-payments` back rather than propose a refused entry. - expect(provisionSchema.safeParse({ ...base, stripeAccount: "acct_1AbCdEfGhIjKlMnO" }).success).toBe(true); - expect(provisionSchema.safeParse({ ...base, stripeAccount: "" }).success).toBe(true); - expect(provisionSchema.safeParse(base).success).toBe(true); + it("no longer takes a Stripe account from the form at all", () => { + // The provenance flip (ADR-011 amendment, E3): the control plane MINTS the account, + // so this stopped being an operator input. Zod strips unknown keys, so a posted + // `stripeAccount` cannot reach the generator even if a stale browser sends one — + // asserted on the PARSED OUTPUT, because `success` alone would be true either way. + const parsed = provisionSchema.safeParse({ ...base, stripeAccount: "acct_1AbCdEfGhIjKlMnO" }); + expect(parsed.success).toBe(true); + expect(parsed.success && "stripeAccount" in parsed.data).toBe(false); }); it("accepts a partner base domain, and treats absent/empty as 'ours' (D1/D2)", () => { @@ -341,13 +342,18 @@ describe("provisionSchema (ADR-012 tenant proposal)", () => { } }); - it("rejects a malformed Stripe account id rather than forwarding it", () => { - // This value reaches the tenant's Stripe env. A typo there is not a validation - // nicety: charges would be addressed to an account that does not exist, and the - // registry guard only checks that the field is NON-EMPTY, never that it is real. - for (const bad of ["acct", "acct_", "1AbCdEfGhIjKlMnO", "acct_short", "acct_has spaces"]) { - expect(provisionSchema.safeParse({ ...base, stripeAccount: bad }).success).toBe(false); + it("still refuses a malformed Stripe account id — now where the value comes from", () => { + // The grammar check outlived the form field. This value reaches the tenant's Stripe + // env, where a wrong string means charges addressed to an account that does not + // exist, and the registry guard only checks that the field is NON-EMPTY. It is now + // applied to what STRIPE returned (lib/provisioning-mint.ts) rather than to what a + // founder typed — the same assertion, one layer along. + for (const bad of ["acct", "acct_", "1AbCdEfGhIjKlMnO", "acct_short", "acct_has spaces", ""]) { + expect(isStripeAccountId(bad)).toBe(false); } + // The positive control: a rule that refused everything would pass the loop above. + expect(isStripeAccountId("acct_1AbCdEfGhIjKlMnO")).toBe(true); + expect(isStripeAccountId("acct_1UCOkhCSPiP2JWOQ")).toBe(true); }); it("rejects a line break inside the tenant name", () => { diff --git a/vitest.config.ts b/vitest.config.ts index 18afa47..637a6d6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -207,6 +207,11 @@ export default defineConfig({ // `lib/stripe-connect-accounts.ts` stays OUT, the same split as // vies/vies-result and stripe-fee-refund's. "lib/connect-account-request.ts", + // E3 — which country a tenant's account is created in. Pure, and in scope + // because its whole job is a REFUSAL: Stripe fixes an account's country at + // creation and refuses it on update, so a wrong derivation is a live account + // in the wrong country for a real restaurant. + "lib/connect-account-country.ts", ], reporter: ["text-summary", "text"], // Floors sit a few points under the current 100/95/100/100 so a trivial From 98df728f769a2de06f58e001ba887214ac9198cf Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:57:00 +0200 Subject: [PATCH 4/8] feat(payments): the restaurant's own onboarding page, minting a fresh Stripe link per request (E4) (#230) * feat(payments): the restaurant's own onboarding page, minting a fresh Stripe link per request (E4) * chore(payments): read-only props on the onboarding page (SonarCloud S6759) --- .../onboarding/payments/[token]/page.tsx | 76 +++++++++++++++++ lib/connect-account-links.ts | 64 ++++++++++++++ lib/connect-account-store.ts | 54 +++++++++++- lib/onboarding-payments.ts | 76 +++++++++++++++++ lib/stripe-connect-accounts.ts | 33 +++++++- messages/ar.json | 9 ++ messages/de.json | 9 ++ messages/en.json | 9 ++ messages/fr.json | 9 ++ messages/nl.json | 9 ++ messages/tr.json | 9 ++ .../migration.sql | 35 ++++++++ prisma/schema.prisma | 12 +++ tests/unit/connect-account-links.test.ts | 83 +++++++++++++++++++ tests/unit/connect-account-store.test.ts | 7 ++ vitest.config.ts | 5 ++ 16 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 app/[locale]/onboarding/payments/[token]/page.tsx create mode 100644 lib/connect-account-links.ts create mode 100644 lib/onboarding-payments.ts create mode 100644 prisma/migrations/20260905210000_connect_account_onboarding_token/migration.sql create mode 100644 tests/unit/connect-account-links.test.ts diff --git a/app/[locale]/onboarding/payments/[token]/page.tsx b/app/[locale]/onboarding/payments/[token]/page.tsx new file mode 100644 index 0000000..723885f --- /dev/null +++ b/app/[locale]/onboarding/payments/[token]/page.tsx @@ -0,0 +1,76 @@ +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; +import { SITE_URL } from "@/lib/seo"; +import { resolvePaymentsLink } from "@/lib/onboarding-payments"; + +// RUNTIME, never prerendered, and never indexed. +// +// Every request must mint a NEW Stripe Account Link: one lives 300 seconds +// (measured), and two calls return two different URLs. A cached page would hand +// a restaurant a dead link and a static one could not exist at all. `noindex` for +// the obvious reason — the URL is the credential. +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "onboardingPayments" }); + return { title: t("meta.title"), robots: { index: false, follow: false } }; +} + +/** + * The one door between a restaurant and Stripe's hosted onboarding (ADR-011 + * amendment, E4). + * + * UNAUTHENTICATED by necessity, and that is why the path carries a 32-byte token + * rather than a slug: the restaurant has no login here — they log into their own + * tenant app, never into the control plane — and the link this page produces is a + * bearer capability over their KYC and their payout bank account. CLAUDE.md §5.1 + * governs the `(control)` plane; this page is deliberately NOT in it. It is on the + * public site, beside `/signup`, because its visitor is a member of the public, + * and it holds the same obligation the five unauthenticated control surfaces + * hold: it answers the same way to every wrong input, so it cannot be asked + * whether a token is nearly right. + * + * It never renders a Stripe URL and never stores one. On success it redirects; the + * body below only exists for the two states where it cannot. + */ +export default async function OnboardingPaymentsPage({ + params, +}: Readonly<{ + params: Promise<{ locale: string; token: string }>; +}>) { + const { locale, token } = await params; + const t = await getTranslations({ locale, namespace: "onboardingPayments" }); + + // The page's OWN url becomes Stripe's refresh_url and return_url, so it is built + // from the request that actually arrived rather than from a constant: a tenant on + // a partner's own zone, or staging, must come back to where it left. + const host = (await headers()).get("host"); + const origin = host ? `https://${host}` : SITE_URL; + const outcome = await resolvePaymentsLink(token, `${origin}/${locale}/onboarding/payments/${token}`); + + // Outside the try/catch-free zone on purpose: `redirect` throws by design in + // Next, so it must be called where nothing will swallow it. + if (outcome.kind === "redirect") redirect(outcome.url); + + const body = outcome.kind === "unknownToken" ? "unknownToken" : "unavailable"; + return ( + <> +
+
+

{t("title")}

+

{t(body)}

+

{t("contact")}

+
+