A chart of accounts is the set of accounts you model in your ledger and the conventions you use to name them. Get this right early and the rest of your integration falls into place — Numscripts read naturally, queries filter cleanly, and an auditor can read your books without a tour guide.
Structuring Account Hierarchies#
Use colons (:) to compose address segments into a hierarchy. The segments are just strings — Ledger doesn't interpret them — but consistent conventions let you query, group, and reason about accounts at scale.
users:{user_id}:wallet:main
users:{user_id}:wallet:pending
users:{user_id}:payment:{payment_id}
merchants:{merchant_id}:earnings
merchants:{merchant_id}:settlements
platform:fees
platform:revenue
platform:taxes:{tax_type}
orders:{order_id}:authorization
orders:{order_id}:capture
orders:{order_id}:refundWhy this works#
- Filtering: query every account under a prefix (e.g.
users:123:*). - Organisation: related accounts live next to each other in any listing.
- Scalability: adding new account types is just another segment — no restructure.
How much of your treasury do you model in the ledger?#
The world account is a built-in "infinite source" — it can always send funds, regardless of balance. It's the natural placeholder for "money came from outside" without committing to where outside.
Whether to lean on world or to model your asset side explicitly is the central design question for a product ledger. There's a spectrum, and where you sit depends on what role you want the ledger to play.
Use world as the asset side (default for fintech product ledgers)#
For most fintech use cases — third-party funds ledgering, embedded finance, neobank-style products — the product ledger's job is to track liabilities: what the platform owes to each user, merchant, partner, or internal pool. The asset side (the actual cash sitting in safeguarding accounts, FBO arrangements, custody providers) is a treasury concern, often spread across many providers, and changing that map shouldn't ripple through your product code.
A user deposit might land across six different safeguarding accounts depending on currency, region, partner bank, and risk policy. Folding all of that into the same ledger means every product feature has to know about treasury topology. Instead, the product ledger treats funds as coming from world and lets a separate system reconcile against the actual asset-side accounts.
// Product ledger only cares that Alice now has a claim worth $100.
send [USD/2 10000] (
source = @world
destination = @users:alice:wallet
)This keeps the product ledger small, focused on the liability side, and decoupled from the asset-side complexity.
Model the asset side explicitly (when treasury belongs in the ledger)#
If you do want the ledger to double as your treasury view — to know exactly which bank account funds sit in, or to enforce that liabilities match asset coverage — replace world with named asset accounts:
treasury:bank:main
treasury:bank:secondary
treasury:bank:eu
treasury:bank:us:main
treasury:custody:circleA deposit then names its actual landing place:
send [USD/2 10000] (
source = @treasury:bank:us:main allowing unbounded overdraft
destination = @users:alice:wallet
)This trades simplicity for tighter coupling: every deposit / payout / settlement flow has to know which asset account moved, and a treasury reshuffle (new safeguarding partner, account migration) is a ledger change. It's the right call when the ledger is your treasury system, less so when treasury is a distinct concern handled upstream.
Pool the asset side (paired with Payments cash pools)#
The two approaches above pull in opposite directions — one keeps the ledger blissfully unaware of treasury topology, the other pulls every bank account into the chart. A pairing with the Payments module's cash pools lets you have both at once.
Model a small, stable set of pool accounts in the ledger — each one standing for "value held somewhere across this group of underlying accounts":
treasury:pool:eu
treasury:pool:us
treasury:pool:cards
treasury:pool:wiresA deposit names the pool, not the specific bank:
send [USD/2 10000] (
source = @treasury:pool:us
destination = @users:alice:wallet
)Each ledger pool has a matching Payments cash pool on the operational side, aggregating the real-world PSP / safeguarding / FBO accounts. A reconciliation policy compares the ledger pool's balance against the Payments cash pool's aggregate balance and flags any drift. The product ledger stays small and stable; treasury reshuffles happen entirely on the Payments side (new partner bank? add it to the cash pool query — the ledger doesn't change).
This is usually the right answer for fintechs that have grown past "one account at one partner" but don't want every flow in the product to depend on the precise asset layout.
Other legitimate world uses#
Regardless of the broader choice, world is the natural source for:
- Initial system bootstrapping.
- Minting new assets you control end-to-end (loyalty points, tokens, in-app credits).
- Non-funded currency conversions (see Currency Conversion).
Specific account addresses on the liability side#
Whichever side of the asset-modeling spectrum you pick, name your liability-side accounts with as much context as you can:
@users:alice:wallet:main
@users:alice:payment:order-123
@merchants:acme:earnings
@platform:feesThe address is your audit trail and your query surface — users:alice:* should list every account belonging to Alice, payment:order-123 should let you trace a single business event end-to-end. Generic names like revenue or pending lose that context the moment you have two of them.
Modeling Common Financial Scenarios#
E-commerce platform#
Lending platform#
Multi-currency wallet#
Modeling Liabilities with Negative Balances#
Accounts in Formance can carry negative balances, which is the natural way to represent obligations: a debt, a drawn credit line, a pending settlement, an unsettled invoice. The negative balance reflects the deficit until it's cleared.
By default, accounts can't go below zero. To allow it, add the allowing overdraft clause on the source in your Numscript:
send [USD/2 1000] (
source = @users:alice allowing unbounded overdraft
destination = @users:bob
)Or with a cap:
send [USD/2 1000] (
source = @users:alice allowing overdraft up to [USD/2 500]
destination = @users:bob
)See Overdraft for the full syntax.
Example: debt settlement#
When a debtor pays down a balance, the same transaction primitive — send — reduces the negative side and increases the creditor's:
- Bob lends 100`.
- Alice pays back 50`.
// Initial debt creation (Bob lends to Alice)
send [USD/2 10000] (
source = @users:alice allowing unbounded overdraft
destination = @users:bob
)
// Debt repayment (Alice pays back Bob)
send [USD/2 5000] (
source = @users:alice
destination = @users:bob
)This mirrors standard double-entry: one account's reduction offsets another's increase, no special "liability" type needed.
Summary#
| Concept | Recommendation |
|---|---|
| Hierarchy | Compose addresses with : to enable filtering and grouping |
| Asset side | world (minimal) → named bank accounts (ledger is treasury) → pool accounts paired with cash pools (the usual fintech sweet spot) |
| Liability side | Specific, contextual addresses (users:alice:payment:order-123) for traceability |
| Liabilities | Model with negative balances + allowing overdraft |
| Debt settlement | Standard send reducing the negative side |
For how Formance's source/destination model differs from traditional double-entry, see Source-Destination Accounting Model.