Designs by Duhart All work
Rank
11 of 34
Tier
Tier A 81 of 120

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?


A webhook that is safe to receive twice
StripeWorkerstripe_eventsHandlersigned eventINSERT, processed_at NULLPK hit: duplicatecredit, ticket, forwardSET processed_at200, or 500 to retry

The row is written before any handler runs, so a redelivery collides on the primary key and moves nothing. A handler that throws leaves the row unprocessed and returns 500, so Stripe retries; the fifth failure is dead-lettered for a human. Readers must test processed_at, never existence.

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:

CodeMeaningWhat Stripe does
200applied, a replay of something applied, or dead-letteredstops
401bad or missing signaturestops; it was never ours
500recorded but not appliedretries; 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:

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:

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

Honest gaps