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