feat(payments): the restaurant's own onboarding page, minting a fresh Stripe link per request (E4) - #230
Merged
Conversation
… Stripe link per request (E4)
mahmutkaya
force-pushed
the
feature/express-onboarding-page
branch
from
September 5, 2026 19:46
0be1937 to
1d2a87e
Compare
|
This was referenced Sep 5, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
/onboarding/payments/<token>— the one door between a restaurant and Stripe's hosted onboarding (ADR-011 amendment, slice E4). It mints a fresh Account Link on every request and redirects; post-onboarding it mints an Express login link instead.StripeConnectAccount.onboardingToken(nullable, unique) — 32 random bytes, minted with the account.Issue / plan link
Type
featMEASURED 2026-09-05 — Stripe TEST mode, fresh CH Express account, deleted after (
GET→ 403)POST /v1/account_linkstype=account_onboardingexpires_at - created= 300 sPOST /v1/accounts/{id}/login_linkson a fresh accountPOST /v1/account_linkstype=account_update["account_onboarding"]'details_submitted: false,charges_enabled: false,payouts_enabled: falsedetails_submittedis the field the login-link refusal is worded against, so it is the one that decides — notcharges_enabled, which is false during review toounavailable, never Stripe's wordingrefresh_urlandreturn_urlboth point back at this same page. Expiry is the ordinary case, not an error: our page mints a new link, so a restaurant that took a phone call simply continues.Why the path carries a token — the security argument, stated
The page is unauthenticated by necessity: the restaurant has no login here (they log into their own tenant app, never the control plane). The link it produces is a bearer capability over their KYC and their payout bank account. So
?slug=rumiwould be an open door onto a real restaurant's payout details.node:crypto, base64url, minted with the account, never derived from anything about the tenant.(control)surfaces, and the reason this page sits on the public site beside/signuprather than becoming a sixth exception in a plane whose rule is "guard everything".Stripe__PaymentsLinkUrl) and in a welcome mail. It sits in a box.envbeside the platform Stripe key — strictly less powerful than its neighbour.noindex, nofollow+force-dynamic: the URL is the credential, and a cached page would hand out a dead link.The one loop this does not close (yours, per your message)
The tenant-facing button reads
Stripe:PaymentsLinkUrl, which reaches a tenant throughprovision-tenant.sh's env render in the deploy repo — a repo I do not own. Exact change, ready to lift:…which needs the token in the registry entry, or an extra registry key. Do not take that shape as decided — the cleaner alternative is that sofra writes the full URL into the entry as one opaque field, so the deploy repo never learns what a token is. Either way it is one line plus one registry key, in a repo with its own gate. Until it lands the tenant tab shows an inert control with an honest sentence, exactly as the shipped frontend/backend contract intends (blank → null, blank is not a fallback).
NFR triage
resolvePaymentsLink("")is tested end-to-end-ish without a DB precisely because the empty-segment refusal must happen before the database — in a suite with no database, a regression there fails loudly.connect-account-links.tsin the coverage floor, its Stripe-calling sibling out), handwritten SQL migration in the house style.(control)surface.Database / migrations
prisma/migrations/20260905210000_connect_account_onboarding_token/— no applied migration editedprisma migrate deploy+migrate diff --exit-code→ 0, "No difference detected" against a throwawaypostgres:16-alpine(the same negative control as feat(payments): persist the connected account we mint, before the registry PR (E1) #227 proved this instrument discriminates)Test plan
typecheck·lint·test:coverage(1118 tests, floor green) ·build(the route compiles asƒ /[locale]/onboarding/payments/[token], i.e. dynamic) ·check-message-parity·check-single-file --allgrep -F) and COMPILED (tsc0) before any test was read:chooseConnectLinkinverted → 3 REDrefresh_urlrepointed at the marketing site → 1 REDnewOnboardingTokenmade a predictable counter → 1 REDGET /v1/accounts?limit=20shows only the two pre-existing platform accounts.Deploy notes
ghcr.io/piwas-21/sofra:migratebefore rolling the app.