# An event-sourced ledger a regulator can replay

Regulated lending needs a financial history you can reconstruct, not one you infer from mutable rows. Event sourcing plus double-entry bookkeeping gives you that, provided you get the arithmetic and the correction path right.

By Elson Tan (https://elsontan.com)
Published: 2025-11-21
Reading time: 5 min
Tags: Architecture, Fintech
Canonical: https://elsontan.com/blog/event-sourced-double-entry-ledger/

> Store money movements as immutable events and derive balances by replay, so the audit trail is the system of record rather than a log beside it. Pair that with double-entry postings that sum to zero and arbitrary-precision arithmetic, and every balance becomes explainable down to the cent.

---
An investor emailed to say a payout looked short. He wanted to know what his balance had been on
14 March, and how it got there.

I could tell him what it was that morning. The number was right there in a column. What I could not
do was explain it, because every update had overwritten the one before it.

That email is why the lending platform I built stores events instead of balances. Auditors ask the
same question. So does the regulator. And a `balance` column answers none of them.

## Stop storing the balance

Every movement of money gets written down and never touched again. A top-up. A withdrawal. An
investment. A fee. The balance is not stored anywhere; it is what you get when you add all of those
up.

That sounds like extra work until you want the 14 March number. Then it is the only thing that
works: you add up the events that had happened by 14 March and stop.

Martin Fowler's write-up of [event sourcing](https://martinfowler.com/eaaDev/EventSourcing.html)
puts the principle plainly. The log is the truth. Everything else is a view you can rebuild.

```php
public function balanceAt(string $accountId, CarbonImmutable $asOf): string
{
    $events = $this->events
        ->forAggregate($accountId)
        ->where('occurred_at', '<=', $asOf)
        ->orderBy('sequence')
        ->cursor();

    $balance = '0';
    foreach ($events as $event) {
        $balance = bcadd($balance, $event->signedAmount(), 8);
    }

    return $balance;
}
```

Two details in that snippet are the whole point. The events are ordered by an explicit `sequence`
rather than a timestamp, because two events can share a millisecond and a ledger cannot tolerate
ambiguity about which came first. And the arithmetic is `bcadd`, not `+`.

## Floats do not belong anywhere near money

`0.1 + 0.2` is `0.30000000000000004` in every language that uses IEEE 754 doubles. On one
transaction that rounding error is invisible. Fold it over a few hundred thousand events with an
amortisation schedule and it becomes a reconciliation ticket.

There are two workable options and one trap.

| Approach | Where it works | The catch |
| --- | --- | --- |
| Integer minor units | Single currency, fixed 2 decimals | Breaks on instruments needing more precision than the currency's own |
| Arbitrary precision decimal | Interest, amortisation, FX | Every operation must be explicit, no operator overloading |
| Float | Nowhere in a ledger | Silent, compounding, discovered during audit |

I used arbitrary precision throughout, via [PHP's BCMath](https://www.php.net/manual/en/book.bc.php)
in the application and PostgreSQL's
[`NUMERIC`](https://www.postgresql.org/docs/current/datatype-numeric.html) in storage. `NUMERIC`
is slower than a float column and stores exactly what you gave it, which for an amortisation
schedule is the trade you want.

The interest calculation is where precision actually bites. A monthly instalment computed to two
decimals and then multiplied across a 36-month schedule drifts from the same figure computed at
eight decimals and rounded once at the end. Decide where rounding happens, write it down, and
apply it in exactly one place.

## Every posting has two sides

Event sourcing gives you history. It does not stop money appearing from nowhere. Double-entry
does that.

Each transaction writes at least two postings, debits and credits, summing to zero. A disbursement
debits the investor's wallet and credits the borrower's. A service fee debits the borrower and
credits the platform's revenue account. If the sum of all postings in a transaction is not exactly
zero, the transaction does not commit.

That invariant is worth enforcing in code rather than trusting:

```php
public function post(array $entries): void
{
    $sum = array_reduce(
        $entries,
        fn (string $carry, LedgerEntry $e) => bcadd($carry, $e->signedAmount(), 8),
        '0'
    );

    if (bccomp($sum, '0', 8) !== 0) {
        throw new UnbalancedTransaction($sum);
    }

    DB::transaction(fn () => $this->writeAll($entries));
}
```

The check is cheap and it has caught real bugs, usually a fee calculated on the wrong base amount.
Without it those bugs surface weeks later as a balance nobody can explain.

## How do you fix a mistake if events are immutable?

You do not edit the event. You post a reversing entry and then the correct one, which is what
accountants have done since long before we had databases.

This is the part engineers push back on hardest, because editing a row is right there and it would
take one query. The reason to refuse is that an edited event destroys the only evidence that the
mistake happened. When the regulator asks why the balance moved, "we corrected an error on 3 April
with reversal `TXN-8841`" is an answer. A silently amended row is not.

Practically this means your event types need a reversal shape from day one. Retrofitting one is
painful because existing projections do not expect negative-signed events of that type.

## What event sourcing costs

I would not reach for this on a system that does not need it, and most systems do not.

Reads are the obvious cost: folding thousands of events for a balance you display on every page
load is not viable, so you build read models, and now you have a projection to keep in sync and
rebuild when it drifts. Schema evolution is worse. An event written two years ago must still
deserialise today, which means versioned serialisers and a rule against ever changing an existing
event's shape.

The rule I use: if the history is a regulatory requirement or the product itself, event sourcing
pays for itself. If you want an audit log, write an audit log.

## Sources

- [Event Sourcing](https://martinfowler.com/eaaDev/EventSourcing.html), Martin Fowler
- [PostgreSQL numeric types](https://www.postgresql.org/docs/current/datatype-numeric.html), PostgreSQL documentation
- [BCMath arbitrary precision mathematics](https://www.php.net/manual/en/book.bc.php), PHP manual
