Skip to content

feat(payments): the restaurant's own onboarding page, minting a fresh Stripe link per request (E4) - #230

Merged
mahmutkaya merged 2 commits into
developfrom
feature/express-onboarding-page
Sep 5, 2026
Merged

mahmutkaya merged 2 commits into
developfrom
feature/express-onboarding-page

Conversation

@mahmutkaya

Copy link
Copy Markdown
Contributor

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.
  • One additive migration: StripeConnectAccount.onboardingToken (nullable, unique) — 32 random bytes, minted with the account.
  • No Stripe URL is ever stored, cached or emailed. The welcome mail and the tenant's own Payments tab point at our page.

Issue / plan link

Type

  • feat

MEASURED 2026-09-05 — Stripe TEST mode, fresh CH Express account, deleted after (GET → 403)

call result what it decided
POST /v1/account_links type=account_onboarding 200, expires_at - created = 300 s a link can never be stored or emailed
the same call again a different URL a link is a one-shot handle, so the page mints per request
POST /v1/accounts/{id}/login_links on a fresh account 400 "Cannot create a login link for an account that has not completed onboarding" the choice must be made before asking
POST /v1/account_links type=account_update 400 'Valid types for this account are ["account_onboarding"]' there is no "edit your details" link on Express; do not build one
fresh account details_submitted: false, charges_enabled: false, payouts_enabled: false details_submitted is the field the login-link refusal is worded against, so it is the one that decides — not charges_enabled, which is false during review too
unknown account id 400 "No such account" the page answers unavailable, never Stripe's wording

refresh_url and return_url both 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=rumi would be an open door onto a real restaurant's payout details.

  • 32 random bytes from node:crypto, base64url, minted with the account, never derived from anything about the tenant.
  • Unknown token and "a row minted before tokens existed" get the same 404 — a page that told them apart would confirm to a stranger that a token is nearly right. That is the same obligation CLAUDE.md §5.1 puts on the five unauthenticated (control) surfaces, and the reason this page sits on the public site beside /signup rather than becoming a sixth exception in a plane whose rule is "guard everything".
  • It is long-lived, unlike the 300-second link it mints, because it travels in the tenant env (Stripe__PaymentsLinkUrl) and in a welcome mail. It sits in a box .env beside 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 through provision-tenant.sh's env render in the deploy repo — a repo I do not own. Exact change, ready to lift:

# beside the existing STRIPE_* lines (~:806-818)
set_env_line STRIPE_PAYMENTS_LINK_URL "https://sofrapiwas.com/onboarding/payments/${REG_STRIPE_ONBOARDING_TOKEN}"

…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

  • D1 security: covered above. Additionally: no PII and no Stripe wording reaches the visitor; the resolver logs a slug and nothing else; every failure is a rendered sentence, never a blank page.
  • D6 i18n: one new namespace, 5 keys × 6 locale files (the parity script compares the 5 non-English bundles against English and reports "All 5 locales in parity (1244 keys each)" — six files, five comparisons).
  • D9 testing: 8 new unit tests over the pure seams; three mutants below. 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.
  • D4 UX: the two non-redirect states say what happened, what to do, and where the right link lives.
  • D10 conventions: pure/impure split (connect-account-links.ts in the coverage floor, its Stripe-calling sibling out), handwritten SQL migration in the house style.
  • Rest: n/a because no money arithmetic, no billing state, no (control) surface.

Database / migrations

  • Handwritten SQL, prisma/migrations/20260905210000_connect_account_onboarding_token/ — no applied migration edited
  • ADDITIVE ONLY: one nullable column + one unique index. Postgres allows many NULLs in a unique index, so it neither collides nor blocks; the read path treats NULL as no match, so such a row is unreachable by the page rather than reachable without a token
  • prisma migrate deploy + migrate diff --exit-code0, "No difference detected" against a throwaway postgres: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 --all
  • Mutation, each LANDED (grep -F) and COMPILED (tsc 0) before any test was read:
    1. chooseConnectLink inverted → 3 RED
    2. refresh_url repointed at the marketing site → 1 RED
    3. newOnboardingToken made a predictable counter → 1 RED
  • The Stripe probes were run by hand against the test key; the account was deleted and GET /v1/accounts?limit=20 shows only the two pre-existing platform accounts.

Deploy notes

  • Migration one-off required: yesghcr.io/piwas-21/sofra:migrate before rolling the app.
  • New env vars on the box: none here. The deploy-repo line above is a separate PR in a separate repo.
  • Rollout order / risk: the page is unreachable without a token, and tokens only exist for accounts this app minted — so on a fleet with no minted accounts the whole surface answers 404, which is the correct state.

@mahmutkaya
mahmutkaya force-pushed the feature/express-onboarding-page branch from 0be1937 to 1d2a87e Compare September 5, 2026 19:46
@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