My database had money that no transaction created
I had just finished migrating my banking project from a JSON file to PostgreSQL. Eighteen accounts, all of them fictional villains from the movie Despicable Me, moved across without a single error. I ran a query to spot-check the result and got this:
username | balance_cents
-------------+---------------
test_user | 5000
BadBoy | 0
MrScientist | 5000
test_user had fifty dollars, which was right.
BadBoy had nothing, which was right.
MrScientist had fifty dollars, and I had never touched that
account.
The thing that made it interesting
A few days earlier I had added a transaction ledger to the project. Every deposit and withdrawal appends a row recording what happened, how much, and what the balance became. The idea is that the balance is a claim and the ledger is the evidence.
So I asked the ledger where the fifty dollars came from:
SELECT t.type, t.amount_cents, t.created_at
FROM transactions t
JOIN accounts a ON a.id = t.account_id
WHERE a.username = 'MrScientist';
type | amount_cents | created_at
------+--------------+------------
(0 rows)
Nothing. Fifty dollars sitting in an account, and no record of anyone putting it there.
Working out what happened
There were three possibilities and only one of them meant my code was fine.
The migration reads clients.json and writes rows into
Postgres. So either the source file already had this problem, or the
migration created it. I checked the source:
{'username': 'MrScientist', 'name': 'Norman Osborn', 'account_number': 'BE015', 'balance': 50.0, 'transactions': []}
Fifty dollars, empty transaction list. The migration had copied exactly what it was given. My code was not the problem.
What actually happened is that at some point during development, before
the Transaction class existed, I ran the terminal app and
deposited fifty dollars into that account to check that deposits worked. The
balance moved. Nothing recorded it, because at that moment nothing
could record it. Then every subsequent migration carried that
orphaned balance forward, faithfully, into the new system.
The bug was not in the migration. It was in the data, and it predated the feature that would have caught it.
Why this is worth writing about
The temptation was to run one UPDATE, set the balance to
zero, and forget about it. I did run that update, because I had endpoints to
finish. But the principle underneath is one I had not properly understood
until I hit it.
I had been thinking of the ledger as a feature — something users can look at, a statement screen. It is not primarily that. It is an invariant:
Every cent in a balance should be explained by the ledger.
Once you state it that way, the account with fifty unexplained dollars is not a cosmetic problem. It is a violation of the property that makes the system trustworthy. And a system that quietly tolerates one violation will tolerate others, because nothing is checking.
What a real migration does
The correct fix is not to zero the balance. Zeroing it destroys information — that fifty dollars might have been real, and in a production system it certainly would be. You cannot make history correct by deleting it.
What real financial systems do when moving data between platforms is create an opening balance entry: a synthetic transaction, dated at the moment of migration, that says this account started here, prior history unavailable. It does not pretend to know what happened before. It just makes the ledger complete going forward, so the invariant holds from that point on.
For my project that means the migration script should do something like:
if balance_cents > 0 and not transactions:
insert_transaction(
type="opening_balance",
amount_cents=balance_cents,
balance_after_cents=balance_cents,
created_at=migration_time,
)
That needs a schema change too — the
CHECK (type IN ('deposit', 'withdrawal')) constraint would
reject it, which is the database correctly refusing a transaction type it
has never heard of.
The test I should have had
The satisfying version of this story ends with a test that would have caught it:
def test_ledger_explains_every_balance(conn):
"""No account may hold money the ledger cannot account for."""
for account in load_accounts(conn).values():
ledger_total = sum(
t.amount if t.type == "deposit" else -t.amount
for t in account.transactions
)
assert ledger_total == account.balance
That test is four lines and it asserts the entire integrity property of the system. It would have failed the moment I migrated. I did not have it, because I was thinking about the ledger as something to display rather than something to verify.
That is the actual lesson, and it took an account of a fictional supervillain holding fifty phantom dollars to teach me.
What I changed
- Zeroed the orphaned balance, for now
- Documented it in the README under known limitations
- Added the opening-balance migration to my list of next steps
I have gone back and forth on whether the honest thing is to fix it properly before writing this. I decided it is more useful to publish the version where I found it, understood it, and consciously deferred the real fix — because that is what the week actually looked like.
Comments