Skip to content

feat(payments): the control plane says what Express actually does (E6-sofra) - #231

Merged
mahmutkaya merged 1 commit into
developfrom
feature/express-control-plane-copy
Sep 5, 2026
Merged

mahmutkaya merged 1 commit into
developfrom
feature/express-control-plane-copy

Conversation

@mahmutkaya

Copy link
Copy Markdown
Contributor

Summary

Issue / plan link

  • Plan: ADR-011 amendment, E6 (sofra half); frontend #726, backend #493 already merged.

Type

  • feat

The link that had to go, and what replaced it

The panel renders exactly when a buyer paid for online-payments and the entry does not grant it. Under Express that means our account creation did not happen. So:

  • https://dashboard.stripe.com pointed at a login the restaurant cannot use — an Express account has no full dashboard, and in this state they have no account at all.
  • Deleting the sentence would lose something useful, so it is re-stated as nextStep: what the short Stripe form will ask for (date of birth, phone, accepting Stripe's terms — the measured currently_due remainder), and why no link can be kept (one lives ~5 minutes).
  • body now says we open the Stripe account. "There is nothing for you to do right now" stays, and is more true than before: the outstanding work is ours, not theirs.
  • The §9 Q1 copy rule still holds — no env/box/deploy/registry/pull request/founder in any of the six locales — and it is enforced by the existing test, which I mutated to prove it still bites.

Contract check, against the MERGED refs rather than my worktree

check command result
the new field exists git grep -F paymentsLinkUrl origin/develop -- src (frontend) PaymentsTab.tsx:194, types/paymentsOnboarding.ts:42, tests
backend side git grep -F PaymentsLinkUrl origin/develop (backend) StripeSettings.cs:49 (default string.Empty), GetPaymentsOnboardingQuery.cs:115 (IsNullOrWhiteSpace → null)
the retired field is gone git grep -iF dashboardurl origin/develop only comments and a test asserting its absence (data.TryGetProperty("dashboardUrl", out _).Should().BeFalse()) — no live field
positive control for those greps same commands, requirementsDue / PaymentsOnboardingState 3 and 3 hits — so the instrument finds what is there and the "no live dashboardUrl" result is real
sofra does not reintroduce it grep -rn "dashboardUrl|dashboard.stripe.com" . one comment in the panel explaining what was removed, and one in a test

Your three review points on #230

  1. Referrer-Policy — done here. next.config.ts gets a second, later-matching entry for /:locale/onboarding/payments/:token with no-referrer (last match wins in Next's merge). Verified against a running next start, with a positive control: that route answers Referrer-Policy: no-referrer, /en/legal answers the site-wide strict-origin-when-cross-origin. One header, not two.
  2. Log hygiene — measured, and it goes in E7's BACKLOG. git grep -nE "^\s*log\s*\{|log_file|access.log" origin/develop -- Caddyfile Caddyfile.staging caddy-tenants tenants/templates in the deploy repo finds nothing, while plain log matches 7 lines in the same file (positive control). So no site explicitly configures access logging — which means Caddy's default behaviour decides, and its default emits access logs to stderr, i.e. into the container log. The token is therefore very likely in docker logs on the box. I have not read a running container's log (no improvised SSH), so that last step is UNVERIFIED and the BACKLOG line will say so.
  3. Rotation — E7 BACKLOG. The column is nullable + unique, so rotation is possible and nothing does it. The line will state the founder's move (null the column, re-mint, re-send) and that the old URL dies the moment it is nulled, because findConnectAccountByToken treats NULL as no match.

NFR triage

  • D1 security: no-referrer on the credential-bearing route, verified live. No other surface changes.
  • D6 i18n: 3 keys × 6 locale files, one key renamed (stripeLinknextStep); parity script → "All 5 locales in parity (1244 keys each)" (it compares the five non-English bundles against English; six files, five comparisons).
  • D9 testing: the copy-rule test's key-set assertion updated with the reason; the E2E spec's link assertion inverted (toHaveCount(0) plus a positive assertion on the replacement text) rather than removed — an absent link that nobody asserts is how a regression sneaks back.
  • D4 UX: the panel now prepares the restaurant for the short form instead of offering a login they do not have.
  • Rest: n/a because no schema change, no money arithmetic, no new endpoint, no PII.

Test plan

  • typecheck · lint · test:coverage (1118) · build · check-message-parity · check-single-file --all
  • Header verified by curl against next start, with a control page
  • Mutation: the nextStep string rewritten to name "the deploy details" → the copy-rule test goes RED (landed via grep -F, tsc 0, checked before reading the result). So the rule that forbids naming our plumbing still bites on the string I added.
  • npm run test:e2e:full — the unmocked suite that owns owner-payments-pending.spec.ts; result appended to this PR as a comment (it is not a CI job here, so it is not gate-visible either way).

Deploy notes

  • Migration one-off required: no. New env vars: none.
  • Rollout order / risk: copy and one response header. The no-referrer entry only matches the onboarding route.

@sonarqubecloud

sonarqubecloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

@mahmutkaya

Copy link
Copy Markdown
Contributor Author

**E2E, unmocked (

sofra@0.1.0 test:e2e:full
bash scripts/e2e-suite.sh

→ throwaway postgres on :55440
→ migrate + seed
seed-e2e: created ADMIN e2e-admin@example.test
seed-e2e: created PARTNER e2e-partner@example.test
→ build (without DATABASE_URL in scope — repo rule)
→ playwright

Running 80 tests using 1 worker

✓ 1 [chromium] › tests/e2e/admin-backups.spec.ts:81:5 › the three machine endpoints are not open to an anonymous caller (527ms)
✓ 2 [chromium] › tests/e2e/admin-backups.spec.ts:101:5 › a malformed inventory is refused without echoing anything back (48ms)
✓ 3 [chromium] › tests/e2e/admin-backups.spec.ts:114:5 › the page shows what is protected and, first, what is not (1.0s)
✓ 4 [chromium] › tests/e2e/admin-backups.spec.ts:175:5 › an artifact the box stops listing is removed, not remembered (117ms)
✓ 5 [chromium] › tests/e2e/admin-backups.spec.ts:198:5 › the founder queues a backup and the box collects it on its next poll (944ms)
✓ 6 [chromium] › tests/e2e/admin-backups.spec.ts:248:5 › deleting a tenant's LAST copy is refused until it is explicitly overridden (1.0s)
✓ 7 [chromium] › tests/e2e/admin-tenants.spec.ts:14:7 › the founder can see which tenants can take a card › an entry with an account shows it, and one without the account is flagged (660ms)
✓ 8 [chromium] › tests/e2e/backup-agent-box-binding.spec.ts:24:5 › a valid agent bearer cannot push an inventory for a box it is not (50ms)
✓ 9 [chromium] › tests/e2e/backup-agent-box-binding.spec.ts:36:5 › …and cannot claim another box's jobs either (48ms)
✓ 10 [chromium] › tests/e2e/backup-agent-box-binding.spec.ts:44:5 › its OWN box still works — the mechanism is a binding, not a block (48ms)
✓ 11 [chromium] › tests/e2e/backup-agent-box-binding.spec.ts:54:5 › a bearer this control plane does not hold is refused outright (43ms)
✓ 12 [chromium] › tests/e2e/backup-alert-cron.spec.ts:23:5 › the sweep endpoint is not an open relay (46ms)
✓ 13 [chromium] › tests/e2e/backup-alert-cron.spec.ts:34:5 › a sweep that could not mail says so, and leaves no marker behind (85ms)
e2e cleanup: cancel sub_3vCBrm2uhP → 200
✓ 14 [chromium] › tests/e2e/billing-mollie.spec.ts:93:7 › Mollie first payment and activation › a self-serve owner pays, the mandate race resolves, and the plan goes ACTIVE (14.4s)
✓ 15 [chromium] › tests/e2e/contact-intake.spec.ts:35:5 › the sixth contact POST from one client is refused (61ms)
✓ 16 [chromium] › tests/e2e/contact-intake.spec.ts:47:5 › a honeypot-filled contact POST is still dropped silently (44ms)
✓ 17 [chromium] › tests/e2e/contact-intake.spec.ts:60:5 › a malformed contact POST is a 400, not a 500 (43ms)
✓ 18 [chromium] › tests/e2e/control-auth.spec.ts:45:5 › admin logs in and lands on the admin console (521ms)
✓ 19 [chromium] › tests/e2e/control-auth.spec.ts:52:5 › partner logs in and lands on their dashboard (543ms)
✓ 20 [chromium] › tests/e2e/control-auth.spec.ts:58:5 › partner is denied the admin console (617ms)
✓ 21 [chromium] › tests/e2e/health.spec.ts:18:7 › /api/health › reports liveness and identifies the service (6ms)
✓ 22 [chromium] › tests/e2e/health.spec.ts:35:7 › /api/health › is never cached (5ms)
✓ 23 [chromium] › tests/e2e/health.spec.ts:43:7 › /api/health › leaks no configuration (4ms)
✓ 24 [chromium] › tests/e2e/invite-resend.spec.ts:27:5 › an expired invite link offers a new one instead of a dead end (138ms)
✓ 25 [chromium] › tests/e2e/invite-resend.spec.ts:36:5 › a re-send answers the same to an unknown address as to a real one (604ms)
✓ 26 [chromium] › tests/e2e/login-timing.spec.ts:75:5 › an unknown address costs the same as a known one (no user enumeration) (4.2s)
✓ 27 [chromium] › tests/e2e/owner-dashboard.spec.ts:39:7 › the owner is told where their app is — and only what is true › before paying, the app panel says nothing at all (4.8s)
✓ 28 [chromium] › tests/e2e/owner-dashboard.spec.ts:50:7 › the owner is told where their app is — and only what is true › paid with nothing proposed yet says 'preparing', and hands out no link (3.8s)
✓ 29 [chromium] › tests/e2e/owner-dashboard.spec.ts:62:7 › the owner is told where their app is — and only what is true › an open proposal says 'being built', and still hands out no link (3.9s)
✓ 30 [chromium] › tests/e2e/owner-dashboard.spec.ts:72:7 › the owner is told where their app is — and only what is true › the mandate-lag window is not told the same thing twice (7.8s)
✓ 31 [chromium] › tests/e2e/owner-dashboard.spec.ts:86:7 › the owner is told where their app is — and only what is true › a MERGED registry entry is still not 'ready' — the app has to answer (1.6s)
✓ 32 [chromium] › tests/e2e/owner-dashboard.spec.ts:109:7 › an owner with an active plan is shown their plan › amount, next charge and payment history — not 'nothing to do here' (3.8s)
✓ 33 [chromium] › tests/e2e/owner-dashboard.spec.ts:145:7 › an owner with an active plan is shown their plan › no pay button once the plan is active — that is the double-charge trap (1.5s)
✓ 34 [chromium] › tests/e2e/owner-payments-pending.spec.ts:46:7 › the buyer is told about the module they cannot see yet › bought and not yet granted — the card is there, and says nothing they can't act on (2.6s)
✓ 35 [chromium] › tests/e2e/owner-payments-pending.spec.ts:78:7 › the buyer is told about the module they cannot see yet › bought and granted — the card is gone (1.6s)
✓ 36 [chromium] › tests/e2e/owner-payments-pending.spec.ts:93:7 › the buyer is told about the module they cannot see yet › never bought it — the card is gone too (1.6s)
✓ 37 [chromium] › tests/e2e/partner-base-domain.spec.ts:33:5 › a partner claims a domain and is told exactly what to publish (1.0s)
✓ 38 [chromium] › tests/e2e/partner-base-domain.spec.ts:59:5 › a check that finds no record leaves the domain unusable, and says so (1.1s)
✓ 39 [chromium] › tests/e2e/partner-base-domain.spec.ts:86:5 › our own zone is refused before any lookup happens (926ms)
✓ 40 [chromium] › tests/e2e/partner-base-domain.spec.ts:102:5 › a partner never sees another partner's zone (1.1s)
✓ 41 [chromium] › tests/e2e/partner-base-domain.spec.ts:121:5 › the founder sees a partner's zones, and which proof has gone stale (1.3s)
✓ 42 [chromium] › tests/e2e/partner-brand.spec.ts:29:5 › a partner saves their public details, and the publish switch is inert (996ms)
✓ 43 [chromium] › tests/e2e/partner-brand.spec.ts:61:5 › the prefill carries the trade name and NOT the billing address (952ms)
✓ 44 [chromium] › tests/e2e/partner-brand.spec.ts:87:5 › a partner cannot write another partner's brand, even by naming them (1.2s)
✓ 45 [chromium] › tests/e2e/partner-brand.spec.ts:119:5 › a sole trader is told why their own name cannot be published (D-B1a) (1.1s)
✓ 46 [chromium] › tests/e2e/partner-client-domain.spec.ts:21:5 › a partner proposes their own zone, and is handed the record to publish (1.1s)
✓ 47 [chromium] › tests/e2e/partner-client-domain.spec.ts:61:5 › the default costs the partner no DNS at all (1.0s)
✓ 48 [chromium] › tests/e2e/partner-client-domain.spec.ts:80:5 › a partner with no verified zone is shown the way to one, not a dead control (919ms)
✓ 49 [chromium] › tests/e2e/partner-client-domain.spec.ts:103:5 › buying a domain through us is shown as unavailable, and refused if forced (3.4s)
✓ 50 [chromium] › tests/e2e/partner-client-domain.spec.ts:133:5 › once the restaurant is set up, the chooser is gone (928ms)
✓ 51 [chromium] › tests/e2e/partner-client-domain.spec.ts:156:5 › the founder can place a tenant under a proven partner zone, and sees whose it is (1.2s)
✓ 52 [chromium] › tests/e2e/partner-tenant-dns.spec.ts:47:5 › a client under the partner's own zone is told exactly which record to publish (893ms)
✓ 53 [chromium] › tests/e2e/partner-tenant-dns.spec.ts:78:5 › a client on our own base domain is asked for nothing (866ms)
✓ 54 [chromium] › tests/e2e/partner-tenant-panel.spec.ts:19:5 › a live client shows its tenant, what it includes and what it costs (982ms)
✓ 55 [chromium] › tests/e2e/partner-tenant-panel.spec.ts:58:5 › a partner can ask for a change, and it is kept as a note (1.1s)
✓ 56 [chromium] › tests/e2e/partner-tenant-panel.spec.ts:81:5 › an onboarding client with no tenant yet says what is being waited on (898ms)
✓ 57 [chromium] › tests/e2e/partner-tenant-panel.spec.ts:99:5 › a partner cannot open another partner's client (1.2s)
✓ 58 [chromium] › tests/e2e/partner-trial.spec.ts:48:5 › a partner inside the free period is told until when, and is never asked to pay (994ms)
✓ 59 [chromium] › tests/e2e/partner-trial.spec.ts:91:5 › once the free period has passed, the ask comes back and nothing else happens (899ms)
✓ 60 [chromium] › tests/e2e/partner-trial.spec.ts:127:5 › the founder can push a trial out, with a reason, and can never pull one in (2.8s)
✓ 61 [chromium] › tests/e2e/partner-trial.spec.ts:202:5 › a partner cannot reach the control that extends their own trial (901ms)
✓ 62 [chromium] › tests/e2e/self-serve-signup.spec.ts:43:7 › the public signup creates a payable account › a free slug mints an OWNER, a PENDING plan at the catalog price, and an invite (2.8s)
✓ 63 [chromium] › tests/e2e/self-serve-signup.spec.ts:80:7 › the public signup creates a payable account › an INVITED owner cannot log in until the invite is used (2.4s)
✓ 64 [chromium] › tests/e2e/self-serve-signup.spec.ts:91:7 › the public signup creates a payable account › invite -> password -> login lands on a dashboard showing the plan and a pay button (1.6s)
✓ 65 [chromium] › tests/e2e/self-serve-signup.spec.ts:128:7 › the mandate-lag window never invites a second payment › a paid first payment with a PENDING plan shows 'activating' and no pay button (3.9s)
✓ 66 [chromium] › tests/e2e/self-serve-signup.spec.ts:168:7 › the slug is refused while the customer can still fix it › a reserved slug is refused at the keyboard, with no request at all (426ms)
✓ 67 [chromium] › tests/e2e/self-serve-signup.spec.ts:183:7 › the slug is refused while the customer can still fix it › a taken slug is refused by the server, and the form keeps every answer (2.0s)
✓ 68 [chromium] › tests/e2e/self-serve-signup.spec.ts:207:7 › the slug is refused while the customer can still fix it › fixing the address on the spot succeeds (1.8s)
✓ 69 [chromium] › tests/e2e/self-serve-signup.spec.ts:223:7 › what the customer cannot fix becomes a founder lead › a second signup on the same email creates no second plan (1.9s)
✓ 70 [chromium] › tests/e2e/self-serve-signup.spec.ts:245:7 › what the customer cannot fix becomes a founder lead › resubmitting the same email AND slug is reported as a lead, never as 'taken' (646ms)
✓ 71 [chromium] › tests/e2e/self-serve-signup.spec.ts:262:7 › what the customer cannot fix becomes a founder lead › an admin's email is never repurposed into an owner (2.7s)
✓ 72 [chromium] › tests/e2e/self-serve-signup.spec.ts:276:7 › provisioning is gated on payment › an unpaid self-serve tenant is refused before any GitHub call (2.7s)
✓ 73 [chromium] › tests/e2e/self-serve-signup.spec.ts:293:7 › provisioning is gated on payment › a tenant with no plan at all is not gated — the founder's own path is untouched (912ms)
✓ 74 [chromium] › tests/e2e/self-serve-signup.spec.ts:311:7 › the signup answer says whether the welcome mail actually went out (G5) › the suite's own signups report emailed:false, and the copy follows the answer (2.0s)
✓ 75 [chromium] › tests/e2e/self-serve-signup.spec.ts:329:7 › the signup answer says whether the welcome mail actually went out (G5) › a signup whose welcome mail DID go out is told to check their email (406ms)
✓ 76 [chromium] › tests/e2e/self-serve-signup.spec.ts:350:7 › the founder can see which mails did not get delivered (G16) › a lead whose welcome mail failed is flagged on /admin/signups (979ms)
✓ 77 [chromium] › tests/e2e/self-serve-signup.spec.ts:372:7 › the account remembers which language the customer speaks (G9) › a signup filled in French mints a French account (430ms)
✓ 78 [chromium] › tests/e2e/trial-warning-cron.spec.ts:41:5 › the sweep endpoint is not an open relay (355ms)
✓ 79 [chromium] › tests/e2e/trial-warning-cron.spec.ts:54:5 › a partner is warned once, and a second sweep says nothing more (392ms)
✓ 80 [chromium] › tests/e2e/trial-warning-cron.spec.ts:103:5 › a plan that is already paying is never warned about its free period (681ms)

80 passed (2.0m))** — not a CI job here, so recorded on the PR.

  • Whole suite: 80 passed (1.9m).
  • Re-run scoped to the spec this PR edits, so the result is decisive rather than inferred from a total: bash scripts/e2e-suite.sh owner-payments-pending -> 3 passed (9.8s), including "bought and not yet granted — the card is there, and says nothing they can't act on", which now asserts a[href="https://dashboard.stripe.com"] has count 0 and that the replacement prose is visible.

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