Skip to content

feat(payments): the platform mints the connected account, so one registry PR carries both halves (E3) - #229

Merged
mahmutkaya merged 1 commit into
developfrom
feature/express-provenance-flip
Sep 5, 2026
Merged

mahmutkaya merged 1 commit into
developfrom
feature/express-provenance-flip

Conversation

@mahmutkaya

Copy link
Copy Markdown
Contributor

Summary

  • The provenance flip (ADR-011 amendment, slice E3). stripe_account: stops being something a founder types and becomes something the control plane MINTS, so the registry PR carries online-payments and stripe_account: in ONE commit and provision-tenant.sh:117 never fires.
  • The mint happens inside openProvisioningPr, beside tenantPartnerBrand and for the same reason: it is the one place that writes a registry entry, so no caller can forget it and no caller can inject one. Both paths (self-serve webhook, founder form) inherit it.
  • The stripeAccount input is gone from the form, the schema and the FormData mapping. Its grammar check survives, applied to what Stripe returned.

Issue / plan link

Type

  • feat

The premise this deletes

Five files stated it: "only the restaurant can create it, through Stripe's hosted onboarding, which cannot be pre-filled (oauth_not_supported)". Measured 2026-09-05: oauth_not_supported is the answer to an UPDATE, not to a CREATE. Prefill at create time works (#228). So the premise is false in the direction that matters, and the whole deferred-module machinery stops being the normal path.

It is not deleted, and that is deliberate:

  • splitDeferredModules stays as the last-resort rule. provision-tenant.sh:117 refuses online-payments without an account before the database, and that guard must stay satisfiable. When a mint fails, withholding the module gives the restaurant everything else and a working cash tenant; proposing the unpaired module would give them no tenant at all.
  • The PR-body section that explains a deferral is rewritten, not removed: reaching it now means the mint failed, so it carries the reason (stripeAccountNote) and tells the founder to fix the cause or add both fields by hand — never "wait for the restaurant".
  • The existing test that shells out to provision-tenant.sh's own guard logic still passes, which is what "still satisfiable" means concretely.

The one thing the plan did not anticipate: a country

Stripe fixes an account's country permanently at creation. sofra holds no country for a tenant — not on SignupRequest, not in the registry (provision-tenant.sh's field whitelist has no country). So lib/connect-account-country.ts derives it from the one fact we do hold, the currency, and refuses to guess:

currency verdict
CHF / GBP / USD / AED CH / GB / US / AE
EUR refused — FR, DE, NL, IT, ES, BE and AT all use it
absent / anything else refused, naming the currency

A refusal costs the founder one hand-edit before merging (the PR body says exactly what to add). A guess costs a live, uncorrectable Stripe account in the wrong country for a real restaurant. CH is the first market and RUMI is a CH tenant, so the path that matters works today. Asking for a country properly (a field on the lead / the form) is recorded in BACKLOG rather than smuggled in here — it is a form change with six locales attached, and this slice is already the wide one.

NFR triage

  • D1 security: the mint runs in the CONTROL PLANE only; provision-tenant.sh keeps reading the registry (ADR-003/007) and gains no capability, and the box key must never get Connect → write. It runs after the "slug already merged" refusal, so a slug that cannot be proposed cannot cost a live Stripe account. mintForProposal never throws (its caller chain ends at the Mollie webhook, where a throw means ~26h of redeliveries for a paying customer) and logs no PII — a slug and Stripe's own message.
  • D6 i18n: 3 keys rewritten and 2 replaced by 1, across all six locales; scripts/check-message-parity.mjs"All 5 locales in parity (1239 keys each)".
  • D8 observability: both audit trails now carry the minted acct_ (founder path) or the reason there is none (mintNote, both paths). An acct_ is an identifier and not a secret — /admin/tenants already renders one — and this is the only durable record that a LIVE Stripe account was created while someone filled in a form.
  • D9 testing: 8 new tests; the old stripeAccount-typed-on-the-form regression test is inverted (the field must now be ignored) rather than deleted, and a new one asserts both halves land in one entry once the mint has attached an account.
  • D4 UX: the form field becomes a read-only sentence rather than disappearing — the founder is about to review a diff containing an acct_ nobody typed, and that is where they learn where it came from.
  • Rest: n/a because no schema change, no money arithmetic, no new endpoint, no PII.

Test plan

  • typecheck · lint · test:coverage (1110 tests, floor green, 100% lines) · build · check-single-file --all · check-message-parity all green locally
  • Mutation, each verified LANDED (grep -F) and COMPILED (tsc exit 0) before any test result was read:
    1. EUR silently resolves to FR2 RED (the unit and the mint seam)
    2. buysPaired forced true (mint for a tenant that never bought it) → 1 RED
    3. readProvisionForm reads stripeAccount from the FormData again → 1 RED
    4. registry key typo stripe_acount:4 RED
  • The mint tests do not mock Stripe (CLAUDE.md §7). They exercise the branches that decide not to call it — so if any of those decisions moved below the call, they would reach the real API from a suite with no key, which fails loudly.

Deploy notes

  • Migration one-off required: no.
  • New env vars on the box: none. The control plane's STRIPE_API_KEY needs Connect → write (owner-side prerequisite P2) before a mint can succeed; without it the code degrades exactly as designed — the module is withheld, the PR body says why, and the tenant still ships.
  • Rollout order / risk: this is the first slice with runtime behaviour. Risk is bounded by the fallback: every failure mode (no key, no Connect → write, EUR, Stripe down) lands on the pre-existing deferral path, which is what the fleet does today for every tenant.

@sonarqubecloud

sonarqubecloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant