A fintech sub-ledger is the part of your platform that knows how much money each customer has. That sentence is deceptively simple. In practice, a sub-ledger has to hold up against ACH returns that arrive three days after settlement, card chargebacks that arrive ninety days later, dual-message networks where authorization and clearing disagree by a few cents, sponsor bank settlement files that occasionally drop a line, and processor webhooks that get replayed because of an outage somewhere upstream.
Most fintech sub-ledgers I have seen are spreadsheet logic dressed up in Postgres. They have a users table, a transactions table, a balance column, and a cron job that updates the balance when transactions arrive. They work for the first six months. They break the first time the sponsor bank's settlement file disagrees with the network's authorization stream — and after that, every reconciliation is manual archaeology.
This guide is how I design a sub-ledger that survives. I have built versions of this at three companies. The design is opinionated. Skip a step at your own risk.
TL;DR
- Double-entry is not optional. If your schema can represent a one-sided money movement, you will eventually have one.
- Authorization, posting, and settlement are three different events with three different timestamps. Model them separately.
- Customers have sub-accounts (pending, available, reserve, dispute-hold), not balances.
- Idempotency keys on every external ingestion path. Always.
- Append-only journal. Balances are computed views, not stored fields.
- Daily automated reconciliation to the sponsor bank's settlement file, with mismatches going to a queue.
Why most sub-ledgers fail
The failure mode is almost always the same. Engineering builds a model where balance is a stored integer on the user row, updated by transaction handlers. It works fine for happy-path debits and credits. Then one of three things happens:
- A chargeback arrives ninety days late. The original transaction has been written, the user has spent the funds, and now the ledger has to reverse a posting that has no symmetric counterparty. The engineer either fudges it (creates a one-sided debit) or unwinds three weeks of postings to make it look right. Both options corrupt the ledger.
- The sponsor bank's settlement file disagrees with the platform's running balance. The bank shows $1,247,331.04. The platform shows $1,247,328.91. The variance is $2.13. Nobody can find it. The variance grows over time because nobody has the tooling to bisect.
- A network webhook gets replayed. A transaction posts twice. The user's balance goes down by twice the amount. Customer support hears about it before engineering does.
Each of these failures is a symptom of the same root cause: the schema permits states that are economically impossible. The fix is to design the schema so that those states are unrepresentable.
Step 1: Double-entry, enforced at the schema level
Every economic event is two entries. A $100 card purchase by Alice is:
- DEBIT
alice:available_balance $100
- CREDIT
chisel:network_payable $100
The two entries sum to zero. The journal table enforces this with a transaction_id that groups entries, a check constraint that requires at least two entries per transaction, and an invariant that the sum of all entries in a transaction is zero.
CREATE TABLE journal_entries (
id UUID PRIMARY KEY,
txn_id UUID NOT NULL,
account_id UUID NOT NULL REFERENCES sub_accounts(id),
amount_cents BIGINT NOT NULL, -- signed; debits negative, credits positive
occurred_at TIMESTAMPTZ NOT NULL,
posted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
event_type TEXT NOT NULL,
metadata JSONB
);
CREATE INDEX ON journal_entries (account_id, occurred_at);
CREATE INDEX ON journal_entries (txn_id);
The constraint that SUM(amount_cents) GROUP BY txn_id = 0 is enforced at write time via a stored procedure or, better, at the application layer with a JournalTransaction aggregate that refuses to commit unsold entries. If you can write a one-sided journal entry, your ledger is already broken; you just don't know it yet.
Step 2: Authorization ≠ posting ≠ settlement
These are three different events:
- Authorization is a promise. The network says "Alice's bank has enough money for $100, and we are holding it." Your sub-ledger should move $100 from
available_balance to pending_debit — a hold, not a posting.
- Posting is recognition. The network confirms the transaction cleared. Your sub-ledger releases the hold and moves $100 from
pending_debit to network_payable. The user's available balance is now permanently down.
- Settlement is money movement. The sponsor bank moves $100 (net of interchange) into your settlement account. Your sub-ledger now reconciles the
network_payable against the actual bank receipt.
If you collapse these three events into one, you cannot represent partial reversals, you cannot represent holds released without posting, and you cannot reconcile the network's authorization stream against the bank's settlement file. They are not optional. Build them as three event types from day one.
Step 3: Sub-accounts, not balances
Each customer's "balance" is actually a small set of sub-accounts:
available_balance — what they can spend right now
pending_debits — authorizations that haven't posted
pending_credits — incoming ACH not yet cleared
reserve — funds set aside for reasons specific to your product
dispute_hold — funds held during chargeback adjudication
fee_owed — fees accrued but not yet collected
The customer-facing UI shows available_balance. The backend reasons about all six. A chargeback moves money into dispute_hold; if the customer wins, it goes back to available_balance; if they lose, it moves to a loss account. None of this requires unwinding history — it is all forward-only postings.
Step 4: Idempotency keys on every ingestion path
Sponsor banks resend files. Processors replay webhooks. AWS SQS will deliver the same message twice on purpose. Every ingestion path must use an idempotency key derived from the event:
- Card webhooks:
network_transaction_id + event_type
- Settlement file lines:
file_id + line_number
- ACH returns:
originating_trace_id + return_code
A row in an idempotency_keys table protects the journal. If the key is already present, the ingestion is a no-op. This is the difference between an outage being a non-event and an outage producing phantom postings that you find in a regulatory audit two years later.
Step 5: Append-only journal, computed balances
The journal is append-only. Balances are computed views over the journal, materialized for performance:
CREATE MATERIALIZED VIEW account_balances AS
SELECT account_id, SUM(amount_cents) AS balance_cents
FROM journal_entries
GROUP BY account_id;
Reversals are new entries, not updates. Adjustments are new entries with an event_type = 'manual_adjustment' and a reference to the human who authorized them. This gives you two things that ledger-mutating designs cannot: a complete audit trail and time-travel — the ability to ask "what was Alice's available balance at 14:32 on March 14?" and get an answer.
If your design ever requires UPDATE journal_entries SET ..., stop and redesign.
Step 6: Automated daily reconciliation
Every business day at a fixed time, ingest the sponsor bank's settlement extract and reconcile it against your sub-ledger's network_payable activity for the matching period. Mismatches go into a reconciliation_breaks queue with enough metadata to bisect.
The reconciliation engine should be built before the dashboard. Founders consistently invert this priority because dashboards are demo-able and reconciliation is invisible. The platforms that get taken offline by regulators are the ones that had a beautiful dashboard and a broken reconciliation pipeline.
A useful guarantee: a sub-ledger should be able to answer, on any business day before noon, "are we in sync with the bank as of yesterday, and if not, where exactly is the break?"
Step 7: Disputes as state machines
A chargeback is not a flag. It is a multi-stage process:
dispute_opened — issue provisional credit, journal entries move funds to dispute_hold
evidence_submitted — internal state change, no journal entries
network_ruling_received — depending on outcome, journal entries either release the hold back to the customer or write it to a loss account
final_posting — close out the dispute and update any aggregate metrics
Each stage is a row in a dispute_state_transitions table with a foreign key to the original transaction. The dispute itself owns its journal entries, separately from the original posting. This is how you avoid the "double-credit" failure mode that fraud rings actively look for.
The sponsor bank's settlement file is a contract. So is the processor's webhook payload. So is the NACHA file format. They will all change. The defense is to wrap each external format in an adapter — SponsorBankSettlementParserV2, ProcessorWebhookV3 — with explicit version handling and contract tests.
If your business logic depends on field names from the bank file, every bank file change becomes a production incident. If your business logic depends on a versioned internal contract that the adapter populates, every bank file change is an adapter upgrade with green tests.
Closing
A sub-ledger built this way is more upfront work than the spreadsheet-in-Postgres design that most fintechs ship first. It is also the difference between a platform that grows past $1B in TPV without incident and one that ends up in a regulatory consent decree.
If you're working on the reconciliation problem and want to compare notes, our pillar on fintech infrastructure economics and the glossary entries on interchange and settlement are the natural next reads.
— Matt Anderton, CTO, Chisel