The invariants
These rules are the reason Beluga is shaped the way it is. They are not style. A
change that violates one will usually compile, pass review, and be wrong in a way
that shows up as money. Copy this list into your forkās CLAUDE.md, AGENTS.md, or
whatever your assistant reads.
- Money never comes from the request. Prices and totals are read from the
database on every path. A request carries product and variant ids and quantities
only. The loop in
server/routes/checkout.tsis where this is enforced. - The webhook is the only thing that marks an order paid. The success redirect proves nothing: a buyer can close the tab, and the URL can be visited directly. Never add a payment state transition anywhere else.
- Webhook handling is idempotent. Events are deduplicated by id, and a failed
handler releases the dedup record so Stripeās retry is actually processed rather
than dismissed as a duplicate. See
recordWebhookEventandforgetWebhookEventindb/orders-repository.ts. - Every admin route is behind
requireAdminandverifyCsrf, applied once to the whole router inserver/routes/admin.ts. Never mount an admin endpoint outside that router, and register every new route inserver/security.test.ts. - Every schema change lands in both dialects.
db/schema.sqlite.tsanddb/schema.pg.tsare edited together, thennpm run db:generateemits a migration for each. Commit all of it. - Products reach Stripe only through explicit publish
(
POST /api/admin/products/:id/publish). Saving never writes to Stripe. - Stripe Prices are immutable. Changing an amount creates a new Price and archives the old one, which is why historic orders still resolve. Do not āfixā this by mutating.
What follows from them
Section titled āWhat follows from themā- The cart stores identifiers, never prices or image URLs, so a price change cannot leave a stale amount in someoneās open tab (rule 1).
- The confirmation page polls the orderās status rather than assuming success (2).
- A refund is requested from Stripe and recorded by the
charge.refundedwebhook, never written directly (2 and 3). - Changing how a store quotes tax reaches Stripe only when each product is republished, and nothing republishes itself (6 and 7).
- A test key and a live key have separate catalogues; publishing under one puts nothing in the other (6).
Conventions that sit beside them
Section titled āConventions that sit beside themāNot invariants, but the codebase holds to them and a contribution should too.
- Validation lives in
shared/as zod schemas, never inline in a route. - Routes stay thin: parse, call a repository function, respond. SQL lives in
db/*-repository.ts. - Errors use
httpError(status, message)with a user-facing message. - Client mutations go through
csrfPost,csrfPutandcsrfDelete, wrapped in a hook insrc/admin/queries.tsthat invalidates the public store key. - Comments explain why, not what.
Conventions and contributing has the longer version, and Gotchas the things that look like bugs and are not.