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.goStack: 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
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:
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.