diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index fd7ef08..64ec54e 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -33,14 +33,22 @@ // not handling it costs nothing today (the dispute is still visible in // the connected account's own Stripe dashboard) and can be added once the // behaviour is actually measured. -// - everything else Connect can send (account.updated, payout.*, …) is -// simply not this endpoint's job. +// - `account.updated` IS handled since the ADR-011 amendment (E5): it is how +// this platform learns a restaurant finished Stripe onboarding, now that the +// platform is the one that created the account. Its branch sits BELOW the +// `event.account` guard — the slot above is reserved for +// `application_fee.created`, which has no `event.account` at all. +// - everything else Connect can send (payout.*, balance.available, …) is +// simply not this endpoint's job yet. `payout.failed` is the next one worth +// having: under Express a bad IBAN becomes our support ticket rather than +// something the restaurant sees in a dashboard they do not have. import { NextResponse } from "next/server"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { stripeConfigured, StripeError } from "@/lib/stripe"; import { verifyingScope, webhookSecrets, type WebhookScope } from "@/lib/stripe-webhook-secrets"; import { refundApplicationFeeForCharge } from "@/lib/stripe-fee-refund"; import { recordApplicationFee } from "@/lib/stripe-fee-earned"; +import { recordAccountStatus } from "@/lib/stripe-account-status"; /** * The ONE error taxonomy this endpoint has, shared by both branches rather than @@ -142,8 +150,33 @@ export async function POST(request: Request) { if (!account) { // A platform-level event we do not act on — acknowledge and ignore rather // than guess at what it might mean. + // + // One exception is LOGGED rather than acted on. `account.updated` is expected + // to arrive on the `connect: true` endpoint naming its account, which is why + // its branch sits below this guard. If it ever arrives without one, that + // branch would never fire and the tell would be an empty table — the exact + // shape of silence that made `application_fee.created` invisible for a week. + // So say it out loud. No PII: an event id and a type. + if (event.type === "account.updated") { + console.warn("stripe webhook: account.updated with no event.account", event.id, scope); + } return NextResponse.json({ ok: true }); } + + // BELOW the account guard, deliberately, and the position is not free: the slot + // above it is taken by `application_fee.created`, which has NO `event.account` + // (measured) and would be discarded by the guard. This event is the opposite — + // it is ABOUT a connected account, so it names one, and reading the id from the + // guard rather than from the body is what keeps the two branches honest about + // which endpoint delivers what. + // + // It is how onboarding completion reaches us at all (E5). The alternative was a + // fleet-wide `GET /v1/accounts` poll, which is the rate-limit shape the + // backend's own account cache warns about. + if (event.type === "account.updated") { + return acknowledge("account status", scope, event.id, () => recordAccountStatus(account)); + } + if (event.type !== "charge.refunded") { return NextResponse.json({ ok: true }); } diff --git a/lib/stripe-account-status.ts b/lib/stripe-account-status.ts new file mode 100644 index 0000000..cf866db --- /dev/null +++ b/lib/stripe-account-status.ts @@ -0,0 +1,115 @@ +// What Stripe last told us about a connected account (ADR-011 amendment, E5). +// +// How this platform learns that a restaurant finished onboarding: a WEBHOOK +// branch, not polling. Polling would mean a fleet-wide `GET /v1/accounts` loop, +// which is exactly the rate-limit shape the backend's StripeAccountClient cache +// comment warns about — and it would still be a snapshot, only a worse one. +// +// The tenant-facing read model does NOT move. `StripeAccountClient` (backend, +// 5-minute cache, derived from `charges_enabled` + `requirements.currently_due`) +// reports Express accounts identically and needs no change. This table is the +// CONTROL plane's copy: it answers "did onboarding finish?" and "is TWINT on +// yet?" for a whole fleet without asking Stripe once per question. +// +// Same split as lib/stripe-fee-earned.ts, and for the same reason: the mapping +// and the write are pure and testable here, the network call is one line at the +// bottom. + +import { db } from "@/lib/db"; +import { stripeGet } from "@/lib/stripe"; + +/** The Account fields this module reads. Everything else Stripe returns is ignored. */ +export type StripeAccountObject = { + id: string; + charges_enabled?: boolean; + payouts_enabled?: boolean; + details_submitted?: boolean; + requirements?: { currently_due?: string[] }; + capabilities?: { twint_payments?: string }; +}; + +/** One row of `StripeAccountStatus`, exactly as the database takes it. */ +export type AccountStatusRow = { + connectedAccountId: string; + chargesEnabled: boolean; + payoutsEnabled: boolean; + detailsSubmitted: boolean; + requirementsDueCount: number; + twintCapability: string | null; + observedAt: Date; +}; + +/** + * The whole Stripe-object -> row mapping. PURE apart from the clock, which is + * passed in for the same reason lib/trial.ts passes `now`: a snapshot's age is + * the only thing anyone asks about it, so the time it was taken must be + * assertable rather than ambient. + * + * Every optional field falls back to the SAFE side, not to an optimistic one: + * an account object that omits `charges_enabled` reads as "cannot charge", and + * one that omits `requirements` reads as "we do not know of any outstanding + * field", which is 0. The asymmetry is deliberate — a missing capability must + * never be rendered as a live tenant, while an absent requirements list is + * genuinely what Stripe sends for an account with nothing due. + */ +export function accountStatusRow(account: StripeAccountObject, observedAt: Date): AccountStatusRow { + return { + connectedAccountId: account.id, + chargesEnabled: account.charges_enabled === true, + payoutsEnabled: account.payouts_enabled === true, + detailsSubmitted: account.details_submitted === true, + // The COUNT, not the list: the field names are Stripe's vocabulary and they + // change, and the only question ever asked of them here is "is it zero yet". + requirementsDueCount: account.requirements?.currently_due?.length ?? 0, + // Null when the account does not list the capability at all, which is a + // different fact from `inactive` and must not be flattened into it. + twintCapability: account.capabilities?.twint_payments ?? null, + observedAt, + }; +} + +/** + * The WRITE, described as data so the anchor is assertable without a database. + * + * `where` is keyed on `connectedAccountId`, the column the migration makes + * UNIQUE — one row per account. + * + * `update` carries the WHOLE row, unlike `feeEarnedUpsert`'s deliberately empty + * one. The difference is what the two tables are: a fee is an immutable event, + * so a redelivery has nothing new to say; a status is a snapshot, so a second + * delivery has EVERYTHING to say. Overwriting is safe here only because the + * caller re-reads the account from Stripe first, so what lands is today's truth + * regardless of how old the event was. + */ +export type AccountStatusUpsert = { + where: { connectedAccountId: string }; + create: AccountStatusRow; + update: Omit; +}; + +export function accountStatusUpsert( + account: StripeAccountObject, + observedAt: Date, +): AccountStatusUpsert { + const { connectedAccountId, ...rest } = accountStatusRow(account, observedAt); + return { where: { connectedAccountId }, create: { connectedAccountId, ...rest }, update: rest }; +} + +export type AccountStatusResult = { kind: "recorded"; connectedAccountId: string }; + +/** + * Record one account's status, taking ONLY its id from the webhook body and + * re-reading it from Stripe (CLAUDE.md §5.3, fetch-and-verify — the same + * discipline lib/stripe-fee-earned.ts follows even though the signature is + * already verified). Here it also buys the idempotency: see the upsert above. + * + * PLATFORM lookup, no `Stripe-Account` header. `GET /v1/accounts/{id}` on the + * platform key is how a platform reads a connected account (measured); sending + * the header is the obvious way to build this wrong. + */ +export async function recordAccountStatus(accountId: string): Promise { + const account = await stripeGet(`/v1/accounts/${accountId}`); + const write = accountStatusUpsert(account, new Date()); + await db.stripeAccountStatus.upsert(write); + return { kind: "recorded", connectedAccountId: write.where.connectedAccountId }; +} diff --git a/prisma/migrations/20260905230000_stripe_account_status/migration.sql b/prisma/migrations/20260905230000_stripe_account_status/migration.sql new file mode 100644 index 0000000..2740d36 --- /dev/null +++ b/prisma/migrations/20260905230000_stripe_account_status/migration.sql @@ -0,0 +1,70 @@ +-- What Stripe last told us about a connected account (ADR-011 amendment, E5). +-- Written by the `account.updated` branch of app/api/webhooks/stripe/route.ts, +-- which is how this platform learns that a restaurant finished onboarding — +-- a WEBHOOK, not polling. Polling would mean a fleet-wide `GET /v1/accounts` +-- loop, which is exactly the rate-limit shape the backend's StripeAccountClient +-- cache comment already warns about. +-- +-- ADDITIVE ONLY, and safe on a DB with live rows: one new table. No column is +-- retyped, no constraint is dropped, no row changes meaning, nothing is +-- backfilled — and nothing CAN be: this table records observations, and no +-- observation was made before it existed. An empty table is the honest state. +-- +-- A SEPARATE TABLE from "StripeConnectAccount" (20260905190000), which records +-- the accounts this platform MINTED. An account we did not mint can still send +-- this event — the hand-run `curl` in docs/runbooks/signup-to-live-tenant.md +-- §2b.1 made some — and dropping its status because we cannot name its tenant +-- would be the same mistake "StripeApplicationFee"'s header refuses to make +-- about money. The join back to a tenant happens at READ time (ADR-007), as it +-- does for both fee tables. +-- +-- "connectedAccountId" is UNIQUE and is the idempotency anchor: one row per +-- account, upserted. Unlike the fee tables, the row is MEANT to be overwritten — +-- it is a cache of an observation, not a ledger entry. That is only safe because +-- the write path takes the account ID from the event and RE-READS the account +-- from Stripe (CLAUDE.md §5.3, fetch-and-verify, the same discipline +-- lib/stripe-fee-earned.ts follows). So a redelivery of a stale event writes +-- TODAY's truth rather than yesterday's, and no event ordering has to be +-- reasoned about anywhere. Trusting the event body instead would have made a +-- redelivered "not yet enabled" silently un-do a tenant that is live. +-- +-- "chargesEnabled" and "payoutsEnabled" are stored separately because they move +-- independently: a restaurant taking cards whose payouts are blocked is a +-- support call nobody would otherwise see coming. "detailsSubmitted" is neither +-- of them — an account under review has submitted everything and still cannot +-- charge — and it is the field that decides which link /onboarding/payments +-- mints, because Stripe REFUSES a login link before onboarding completes +-- (measured 2026-09-05: 400 "Cannot create a login link for an account that has +-- not completed onboarding"). +-- +-- "requirementsDueCount" is the COUNT of `requirements.currently_due`, 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 a CH Express +-- account: 16 bare, 13 prefilled without a business type, 6 fully prefilled. +-- +-- "twintCapability" is nullable because an account may not list the capability +-- at all. It is recorded because TWINT has a Stripe-side approval queue behind +-- it, so "the tenant is live but TWINT is not" is a real state that nothing else +-- in the fleet reports. +-- +-- "observedAt" is OUR clock, and here that is the right one — the opposite of +-- the two fee tables, which periodise on Stripe's. Those record events that +-- happened at a Stripe timestamp; this records a snapshot, and what matters +-- about a snapshot is how old it is. + +CREATE TABLE "StripeAccountStatus" ( + "id" TEXT NOT NULL, + "connectedAccountId" TEXT NOT NULL, + "chargesEnabled" BOOLEAN NOT NULL, + "payoutsEnabled" BOOLEAN NOT NULL, + "detailsSubmitted" BOOLEAN NOT NULL, + "requirementsDueCount" INTEGER NOT NULL, + "twintCapability" TEXT, + "observedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StripeAccountStatus_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "StripeAccountStatus_connectedAccountId_key" + ON "StripeAccountStatus"("connectedAccountId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index edb8d8d..da18e48 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -992,3 +992,68 @@ model StripeConnectAccount { /// be a field that can only ever disagree. createdAt DateTime @default(now()) } + +/// What Stripe last told us about a connected account (ADR-011 amendment, E5), +/// written by the `account.updated` branch of app/api/webhooks/stripe/route.ts. +/// +/// The third table of the shape this app already has twice (StripeFeeRefund, +/// StripeApplicationFee): keyed on a Stripe id, joined back to a tenant at READ +/// time, never by a foreign key (ADR-007). Separate from StripeConnectAccount — +/// which records accounts WE minted — because an account this platform 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 +/// same mistake StripeApplicationFee's header refuses to make about money. +/// +/// It is a CACHE OF AN OBSERVATION, not a ledger: every row is overwritten by the +/// next delivery. That is safe here only because the write path takes the account +/// ID from the event and RE-READS the account from Stripe (CLAUDE.md §5.3, +/// fetch-and-verify) — so a redelivery of a stale event writes today's truth +/// rather than yesterday's, and no event ordering has to be reasoned about. +/// +/// The tenant-facing read model is unchanged and stays in the backend +/// (`StripeAccountClient`, 5-minute cache, derived from `charges_enabled` + +/// `requirements.currently_due`). This table exists for the CONTROL plane: to +/// drive "your card payments are live" and to answer "did onboarding finish?" +/// without a fleet-wide loop over `GET /v1/accounts`. +model StripeAccountStatus { + id String @id @default(cuid()) + + /// `acct_...`. @unique is the idempotency anchor, the same role + /// StripeApplicationFee.applicationFeeId plays — one row per account, upserted. + connectedAccountId String @unique + + /// Can this account take a card RIGHT NOW. The single fact everything + /// tenant-facing is derived from. + chargesEnabled Boolean + + /// Can Stripe pay this account's balance out. Separate from `chargesEnabled` + /// on purpose: they move independently, and a restaurant taking cards whose + /// payouts are blocked is a support call nobody would otherwise see coming. + payoutsEnabled Boolean + + /// Has the restaurant finished Stripe's hosted form. NOT the same as + /// `chargesEnabled` — an account under review has submitted everything and + /// still cannot charge — and it is the field that decides which link + /// /onboarding/payments mints (lib/connect-account-links.ts). + detailsSubmitted Boolean + + /// How many fields Stripe is still waiting for + /// (`requirements.currently_due.length`). The COUNT, not the list: the field + /// names are Stripe's vocabulary, they change, and the only question anyone + /// asks of them here is "is it zero yet". MEASURED on a CH Express account: + /// 16 bare, 13 prefilled without a business type, 6 fully prefilled. + requirementsDueCount Int + + /// The TWINT capability's status (`active` / `inactive` / `pending`), or null + /// when the account does not list it at all. Recorded because it is the one + /// capability with a Stripe-side approval queue behind it, so "the tenant is + /// live but TWINT is not" is a state that exists and that nothing else reports. + twintCapability String? + + /// When WE read this from Stripe — and this IS the right clock, unlike in the + /// two fee tables. Those record events that happened at a Stripe timestamp; a + /// status is a snapshot, so what matters is how old the snapshot is. + observedAt DateTime + + createdAt DateTime @default(now()) +} diff --git a/tests/unit/stripe-account-status.test.ts b/tests/unit/stripe-account-status.test.ts new file mode 100644 index 0000000..90c179c --- /dev/null +++ b/tests/unit/stripe-account-status.test.ts @@ -0,0 +1,148 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + accountStatusRow, + accountStatusUpsert, + type StripeAccountObject, +} from "@/lib/stripe-account-status"; + +// Shaped from a REAL Express account read off the Stripe API in test mode +// 2026-09-05 (acct_1UCPNg…, since deleted; GET on it now returns 403), not from +// the documentation. +const account = (over: Partial = {}): StripeAccountObject => ({ + id: "acct_1UCPNgFqVH9gPNlT", + charges_enabled: false, + payouts_enabled: false, + details_submitted: false, + requirements: { + currently_due: [ + "individual.dob.day", + "individual.dob.month", + "individual.dob.year", + "individual.phone", + "tos_acceptance.date", + "tos_acceptance.ip", + ], + }, + capabilities: { twint_payments: "inactive" }, + ...over, +}); + +const AT = new Date("2026-09-05T19:30:00.000Z"); + +describe("accountStatusRow", () => { + it("records the three enabled flags separately", () => { + // They move independently. A restaurant taking cards whose PAYOUTS are + // blocked is a support call nobody would otherwise see coming, and + // `details_submitted` is neither of the other two — an account under review + // has submitted everything and still cannot charge. + const row = accountStatusRow( + account({ charges_enabled: true, payouts_enabled: false, details_submitted: true }), + AT, + ); + expect(row.chargesEnabled).toBe(true); + expect(row.payoutsEnabled).toBe(false); + expect(row.detailsSubmitted).toBe(true); + }); + + it("counts what is still due rather than storing Stripe's field names", () => { + // 6 is the measured remainder for a fully prefilled CH Express account: + // dob x3, phone, tos.date, tos.ip. + expect(accountStatusRow(account(), AT).requirementsDueCount).toBe(6); + expect(accountStatusRow(account({ requirements: { currently_due: [] } }), AT).requirementsDueCount).toBe(0); + }); + + it("keeps the TWINT capability, including the difference between inactive and absent", () => { + // `inactive` means requested and waiting; absent means the account does not + // list the capability at all. Flattening them would hide the one state with a + // Stripe-side approval queue behind it. + expect(accountStatusRow(account(), AT).twintCapability).toBe("inactive"); + expect(accountStatusRow(account({ capabilities: {} }), AT).twintCapability).toBeNull(); + expect(accountStatusRow(account({ capabilities: undefined }), AT).twintCapability).toBeNull(); + expect(accountStatusRow(account({ capabilities: { twint_payments: "active" } }), AT).twintCapability).toBe("active"); + }); + + it("falls to the SAFE side when Stripe omits a flag", () => { + // A missing capability must never render as a live tenant. An absent + // requirements list is the opposite case and genuinely means nothing is due. + const bare = accountStatusRow({ id: "acct_1Bare" }, AT); + expect(bare.chargesEnabled).toBe(false); + expect(bare.payoutsEnabled).toBe(false); + expect(bare.detailsSubmitted).toBe(false); + expect(bare.requirementsDueCount).toBe(0); + expect(bare.twintCapability).toBeNull(); + }); + + it("takes the observation time from the caller, never from an ambient clock", () => { + // A snapshot's only interesting property is its age, so the moment it was + // taken has to be assertable — same discipline as lib/trial.ts. + expect(accountStatusRow(account(), AT).observedAt).toBe(AT); + }); +}); + +describe("accountStatusUpsert — the anchor, and why it overwrites", () => { + it("keys the write on connectedAccountId and on nothing else", () => { + expect(accountStatusUpsert(account(), AT).where).toEqual({ + connectedAccountId: "acct_1UCPNgFqVH9gPNlT", + }); + }); + + it("REWRITES the row on a second delivery, unlike the fee tables", () => { + // The opposite of `feeEarnedUpsert`, whose `update` is deliberately empty. A + // fee is an immutable event; a status is a snapshot, so a second delivery has + // everything to say. Safe only because the caller re-reads the account from + // Stripe first, so what lands is today's truth however old the event was. + const write = accountStatusUpsert(account({ charges_enabled: true }), AT); + expect(write.update.chargesEnabled).toBe(true); + expect(Object.keys(write.update).sort()).toEqual( + ["chargesEnabled", "detailsSubmitted", "observedAt", "payoutsEnabled", "requirementsDueCount", "twintCapability"], + ); + // The anchor is never in the update — repointing a row's account id would + // move one restaurant's status onto another's account. + expect(Object.keys(write.update)).not.toContain("connectedAccountId"); + }); + + it("creates exactly the row accountStatusRow computes", () => { + expect(accountStatusUpsert(account(), AT).create).toEqual(accountStatusRow(account(), AT)); + }); +}); + +// The BRANCH POSITION is a property of the route file, not of any function, and +// it is load-bearing twice over: above the `event.account` guard is reserved for +// `application_fee.created`, which has no `event.account` at all (measured), and +// below it is where an event that names its account has to be. Read off disk, in +// keeping with §7 (no DB, no network), the same way the migration constraints are +// asserted in connect-account-store.test.ts. +const routeSource = readFileSync( + fileURLToPath(new URL("../../app/api/webhooks/stripe/route.ts", import.meta.url)), + "utf8", +); + +describe("the account.updated branch sits below the account guard", () => { + const at = (needle: string) => { + const i = routeSource.indexOf(needle); + expect(i, `route.ts no longer contains ${needle}`).toBeGreaterThan(-1); + return i; + }; + + it("handles application_fee.created ABOVE the guard", () => { + expect(at('event.type === "application_fee.created"')).toBeLessThan(at("const account = event.account;")); + }); + + it("handles account.updated BELOW the guard", () => { + // Moved above it, this branch would run for platform events that name no + // account and would call Stripe with `undefined`. Left out entirely, nothing + // would ever learn that a restaurant finished onboarding. Anchored on the CALL + // rather than on the event-type string, because that string also appears in + // the warning below and `indexOf` would find the wrong one. + expect(at("recordAccountStatus(account)")).toBeGreaterThan(at("const account = event.account;")); + }); + + it("says out loud if an account.updated ever arrives without an account", () => { + // The failure this guards is silence: the branch simply never firing, whose + // only symptom is an empty table — the exact shape that hid + // `application_fee.created` until someone went looking. + expect(at("account.updated with no event.account")).toBeLessThan(at("recordAccountStatus(account)")); + }); +});