Skip to content

feat(payments): learn from Stripe when a restaurant finishes onboarding (E5) - #232

Merged
mahmutkaya merged 1 commit into
developfrom
feature/express-account-updated-webhook
Sep 5, 2026
Merged

mahmutkaya merged 1 commit into
developfrom
feature/express-account-updated-webhook

Conversation

@mahmutkaya

Copy link
Copy Markdown
Contributor

Summary

  • The account.updated branch (ADR-011 amendment, slice E5) — how this platform learns that a restaurant finished Stripe onboarding, now that we are the one who created the account.
  • New table StripeAccountStatus (one additive migration) recording charges_enabled, payouts_enabled, details_submitted, requirements.currently_due.length and capabilities.twint_payments.
  • The branch sits below the event.account guard. The tenant-facing read model (StripeAccountClient, 5-minute cache) is unchanged and stays where it is.

Issue / plan link

Type

  • feat

Position, and why it is not free

slot occupant why
above the event.account guard application_fee.created it has no event.account (measured 2026-09-04, comment at :131-134) — the guard would discard it
below the guard account.updated it is about a connected account, so it names one; the id comes from the guard rather than from the body

Moved above, this branch would run for platform events that name no account. Removed, nothing would ever learn a restaurant finished onboarding. Both are mutants below, and both go red.

There is one residual risk and it is handled rather than assumed away: if account.updated ever arrived without event.account, the branch would never fire and the only symptom would be an empty table — the exact shape of silence that hid application_fee.created. So the !account path now logs a warning naming that event type. A tell, not a guess.

Polling was the alternative, and it is worse

A fleet-wide GET /v1/accounts loop is the rate-limit shape the backend's own account cache comment warns about, and it would still only be a snapshot. One event type on an endpoint we already run, with a verified secret, is a branch — not an endpoint.

Why this table upserts and OVERWRITES, unlike the two fee tables

feeEarnedUpsert's update is deliberately empty: a fee is an immutable event, so a redelivery has nothing to say. A status is a snapshot, so a second delivery has everything to say. Overwriting is safe here for one specific reason: the write path takes only the id from the event and re-reads the account from Stripe (CLAUDE.md §5.3, fetch-and-verify — the same discipline stripe-fee-earned.ts follows). So a redelivery of a stale event writes today's truth, and no event ordering has to be reasoned about anywhere. Trusting the event body instead would let a redelivered "not yet enabled" silently un-do a tenant that is live.

Other decisions, each with a reason in the migration header:

  • chargesEnabled / payoutsEnabled / detailsSubmitted are three columns, not one. They move independently — a restaurant taking cards whose payouts are blocked is a support call nobody sees coming — and details_submitted is the field that decides which link /onboarding/payments mints, because Stripe refuses a login link before onboarding completes (400, measured).
  • The requirements COUNT, not the list. The field names are Stripe's vocabulary and they change; the only question ever asked of them here is "is it zero yet". Measured on CH Express: 16 bare, 13 prefilled without a business type, 6 fully prefilled.
  • twintCapability keeps inactive distinct from absent. TWINT has a Stripe-side approval queue behind it, so "the tenant is live but TWINT is not" is a real state nothing else in the fleet reports.
  • A separate table from StripeConnectAccount. That one records accounts we minted; an account we did not mint can still send this event (the hand-run curl in runbook §2b.1 made some), and dropping its status because we cannot name its tenant would be the mistake StripeApplicationFee's header refuses to make about money.
  • observedAt is OUR clock — the opposite of the fee tables, and deliberately: they record events that happened at a Stripe timestamp; this records a snapshot, and what matters about a snapshot is how old it is.

NFR triage

  • D1 security: no new endpoint, no new secret, no new scope; the branch is inside the already-signature-verified handler. Logs carry an event id, an account id and a scope — no PII.
  • D8 observability: the new warning turns the one silent failure mode into a log line. The acknowledge() taxonomy is reused unchanged, so a 404 is acknowledged and anything else is a 5xx Stripe retries.
  • D9 testing: 11 unit tests. Three of them read the ROUTE FILE off disk to assert branch ORDER, which is a property no function can express — the same technique the migration-constraint tests use, and it is mutation-proven below.
  • D3 cpu/mem: one extra Stripe read per account.updated, replacing a poll that does not exist. No loop, no fan-out.
  • Rest: n/a because no UI, no user-facing string, no money arithmetic, no i18n.

Database / migrations

  • Handwritten SQL, prisma/migrations/20260905230000_stripe_account_status/ — no applied migration edited
  • ADDITIVE ONLY: one new table. Nothing is backfilled and nothing can be — this table records observations, and none was made before it existed. An empty table is the honest state.
  • migrate deploy + migrate diff --exit-code0, "No difference detected" against a throwaway postgres:16-alpine

Test plan

  • typecheck · lint · test:coverage (1129 tests) · build · check-single-file --all
  • Mutation, each LANDED (grep -F) and COMPILED (tsc 0) before any test result was read:
    1. the branch deleted2 RED
    2. the branch moved ABOVE the event.account guard (reading the id from event.data.object.id so it still compiles) → 2 RED
    3. requirementsDueCount hardcoded to 01 RED
    4. update emptied, i.e. a redelivery can no longer restate the row → 1 RED
  • The account shape in the fixtures is a real Express account read off the API in test mode (acct_1UCPNg…, since deleted; GET on it now returns 403), not the documentation.

Deploy notes

  • Migration one-off required: yesghcr.io/piwas-21/sofra:migrate before rolling the app.
  • New env vars on the box: none. At Stripe, the existing connect: true endpoint must have account.updated ticked — otherwise this branch is correct and never fires, and the tell is an empty table plus no account status line in the logs.
  • Rollout order / risk: additive. Until that event type is enabled the behaviour is exactly today's.

@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