All posts
PaymentsBillingPaymeNestJS

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 wallet
  • subscription_fee โ€” monthly auto-deduction
  • refund โ€” manual credit from support
  • adjustment โ€” rare manual correction

The balance at any point is just a SUM query:

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:

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:

  1. Check if a top_up row with payme_transaction_id = X already exists
  2. If yes โ†’ return success, don't write anything
  3. 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.

The subscription auto-deduction

First of each month, a NestJS cron runs and charges every active tenant. The logic:

  1. Read current balance (the SUM)
  2. If balance โ‰ฅ fee: insert a negative subscription_fee transaction
  3. 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.