Skip to content

Demo: This is a demo template by Ruji Labs. The business, people, products, prices and reviews on this page are fictional and do not represent any real company.

zahin ooi
menu

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%

reconciliation breaks, down from 0.41%

Client
Selasih Wallet
Role
Lead engineer, team of four
Duration
7 months
Shipped
2026.03, v3.0
Stack
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

4 that shaped the design

  • 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

scroll sideways to see all of it

Every money movement becomes a journal with two or more postings. Balances are a projection, never the source.
intentpostingswallet-apitop-up, pay, refundposting servicevalidates, signsjournalappend-onlybalance viewprojectionevent streamjournal.postedreconcilerbank files, T+0finance exportGL, daily

boxes are servicesdouble top rule is a storedashed is async or advisorygreen is what this project built

Code

excerpt, lightly trimmed

A journal is rejected unless its postings sum to zero per currency. This one check removed a whole class of bugs.

posting/journal.gogo
// 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

measured, not estimated

daily reconciliation breakswas 0.41%
0.002%
month-end closewas 6 days
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.

Demo only

This button is part of a demo template. Nothing was sent, booked or charged, and there is no real business behind this page.

Want a page like this for your business?

QR code: Darus Ishak, Ruji Labs contact cardScan or tap to get the contact card of Darus Ishak, Ruji Labs.Open contact card ↗