# 31 · Storefront Money Spine & Connect Payouts **Rank 11 of 34** · Tier A · `app/GlobalOneStorefrontGateway` (`money/`, `ledger/`, `idempotency/`, `migrations/`), `workers/storefront/src` (`WebhookService.ts`, `ConnectAccounts.ts`, `BoxOfficeEvents.ts`), `app/AuthGateway/billing_tiers.go` **Stack:** Go, TypeScript (Cloudflare Worker), Postgres via Hyperdrive, Stripe Checkout + Connect Express, forward-only SQL migrations with pre/postflight checks > **Where this sits.** This is the part of the platform that touches money: > marketplace purchases, subscriptions, event tickets and creator payouts. > It is the piece to read if you are hiring for a bank or a payments team, > because every decision in it is about one question. When a message arrives > twice, or arrives and then dies halfway through, does the money still add up? --- ## The shape ``` Stripe ──► /webhooks/stripe (Worker) │ verify signature (401 if bad, never retried) ▼ INSERT stripe_events (stripe_event_id PK, processed_at NULL) │ duplicate PK → 200 {duplicate: true}, no side effects ▼ route by owner ──► storefront handler (consumable credit, tickets) └──► AuthGateway forward (subscriptions) │ ▼ UPDATE processed_at or 500 → Stripe retries, attempts++ fifth failure → dead letter, 200, needs a human ``` One table guards both services. That was a deliberate call in `PLAN.md` §8. Two services each keeping their own idempotency record is how the two records drift apart, and then nobody can say which one is true. ## Insert, then process The webhook writes the event row **before any handler runs**. A duplicate delivery collides on the primary key and is dropped. An event that arrives and then crashes mid-handler is still on record, still unprocessed, and still in Stripe's retry queue. A replayed event was verified live: it returns `duplicate: true` and moves nothing. The status codes are part of the design, and `WebhookService.ts` documents them next to the code: | Code | Meaning | What Stripe does | |---|---|---| | 200 | applied, a replay of something applied, or dead-lettered | stops | | 401 | bad or missing signature | stops; it was never ours | | 500 | recorded but not applied | retries; `attempts` climbs | The old handler returned 200 unconditionally, including after a handler threw. So a failed event was neither retried nor recorded anywhere. That is the worst combination available: the money is wrong and there is no evidence. Migration 0001 also refuses to finish if the guard cannot work. Its postflight raises an exception unless `processed_at` is nullable **and has no default**, because the legacy table defaulted it to `now()`. With that default left in place, every event would be marked processed the instant it arrived and insert-then-process would silently become insert-and-forget. ## The bug the new guard would have caused Changing ingest broke an assumption somewhere else. `GlobalBalanceService`, which credits consumable purchases, checked whether an event row **existed** before crediting. Once ingest started inserting the row first, "a row exists" became true for every event. Every credit would have been skipped. Silently, because the webhook still returned 200. The fix is one predicate, `AND processed_at IS NOT NULL`, and a comment that explains why. The lesson is the useful part: an idempotency guard is a contract between the writer and every reader, and moving the write changes what every reader's check means. A related rule in the same file: a purchase with no SKU mapping **throws** instead of returning. Throwing keeps the event unprocessed so Stripe redelivers it once the mapping exists. Returning would have recorded a paid purchase that credited nothing. ## Money as integers, ledger as append-only The Go `money` package holds an amount as `int64` minor units plus an ISO 4217 code. There is no float anywhere. The zero value has no currency and refuses arithmetic, because a zero that quietly behaves as "0 of whatever currency you like" is exactly how currencies get mixed. Stripe sends `"eur"`; the database domain requires `EUR`; the conversion happens once, at the boundary. The ledger is double-entry. Every transaction must sum to zero per currency or it fails with `ErrUnbalanced`, naming the currency and how many minor units it is off by. `ledger_entries` carries Postgres rules that turn `UPDATE` and `DELETE` into `DO INSTEAD NOTHING`, and the Go package has no `Update` method, because a method the table will ignore is a lie. That rule had a consequence I wrote down. A GDPR pseudonymiser that runs `UPDATE` against `ledger_entries` would rewrite nothing and report success, leaving an erased person's identifier in a financial record for its whole retention period. So identity columns are banned from that table and live on tables that can be rewritten. ## Refund proration, and where the odd cent lands `ledger/refund.go` prorates a partial refund across tax, the seller's share and platform commission using exact integer arithmetic through `math/big`. The rounding is asymmetric on purpose: - **Tax rounds to nearest.** It is remitted to an authority and has to match. - **The seller's share floors.** - **The platform takes the remainder**, which is then always zero or more. Rounding both to nearest looks fair and is wrong. On 100 gross with 5 tax, refunding 50 gives tax 3 and seller 48 against a 50 refund, which leaves the platform at minus one cent. That case was found by a test sweep, not by review: 10 gross amounts × 8 commission rates × 4 tax rates × 7 refund fractions, 2,240 cases, each asserting the legs sum to exactly the refunded amount. Stripe's processing fee is deliberately left alone on a refund. Stripe keeps it, so reversing it would invent a recovery that never happened. ## Tier by price ID, not by amount Subscription tier used to be inferred from the amount paid, using bands. The top tier costs 1,599.99 EUR a month. That cleared the `>= 199` band of a lower tier first and resolved as the lower tier everywhere, including the value published to the service that decides entitlements. The most expensive customer got the cheaper product. `billing_tiers.go` now resolves by **Stripe price ID**. The amount ladder survives only as a fallback for grandfathered prices, and it picks the highest tier the amount certainly covers. The authority is Stripe's own price export, committed as `testdata/stripe_prices.csv`. `TestCatalogMatchesStripeExport` fails the build when the Go catalogue and that export disagree, so a price change has to update both in the same commit. ## Creator payouts on Stripe Connect Event tickets settle on the creator, not the platform. The platform account is registered in Germany and most creators are not in Europe. Stripe refuses a cross-region destination charge unless the connected account is the settlement merchant (`on_behalf_of`), and `on_behalf_of` needs a card-payments capability on that account. Transfers alone cannot take it. The first version of the events lane omitted `on_behalf_of`. **Every US ticket would have failed at checkout.** So accounts are Express with both `card_payments` and `transfers`, and every event checkout names the creator. Other choices in `ConnectAccounts.ts`, each with its reason in the header: - **One account per seller, created at most once.** The row is written first and the create call carries an idempotency key derived from the seller, so two clicks in two tabs make one account. - **No Connect webhook.** `seller_payout_accounts` is a cache of Stripe's answer with its age recorded. The checkout gate re-reads Stripe when the cached answer is older than an hour or says "not ready". A missed update can make one checkout wait on one Stripe read. It can never let an unverified account take money. - **A paid event cannot publish** until the storefront says the creator's account is ready. The platform fee is 5% plus 0.99 per paid ticket, capped at 15.00, with free tickets free and paid tickets starting at 2.00. The fee is clamped below the ticket price because Stripe refuses an application fee that is not less than the charge. Refunds are whole orders with `reverse_transfer`, which pulls the money back from the creator's balance. The platform fee is kept, for the same reason as the processing fee above, and that is one named constant (`REFUND_KEEPS_FEE`) if the owner decides otherwise. ## Evidence - Go gateway: 382 test functions across 48 test files, green under `-race`; DB-backed tests run inside a transaction that is always rolled back. - Worker: 66 tests, including 12 for Connect accounts and 16 for event checkout and refunds. - 11 forward-only migrations, each with a `down` file and its own preflight/postflight assertions. ## Honest gaps - **Nothing here has moved real money yet.** The Stripe key is live and real Checkout sessions have been created, but no Connect payout, ticket refund or `reverse_transfer` has been exercised against real funds. - **The double-entry ledger is inert in production.** It holds zero rows and nothing outside its own tests calls `ledger.Post`. The tests prove the code, not the pipeline. - `schema_migrations` lists 0001, 0002 and 0005 to 0008 but not 0003 or 0004, whose tables exist. Something applied them outside the documented path, and I have not backfilled the rows because that would assert they were applied as written, which nobody has verified. - The Go gateway listens on loopback and authenticates callers with one shared token. It has no JWT verification of its own. - The Worker's source of record lives on a branch rather than the main line of the app repo.