diff --git a/docs/adr/ADR-011-payments-split.md b/docs/adr/ADR-011-payments-split.md index cff35ba..d955bff 100644 --- a/docs/adr/ADR-011-payments-split.md +++ b/docs/adr/ADR-011-payments-split.md @@ -1,8 +1,10 @@ # ADR-011 — Payments split: Mollie for Sofra billing, Stripe Connect for tenant payments **Status:** accepted 2026-07-05 (owner decision); **amended 2026-09-04** — Job B -gains the application-fee mechanism, still defaulting to 0 for every tenant. See -§Amendment. +gains the application-fee mechanism, still defaulting to 0 for every tenant; +**amended 2026-09-05** — the implementation moves to **Express** connected +accounts minted by the control plane, which is what Job B always said. See the +two §Amendment sections, in that order. Two distinct payment jobs, two providers: @@ -82,6 +84,88 @@ ar` (twelve strings, verified by grep 2026-09-04) — is still **true**, because every tenant is at 0. That copy is exactly what must change, in all twelve places, the day a tenant is first put on a non-zero rate — and not before. +## Amendment (2026-09-05) — the implementation moves to Express, which is what this ADR always said + +Job B said each restaurant becomes a connected account via **Express onboarding**. The implementation shipped +**Standard** direct charges instead, and the 2026-09-04 amendment above recorded that as the mechanism. This +amendment closes the drift in the ADR's favour: **new tenants get Express connected accounts, minted by the +control plane.** Nothing about the charge type changes — still Direct charges with `application_fee_amount`, +still money that never touches Sofra's balance. + +**Migration cost is zero, and that is why now.** No tenant has a `stripe_account` today: all six mentions in +`deploy/tenants/registry.yml` are comment lines (`grep -n "^ *stripe_account:"` returns nothing, exit 1, while +the same-shaped `grep -c "^ *modules:"` on the same file returns 6, which is the control that makes the zero +believable). There is no account to migrate, and an account's type cannot be changed after creation. + +**Measured 2026-09-05**, platform `acct_1TpwTNCAHTt6eZ8i` (NL), Stripe **test** mode, against CH Express +connected accounts created and then deleted (`GET` on each afterwards returns 403; `GET /v1/accounts?limit=20` +lists only the two pre-existing accounts): + +- **TWINT works on CH Express, and our automation is the right mechanism.** `twint_payments` is requestable at + create time alongside `card_payments` + `transfers`; its only blocker is `requirements.fields_needed`. An + Express account carries the same **two** payment-method configurations as a Standard one — exactly one with + `parent: null` — and `POST /v1/payment_method_configurations/{id} twint[display_preference][preference]=on` + returns `available: true`. **`provision-tenant.sh:1001-1058` needs no change.** Stripe's TWINT page confirms + the direction: for Express-dashboard accounts, enabling TWINT is the *platform's* job. This closes this + ADR's open "TWINT availability matrix / Connect Express in CH" item. +- **The platform still cannot accept ToS**, and Express does not change that: `POST /v1/accounts/{id} + tos_acceptance[...]` → `400 "You cannot accept the Terms of Service on behalf of accounts where + controller[requirement_collection]=stripe, which includes Standard and Express accounts."` BACKLOG's + wording ("a **Standard** account") was too narrow and is corrected, not removed. +- **Prefill is real, and it is create-only.** A bare CH Express account has **16** `currently_due` fields; + created with name, address, email, MCC, URL and the restaurant's **IBAN** as `external_account`, it has + **6**: `individual.dob.{day,month,year}`, `individual.phone`, `tos_acceptance.{date,ip}`. The same fields + are refused on **update** (`403 oauth_not_supported`), so the mint call is the only chance to prefill. This + is the answer to "can we just collect the IBAN": yes — and the restaurant still spends about two minutes in + Stripe's hosted form, which cannot be removed. +- **An address is not a neutral prefill.** `individual[...]` without `business_type` is a hard `400`, and + `business_type` is itself refused on update — so prefilling an address *commits* the account to a natural + person, uncorrectably, for a restaurant that may turn out to be a company. Without that knowledge the + implementation sends neither and accepts **13** `currently_due` instead of 6. Thirteen beats wrong. +- **The commission mechanism survives untouched.** On an Express account, `POST /v1/payment_intents` with + `application_fee_amount` and `POST /v1/checkout/sessions` with + `payment_intent_data[application_fee_amount]` both return **200**; only confirming the intent discriminates + (*"Your account cannot currently make charges"*). Identical to the 2026-09-04 finding. + `IStripeGateway.BuildRequestOptions` and `StripeCheckoutClient` change **not at all**, and neither do the + four `provision-tenant.sh` guards or either Prisma fee table. +- **Correction to the 2026-09-04 amendment above.** It describes the mechanism as "the existing Connect + **Standard** direct charge". The account that measurement ran against, `acct_1UC065FfnKu8VnLM`, is + `type: none` with `controller.losses.payments = application` and + `controller.requirement_collection = application` — **the same loss model as Express, and not Standard at + all.** The NL→CH application fee was therefore already proven on a platform-loss-liable account. + +**Account Links have a 300-second fuse.** `POST /v1/account_links` returns `expires_at = created + 300`, two +calls return two different URLs, `type=account_update` is refused on Express (*"Valid types for this account +are `["account_onboarding"]`"*), and a login link is refused until onboarding completes. So the restaurant is +sent a page of **ours** that mints a link per click — never a Stripe URL, which would be dead before the email +was read. + +**What Express costs, recorded as accepted:** `controller.losses.payments = "application"`. Stripe debits the +connected account and its external account first; if that fails the loss is the platform's, Stripe holds a +reserve in our balance meanwhile, and after 180 days it takes the reserve. Managed Risk is unavailable to +loss-liable platforms, so fraud vetting at signup is ours. Dispute *fees* still fall on the connected account. +The mitigation we gain: the platform can set `settings.payouts.schedule` (`delay_days`, `interval: manual`) +and should leave `debit_negative_balances` at its Express default of `true`. + +**Owner-side prerequisites, neither of which is code:** the Connect platform profile must be complete, and +**Express onboarding must be enabled for Switzerland** in the Dashboard's Connect Settings — Stripe enables it +per country, and our platform is NL while our tenants are CH. A third is a key scope: the control plane's +`STRIPE_API_KEY` needs `Connect → write`, and the **box** key must never get it. + +**Not verified, and not to be published as fact until the first live tenant:** that `twint_payments` reaches +`active` on an Express account (activation needs ToS, which needs the hosted flow); Express fee rates for CH +(Stripe exposes no fee-schedule endpoint); and whether `DELETE /v1/accounts/{id}` succeeds on a **livemode** +Express account. On that last one the runbook's "one-way door" refusal is explicitly conditioned on +`controller[losses][payments]=stripe` **and** `controller[stripe_dashboard][type]=full`, neither of which +Express has, and the delete **did** succeed in test mode — so the door probably reopens. Re-measure in +`piwas sandbox` before rewriting that box. + +**Where it shipped:** sofra #227 (persistence + slug-derived idempotency key), #228 (the mint), #229 (the +provenance flip — `stripe_account:` is server-derived, one registry PR carries both halves), #230 +(`/onboarding/payments/`), #231 (control-plane copy), #232 (the `account.updated` branch), #233 +(`payments_link_url:` in the entry); frontend #726 and backend #493 for the tenant-facing copy and the +`paymentsLinkUrl` contract. + ## Why split rather than one provider - Sofra-the-merchant is EU/NL-anchored — Mollie's home turf, cheapest @@ -95,5 +179,6 @@ places, the day a tenant is first put on a non-zero rate — and not before. research was cut short; do NOT publish these as claims before checking) - Mollie: Swiss **sub-merchant**/platform coverage; current TWINT status. -- Stripe: TWINT availability matrix (merchant countries), Connect Express in CH. +- ~~Stripe: TWINT availability matrix (merchant countries), Connect Express in CH.~~ **CLOSED 2026-09-05** by + the Express amendment — measured on a CH Express account, including the per-account TWINT flip. - Current fee tables for both (EU cards, TWINT, iDEAL) — feeds ADR-010 pricing.