A common setup goes like this: the database stores data, and all the logic lives in the application. That's fine for a blog. It's not fine for a product where three apps touch the same money. Here's why, on the platform I'm building (a B2C home-services product, name under NDA), pricing, discounts, order states and money are computed in PostgreSQL.
Three apps, one truth
The monorepo has three Next.js apps: a public site with a price calculator and customer account, a specialist app, and an operator back office with five roles. All three touch an order and its price in some way.
If pricing rules live in application code, sooner or later there will be three copies. The customer sees one total in the calculator, the operator sees another in the back office, and the specialist sees a third. Each is "correct" according to its own code. Two calculators mean two truths; three mean a complaint waiting to happen.
So the rules live in one place: the database. The apps don't calculate money. They ask.
RPC functions and RLS
In practice that's Supabase: PostgreSQL with RPC functions and row-level security policies. A business operation — create an order, apply a discount, change a status — is a database function. It checks invariants and changes data in a single transaction.
Take the pricing rules: +10% at peak times, total discount capped at 20%, bonus points can cover up to 15% of an order, and discounts come out of the platform's share, not the specialist's. The database enforces those limits. If the frontend sends something odd, the database just says no.
RLS decides who sees what. Even if the UI has a bug, the database won't hand a customer someone else's order, or show an operator data their role doesn't allow. Permissions aren't a handshake between frontend and backend; they're a rule no query can route around.
The main database has accumulated more than 1,000 migrations. One rule: never edit an applied migration — write a new one. The migration history is a decision log, just like git.
An outbox in the same transaction
The classic trap: the order is saved, but the notification never goes out. Or the reverse: the notification goes out, the transaction rolls back, and the customer gets a message about an order that doesn't exist.
The fix is an outbox. The "send a notification" event is written to its own table in the same transaction as the order. A separate process reads the outbox and sends messages — to Telegram, for example. Either both are saved, or neither is.
Payments: holds, refunds, webhooks
Online payments go through bePaid. There are two modes: charge immediately, or place an authorization hold and capture later. Which one applies is a setting, not a code change. On top of that: refunds, chargebacks and reconciliation with the gateway.
The gateway reports events via webhooks, and the golden rule is that a webhook can arrive twice. That's normal. What's not normal is a balance changing twice because of it. So processing is idempotent: the payment status lives in the database, and a repeated event changes nothing.
Operator actions on money and personal data require 2FA and land in an audit log. When something goes wrong — and something will — you can see who did what, and when.
Any downsides?
Of course. SQL functions are harder to debug than TypeScript. You need database tests; I run mine on PGlite so I don't need a separate server. And you need discipline with migrations.
I choose that complexity on purpose. A layout bug is ugly. A money bug is a conversation with a customer, accounting and possibly a lawyer. I'd rather have a strict database.
In short
Money in the database. Permissions in the database. Events in the same transaction. Webhooks handled idempotently. Apps as a thin layer that displays and asks. It sounds boring — which is the best compliment money logic can get.