Modeling money as a ledger (and why I had to rewrite billing twice)
Published September 18, 2025 ยท 5 min read
What the first version looked like
Honestly, it was embarrassing. A subscription_active boolean on the tenant record. A cron job that flipped it to false at the end of the month if payment hadn't come in. A separate "top up" endpoint that called the Payme API.
It worked fine for the first two weeks. Then the support messages started: "I topped up but my account is still locked." "Why was I charged twice?" "My subscription ran out mid-month even though I had credit."
The root problem was simple: I was treating money as a state (active / inactive), when money is actually a history of events. You can't reconstruct what happened from a boolean. When something goes wrong, you have no audit trail.
Money is a ledger
The fix: stop storing balances, start storing movements.
Every time money touches the system, it becomes a row in wallet_transactions. The row has a type, a signed amount (positive = credit, negative = debit), a reference ID, and a timestamp. Nothing else.
Types I use:
top_upโ Payme payment credited to the walletsubscription_feeโ monthly auto-deductionrefundโ manual credit from supportadjustmentโ rare manual correction
The balance at any point is just a SUM query:
SELECT SUM(amount) AS balance FROM wallet_transactions WHERE tenant_id = $1;
That's it. One query, always accurate, impossible to drift out of sync because there's nothing to sync โ the balance is derived on demand.
When a support ticket comes in now, I can run:
SELECT type, amount, reference, created_at FROM wallet_transactions WHERE tenant_id = $1 ORDER BY created_at DESC;
And immediately see exactly what happened. This alone was worth the rewrite.
Integrating Payme
Payme is the dominant payment processor in Uzbekistan. The flow is callback-based: you expose a JSON-RPC endpoint, Payme calls it when payment state changes.
The one thing you must get right: idempotency. Payme will retry callbacks. If your handler isn't idempotent, you'll double-credit accounts.
My handler for PerformTransaction:
- Check if a
top_uprow withpayme_transaction_id = Xalready exists - If yes โ return success, don't write anything
- If no โ insert the row in a DB transaction, return success
The check-then-insert is wrapped in a database transaction. No race conditions, no duplicates.
async performTransaction(params: PaymeParams) {
return this.db.$transaction(async (tx) => {
const existing = await tx.walletTransaction.findFirst({
where: { reference: params.id, type: 'top_up' }
});
if (existing) return { result: { transaction: existing.id } };
const txn = await tx.walletTransaction.create({
data: {
tenant_id: params.account.tenant_id,
type: 'top_up',
amount: params.amount / 100, // Payme sends tiyin
reference: params.id,
}
});
return { result: { transaction: txn.id } };
});
}The subscription auto-deduction
First of each month, a NestJS cron runs and charges every active tenant. The logic:
- Read current balance (the SUM)
- If balance โฅ fee: insert a negative
subscription_feetransaction - If balance < fee: set
payment_overdue = true, queue a notification
No money leaves the system โ this is a prepaid ledger. The tenant loaded credit in advance; we just move it from "available" to "used."
Edge cases I learned the hard way
Concurrent writes. If a top-up and a deduction both read the balance at the same time and it's 0, both might proceed in a way that creates a negative balance. Solution: SELECT FOR UPDATE on the wallet when you need to check-and-debit atomically.
Timezone. "First of the month" means first of the month in Tashkent (UTC+5). I got this wrong once. The billing job ran at 8 PM on January 31st for some users. Store the timezone, run the job in it.
Partial job failure. If the cron charges 400 out of 500 tenants and crashes, a naive restart will charge some tenants twice. Process in idempotent batches โ track which tenant/billing-period combos have been charged in a separate table.
Billing is boring until it isn't. Model money as events, write idempotency checks before the happy path, and you'll sleep better when Payme retries at 3 AM.