<?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: Tomas Almeida</title>
    <description>The latest articles on DEV Community by Tomas Almeida (@tomasralmeida).</description>
    <link>https://dev.to/tomasralmeida</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F19145%2F11ccf8c8-edf9-46d1-80f6-0fb135988387.jpg</url>
      <title>DEV Community: Tomas Almeida</title>
      <link>https://dev.to/tomasralmeida</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tomasralmeida"/>
    <language>en</language>
    <item>
      <title>Why Building a Ledger Is Harder Than It Looks</title>
      <dc:creator>Tomas Almeida</dc:creator>
      <pubDate>Thu, 11 Jun 2026 16:54:47 +0000</pubDate>
      <link>https://dev.to/tomasralmeida/why-building-a-ledger-is-harder-than-it-looks-21cm</link>
      <guid>https://dev.to/tomasralmeida/why-building-a-ledger-is-harder-than-it-looks-21cm</guid>
      <description>&lt;p&gt;On August 1, 2012, Knight Capital pushed a faulty deployment to production. Within 45 minutes, its router fired more than 4 million unintended orders into the market, and the firm lost over $460 million (&lt;a href="https://www.sec.gov/news/press-release/2013-222" rel="noopener noreferrer"&gt;U.S. SEC&lt;/a&gt;, 2013). One build, one morning, one company nearly gone.&lt;/p&gt;

&lt;p&gt;Most money systems don’t fail like that. They fail quietly: a balance drifting by a cent every few thousand operations, a webhook replay that doubles a payout, a reconciliation job that “almost matches” forever.&lt;/p&gt;

&lt;p&gt;We’ve spent years building a double-entry ledger for a crypto payments system. What follows isn’t theory. It’s the set of constraints we ended up needing once the system started seeing real money, retries, and adversarial failure modes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Floating Point Breaks Finance Systems
&lt;/h2&gt;

&lt;p&gt;Floating point isn’t “slightly imprecise.” It’s structurally incompatible with exact accounting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$1.23 USD    -&amp;gt;  123
1.23 USDC     -&amp;gt;  1230000
0.5 ETH       -&amp;gt;  500000000000000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule we landed on is simple: &lt;strong&gt;money is always an integer in the smallest unit of the asset.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not decimals. Not floats. Not “precision-aware” types that still round under the hood.&lt;/p&gt;

&lt;p&gt;We use &lt;code&gt;numeric(38, 0)&lt;/code&gt; so fractional units cannot exist at rest. If a half-unit appears in a calculation, it fails before it becomes debt.&lt;/p&gt;

&lt;p&gt;The real complexity shows up elsewhere: fees.&lt;/p&gt;

&lt;p&gt;A 1% fee on 99,999,999 units produces a remainder that must be deterministically assigned. If you don’t decide where that remainder goes, your ledger will drift. Slowly. Invisibly. Permanently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Enforcing Double-Entry at the Database Layer
&lt;/h2&gt;

&lt;p&gt;Application-level invariants don’t survive production reality.&lt;/p&gt;

&lt;p&gt;Backfills, scripts, incident patches, and “temporary” admin queries bypass them instantly.&lt;/p&gt;

&lt;p&gt;So we push the invariant into Postgres.&lt;/p&gt;

&lt;p&gt;The rule is strict:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Within a transaction, debits must equal credits per currency.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And it is enforced at commit time, not row time.&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;CONSTRAINT&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;postings_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;postings&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;enforce_balanced_postings&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not the trigger itself. It’s the &lt;strong&gt;deferred validation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Without deferral, you can’t validate a group of rows that doesn’t exist yet.&lt;/p&gt;

&lt;p&gt;With it, Postgres becomes a transactional accounting engine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
  A[Insert postings rows] --&amp;gt; B[Transaction open]
  B --&amp;gt; C[Deferred trigger at commit]
  C --&amp;gt; D{Debits == Credits per currency?}
  D --&amp;gt;|Yes| E[Commit]
  D --&amp;gt;|No| F[Reject transaction]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is one of those rare cases where “database constraint” is not defensive programming. It is the system boundary.&lt;/p&gt;




&lt;h2&gt;
  
  
  Idempotency Is Not a Feature, It’s the System Model
&lt;/h2&gt;

&lt;p&gt;Every payment system is at-least-once.&lt;/p&gt;

&lt;p&gt;Not “sometimes.” Always.&lt;/p&gt;

&lt;p&gt;Stripe explicitly retries webhooks for days and may deliver duplicates by design.&lt;/p&gt;

&lt;p&gt;That means your system is not correct if retries are handled at the edge. It must be correct under duplication everywhere.&lt;/p&gt;

&lt;p&gt;We treat idempotency as a hard uniqueness rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One intent → one transaction, forever.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
  A[Incoming request] --&amp;gt; B{Idempotency key seen?}

  B --&amp;gt;|Yes| C[Return existing transaction]
  B --&amp;gt;|No| D[Insert transaction]

  D --&amp;gt; E{Unique constraint hit?}
  E --&amp;gt;|Yes| C
  E --&amp;gt;|No| F[Create postings]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real guarantee is the database index:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;(ledger_id, idempotency_key)&lt;/code&gt; is the source of truth&lt;/li&gt;
&lt;li&gt;cache is only a performance optimization&lt;/li&gt;
&lt;li&gt;concurrency collapses naturally into a single winner&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If two requests race, one wins the insert, the other resolves to it. No locks. No coordination layer. Just relational constraints doing what they were designed for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Append-Only Is an Audit Model, Not a Storage Choice
&lt;/h2&gt;

&lt;p&gt;The hardest habit to break is mutability.&lt;/p&gt;

&lt;p&gt;CRUD thinking says: fix incorrect rows.&lt;/p&gt;

&lt;p&gt;Ledger thinking says: &lt;strong&gt;never change history, only extend it.&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;flowchart LR
  A[Original entry] --&amp;gt; B[Posted]
  B --&amp;gt; C{Error discovered}
  C --&amp;gt; D[Reversal entry]
  D --&amp;gt; E[Corrective entry]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not aesthetic purity. It’s operational safety.&lt;/p&gt;

&lt;p&gt;If you allow updates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you destroy audit trails&lt;/li&gt;
&lt;li&gt;you break reproducibility&lt;/li&gt;
&lt;li&gt;you make reconciliation non-deterministic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you forbid updates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every correction becomes explicit&lt;/li&gt;
&lt;li&gt;every mistake is traceable&lt;/li&gt;
&lt;li&gt;every balance is recomputable from first principles&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We enforce this at two levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;application layer rejects updates/deletes&lt;/li&gt;
&lt;li&gt;database triggers enforce it as a final gate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only mutable fields are metadata (tags, references). Money fields are immutable by design.&lt;/p&gt;




&lt;h2&gt;
  
  
  Exchange Rates Must Be Frozen, Not Recomputed
&lt;/h2&gt;

&lt;p&gt;Any call to “current price” inside a ledger pipeline is a correctness bug.&lt;/p&gt;

&lt;p&gt;Not a race condition. A correctness bug.&lt;/p&gt;

&lt;p&gt;Rates are captured once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;exchange_rate   decimal(38, 18)
base_currency   text
quote_currency  text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And stored on every affected entry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
  A[Payment confirmed] --&amp;gt; B[Fetch FX rate]
  B --&amp;gt; C[Write rate into ledger entries]
  C --&amp;gt; D[Downstream systems use snapshot only]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retries don’t change accounting&lt;/li&gt;
&lt;li&gt;reports are deterministic across time&lt;/li&gt;
&lt;li&gt;historical balances remain reproducible&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A refund six months later uses the original rate, not today’s market rate. That’s not “approximate fairness.” It’s consistency.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reconciliation Is Where Systems Stop Agreeing With Each Other
&lt;/h2&gt;

&lt;p&gt;Everything before this point ensures internal consistency.&lt;/p&gt;

&lt;p&gt;Reconciliation is where reality enters.&lt;/p&gt;

&lt;p&gt;And reality is messy.&lt;/p&gt;

&lt;p&gt;External systems differ in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;timing&lt;/li&gt;
&lt;li&gt;batching&lt;/li&gt;
&lt;li&gt;rounding rules&lt;/li&gt;
&lt;li&gt;partial settlements&lt;/li&gt;
&lt;li&gt;eventual consistency windows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So reconciliation cannot be a simple equality check.&lt;/p&gt;

&lt;p&gt;It becomes a matching engine with strategies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
  A[External records] --&amp;gt; B[Reconciliation engine]

  B --&amp;gt; C[Exact match]
  B --&amp;gt; D[Windowed sum match]
  B --&amp;gt; E[Tolerance-based match]

  C --&amp;gt; F[Mark reconciled]
  D --&amp;gt; F
  E --&amp;gt; F

  B --&amp;gt; G[Unmatched queue for review]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key design choice: reconciliation writes back into the ledger.&lt;/p&gt;

&lt;p&gt;A posting is either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reconciled&lt;/li&gt;
&lt;li&gt;tolerably auto-matched&lt;/li&gt;
&lt;li&gt;or explicitly flagged for review&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No spreadsheets. No external truth tables.&lt;/p&gt;

&lt;p&gt;The ledger is the system of record, not the reconciliation output.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Minimal Model Behind a Payment Gateway
&lt;/h2&gt;

&lt;p&gt;At runtime, the entire system collapses into four accounts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TD
  A[Crypto Received] --&amp;gt; B[Merchant Payable]
  A --&amp;gt; C[Fee Revenue]
  B --&amp;gt; D[Payout Disbursed]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That simplicity is misleading.&lt;/p&gt;

&lt;p&gt;The correctness doesn’t come from the graph.&lt;/p&gt;

&lt;p&gt;It comes from everything around it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;integer-only math&lt;/li&gt;
&lt;li&gt;deferred constraints&lt;/li&gt;
&lt;li&gt;idempotency at the database level&lt;/li&gt;
&lt;li&gt;append-only history&lt;/li&gt;
&lt;li&gt;frozen exchange rates&lt;/li&gt;
&lt;li&gt;reconciliation as a write-back process&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without those, the diagram is just wishful thinking.&lt;/p&gt;

&lt;p&gt;With them, it becomes a financial system.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing Thought
&lt;/h2&gt;

&lt;p&gt;None of this is exotic. That’s the uncomfortable part.&lt;/p&gt;

&lt;p&gt;Each rule is simple in isolation. Every failure mode only appears under production pressure: retries, concurrency, partial failure, and time.&lt;/p&gt;

&lt;p&gt;The systems that survive are the ones that assume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the network will duplicate requests&lt;/li&gt;
&lt;li&gt;the database is the only trustworthy boundary&lt;/li&gt;
&lt;li&gt;time will invalidate assumptions&lt;/li&gt;
&lt;li&gt;humans will eventually need to audit everything&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A ledger isn’t a data model.&lt;/p&gt;

&lt;p&gt;It’s a collection of enforced invariants that happen to store money.&lt;/p&gt;

&lt;p&gt;If you don’t want to build all of this from scratch, there’s also SouthPay Ledger: &lt;a href="https://southpay.io/ledger?utm_source=dev.to"&gt;SouthPay Ledger&lt;/a&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>postgres</category>
      <category>payments</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
