FBO accounts and sub-accounting
One bulk asset at the sponsor bank, many user liabilities on your books.
By Solomon Ajayi · Free to read, no signup
You're a fintech wallet without a banking license. Your users' funds sit in an 'For Benefit Of' (FBO) account at your sponsor bank. From the sponsor's view: ONE bulk deposit. From yours: many user wallets, summing to that same total. The invariant you MUST maintain forever: sum(user wallets) = FBO balance. If they ever disagree, you're either commingling, you have a bug, or you've been robbed. This is THE foundational pattern for any unlicensed fintech that holds user money.
Without a banking license you cannot hold customer money directly, so it lives in a single pooled account at your sponsor bank, held for the benefit of your users. The bank sees one number, the FBO balance. You see many numbers, one wallet liability per user, and they have to add up to that one number at every instant. The bank tracks the cash; you track who owns which slice of it.
That gives you the iron law of this entire pattern: the sum of all user wallet liabilities equals the FBO asset balance, always. A deposit grows both sides at once, so the invariant holds. A peer-to-peer transfer is the interesting case: Charlie's wallet drops and Bob's rises, but the FBO does not move at all, because no real money left the bank, you only rearranged who owns what inside your own sub-ledger. That is why a P2P transfer is essentially free for you.
The invariant is not a nice-to-have; it is the thing you prove to keep operating. If the sum of wallets ever drifts from the FBO balance, there are only ugly explanations: a bug dropped an entry, you commingled funds, or someone is stealing. So you encode the check as a query that returns zero when the books balance, run it on a schedule, and hard-fail on anything else, blocking deposits until it is resolved.
Worked example, step by step
Alice deposits ₦10,000
Alice funds her wallet via card. Money lands in your FBO account at the sponsor bank. You credit Alice's wallet for the same amount.
| Account | Debit | Credit |
|---|---|---|
| FBO at Sponsor Bank (1300) | ₦10,000.00 | |
| Alice Wallet (2000) | ₦10,000.00 |
FBO at Sponsor Bank UP ₦10,000, the bank deposit grew. Alice Wallet UP ₦10,000, you owe Alice that amount. Check the invariant: FBO ₦10,000 = sum(wallets) ₦10,000. ✓ This is the simplest case, one user, one deposit.
Bob deposits ₦5,000
Bob funds his wallet. Same shape as Alice's deposit, different user.
| Account | Debit | Credit |
|---|---|---|
| FBO at Sponsor Bank (1300) | ₦5,000.00 | |
| Bob Wallet (2001) | ₦5,000.00 |
FBO UP ₦5,000, now ₦15,000 at the bank. Bob Wallet UP ₦5,000. Invariant check: FBO ₦15,000 = Alice ₦10,000 + Bob ₦5,000. ✓
Charlie deposits ₦15,000
Charlie funds in. Now you have three users with claims against the same FBO pool.
| Account | Debit | Credit |
|---|---|---|
| FBO at Sponsor Bank (1300) | ₦15,000.00 | |
| Charlie Wallet (2002) | ₦15,000.00 |
FBO UP ₦15,000, now ₦30,000 at the bank. Charlie Wallet UP ₦15,000. Invariant: FBO ₦30,000 = Alice ₦10,000 + Bob ₦5,000 + Charlie ₦15,000. ✓ Switch to the Statement view and pick FBO to see all three deposits stacked.
Charlie sends Bob ₦3,000 (P2P), FBO does NOT move
Charlie pays Bob ₦3,000 through your platform. No money leaves your FBO, it's still all at the sponsor bank. You just rearrange WHO OWNS WHAT within your sub-ledger.
| Account | Debit | Credit |
|---|---|---|
| Charlie Wallet (2002) | ₦3,000.00 | |
| Bob Wallet (2001) | ₦3,000.00 |
Charlie Wallet DOWN ₦3,000. Bob Wallet UP ₦3,000. FBO UNCHANGED at ₦30,000. Invariant: ₦30,000 = ₦10,000 (Alice) + ₦8,000 (Bob) + ₦12,000 (Charlie). ✓ This is why a P2P transfer is essentially free for you, no real money movement, just a rebalance within your liabilities. Lesson 3 taught this shape; this lesson connects it to the FBO model.
The invariant proof: a query you can run any moment.
-- The iron law of FBO sub-accounting:
-- sum(user wallet liabilities) = FBO asset balance
-- This query returns 0 if the books balance. Anything else is a bug,
-- commingling, or a missing entry. Run it on a cron, alert on != 0.
SELECT
(SELECT COALESCE(SUM(debit) - SUM(credit), 0)
FROM journal_line WHERE account_code = '1300') AS fbo_balance,
(SELECT COALESCE(SUM(credit) - SUM(debit), 0)
FROM journal_line WHERE account_code LIKE '20%') AS user_wallets_total,
(SELECT COALESCE(SUM(debit) - SUM(credit), 0)
FROM journal_line WHERE account_code = '1300')
-
(SELECT COALESCE(SUM(credit) - SUM(debit), 0)
FROM journal_line WHERE account_code LIKE '20%') AS drift;Cron job that hard-fails on drift. Alert and block writes.
def check_fbo_invariant(conn) -> None:
row = conn.execute(
"""
SELECT
(SELECT COALESCE(SUM(debit) - SUM(credit), 0)
FROM journal_line WHERE account_code = '1300') AS fbo,
(SELECT COALESCE(SUM(credit) - SUM(debit), 0)
FROM journal_line WHERE account_code LIKE '20%') AS wallets
"""
).fetchone()
drift = row["fbo"] - row["wallets"]
if drift != 0:
# This is a P0. Page on-call. Stop accepting deposits until resolved.
raise FBOInvariantBroken(
f"FBO {row['fbo']} != sum(wallets) {row['wallets']} (drift {drift})"
)Takeaway
FBO sub-accounting is the architecture of every regulated fintech wallet. ONE bulk asset on the sponsor bank's books, MANY granular liabilities on yours, and the iron law: sum(user wallets) = FBO balance at every moment. If you can't prove that invariant holds, you can't pass a sponsor-bank audit, and your sponsor will cut you off. Daily automated reconciliation between your wallet aggregate and the bank's FBO balance is non-negotiable for production fintechs.
The code behind it
Reference solution
-- Daily FBO invariant: sum(FBO debit-credit) must equal sum(user wallet credit-debit).
-- COALESCE(..., 0) catches the SUM-over-zero-rows = NULL case so the
-- drift check returns a real number even on an empty ledger and the
-- alert fires correctly on actual mismatches.
SELECT
(SELECT COALESCE(SUM(debit) - SUM(credit), 0)
FROM journal_line
WHERE account_code = '1300') AS fbo_balance,
(SELECT COALESCE(SUM(credit) - SUM(debit), 0)
FROM journal_line
WHERE account_code LIKE '20%') AS user_wallets_total,
(SELECT COALESCE(SUM(debit) - SUM(credit), 0)
FROM journal_line
WHERE account_code = '1300')
-
(SELECT COALESCE(SUM(credit) - SUM(debit), 0)
FROM journal_line
WHERE account_code LIKE '20%') AS drift;Practice this on a real ledger
Reading is half of it. Open this lesson in the lab to post the entries yourself against a real Postgres-backed double-entry ledger, with the validation on. Free, your sandbox is yours.