<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Elewude Okikijesu</title>
    <description>The latest articles on DEV Community by Elewude Okikijesu (@helewud).</description>
    <link>https://dev.to/helewud</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2910769%2Fdae48a0a-f182-4655-a439-67919d52fef1.png</url>
      <title>DEV Community: Elewude Okikijesu</title>
      <link>https://dev.to/helewud</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/helewud"/>
    <language>en</language>
    <item>
      <title>Your wallets are a liability</title>
      <dc:creator>Elewude Okikijesu</dc:creator>
      <pubDate>Thu, 27 Aug 2026 09:31:38 +0000</pubDate>
      <link>https://dev.to/helewud/your-wallets-are-a-liability-3km8</link>
      <guid>https://dev.to/helewud/your-wallets-are-a-liability-3km8</guid>
      <description>&lt;h2&gt;
  
  
  Your wallets are a liability
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Building a Ledger, #2 of 12&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Last year, first day on a wallet ledger for one of the big processors here. Vendor meeting, before anybody had opened a laptop, and somebody said the line I have not been able to unhear since:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not write code you will have to defend in front of the EFCC.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everybody laughed. Nobody was joking.&lt;/p&gt;

&lt;p&gt;It took me a while to work out what was actually being said. It was not &lt;em&gt;do not commit fraud&lt;/em&gt;. Nobody in that room was planning to. It was narrower than that, and much more uncomfortable:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;If this is ever examined, nobody reads your code. They read your rows.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Post #1 built a ledger that cannot create or destroy money. Every transaction sums to zero, enforced by the database, no exceptions. It works.&lt;/p&gt;

&lt;p&gt;It also cannot tell you whether the money in it is yours.&lt;/p&gt;




&lt;h3&gt;
  
  
  The founder on the call
&lt;/h3&gt;

&lt;p&gt;He is asked how much the company is holding. He runs this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 2,400,000,000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;₦2.4 billion. He says it out loud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That number is wrong in the most important way a number can be wrong.&lt;/strong&gt; It is not money the company has. It is money the company &lt;em&gt;owes&lt;/em&gt;. Every naira in a customer wallet is a debt, payable on demand, to somebody who can walk into an agent shop and ask for it. What the company actually has is whatever is sitting in the settlement bank account, and that is a completely different number.&lt;/p&gt;

&lt;p&gt;The gap between those two numbers is what this post is about. In this market it is also the distance between a soft chair in your own office and a concrete floor in an EFCC detention facility.&lt;/p&gt;

&lt;p&gt;Which is why it belongs in the schema on day zero, not day one.&lt;/p&gt;




&lt;h3&gt;
  
  
  Nobody tells you which rows to write
&lt;/h3&gt;

&lt;p&gt;Fair warning before we go further: there are about thirty lines of code in this post and they are the least interesting part.&lt;/p&gt;

&lt;p&gt;Here is the bit that never makes it into the ticket.&lt;/p&gt;

&lt;p&gt;You get handed &lt;em&gt;let users send money to each other&lt;/em&gt;. You write the function. Debit one wallet, credit the other, wrap it in a transaction, done before lunch. Post #1 already dealt with the part that looked hard, which was the concurrency.&lt;/p&gt;

&lt;p&gt;What nobody mentions is that the function you just wrote &lt;strong&gt;will move money it does not understand.&lt;/strong&gt; It cannot tell a customer's deposit from a promo credit. It cannot tell your revenue from somebody else's float. It does not know that one of those numbers is not yours. It moves all of them with equal confidence, the sum-to-zero check passes every single time, and the books balance the whole way down.&lt;/p&gt;

&lt;p&gt;Then somebody asks where the money went, and the answer gets reconstructed from the rows you chose to write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not the code. The rows.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the claim is this: &lt;strong&gt;&lt;em&gt;an amount is not a fact until you know what kind of account it sits on.&lt;/em&gt;&lt;/strong&gt; Post #1 gave us numbers that always conserve. This post gives those numbers meaning.&lt;/p&gt;




&lt;h3&gt;
  
  
  What is −5,000?
&lt;/h3&gt;

&lt;p&gt;Start with a puzzle, because it is the whole problem in one line.&lt;/p&gt;

&lt;p&gt;An account in your ledger is sitting at &lt;code&gt;−5,000&lt;/code&gt;. Is that a bug?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot answer that. Neither can your database.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If it is a customer wallet, it is a serious bug, because a customer cannot owe you money they never borrowed.&lt;/p&gt;

&lt;p&gt;If it is your fee revenue account, it is completely normal. That is what revenue looks like from the inside.&lt;/p&gt;

&lt;p&gt;If it is your settlement account, you have an emergency, because you are claiming to hold cash you do not have.&lt;/p&gt;

&lt;p&gt;Same number. Three different worlds. &lt;strong&gt;Signed integers carry magnitude and direction. They carry no meaning at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the invariant from post #1 proves nothing was destroyed. It does not prove anything is &lt;em&gt;right&lt;/em&gt;. To get closer to right, the ledger has to know what kind of thing each account is.&lt;/p&gt;




&lt;h3&gt;
  
  
  Five kinds of account
&lt;/h3&gt;

&lt;p&gt;Every account answers one question: &lt;em&gt;does this represent something you have, something you owe, or something that moved?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There are five answers. The machinery underneath them is about 500 years old, and the five-way split on top has been stable for most of the modern era.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asset.&lt;/strong&gt; Something you have or are owed. Your settlement account. Cash in an ATM. Money a merchant owes you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Liability.&lt;/strong&gt; Something you owe. &lt;strong&gt;Customer wallets go here.&lt;/strong&gt; Tax withheld and not yet remitted. Merchant payouts you have not sent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Equity.&lt;/strong&gt; What is left for the owners after liabilities. Share capital, retained earnings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revenue.&lt;/strong&gt; Money earned. Transaction fees, subscription income.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expense.&lt;/strong&gt; Money spent. Promo credits, provider costs, fraud losses.&lt;/p&gt;

&lt;p&gt;Assets and expenses are things you &lt;em&gt;hold or consumed&lt;/em&gt;. Liabilities, equity and revenue are things you &lt;em&gt;owe or earned&lt;/em&gt;. That split produces the equation everything hangs on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Assets = Liabilities + Equity + (Revenue − Expenses)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now go back to &lt;code&gt;−5,000&lt;/code&gt;. It is only answerable because the account has a kind. That is the entire mechanism.&lt;/p&gt;




&lt;h3&gt;
  
  
  Your wallets are a liability
&lt;/h3&gt;

&lt;p&gt;This is the one that matters, and it is not an accounting technicality. &lt;strong&gt;If you hold a mobile money licence in Nigeria, it is written into your licence conditions.&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  What the regulator actually says
&lt;/h4&gt;

&lt;p&gt;Under the CBN's July 2021 Guidelines, operators settle all obligations into settlement accounts at deposit money banks and keep separate accounts for their other business. Those settlement accounts are opened as &lt;strong&gt;nominee accounts on behalf of customers&lt;/strong&gt;, and section 9(c) governs them with four conditions [1]:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The account &lt;strong&gt;shall not be interest bearing&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No right of set-off.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Debits only for settlement-related transactions.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No bank charges&lt;/strong&gt; of any kind.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Section 9(d) then adds a separate prohibition: the account &lt;strong&gt;cannot be used as collateral&lt;/strong&gt; for loans, under any guise.&lt;/p&gt;

&lt;p&gt;Read those as an engineer rather than a lawyer. &lt;em&gt;No right of set-off&lt;/em&gt; means you cannot take what customers owe you out of the pool. &lt;em&gt;Debits only for settlement&lt;/em&gt; means money may not leave that account for any other reason, including yours. &lt;em&gt;No collateral&lt;/em&gt; means you cannot borrow against it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The regulator is saying, in legal language, that this is not your money.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then comes the line that should make anyone building a ledger sit up. Section 9(e) requires that the settlement account balance always equal the total unspent balance of every e-money holder [1].&lt;/p&gt;

&lt;p&gt;That is &lt;code&gt;Assets = Liabilities&lt;/code&gt;. One asset account, one class of liability, mandated by law, checkable with a query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;The accounting equation is not a nice-to-have here. It is a licence condition.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(One caveat, because it matters if you build savings: those funds are an explicit exception. They go into a separate pool and get invested in Nigerian Treasury Bills, and section 10.1.3(a) caps fees and charges for managing that investment at 10% of the interest income it earns. Read section 10 properly before you model any of it.)&lt;/em&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Why those conditions exist
&lt;/h4&gt;

&lt;p&gt;Every one of them is there because somebody did the opposite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pool account is the largest number in the company and it sits there looking spendable.&lt;/strong&gt; When payroll is due and revenue is thin, the distance between &lt;em&gt;we are holding ₦2.4 billion&lt;/em&gt; and &lt;em&gt;we have ₦2.4 billion&lt;/em&gt; is the distance between a going concern and that concrete floor. In this market it is not hypothetical. It is a recurring news story, and it is what that vendor meeting was actually about.&lt;/p&gt;

&lt;p&gt;A schema will not stop a determined founder. But a ledger where customer float is typed as a liability, sitting next to a settlement asset that is supposed to match it, makes the gap visible on day one rather than on the day somebody comes asking.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why the branch closes at 4pm
&lt;/h4&gt;

&lt;p&gt;There is a reporting consequence that will feel familiar to anyone who has worked near a Nigerian bank. Operators must reconcile pool balances &lt;strong&gt;daily&lt;/strong&gt; and make &lt;strong&gt;weekly returns&lt;/strong&gt; to the CBN [1].&lt;/p&gt;

&lt;p&gt;That is a trial balance, turned into a legal obligation.&lt;/p&gt;

&lt;p&gt;It is also why the branch shuts its doors at 4pm and the banker you know still gets home at 9. &lt;strong&gt;The doors close so the day can be counted.&lt;/strong&gt; Somebody has to prove that what the system says matches what the accounts actually hold, line by line, and sign it before anyone leaves.&lt;/p&gt;

&lt;p&gt;That work does not disappear because you have Postgres instead of a branch network. &lt;em&gt;It just moves.&lt;/em&gt; If your ledger cannot produce that number on demand, somebody on your team produces it by hand every evening, forever, and they are not enjoying it.&lt;/p&gt;




&lt;h3&gt;
  
  
  Reading the sign
&lt;/h3&gt;

&lt;p&gt;The mechanical bit, and it is short. We keep the signed &lt;code&gt;bigint&lt;/code&gt; from post #1. &lt;strong&gt;Positive means debit. Negative means credit.&lt;/strong&gt; That is the only convention to memorise.&lt;/p&gt;

&lt;p&gt;Combine it with the five kinds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;Increases with&lt;/th&gt;
&lt;th&gt;Healthy balance is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Asset&lt;/td&gt;
&lt;td&gt;debit (+)&lt;/td&gt;
&lt;td&gt;positive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expense&lt;/td&gt;
&lt;td&gt;debit (+)&lt;/td&gt;
&lt;td&gt;positive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Liability&lt;/td&gt;
&lt;td&gt;credit (−)&lt;/td&gt;
&lt;td&gt;negative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Equity&lt;/td&gt;
&lt;td&gt;credit (−)&lt;/td&gt;
&lt;td&gt;negative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revenue&lt;/td&gt;
&lt;td&gt;credit (−)&lt;/td&gt;
&lt;td&gt;negative&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Which produces the thing that trips everyone up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A customer with ₦5,000 in their wallet has a raw ledger balance of &lt;code&gt;−5,000&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That looks wrong. It is not. It is negative because &lt;em&gt;from the company's point of view, that is a debt&lt;/em&gt;. The minus sign is the ledger being honest about whose money it is.&lt;/p&gt;

&lt;p&gt;You obviously do not show a customer &lt;code&gt;−5,000&lt;/code&gt;. So you convert. And since this rule is going to be needed in more than one place, it goes in one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;natural_sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="n"&gt;account_kind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;IMMUTABLE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'liability'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'equity'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'revenue'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Anywhere you need the number a human expects:&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;natural_sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;natural_balance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two copies of a sign rule will drift, and the day they drift is the day your dashboard and your trial balance disagree about the same account. One function, &lt;code&gt;IMMUTABLE&lt;/code&gt; so the planner can inline it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;The number in your database and the number on the customer's screen are different things, and the account kind is the function between them.&lt;/em&gt;&lt;/strong&gt; Literally, in this case. That is most of what debits and credits actually buy you.&lt;/p&gt;




&lt;h3&gt;
  
  
  Three transactions
&lt;/h3&gt;

&lt;p&gt;Watch the equation hold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Customer deposits ₦5,000 by bank transfer.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Settlement account  (asset)     + 5,000    debit
Customer wallet     (liability) − 5,000    credit
                                 -------
                                       0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You have ₦5,000 more in the bank and you owe ₦5,000 more to a customer. Assets 5,000, Liabilities 5,000.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. You charge a ₦25 fee.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer wallet     (liability) +    25    debit
Fee revenue         (revenue)   −    25    credit
                                 -------
                                       0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You owe slightly less, and you earned ₦25. &lt;strong&gt;No cash moved.&lt;/strong&gt; The bank balance is untouched. What changed is who has a claim on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. You give a ₦1,000 promo credit.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Marketing expense   (expense)   + 1,000    debit
Customer wallet     (liability) − 1,000    credit
                                 -------
                                       0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look carefully, because this is where a balance column would quietly lie to you.&lt;/p&gt;

&lt;p&gt;The wallet went up by ₦1,000. &lt;strong&gt;Your bank account did not.&lt;/strong&gt; You now owe ₦5,975 while holding ₦5,000. That is not a bug. It is the accurate, slightly uncomfortable fact that you promised money you have not funded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Assets      5,000
Liabilities 5,975
Revenue        25
Expenses    1,000

5,000 = 5,975 + 0 + (25 − 1,000)
5,000 = 5,000  ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still balanced, and now telling you something a &lt;code&gt;balance&lt;/code&gt; column never could: &lt;strong&gt;the promotion made you poorer, and by exactly ₦1,000.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the whole argument in one example. A deposit and a promo credit both add money to a wallet. With a balance column &lt;em&gt;they are literally the same operation.&lt;/em&gt; In a typed ledger they are opposites. One increases an asset. The other increases an expense. One is funded. The other is a promise.&lt;/p&gt;

&lt;p&gt;And it opens exactly the way you would expect. Growth runs a promo over the weekend. Finance hears about it on Monday. Nothing in the &lt;code&gt;wallets&lt;/code&gt; table separates ₦40 million of promo credits from ₦40 million customers actually deposited, so &lt;strong&gt;the float looks healthy right up until enough people withdraw at once.&lt;/strong&gt; By then the question is not what your database says. It is whether the settlement account can cover it.&lt;/p&gt;




&lt;h3&gt;
  
  
  Where money enters
&lt;/h3&gt;

&lt;p&gt;Post #1 said the ledger cannot create money. Typed accounts force you to confront what that means at the edges.&lt;/p&gt;

&lt;p&gt;A customer deposits ₦5,000. You cannot simply credit their wallet, because &lt;strong&gt;the entry has to come from somewhere or it will not balance.&lt;/strong&gt; You are physically prevented from conjuring it.&lt;/p&gt;

&lt;p&gt;That constraint is doing you a favour. Every naira entering the system has to name its counterparty, and the counterparty is a real thing in the world: the bank account the money landed in.&lt;/p&gt;

&lt;h4&gt;
  
  
  The &lt;code&gt;system&lt;/code&gt; account
&lt;/h4&gt;

&lt;p&gt;Most teams discover this and then defeat it. They create an account called &lt;code&gt;system&lt;/code&gt; or &lt;code&gt;main&lt;/code&gt; or &lt;code&gt;bank&lt;/code&gt;, with no type, and post everything against it. It grows into an enormous number nobody can explain, and becomes the black hole where every unexplained naira goes to hide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;system&lt;/code&gt; account is a confession that you have not modelled something.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Banks already have a name for this. It is the suspense account, and anyone who has worked in bank operations has a story about a suspense balance nobody could explain, that grew quietly for years, and was eventually written off because everyone who knew what it was had left. &lt;strong&gt;A &lt;code&gt;system&lt;/code&gt; account is a suspense account you built deliberately, before you had a single customer.&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  What it costs
&lt;/h4&gt;

&lt;p&gt;A founder told me how this played out at his company. The shape is worth walking through, because at no point did anyone have to be dishonest.&lt;/p&gt;

&lt;p&gt;They had a &lt;code&gt;system&lt;/code&gt; account. Customer money came in, wallets were credited, the counter-entry went to &lt;code&gt;system&lt;/code&gt;. Provider fees went out of &lt;code&gt;system&lt;/code&gt;. Operating costs came out of &lt;code&gt;system&lt;/code&gt;. A promo campaign came out of &lt;code&gt;system&lt;/code&gt;. It was the account for everything that was not a user wallet, &lt;strong&gt;which made it the account for everything nobody had thought about yet.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every decision was defensible on the day it was made. The money was in the bank. The number looked fine. Nothing objected, because nothing in the system knew the difference between money they had earned and money they were holding for somebody else.&lt;/p&gt;

&lt;p&gt;By the time a real reconciliation happened, the hole nearly took the company down. &lt;strong&gt;Every customer balance was correct.&lt;/strong&gt; Every wallet showed exactly the right number. The money to honour those numbers was not there. They had been running the business on customer float for months, and the ledger reported balanced books the entire time, because it &lt;em&gt;was&lt;/em&gt; balanced.&lt;/p&gt;

&lt;p&gt;Sum-to-zero held on every transaction. It just could not say what kind of zero it was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They had magnitude and direction, and no meaning.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Typed accounts would not have stopped anyone spending that money. They would have made it &lt;strong&gt;impossible to spend without seeing it.&lt;/strong&gt; An operating cost posted against a liability account is a question somebody answers out loud. &lt;em&gt;An operating cost posted against &lt;code&gt;system&lt;/code&gt; is just Tuesday.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It is also, eventually, an afternoon spent explaining what &lt;code&gt;system&lt;/code&gt; means to people who do not find it funny.&lt;/p&gt;

&lt;h4&gt;
  
  
  Starting from nothing
&lt;/h4&gt;

&lt;p&gt;Small practical problem everyone hits on day one, same answer.&lt;/p&gt;

&lt;p&gt;Your ledger is empty. You want to record that the company started with ₦10,000,000 of capital in the bank. Debit the settlement account ₦10,000,000. What do you credit?&lt;/p&gt;

&lt;p&gt;Nothing exists yet, and a single-sided entry gets rejected by the trigger.&lt;/p&gt;

&lt;p&gt;The answer is equity. &lt;strong&gt;Equity is the account that exists so the books can start.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Settlement account  (asset)   + 10,000,000    debit
Share capital       (equity)  − 10,000,000    credit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same pattern handles migrations, which matters more than the founding case. Moving off a legacy system with existing balances? You do not insert balances. &lt;strong&gt;You post an opening transaction per account against an opening-balances equity account&lt;/strong&gt;, and from that moment every naira has a traceable origin. The migration becomes a ledger entry you can point an auditor at.&lt;/p&gt;




&lt;h3&gt;
  
  
  One query for the whole business
&lt;/h3&gt;

&lt;p&gt;Post #1 checked one thing per transaction. Typed accounts give you a check across the &lt;strong&gt;entire database&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Every entry ever written, per currency. Must be zero.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;imbalance&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;
 &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the per-transaction trigger is doing its job, this always returns nothing. Which is the point. It returns a row only if somebody bypassed the trigger, disabled it, or wrote straight to the table. &lt;strong&gt;It is your tamper detector.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The typed version is the trial balance. &lt;strong&gt;One row per account&lt;/strong&gt;, which is what an accountant means by the phrase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;trial_balance&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;raw_balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;natural_sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;natural_balance&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
         &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_rollup&lt;/span&gt;
 &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details. It is a &lt;code&gt;LEFT JOIN&lt;/code&gt; so accounts with no entries still show as zero, because an account silently missing from a trial balance is worse than one showing nothing. It joins on both &lt;code&gt;account_id&lt;/code&gt; and &lt;code&gt;currency&lt;/code&gt;, matching post #1's composite foreign key instead of relying on you remembering it exists. And it excludes rollups, because summing a parent alongside its children counts the same money twice.&lt;/p&gt;

&lt;p&gt;Roll that up by kind and you get the equation itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;accounting_equation&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;natural_balance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;trial_balance&lt;/span&gt;
 &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two views, not one, and the names are worth getting right. A trial balance lists &lt;strong&gt;every account&lt;/strong&gt;. Grouping by kind gives you the &lt;strong&gt;accounting equation&lt;/strong&gt;, which is a different report answering a different question. Call the second one a trial balance and the first accountant who opens your repo will quietly downgrade their opinion of everything else in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the number the founder should have quoted on that call.&lt;/strong&gt; It is also, roughly, what the CBN wants reconciled every day.&lt;/p&gt;




&lt;h3&gt;
  
  
  Why accountants use two columns
&lt;/h3&gt;

&lt;p&gt;Time to answer the title, because I have been using one signed column and real accounting systems do not.&lt;/p&gt;

&lt;p&gt;A traditional ledger has &lt;code&gt;debit&lt;/code&gt; and &lt;code&gt;credit&lt;/code&gt;, both always positive, and the two must total the same:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Account&lt;/th&gt;
&lt;th&gt;Debit&lt;/th&gt;
&lt;th&gt;Credit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Settlement&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer wallet&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three real advantages:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No negative numbers anywhere.&lt;/strong&gt; You cannot fat-finger a sign, because there are no signs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A category of mistake gets harder to express.&lt;/strong&gt; With one signed column, &lt;code&gt;+5000&lt;/code&gt; and &lt;code&gt;−5000&lt;/code&gt; posted to the &lt;em&gt;wrong two accounts&lt;/em&gt; passes the sum check happily. Two columns make that harder to write by accident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is what every finance system on earth expects.&lt;/strong&gt; Your accountant, your auditor, your ERP import and every regulatory return assume DR/CR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I am still using one signed column.&lt;/strong&gt; The sum-to-zero check is one operation on one column and post #1's constraint works unchanged. Two columns need &lt;code&gt;SUM(debit) = SUM(credit)&lt;/code&gt;, a check that exactly one is non-zero per row, and decisions about nulls versus zeros. More surface for the same guarantee.&lt;/p&gt;

&lt;p&gt;And DR/CR is a presentation format, so you can always derive it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;  &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;debit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You cannot go the other way as cleanly.&lt;/p&gt;

&lt;p&gt;If you are building for an organisation that already has a finance team with opinions, &lt;strong&gt;store DR/CR natively and save yourself the argument.&lt;/strong&gt; The important thing is not which representation you pick. It is that &lt;strong&gt;&lt;em&gt;the account kind is recorded, so the sign has meaning.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  The schema
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;account_kind&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;ENUM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'asset'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'liability'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'equity'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'revenue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expense'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An enum rather than &lt;code&gt;text&lt;/code&gt; with a &lt;code&gt;CHECK&lt;/code&gt;: same validation, and adding a sixth kind becomes a deliberate migration rather than a typo that silently succeeds.&lt;/p&gt;

&lt;p&gt;Now the migration. Post #1 shipped an &lt;code&gt;accounts&lt;/code&gt; table, so yours has rows in it, and &lt;strong&gt;you cannot add a &lt;code&gt;NOT NULL&lt;/code&gt; column to a table that already has rows.&lt;/strong&gt; The order of the next four steps matters more than it looks, and I will come back to why.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 1. Add the columns, nullable for now.&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;      &lt;span class="n"&gt;account_kind&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;is_rollup&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;accounts_parent_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Seed the chart of accounts FIRST, so the rollup exists&lt;/span&gt;
&lt;span class="c1"&gt;--    before anything tries to point at it.&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_rollup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'asset:settlement:gtb'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="s1"&gt;'GTB settlement account'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'asset'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="s1"&gt;'NGN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'asset:cash:agent_float'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="s1"&gt;'Agent cash float'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="s1"&gt;'asset'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="s1"&gt;'NGN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'liability:wallets'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="s1"&gt;'User wallets'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="s1"&gt;'liability'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'NGN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'liability:tax:withholding'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Withholding tax payable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'liability'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'NGN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'liability:payouts:pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Merchant payouts pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'liability'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'NGN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'equity:opening_balances'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'Opening balances'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="s1"&gt;'equity'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="s1"&gt;'NGN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'revenue:fees:transfer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="s1"&gt;'Transfer fee revenue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="s1"&gt;'revenue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'NGN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'expense:promotions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="s1"&gt;'Promotional credits'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="s1"&gt;'expense'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'NGN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'expense:provider_fees'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="s1"&gt;'Payment provider fees'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="s1"&gt;'expense'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'NGN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Backfill. Everything that existed before this migration was a wallet,&lt;/span&gt;
&lt;span class="c1"&gt;--    so it is a liability and it hangs off the rollup we just created.&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'liability'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;code&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'liability:wallets:'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'liability:wallets'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. Tighten.&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
    &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt;  &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;accounts_code_unique&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seed before backfill, not after. Do it the other way round and every migrated wallet gets a null &lt;code&gt;parent_id&lt;/code&gt;, because the rollup it should point at does not exist yet. Nothing errors. The wallets simply never roll up, and you find out during your first reconciliation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;code&lt;/code&gt; gives you stable, readable identifiers. You will use them in code, in reconciliation scripts, and in conversations with your accountant, all of which go better when the thing has a name instead of a UUID.&lt;/p&gt;

&lt;h4&gt;
  
  
  Rollups, and why &lt;code&gt;parent_id&lt;/code&gt; is there
&lt;/h4&gt;

&lt;p&gt;Individual user wallets are still their own rows, all of kind &lt;code&gt;liability&lt;/code&gt;, each with &lt;code&gt;parent_id&lt;/code&gt; pointing at &lt;code&gt;liability:wallets&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That parent is a &lt;strong&gt;rollup, not a posting account&lt;/strong&gt;, and the distinction matters more than it looks. Post entries to both a wallet and its parent and you count the same money twice, so your trial balance is quietly wrong. Never post to the parent and its balance is zero, so comparing against it is meaningless.&lt;/p&gt;

&lt;p&gt;Now, the tempting way to enforce this is to work it out from the data. An account with children is a rollup, so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Do not do this.&lt;/span&gt;
&lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;That is a bug, and a nasty one.&lt;/strong&gt; An account is postable right up until the moment it gets its first child, and then it silently stops being postable. &lt;code&gt;liability:wallets&lt;/code&gt; accepts entries on Monday. On Tuesday the first wallet points at it. Those Monday entries are now double-counted forever, and nothing anywhere raises a complaint, because on Monday the rule genuinely did not apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A classification that can change when unrelated data changes is not a classification.&lt;/strong&gt; So state it structurally, at creation, with the &lt;code&gt;is_rollup&lt;/code&gt; column from the migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;reject_posting_to_rollup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;is_rollup&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;EXCEPTION&lt;/span&gt; &lt;span class="s1"&gt;'account % is a rollup and cannot be posted to directly'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;ERRCODE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'restrict_violation'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;entries_leaf_accounts_only&lt;/span&gt;
    &lt;span class="k"&gt;BEFORE&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;reject_posting_to_rollup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flag is set when the account is created and cannot drift with the data. If you want to go further, the composite foreign key trick from post #1 works here too: a &lt;code&gt;UNIQUE (id, is_rollup)&lt;/code&gt; on &lt;code&gt;accounts&lt;/code&gt; lets you require that every &lt;code&gt;parent_id&lt;/code&gt; points at a row where &lt;code&gt;is_rollup&lt;/code&gt; is true, so a wallet cannot hang off another wallet.&lt;/p&gt;

&lt;p&gt;A rollup's balance is the sum of its children, computed on demand. Which means &lt;strong&gt;the sum of every user wallet is the wallet total by construction, not by luck&lt;/strong&gt;, and comparing that against &lt;code&gt;asset:settlement:gtb&lt;/code&gt; is the licence condition from earlier, expressed as a query.&lt;/p&gt;

&lt;p&gt;That is post #12 in miniature.&lt;/p&gt;




&lt;h3&gt;
  
  
  What this does not solve
&lt;/h3&gt;

&lt;p&gt;Same discipline as last time, because a rule that gets oversold gets ignored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typed accounts still do not mean correct.&lt;/strong&gt; Debit &lt;code&gt;expense:promotions&lt;/code&gt; when you meant &lt;code&gt;expense:provider_fees&lt;/code&gt; and everything balances perfectly while your P&amp;amp;L is wrong. Post #1 said conservation is not accuracy. Classification is not accuracy either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A balanced equation does not mean you are solvent.&lt;/strong&gt; Assets equals liabilities plus equity holds even when equity is deeply negative. The ledger balances beautifully all the way into insolvency. It is an integrity check, not a health check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing here handles timing.&lt;/strong&gt; A fee earned today and collected next month is a receivable, and this model has no concept of accrual versus cash. That is where a lot of fintech reporting goes wrong, and it deserves its own post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The FX case is still open.&lt;/strong&gt; Post #1's trigger groups by currency, so a multi-currency transaction must balance in each currency independently. Real FX needs balancing legs through an FX position account, and revaluation when rates move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This chart of accounts is not portable.&lt;/strong&gt; The invariant survives any asset. Almost nothing around it does. Yen has no minor unit, so post #1's "store everything in kobo" assumption breaks on day one. On-chain assets settle by confirmation rather than bank transfer, and confirmations can be reorganised away. What counts as settled, how many decimals you need, and who you reconcile against are decisions you make before your posting logic means anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And check the regulations yourself.&lt;/strong&gt; The guidelines here are from July 2021, the CBN revises them as it sees fit, and they cover mobile money operators specifically. Hold a PSSP, switching, PSB or microfinance licence and your conditions differ. The accounting argument holds regardless. The legal detail may not.&lt;/p&gt;




&lt;h3&gt;
  
  
  What ships
&lt;/h3&gt;

&lt;p&gt;Tag &lt;code&gt;v0.2-post-02&lt;/code&gt;: the &lt;code&gt;account_kind&lt;/code&gt; enum, the four-step migration, &lt;code&gt;is_rollup&lt;/code&gt; accounts with the leaf-only posting trigger, the &lt;code&gt;natural_sign&lt;/code&gt; function, the seeded chart of accounts, the &lt;code&gt;trial_balance&lt;/code&gt; and &lt;code&gt;accounting_equation&lt;/code&gt; views, and tests asserting that the whole ledger sums to zero per currency, that the equation holds after a deposit, a fee and a promo, that posting to a rollup is rejected, and that the wallet rollup equals the sum of individual wallets.&lt;/p&gt;

&lt;p&gt;The founder on that call had one number. &lt;strong&gt;He should have had three:&lt;/strong&gt; ₦2.4 billion owed to customers, whatever is actually in the settlement account, and the difference between them. If the first two are not close together, that is the only fact on the call that matters.&lt;/p&gt;

&lt;p&gt;And it is the difference somebody else will eventually calculate, from your rows, in a room you did not choose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next post:&lt;/strong&gt; splitting ₦100 three ways without losing a kobo. Money types, minor units, and why the remainder has to go somewhere on purpose.&lt;/p&gt;




&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;p&gt;[1] Central Bank of Nigeria (2021). &lt;em&gt;Framework and Guidelines on Mobile Money Services in Nigeria.&lt;/em&gt; Settlement account conditions at section 9; savings wallets at section 10. &lt;a href="https://www.cbn.gov.ng/Out/2021/CCD/Framework%20and%20Guidelines%20on%20Mobile%20Money%20Services%20in%20Nigeria%20-%20July%202021.pdf" rel="noopener noreferrer"&gt;https://www.cbn.gov.ng/Out/2021/CCD/Framework%20and%20Guidelines%20on%20Mobile%20Money%20Services%20in%20Nigeria%20-%20July%202021.pdf&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;#2 of 12 in Building a Ledger, a double-entry ledger engine in Go. Code: &lt;a href="https://github.com/Helewud/kobo" rel="noopener noreferrer"&gt;https://github.com/Helewud/kobo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>wallet</category>
      <category>database</category>
      <category>go</category>
    </item>
    <item>
      <title>balance is a summary, not a fact. In this post, I break down the 5 major failure modes of storing wallet balances in a single column and show how to enforce zero-sum invariants directly in Postgres.</title>
      <dc:creator>Elewude Okikijesu</dc:creator>
      <pubDate>Tue, 25 Aug 2026 06:54:58 +0000</pubDate>
      <link>https://dev.to/helewud/balance-is-a-summary-not-a-fact-in-this-post-i-break-down-the-5-major-failure-modes-of-storing-1gbm</link>
      <guid>https://dev.to/helewud/balance-is-a-summary-not-a-fact-in-this-post-i-break-down-the-5-major-failure-modes-of-storing-1gbm</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/helewud/your-wallet-balance-shouldnt-be-a-column-41k9" class="crayons-story__hidden-navigation-link"&gt;Your wallet balance shouldn't be a column&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/helewud" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2910769%2Fdae48a0a-f182-4655-a439-67919d52fef1.png" alt="helewud profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/helewud" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Elewude Okikijesu
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Elewude Okikijesu
                
                
              
              &lt;div id="story-author-preview-content-4478905" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/helewud" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2910769%2Fdae48a0a-f182-4655-a439-67919d52fef1.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Elewude Okikijesu&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/helewud/your-wallet-balance-shouldnt-be-a-column-41k9" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Aug 25&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/helewud/your-wallet-balance-shouldnt-be-a-column-41k9" id="article-link-4478905"&gt;
          Your wallet balance shouldn't be a column
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/wallet"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;wallet&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/database"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;database&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/fintech"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;fintech&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/go"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;go&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/helewud/your-wallet-balance-shouldnt-be-a-column-41k9" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt;&amp;nbsp;reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/helewud/your-wallet-balance-shouldnt-be-a-column-41k9#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            16 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>backend</category>
      <category>database</category>
      <category>postgres</category>
      <category>sql</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Elewude Okikijesu</dc:creator>
      <pubDate>Tue, 25 Aug 2026 06:49:29 +0000</pubDate>
      <link>https://dev.to/helewud/-lh0</link>
      <guid>https://dev.to/helewud/-lh0</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/helewud/your-wallet-balance-shouldnt-be-a-column-41k9" class="crayons-story__hidden-navigation-link"&gt;Your wallet balance shouldn't be a column&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/helewud" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2910769%2Fdae48a0a-f182-4655-a439-67919d52fef1.png" alt="helewud profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/helewud" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Elewude Okikijesu
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Elewude Okikijesu
                
                
              
              &lt;div id="story-author-preview-content-4478905" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/helewud" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2910769%2Fdae48a0a-f182-4655-a439-67919d52fef1.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Elewude Okikijesu&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/helewud/your-wallet-balance-shouldnt-be-a-column-41k9" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Aug 25&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/helewud/your-wallet-balance-shouldnt-be-a-column-41k9" id="article-link-4478905"&gt;
          Your wallet balance shouldn't be a column
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/wallet"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;wallet&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/database"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;database&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/fintech"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;fintech&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/go"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;go&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/helewud/your-wallet-balance-shouldnt-be-a-column-41k9" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt;&amp;nbsp;reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/helewud/your-wallet-balance-shouldnt-be-a-column-41k9#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            16 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Your wallet balance shouldn't be a column</title>
      <dc:creator>Elewude Okikijesu</dc:creator>
      <pubDate>Tue, 25 Aug 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/helewud/your-wallet-balance-shouldnt-be-a-column-41k9</link>
      <guid>https://dev.to/helewud/your-wallet-balance-shouldnt-be-a-column-41k9</guid>
      <description>&lt;h2&gt;
  
  
  Your wallet balance shouldn't be a column
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Building a Ledger, #1 of 12&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An agent is standing in a shop in Ikeja. She has ₦80,000 in her wallet. She tries to send ₦5,000 to a supplier and the app spins, then times out.&lt;/p&gt;

&lt;p&gt;Her money is there. The system just can't give it to her right now, because it is busy paying her. Somewhere in a data centre, her wallet row is locked as part of a fee posting for a transaction she has nothing to do with.&lt;/p&gt;

&lt;p&gt;This post is about the one design decision that leads there, and how to avoid it.&lt;/p&gt;

&lt;p&gt;This is the first post in a series where I build a working double-entry ledger in Go, one post at a time.&lt;/p&gt;




&lt;h3&gt;
  
  
  The line everybody writes
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. Fast. Obvious.&lt;/p&gt;

&lt;p&gt;Let me be fair to it first, because most posts on this topic are not. This exact statement is safer than people think. In Postgres, if two of these run at once, they do not lose money. The second one waits for the first to commit, then re-reads the row and applies its change to the new value. Postgres calls this the EvalPlanQual recheck, and it exists precisely to stop lost updates at the default isolation level [6].&lt;/p&gt;

&lt;p&gt;So the one-liner survives. The problem is that almost nobody ships the one-liner, because it can't answer the question that actually matters: &lt;em&gt;does this wallet have enough?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There are five things wrong with keeping a balance in a column.&lt;/strong&gt; Only one of them can be fixed with a lock, and that fix is what causes the fifth.&lt;/p&gt;




&lt;h3&gt;
  
  
  Failure one: the lost update
&lt;/h3&gt;

&lt;p&gt;To check the balance before spending it, you write this instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT balance FROM wallets WHERE id = $1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// balance = 10,000&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ErrInsufficientFunds&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UPDATE wallets SET balance = $1 WHERE id = $2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the version in production at a lot of companies. It is also broken.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Balance starts at ₦10,000

Request A:  read balance    → 10,000
Request B:  read balance    → 10,000
Request A:  write 10,000 - 3,000 = 7,000
Request B:  write 10,000 - 4,000 = 6,000

Final balance: ₦6,000
Money withdrawn: ₦7,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user took out ₦7,000 but only ₦4,000 left their balance. You gave away ₦3,000.&lt;/p&gt;

&lt;p&gt;The read and the write are two separate statements, so nothing connects them. Postgres has no idea the second statement depends on the first.&lt;/p&gt;

&lt;p&gt;This is not new. It was named three decades ago as phenomenon P4, "Lost Update," in the 1995 SIGMOD paper by Berenson, Bernstein, Gray, Melton and the O'Neils [5].&lt;/p&gt;

&lt;p&gt;Two things worth knowing about isolation levels here, because they get repeated wrong constantly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Postgres defaults to READ COMMITTED&lt;/strong&gt;, which allows this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MySQL/InnoDB defaults to REPEATABLE READ&lt;/strong&gt;, which does not. Different default, different failure mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have never explicitly thought about isolation levels, you do not know which of those you are running.&lt;/p&gt;

&lt;p&gt;I've watched this one play out more times than I'd like. It is the most common way I have seen a young fintech lose real money, and it is usually not an accident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is a known attack. People go looking for it.&lt;/strong&gt; Fire twenty withdrawal requests at the same endpoint in the same second, then check what the balance says afterwards. It costs nothing to test, it needs no stolen credentials and no insider, and when it works, it works again and again until somebody reconciles. By then the money has been cashed out.&lt;/p&gt;

&lt;p&gt;So do not file this under "bug we should get to eventually." A balance column with a read-then-write on it is &lt;strong&gt;&lt;em&gt;an open door, and in this market people are already checking the handle.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Now the honest part. You are already thinking: just use &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;. You are right. That fixes this one.&lt;/p&gt;

&lt;p&gt;Hold that thought. It is the only one of the five that locking solves, and in failure five, it becomes the problem.&lt;/p&gt;




&lt;h3&gt;
  
  
  Failure two: you cannot answer "why?"
&lt;/h3&gt;

&lt;p&gt;A customer says they were debited twice. You open the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;balance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12,400&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now what?&lt;/p&gt;

&lt;p&gt;That number tells you what the balance is. It says nothing about how it got there. You can't see what it was yesterday. You can't see which operations changed it. You can't tell whether there were two debits or one.&lt;/p&gt;

&lt;p&gt;Every investigation becomes archaeology through application logs, if you kept them, and if they weren't rotated away last week.&lt;/p&gt;

&lt;p&gt;Some teams do keep a &lt;code&gt;transactions&lt;/code&gt; table alongside the balance. That helps, but it doesn't fix this. If the balance isn't &lt;em&gt;derived&lt;/em&gt; from that table, the two can drift, and when they disagree you have no way to say which one is right.&lt;/p&gt;

&lt;p&gt;This goes beyond support tickets. Handle other people's money long enough and an auditor, a regulator or a partner bank will ask you to prove a balance. "Our application wrote this number" is not proof. It's a claim.&lt;/p&gt;

&lt;p&gt;Pat Helland said it best: "Accountants don't use erasers or they go to jail" [1]. Accounting solved this centuries ago. Nothing is erased. Mistakes are fixed by adding new entries, never by changing old ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An &lt;code&gt;UPDATE&lt;/code&gt; on a balance column is an eraser.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Failure three: money can be created and destroyed
&lt;/h3&gt;

&lt;p&gt;This is the one that should worry you most, and the one discussed least.&lt;/p&gt;

&lt;p&gt;A transfer is two writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sender'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'receiver'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap it in a transaction and both succeed or both fail. Good. But think about what your schema actually knows.&lt;/p&gt;

&lt;p&gt;It knows there are two rows. It does not know they are related. It does not know that ₦5,000 leaving one place and ₦5,000 arriving in another are two halves of one event. There is no rule anywhere saying money must be conserved.&lt;/p&gt;

&lt;p&gt;So the day a bug writes the debit and skips the credit, whether from a bad code path, a half-completed retry, or a migration script someone ran at 2am, then ₦5,000 stops existing. Nothing notices. There is no query that says "something is wrong here," because the schema has no concept of right and wrong. It only has numbers.&lt;/p&gt;

&lt;p&gt;Uber published a ten-year retrospective on their payments platform this year. Among the principles they say they defended for a decade is zero-sum accounting, which ensured "money was never created or destroyed" [2]. That's a property they designed for on purpose. It isn't free.&lt;/p&gt;

&lt;p&gt;You can't have it with a balance column. There's nothing for it to be a property of.&lt;/p&gt;




&lt;h3&gt;
  
  
  Failure four: corruption is permanent
&lt;/h3&gt;

&lt;p&gt;The balance is now wrong. How do you fix it?&lt;/p&gt;

&lt;p&gt;You can't recompute it, because there's nothing to recompute from. The column was always the only copy of the truth. So your only option is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8500&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is the exact operation that caused the problem. It leaves no record. Six months later nobody knows this number was typed by hand during an incident. It looks identical to a balance earned honestly.&lt;/p&gt;

&lt;p&gt;Corruption here isn't just possible. &lt;strong&gt;It's permanent and invisible.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Failure five: the hot row
&lt;/h3&gt;

&lt;p&gt;Failure one was about being wrong. This one is about being unavailable.&lt;/p&gt;

&lt;p&gt;You added &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;. Correctness is fixed. Now a popular merchant starts receiving payments. Fifty credits land on one wallet in the same second.&lt;/p&gt;

&lt;p&gt;Every one of them wants the same row. They queue behind a single lock. Throughput on that wallet is now &lt;code&gt;1 / lock_hold_time&lt;/code&gt;, no matter how many servers you run. Add capacity and nothing improves, because the bottleneck is one row in one table.&lt;/p&gt;

&lt;p&gt;Note that the plain one-liner has this problem too. Locking made you correct; it did not make you fast, and nothing about a mutable balance column ever will.&lt;/p&gt;

&lt;p&gt;Then it gets worse. Transfers lock two rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transfer A→B:  lock A, then lock B
Transfer B→A:  lock B, then lock A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both hold one lock and wait for the other. Deadlock. Postgres detects it and kills one transaction. Your user sees a failed transfer that should have worked.&lt;/p&gt;

&lt;p&gt;The standard fix is to always acquire locks in a fixed order: sort by account ID, lock the smaller first. It works. It also requires every developer who ever touches money code to remember it, forever, including the one who joins next year and writes a batch settlement job at 2am.&lt;/p&gt;

&lt;h4&gt;
  
  
  The fee split: where this gets ugly
&lt;/h4&gt;

&lt;p&gt;Take one POS payment of ₦20,000 through an agent network. That transaction doesn't touch two wallets. The fee gets split:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer            − ₦20,100
Merchant            + ₦20,000
Agent (net)         +     ₦40
Tax withheld        +      ₦5
Aggregator          +     ₦20
Platform            +     ₦25
Switch / processor  +     ₦10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent's gross commission is ₦45; ₦5 is withheld at source, so ₦40 reaches her wallet and ₦5 goes to a tax liability account. Seven wallets, one payment, and the numbers sum to zero.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;, that's seven row locks held together in one transaction. Now look at &lt;em&gt;which&lt;/em&gt; rows. The customer and merchant change every time. But the aggregator, the platform, the switch and the tax account are in every single transaction the network processes. They are not occasionally hot. They are locked on every payment, all day.&lt;/p&gt;

&lt;p&gt;At a few hundred payments a second, those four rows are the whole system. Run twenty application servers and nothing improves, because all twenty are waiting on the same four rows.&lt;/p&gt;

&lt;p&gt;And then you get the agent in the shop. Her wallet is locked as part of somebody else's fee posting. She tries to send ₦5,000 to a supplier and it hangs, times out, or deadlocks against a settlement job running in the other direction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;She can't spend her money because the system is busy paying her.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is not a throughput problem you can buy your way out of. &lt;strong&gt;&lt;em&gt;It is the data model.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  "Just use a queue"
&lt;/h4&gt;

&lt;p&gt;This is where most teams land. Put transactions on a queue, partition by wallet ID, process each wallet serially. One consumer per wallet, no concurrent writes, no locks, no deadlocks.&lt;/p&gt;

&lt;p&gt;It works. And notice what you just decided: serialise the events and apply them in order. That is a ledger. You have arrived at the right idea.&lt;/p&gt;

&lt;p&gt;But look at where you put it.&lt;/p&gt;

&lt;p&gt;Your system of record is now two systems. The truth about a wallet is "what Postgres says, plus whatever is still in flight in the queue." Those two things are not in the same transaction and cannot be made consistent with each other. There's a window where money has left the sender and not arrived at the receiver, and nothing in your database represents that window.&lt;/p&gt;

&lt;p&gt;Queues also deliver at-least-once. A consumer that crashes after applying a credit but before acknowledging will apply it again, so you need idempotency keys anyway, and that is a database concern.&lt;/p&gt;

&lt;p&gt;And ordering is only per-partition. Repartition, rebalance or scale consumers and your careful per-wallet ordering can break in ways that are painful to reproduce.&lt;/p&gt;

&lt;p&gt;The queue is a good tool. &lt;strong&gt;It is a bad system of record.&lt;/strong&gt; You've taken the most important invariant in your business and handed it to a component sitting outside your database, when it could have been native to your schema.&lt;/p&gt;

&lt;h4&gt;
  
  
  How the big processors actually handle it
&lt;/h4&gt;

&lt;p&gt;It's tempting to settle this by quoting transaction volumes. That's the wrong lesson. The interesting thing about Stripe isn't how much they process. It's how.&lt;/p&gt;

&lt;p&gt;Stripe's internal Ledger is an immutable, auditable log that serves as the system of record for their financial data, handling five billion events a day [4]. Adyen has described roughly fifty rows being inserted into their accounting database over the lifetime of a single payment [7].&lt;/p&gt;

&lt;p&gt;Note what both of those are: &lt;strong&gt;inserts&lt;/strong&gt;. Not updates. The scale comes from appending, because appends don't fight each other.&lt;/p&gt;

&lt;p&gt;Now the counterexample, because it matters more than the supporting ones. Uber's Gulfstream has had immutable money orders and zero-sum accounting for a decade, and they still hit a wall. In a separate post this year they describe hot-key accounts that by 2023 needed far more than their system's limit of 3 to 4 update operations per second [3].&lt;/p&gt;

&lt;p&gt;So &lt;strong&gt;append-only is not a magic word.&lt;/strong&gt; Uber kept a stored-balance layer for reads, and that layer is where the heat landed. What appending buys you is that the &lt;em&gt;write&lt;/em&gt; path stops conflicting. Where you keep derived balances, and how you shard them, is a separate problem you still have to solve.&lt;/p&gt;

&lt;p&gt;Our fee split, done as a ledger, is seven &lt;code&gt;INSERT&lt;/code&gt;s into &lt;code&gt;entries&lt;/code&gt; inside one transaction, summing to zero. No row is &lt;em&gt;exclusively&lt;/em&gt; locked. The platform account can receive a thousand fee entries a second while the agent spends from her wallet in the same millisecond, because those are different rows and neither blocks the other.&lt;/p&gt;

&lt;p&gt;One caveat, so you hear it from me and not from production: those inserts do take a shared &lt;code&gt;FOR KEY SHARE&lt;/code&gt; lock on the referenced &lt;code&gt;accounts&lt;/code&gt; row, because that's how foreign keys work. Shared locks don't block each other, so throughput holds. But very high concurrency on the same referenced row generates multixact traffic, and that has its own costs at scale. Better trade, not zero cost.&lt;/p&gt;

&lt;h4&gt;
  
  
  On "correctness over efficiency"
&lt;/h4&gt;

&lt;p&gt;You'll hear that fintech should always choose correctness over speed. The sentiment is right, but stated that way it hands the argument to the other side, because if correctness always wins, then locking all seven wallets is justified and the agent can just wait.&lt;/p&gt;

&lt;p&gt;Better framing: with an append-only ledger, you are not making that trade on the write path at all. The lock-based design buys correctness by spending availability. The ledger keeps the same correctness and moves the cost somewhere much smaller.&lt;/p&gt;

&lt;p&gt;Where a real trade does show up, it's narrower than people think, and it's about &lt;strong&gt;reads&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Showing a balance can be slightly stale. A dashboard 200ms behind harms nobody.&lt;/li&gt;
&lt;li&gt;Authorising a debit cannot be stale. That read must be authoritative, or you've reinvented failure one with extra steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep those two apart, in your head and in your code. Conflating them is how a team "fixes" contention by reading a cached balance and quietly reintroduces overdrafts.&lt;/p&gt;

&lt;p&gt;I'll be honest about the limit: appending makes credits free, and credits are the high-volume case. A debit that must check "does this wallet have enough?" still needs an authoritative balance, and that still needs a correctness story. That's post #6, where I benchmark three of them. But solving the high-volume half completely, with no extra infrastructure, is not a small win.&lt;/p&gt;




&lt;h3&gt;
  
  
  The fix: derive the balance
&lt;/h3&gt;

&lt;p&gt;Every failure above comes from one decision: storing a conclusion instead of the facts that produce it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A balance is not a fact. It's a summary.&lt;/strong&gt; It's the answer to a question you can ask at any time, as long as you kept the inputs.&lt;/p&gt;

&lt;p&gt;So keep the inputs. Append what happened. The balance becomes a &lt;code&gt;SUM&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Helland calls this append-only computing: record observations permanently, calculate results on demand [1].&lt;/p&gt;

&lt;p&gt;To be precise about the claim, because "never store a balance" is too strong: you can absolutely keep a materialised balance. Uber does. What you must not do is make it the &lt;em&gt;only&lt;/em&gt; record of truth. &lt;strong&gt;If it can be rebuilt by replaying entries, it's a cache and you can fix it. If it can't, it's a liability.&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  The schema
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;currency&lt;/span&gt;   &lt;span class="nb"&gt;char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;reference&lt;/span&gt;   &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;             &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;transaction_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;account_id&lt;/span&gt;     &lt;span class="n"&gt;uuid&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;currency&lt;/span&gt;       &lt;span class="nb"&gt;char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;         &lt;span class="nb"&gt;bigint&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;     &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Balance reads: INCLUDE (amount) allows an index-only scan.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;entries_account_idx&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;INCLUDE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- The balance trigger below queries by transaction_id on every insert.&lt;/span&gt;
&lt;span class="c1"&gt;-- Without this index that is a sequential scan of the whole table.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;entries_transaction_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transaction_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A transaction is one event: a transfer, a fee, a top-up. An entry is one side of it. A simple transfer has two entries. The POS payment above has seven.&lt;/p&gt;

&lt;p&gt;Four decisions worth explaining:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;amount&lt;/code&gt; is &lt;code&gt;bigint&lt;/code&gt;, in minor units.&lt;/strong&gt; ₦50.00 is 5000 kobo. Never a float, because floats can't represent 0.1 exactly and the errors accumulate. (Not every currency divides by 100. JPY has no minor unit, KWD has three digits, so you'll eventually want an exponent per currency. Post #3.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;amount&lt;/code&gt; is signed.&lt;/strong&gt; Negative takes money out, positive puts it in, and entries in one transaction must sum to zero. Accountants say debit and credit; that's post #2. For now, signed integers are the same idea with less vocabulary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;CHECK (amount &amp;lt;&amp;gt; 0)&lt;/code&gt;.&lt;/strong&gt; A zero entry would satisfy the sum-to-zero rule while meaning nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The composite foreign key.&lt;/strong&gt; &lt;code&gt;accounts&lt;/code&gt; has &lt;code&gt;UNIQUE (id, currency)&lt;/code&gt; and &lt;code&gt;entries&lt;/code&gt; references &lt;code&gt;(account_id, currency)&lt;/code&gt;. A naira entry physically cannot be written against a dollar account. No trigger, no application check. The database rejects it. The redundant unique index is the price, and it's worth it.&lt;/p&gt;

&lt;h4&gt;
  
  
  Put the invariant in the database
&lt;/h4&gt;

&lt;p&gt;Entries in a transaction must sum to zero, &lt;strong&gt;per currency&lt;/strong&gt;. That rule is what stops money being created or destroyed.&lt;/p&gt;

&lt;p&gt;The tempting place to enforce it is in Go, before the insert. Do that too. Just don't let it be the only place.&lt;/p&gt;

&lt;p&gt;Bailis and colleagues studied this in &lt;em&gt;Feral Concurrency Control&lt;/em&gt;. Across 67 real Rails applications, they found application-level validations were over 37 times more common than database transactions [8]. Their more interesting finding is the nuance: most of those validations are accidentally safe under concurrency, but the unsafe remainder is dominated by uniqueness and aggregate checks. Sum-to-zero is exactly that kind of check. It's in the group that breaks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;assert_transaction_balances&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
    &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;imbalance&lt;/span&gt;
      &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;
      &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;
     &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;transaction_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transaction_id&lt;/span&gt;
     &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;
    &lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
     &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;FOUND&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;EXCEPTION&lt;/span&gt; &lt;span class="s1"&gt;'transaction % does not balance in % (off by %)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transaction_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;imbalance&lt;/span&gt;
            &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;ERRCODE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'check_violation'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;entries_must_balance&lt;/span&gt;
    &lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;
    &lt;span class="k"&gt;DEFERRABLE&lt;/span&gt; &lt;span class="k"&gt;INITIALLY&lt;/span&gt; &lt;span class="k"&gt;DEFERRED&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;assert_transaction_balances&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details doing real work here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;GROUP BY currency&lt;/code&gt;.&lt;/strong&gt; A naive &lt;code&gt;SUM(amount)&lt;/code&gt; over the whole transaction sums across currencies. A transaction with −5,000 NGN and +5,000 USD sums to zero and passes, creating $50 out of naira. Grouping closes that hole. (Real FX needs balancing legs through an FX position account. Later post.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;DEFERRABLE INITIALLY DEFERRED&lt;/code&gt;.&lt;/strong&gt; Without it the check fires after the first entry, when the transaction is legitimately unbalanced. Deferring to commit time means all seven entries land first and the rule is checked at the end.&lt;/p&gt;

&lt;p&gt;One cost to be aware of: constraint triggers in Postgres must be row-level, so a seven-entry transaction runs that check seven times. It's bounded by fanout and it's cheap with the index, but it isn't free.&lt;/p&gt;

&lt;p&gt;An unbalanced transaction is not an error your code has to handle. &lt;strong&gt;&lt;em&gt;It's a state that cannot exist.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Make it append-only for real
&lt;/h4&gt;

&lt;p&gt;Block edits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;reject_mutation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;EXCEPTION&lt;/span&gt; &lt;span class="s1"&gt;'% on % is not permitted: this table is append-only'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;TG_OP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TG_TABLE_NAME&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;ERRCODE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'restrict_violation'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;entries_immutable&lt;/span&gt;
    &lt;span class="k"&gt;BEFORE&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;reject_mutation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;entries_no_truncate&lt;/span&gt;
    &lt;span class="k"&gt;BEFORE&lt;/span&gt; &lt;span class="k"&gt;TRUNCATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;STATEMENT&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;reject_mutation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the second trigger. Row-level triggers don't fire on &lt;code&gt;TRUNCATE&lt;/code&gt;, so without it, one statement empties your ledger.&lt;/p&gt;

&lt;p&gt;And here is the part most posts skip. &lt;strong&gt;A trigger is a guardrail, not a guarantee.&lt;/strong&gt; Whoever owns the table can run &lt;code&gt;ALTER TABLE entries DISABLE TRIGGER ...&lt;/code&gt; and walk straight through it. If you're going to argue that application checks are one engineer with psql away from being irrelevant, and I did, above, then you have to hold your own fix to the same standard. The real control is permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;TRUNCATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run migrations as a different role. And accept that even this stops at your DBA, which is why serious ledgers hash-chain their entries: you can't prevent tampering, but you can make it detectable.&lt;/p&gt;

&lt;p&gt;Wrote a bad entry? You don't delete it. You write a reversing entry and both stay visible forever. That's post #11, and it is the eraser rule, enforced by Postgres.&lt;/p&gt;




&lt;h3&gt;
  
  
  What this does not solve
&lt;/h3&gt;

&lt;p&gt;Being clear about the boundary, because a rule that gets oversold gets ignored:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sum-to-zero doesn't mean correct.&lt;/strong&gt; Debit the wrong customer, credit the right merchant: perfectly balanced, completely wrong. &lt;strong&gt;Conservation is not accuracy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal consistency isn't external truth.&lt;/strong&gt; Your ledger can balance to the kobo and still disagree with your partner bank. Reconciliation against the outside world is a separate discipline, and no schema gives it to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There are no holds yet.&lt;/strong&gt; Card and POS flows need authorisations: money committed but not captured, an available balance distinct from a posted one. This model has no concept of a pending entry. That's coming.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ordering by &lt;code&gt;id&lt;/code&gt; is not ordering by commit.&lt;/strong&gt; A transaction can take entry id 1000 and commit &lt;em&gt;after&lt;/em&gt; one that took 1005. So "sum everything after entry N" can permanently miss in-flight rows. That's the trap in post #8's checkpointing, and it's a consequence of a decision made right here in post #1.&lt;/p&gt;




&lt;h3&gt;
  
  
  "But summing a million rows is slow"
&lt;/h3&gt;

&lt;p&gt;Yes. Eventually.&lt;/p&gt;

&lt;p&gt;A few thousand entries with the right index is sub-millisecond and you won't notice. A busy platform account two years in, with tens of millions of fee entries, you will.&lt;/p&gt;

&lt;p&gt;The fix is checkpointed snapshots: store the balance as of entry N, then sum only what came after. That's post #8, and the trap above is exactly why it needs its own post.&lt;/p&gt;

&lt;p&gt;The ordering is deliberate. &lt;strong&gt;Get it correct, then get it fast.&lt;/strong&gt; The reverse is how you end up back at a mutable column with extra steps.&lt;/p&gt;




&lt;h3&gt;
  
  
  What ships
&lt;/h3&gt;

&lt;p&gt;Tag &lt;code&gt;v0.1-post-01&lt;/code&gt; has the migration, the schema above, and tests proving that an unbalanced transaction is rejected, that a cross-currency "balanced" transaction is rejected, and that entries cannot be updated, deleted or truncated.&lt;/p&gt;

&lt;p&gt;The agent in Ikeja sends her ₦5,000 and it goes through, while the platform account takes a thousand fee entries in the same second. Not because the system got faster. &lt;strong&gt;&lt;em&gt;Because nothing in that transaction ever needed to hold her wallet still.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next post:&lt;/strong&gt; your wallets are a liability. Account types, why the CBN wrote one into your licence conditions, and why accountants use two columns where I used a minus sign..&lt;/p&gt;




&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;p&gt;[1] Helland, P. (2015). &lt;em&gt;Immutability Changes Everything.&lt;/em&gt; CIDR 2015; ACM Queue 13(9). &lt;a href="https://queue.acm.org/detail.cfm?id=2884038" rel="noopener noreferrer"&gt;https://queue.acm.org/detail.cfm?id=2884038&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[2] Uber Engineering (2026). &lt;em&gt;Zero-Sum by Design: 10 Years of Uber's Payments Platform.&lt;/em&gt; &lt;a href="https://www.uber.com/us/en/blog/ubers-payments-platform/" rel="noopener noreferrer"&gt;https://www.uber.com/us/en/blog/ubers-payments-platform/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[3] Uber Engineering (2026). &lt;em&gt;Building High Throughput Payment Account Processing.&lt;/em&gt; &lt;a href="https://www.uber.com/en-DO/blog/high-throughput-processing/" rel="noopener noreferrer"&gt;https://www.uber.com/en-DO/blog/high-throughput-processing/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[4] Ganelin, I. (2024). &lt;em&gt;Ledger: Stripe's system for tracking and validating money movement.&lt;/em&gt; Stripe. &lt;a href="https://stripe.com/blog/ledger-stripe-system-for-tracking-and-validating-money-movement" rel="noopener noreferrer"&gt;https://stripe.com/blog/ledger-stripe-system-for-tracking-and-validating-money-movement&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[5] Berenson, H., Bernstein, P., Gray, J., Melton, J., O'Neil, E., O'Neil, P. (1995). &lt;em&gt;A Critique of ANSI SQL Isolation Levels.&lt;/em&gt; ACM SIGMOD.&lt;/p&gt;

&lt;p&gt;[6] PostgreSQL Documentation. &lt;em&gt;Transaction Isolation.&lt;/em&gt; &lt;a href="https://www.postgresql.org/docs/current/transaction-iso.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/current/transaction-iso.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[7] Adyen's accounting database write volume per payment, as summarised in &lt;a href="https://www.martinrichards.me/post/ledger_p2_scaling_double_entry_ledger_massive_psp/" rel="noopener noreferrer"&gt;https://www.martinrichards.me/post/ledger_p2_scaling_double_entry_ledger_massive_psp/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[8] Bailis, P., Fekete, A., Franklin, M. J., Ghodsi, A., Hellerstein, J. M., Stoica, I. (2015). &lt;em&gt;Feral Concurrency Control: An Empirical Investigation of Modern Application Integrity.&lt;/em&gt; ACM SIGMOD. &lt;a href="https://doi.org/10.1145/2723372.2737784" rel="noopener noreferrer"&gt;https://doi.org/10.1145/2723372.2737784&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;#1 of 12 in Building a Ledger, a double-entry ledger engine in Go. Code: &lt;a href="https://github.com/Helewud/kobo" rel="noopener noreferrer"&gt;https://github.com/Helewud/kobo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/@mkumbwajr" rel="noopener noreferrer"&gt;Ali Mkumbwa&lt;/a&gt; on &lt;a href="https://unsplash.com/photos/a-person-holding-a-credit-card-in-their-hand-AEz70PS5eSU" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wallet</category>
      <category>database</category>
      <category>fintech</category>
      <category>go</category>
    </item>
  </channel>
</rss>
