I built a backend that moves money between currencies. The product is deliberately small: two currencies (NGN and CAD), mocked FX and webhook providers, and a handful of endpoints. The point was the layer underneath, where payment software fails: rounding errors, balances that drift from their history, retries that double-send, and customers who never know the money arrived.
This post covers six decisions I would defend in a design review, what each one costs, and what went wrong along the way. The code and the README are here.
1. Money is never a float
In JavaScript, 0.1 + 0.2 is 0.30000000000000004. In a ledger, that bug compounds. So amounts travel as strings, all arithmetic goes through one small module built on decimal.js, and the database stores them as NUMERIC. A unit test fails against a float implementation, which is how I know the rule is enforced and not just intended.
The cost is ceremony: nobody gets to write a + b on money. I think that is the right price, because funnelling every calculation through one module is the only way a rule like this survives a team.
2. An append-only, double-entry ledger
Every movement of money is a set of ledger entries that net to zero. Entries are never updated or deleted; a correction is a new, offsetting entry. That makes the system auditable by construction, and any balance can be rebuilt by replaying entries.
I also keep a cached balance on each account, updated in the same database transaction as the entries, because summing a growing table on every request is a poor trade. The cost is that it is a second copy of the truth. A scheduled job that compares the cache with the ledger is the first thing on my “next” list, and the README says so plainly.
3. Cross-currency transfers need four entries, not two
Sending 500 NGN to a CAD account is not one debit and one credit. The two sides are different currencies and different amounts. I use a pool account per currency as the counterparty:
The first two entries net to zero in NGN and the last two net to zero in CAD, so each currency balances on its own. The 0.46 is 500 multiplied by the rate locked when the transfer was created, rounded to two decimals. That rate is tied to the transfer with a unique constraint, so it can never be re-locked: the price shown is the price charged.
The simpler option was a single debit and credit with a conversion note on the side. I rejected it because then no currency’s ledger actually balances. The cost is real: a pool’s balance goes negative in one currency and positive in the other, which represents the platform’s open position, and there is no liquidity management on top of it yet.
4. Lock the rows, then prove the lock works
Two requests try to spend the same 100 at the same moment. Both read the balance, both see enough money, both proceed. The fix is pessimistic locking: inside the ledger transaction, I lock every account involved with SELECT ... FOR UPDATE before checking the balance, so the second request waits and then sees the true balance. Locks are always taken in account-id order, which prevents a deadlock when A is paying B while B is paying A.
I chose pessimistic locking over optimistic retries because correctness matters more than throughput on a hot account, and because the failure mode is simple to reason about. The cost is that a very busy account becomes a serialization point.
The part I care about most is the test. Two transfers of 60 against a balance of 100: exactly one must succeed. Five transfers of 30: exactly three. Then I deleted the lock and watched both go red. A test you have never seen fail has not proven anything.
One accident helped. My database was on Neon and my Redis on Redis Cloud, about 190 milliseconds per round trip. That made every transaction slow and every race wide, which is exactly what you want when you are trying to catch one.
5. Idempotency has two layers
Clients retry and networks time out, and a retried “send money” request must not send money twice. Each transfer request carries an Idempotency-Key. A short Redis lock ( SET NX with a 30-second expiry) ensures only one request with that key is processed at a time. A Postgres record stores the response. A concurrent duplicate waits for the first to finish and gets the identical response; a later duplicate gets the stored response straight away, and the same key with a different payload is rejected.
Two details worth pointing out. Keys are namespaced by user, so one customer cannot collide with another. And I store 4xx responses but not 5xx ones: a 5xx means “we don’t know what happened,” and the client should be able to retry. The cost is that stored keys have no expiry job yet.
6. Notifications are data, not fire-and-forget
“Money sent, no alert received” is a support ticket waiting to happen. So each notification is a row with a status, an attempt count, and the last error, created before the job is queued. A worker sends it. On failure, it retries with exponential backoff (1, 2, 4, and 8 seconds, five attempts), and after the last attempt the row becomes dead_letter, and the job stays in BullMQ’s failed set for manual follow-up. “Did they get notified?” becomes a query instead of an escalation.
Two consequences. Delivery is at least once, so receivers should deduplicate. And a notification failure never fails the transfer, because by then the money has already moved: failing the request would invite a retry of something that already happened.
Where it runs
During development, the code runs against Neon (PostgreSQL) and Redis Cloud. The target deployment is one EC2 instance in a public subnet, with RDS PostgreSQL and ElastiCache Redis in private subnets, each reachable only from the instance’s security group. BullMQ is a library: its queues live in Redis, and the worker is simply a second process next to the API.
Smaller calls that mattered
- The state machine is enforced twice: a pure module in the code and a
CHECKconstraint in the database. Status changes are compare-and-set, and thefunds_movedupdate happens inside the ledger transaction, so money and status cannot disagree. - Redis holds only what can be recomputed: the rate cache, short locks, and queue state. If it vanished, no financial record would be lost.
- Plain SQL through pg instead of an ORM, because transactions, locks, and constraints should be visible in code review.
- Tests run against real PostgreSQL and Redis, with failure injection: one test makes the ledger throw halfway and checks that nothing persisted.
What went wrong during development
- I seeded demo balances straight into the cached column, so they have no ledger entries behind them and cannot reconcile. I caught it while writing the balance-reconstruction function. The lifecycle test now funds its account through the ledger, and a real deposit flow is on the list.
- A timing test failed because 190-millisecond round trips swamped a 200-millisecond retry delay. I fixed it by making the delay dominate the noise instead of loosening the assertion.
- A test teardown deleted rows in the wrong order, hit a foreign key, and hung the whole run because the connections never closed. Teardown now closes everything in a finally block.
What I would build next
- A reconciliation job that checks every cached balance against the ledger and alerts on drift.
- A transactional outbox for notifications, which closes the window where Redis is down and a delivery row exists with no queued job.
- A recovery job for transfers left between states by a crash.
- Quote-then-confirm FX, where the stored rate expiry finally gets enforced.
- A real FX provider with a spread model.
- A per-attempt notification log, signed webhooks, and real push and SMS providers.
The full README, with the diagrams and every trade-off above, is here. 73 tests pass. If you had decided any of these differently, I would like to hear why.








