Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
}
Expand Down
115 changes: 115 additions & 0 deletions lib/stripe-account-status.ts
Original file line number Diff line number Diff line change
@@ -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<AccountStatusRow, "connectedAccountId">;
};

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<AccountStatusResult> {
const account = await stripeGet<StripeAccountObject>(`/v1/accounts/${accountId}`);
const write = accountStatusUpsert(account, new Date());
await db.stripeAccountStatus.upsert(write);
return { kind: "recorded", connectedAccountId: write.where.connectedAccountId };
}
Original file line number Diff line number Diff line change
@@ -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");
65 changes: 65 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Loading
Loading