A money ledger that checks itself.
Porting Laayo's money logic to Go as explicit, locked, idempotent plans, and a read-only replay that recomputes history and found real bugs in the legacy code.
Every Laayo order moves money between several parties. The customer pays by cash, QR, card, wallet, or a mix of wallet and something else. The restaurant earns its share minus commission. The rider earns a delivery fee and sometimes a tip, and may be holding cash that belongs to the company. The company keeps its commission and the delivery margin. Cancellations, refunds, edited orders and payouts all move that money again.
All of this lived in the legacy Laravel code as a set of functions that update wallet rows and insert transaction rows. When I started moving the backend to Go, money was the part I was least willing to get wrong, and the part where "it looks right" is worth nothing. This case study covers how I rebuilt the money logic in Go and how a read-only replay checker proved it against real history. It also covers the uncomfortable part: the replay found bugs in the code that was already handling real money.
The problem and the constraints
The constraints are specific to how the migration works. Go and Laravel run side by side and both write to the same database, and routes move from one to the other gradually. During that period, a delivered order might be settled by Laravel and refunded by Go, or the other way round. So the Go code cannot simply be a cleaner design. It has to write the same rows, with the same values, to the last stored decimal, as the Laravel code it replaces. Otherwise the two halves of the system disagree about balances.
That rules out the obvious modern choice. Laravel does its money arithmetic in PHP floats, converts each float to a string with PHP's precision rules, and lets the database round that string into a decimal column. To match it exactly, Go has to do the same: the same float operations in the same order, the same string conversion, and the same database rounding. Integer minor units would be more correct in isolation and would still produce different stored values. Correct in isolation is not the goal while two stacks share one database.
The other constraints are the usual ones at Laayo. There is one developer, no finance team reconciling by hand every night, and real customers and riders who notice immediately when a wallet balance is wrong.
What I built
There are three parts.
Plans. Every money operation is a pure function that takes a snapshot of the order, the people involved and the relevant settings, and returns a plan: an ordered list of steps. Examples are "load this wallet", "add these amounts to this column", "save", "insert this transaction row" and "mark this payment as paid". Building a plan reads nothing from the database and writes nothing. Values that depend on the state at write time, such as the current wallet balance, are not baked into the plan. A step says how to compute them from the locked row.
An engine with two backends. The same engine runs a plan against SQL inside a transaction, or against an in-memory world. The SQL backend is what production writes would use. The memory backend is what tests and the replay checker use. The engine is shared, so what the checker proves is exactly what would be written.
A replay checker. It connects with a read-only database user, sets its session to read-only as well, and walks through historical orders. For each one it rebuilds the inputs, runs the plan in memory, and compares every row the plan would write with the rows the legacy code actually wrote.
How it works in detail
Locking and ordering. When a plan runs against SQL, every wallet row it touches is loaded with SELECT ... FOR UPDATE inside one transaction. The lock order is fixed (company, then restaurant, then rider), the same order the legacy settlement code updates them in. Two Go transactions cannot deadlock on each other, a Go transaction and a Laravel transaction cannot deadlock on each other, and neither can lose the other's update.
Idempotency, twice. Each plan carries a key that names the business operation, such as "settle order X as delivered". The engine checks two guards inside the same transaction. The first is the natural one: if a settlement row for that order already exists, the operation has already happened, whichever stack did it. The second is a small keys table that stores the operation key with a hash of the plan:
-- inside the same transaction as the money writes
INSERT INTO ledger_ops (op_key, plan_hash, created_at)
VALUES (?, ?, NOW());
-- duplicate key + same hash -> already applied, return the earlier result
-- duplicate key + different hash -> conflict: the same operation computed
-- differently, refuse and alert-- inside the same transaction as the money writes
INSERT INTO ledger_ops (op_key, plan_hash, created_at)
VALUES (?, ?, NOW());
-- duplicate key + same hash -> already applied, return the earlier result
-- duplicate key + different hash -> conflict: the same operation computed
-- differently, refuse and alertThe hash matters. A retry with the same inputs is harmless and returns "already applied". The same key with a different plan means something changed between attempts, such as an edited order or different settings, and that should never be applied silently.
PHP arithmetic in Go. A small package reproduces PHP's float-to-string conversion, PHP's round (which pre-rounds to a fixed number of significant digits before rounding half away from zero), and the database's rounding for decimal and double columns. Each is verified against the real PHP binary in tests. Every point where rounding happens is marked in the code, so a reviewer can find them. Integer thousandths are used only for comparing stored values, never for computing them.
// The plan is pure; the engine evaluates it under row locks.
func SettleDelivered(o Order, s Settings) *Plan {
p := NewPlan("delivered:" + o.Ref())
p.Load(CompanyWallet())
p.Load(StoreWallet(o.VendorID))
if o.RiderID != 0 {
p.Load(RiderWallet(o.RiderID))
}
share := storeShare(o, s) // PHP float semantics, same operand order
p.Add(StoreWallet(o.VendorID), "earned", share)
if o.PaidInCash() && o.RiderID != 0 {
p.Add(RiderWallet(o.RiderID), "cash_held", Num(o.Total))
}
p.Insert("settlements", settlementRow(o, share, s))
p.SaveAll()
return p
}// The plan is pure; the engine evaluates it under row locks.
func SettleDelivered(o Order, s Settings) *Plan {
p := NewPlan("delivered:" + o.Ref())
p.Load(CompanyWallet())
p.Load(StoreWallet(o.VendorID))
if o.RiderID != 0 {
p.Load(RiderWallet(o.RiderID))
}
share := storeShare(o, s) // PHP float semantics, same operand order
p.Add(StoreWallet(o.VendorID), "earned", share)
if o.PaidInCash() && o.RiderID != 0 {
p.Add(RiderWallet(o.RiderID), "cash_held", Num(o.Total))
}
p.Insert("settlements", settlementRow(o, share, s))
p.SaveAll()
return p
}That is a simplified shape, not the real function. The real one covers commission rules, self-delivery restaurants, subscriptions, partial payments, tips, loyalty points and referral rewards.
What the replay compares. The checker runs several kinds of comparison:
- Delivered orders. Rebuild the order as it was at settlement, run the settlement plan in memory, and compare every column of every row it would write against what is stored.
- Cancelled orders that had been paid. Run the refund plan and compare it with the refund rows that exist, or notice that none exist.
- Wallet placements. For orders paid fully or partly from the wallet, compare the debit.
- Running balances. Every wallet and loyalty transaction stores the balance after it. Walk each customer's rows in order and check that each balance equals the previous balance plus credits minus debits. A break in the chain means two updates overlapped and one was lost.
- Restaurant wallet totals. Each restaurant's lifetime earnings in its wallet should equal the sum of what settlement credited, minus what refunds took back.
Each order is tagged by type (cash, QR, wallet, partial, coupon, membership, tip, refund and so on), so the report shows match rates per type and groups mismatches by their pattern rather than listing orders one by one. Over a recent window, every delivered order matched across all payment types. Over all of history, the remaining differences traced back to settings, code or data that had changed over time, and a handful of real bugs.
What the replay found
This is the part I did not expect. The checker was meant to prove the Go code. It also checked the legacy code against itself, and some of the history did not add up. These are the classes of bug it found, all in code that had been running in production:
- A wallet not refunded on cancel. When an order paid fully or partly from the wallet was cancelled, the wallet portion was never returned.
- A late QR payment counted twice. If a QR payment arrived after the order had already been settled as cash, the rider was still charged for the cash and the company also received the QR money.
- A tip paid after delivery was never booked. The customer paid it, but it was never recorded against the order.
- An edited wallet order was never corrected. When an order paid by wallet was edited afterwards, the wallet charge stayed at the original total.
- Lost updates on wallets. Some admin and staff wallet adjustments were read-then-write without a row lock, so two concurrent adjustments could overwrite each other. This was exactly the kind of break the running-balance chain check exists to find.
A related bug surfaced through an order rather than the replay: an online order whose payment never completed could still be dispatched and marked delivered as if it had been paid. The fix became a server rule in both stacks. An unpaid online order may not leave its pending state unless the same write pays it, or the customer explicitly switches to cash. There is no silent conversion, and the rule is checked on every path that moves an order forward, including the staff tools.
All of these were fixed in the legacy code first, through the same tests, then staging, then production gate as everything else. The fixes were forward-only. Some affected customers had already been made whole by hand, so an automatic sweep over history would have paid them twice. Any correction for the past has to be a deliberate, named decision, not a script. The Go ledger then gained matching plans (refund the wallet on cancel, book a late digital payment, book a late tip, settle a wallet after an edit), so it matches the fixed behaviour, not the old one.
When later work ported the admin money tools to Go, the same mindset carried over. Wallet and cash actions carry an idempotency key from the client, a second decision on the same request returns a conflict instead of moving money again, and double submissions are tested explicitly.
Trade-offs and what went wrong
This is not textbook double-entry. I did not replace the legacy money model with an append-only double-entry ledger. While Laravel still writes the same tables, a new model would mean two sources of truth and a translation layer between them. What I built is the existing model made explicit, locked, idempotent and checkable. It is less elegant than a clean design. It is also something I could prove row for row against real history, and that mattered more.
Bug-for-bug compatibility, until it isn't. Matching legacy rows exactly means matching legacy mistakes. The rule I settled on was to fix Laravel first, then make Go match the fixed version, never the other way round. Otherwise the replay either keeps flagging the bug forever or the bug gets written into the new code as "expected".
Floats on purpose. Keeping PHP float semantics in Go feels wrong to anyone who has handled money before, me included. It is a deliberate transitional choice. Integer minor units come after Laravel stops writing money rows, not before.
Old history is noisy. Settings changed over time, and so did the code. Some differences in older orders are explained by a setting that was different that month, not by a bug. The checker classifies these rather than hiding them, but reading them still takes judgement, and I spent real time ruling out false alarms before trusting the real ones.
What I would do differently
I would build the replay checker first, before writing any new money code. It turned out to be the most valuable tool in the whole migration. It is read-only, cheap to run, and it answers "is the money right?" for all of history, not just for the cases I thought to test.
I would also store an operation key on every money-moving row from the beginning, in the legacy system too. Idempotency was easy to add in Go. It was much harder to reason about after the fact, for operations the old code had already performed without keys.
What is next
Money routes are the last group to move from Laravel to Go. Order placement, payment callbacks, wallet top-ups, refunds and payouts are ported or being ported, and each is proven on the test and staging environments before anything else happens. In production, each sits behind its own switch that starts off and is turned on one at a time. The replay is the check I run after any change to settlement logic and before any of those switches moves.
Once Laravel no longer writes money rows, the PHP float layer has no reason to exist. At that point the ledger can move to integer amounts and an append-only entry table, with wallet balances as a view over it, and the replay checker is how that change would be proven too.
Get new posts by email
One email when something new goes up. No tracking pixels, unsubscribe in one click.