Ledger rewrite for Selasih Wallet
Moved every balance in the wallet from mutable rows to an append-only double-entry ledger. Selasih Wallet: e-wallet, Malaysia and Indonesia, 3.1 million monthly users.
0.002%
- Selasih Wallet
- Lead engineer, team of four
- 7 months
- 2026.03, v3.0
- Go, PostgreSQL 16, Kafka, Temporal
Problem
Selasih stored each user's balance as a number on a row, and every top-up, transfer and refund updated that number in place. It worked for two years. Then the wallet passed a million users, added a second country, and finance started closing the month by hand.
About 0.41% of daily transactions did not match the bank settlement file. Each break took an analyst twenty minutes to trace, because the only history of a balance was a log table that some code paths forgot to write. Month-end close took six working days, and twice a year it found real money that could not be explained.
Constraints
- No downtime. The wallet takes payments at petrol stations at 3 am, so there was no maintenance window.
- Two currencies, MYR and IDR, with different minor units. IDR has no sen in practice; MYR does.
- Bank Negara audit trail rules: every change to a customer balance must be traceable to a source event for seven years.
- The old balance column had to stay readable for eleven downstream services until each one migrated.
Architecture
Code
A journal is rejected unless its postings sum to zero per currency. This one check removed a whole class of bugs.
// Post writes a journal atomically. Amounts are int64 minor units.func (s *Service) Post(ctx context.Context, j Journal) error { sums := map[Currency]int64{} for _, p := range j.Postings { sums[p.Currency] += p.Amount } for cur, total := range sums { if total != 0 { return fmt.Errorf("journal %s unbalanced in %s by %d", j.ID, cur, total) } } return s.db.InTx(ctx, func(tx *sql.Tx) error { if err := insertJournal(tx, j); err != nil { return err // unique (source, source_id) makes retries safe } return insertPostings(tx, j.ID, j.Postings) })}
Result
- daily reconciliation breaks
- 0.002%
- month-end close
- 4 hours
- unexplained difference in 14 closes
- RM 0.00
- services moved off the old balance column
- 11 of 11
The finance team now closes the month on the first working day. The ledger has since taken on loyalty points as a third currency without a schema change.