Money Core: The Ledger
This chapter is the one to read if you are assessing risk. Everything else in the platform moves money by asking this layer to do it.
Every movement has two sides
The ledger is double-entry. A movement is not "the player's balance went down by 2.00" — it is "2.00 moved from the player's wallet to the account holding stakes on bets that haven't settled yet." Both sides are named, always.
That has a consequence worth stating plainly: money can never be created or destroyed by a bug. A movement that debits without crediting is not a movement this system can express. Sum every account in a currency and the answer must be zero.
Amounts are always positive. Direction comes from which side of the movement an account sits on, not from a minus sign.
The house accounts
The counterparty to every player movement is a real account with a real balance. Every account has a path — a short colon-separated label that says what it is and, for a player account, whose it is. You will see these paths verbatim on statements and in the ledger forensics screen, so it is worth learning the shape rather than treating it as an internal code:
| Account path | What flows through it |
|---|---|
liability:player:<id>:cash |
A player's spendable balance |
liability:player:<id>:bonus |
A player's bonus funds, still under wagering |
liability:player:<id>:held |
Funds a staff member has frozen — see below |
liability:unsettled_bets |
Stakes on bets and rounds that have not settled yet |
liability:jackpot[...] |
Jackpot pools, one path per prize tier |
liability:payable:affiliate |
Commission owed to affiliates, drained when payouts are made |
liability:payable:game_provider[...] |
Commission owed to a game provider, one path per provider |
liability:payable:data_feed[...] |
Owed to a data feed supplier between invoice and payment, one path per supplier |
asset:receivable:psp[...] |
Money held at a payment provider, one path per provider |
asset:reserve:psp[...] |
Collateral an acquirer withholds from settlements against chargebacks, one path per provider (built, inert until an acquirer imposes one) |
asset:bank:client[...] |
Player money that has left the providers and sits in the operator's bank, one path per bank |
asset:bank:operating[...] |
The operator's own money at the bank, one path per bank. Not player funds |
revenue:stakes |
Stakes realised as revenue once a bet or round settles |
revenue:wins |
Winnings paid out on settled bets and rounds |
revenue:promo_cost |
The cost of bonus grants credited to players |
expense:adjustments |
Manual credits and debits applied by staff |
expense:psp_fees[...] |
Fees charged by a payment provider, one path per provider |
expense:game_provider_commission[...] |
The period cost of a game provider's commission, one path per provider |
expense:affiliate_commission |
The period cost of affiliate commission |
expense:data_feed[...] |
What a data feed supplier bills, one path per supplier |
expense:fx |
The two legs of a currency conversion — today used only by the affiliate payout currency (see Scope & Roadmap) |
expense:rounding |
Sub-unit remainders left over from rounding (the "house rounding" account) |
equity:operating |
The operator's own funds outside the bank accounts |
equity:capital |
Capital the owner has put into the business, moved against the bank operating account |
Because these are accounts rather than derived report figures, your revenue numbers are the ledger. Gross gaming revenue is the difference between two account balances, not a separate calculation that might disagree with the books.
What the account classes mean
The first word of every path is one of five prefixes, and that prefix is not decoration — it is the accounting meaning of the account, and the ledger enforces it:
liability:— money you owe. To a player, to an affiliate, to a future jackpot winner. Every such obligation — player wallets, payables, jackpot pools — is stopped from going negative: the platform refuses a movement that would leave it owing less than zero, because that would mean a balance existed that was never actually granted. Two deliberate exceptions may take a player below zero, because refusing them would hide a real debt: a correction clawing back an over-payout the player already spent, and a chargeback on a deposit whose funds are gone. The one liability not guarded at write time isliability:unsettled_bets, which pools every open stake in a currency: a pool total cannot tell whether one bet's refund is right, so each bet is protected by its own lock, open-status check and unique reference instead. The fifteen-minute integrity job still reports any liability, including that pool, that goes negative.asset:— money you are owed, or that you hold somewhere else. The payment-provider accounts are the main example: when a deposit lands, the platform records that the provider is holding that much of your money until it is settled or paid out. Bank accounts are assets too, split in two: the client account holds player money and counts as segregated, the operating account holds the operator's own money and does not.revenue:andexpense:— your profit and loss. Stakes and winnings, bonus cost, adjustments, FX spread, rounding.equity:— the operator's own funds and the owner's capital, separate from anything owed to a player or sitting at a provider.
The practical consequence is what makes this more than bookkeeping trivia:
"what do we currently owe players" is a single query over every account whose
path starts liability:player:, not a report that has to be assembled and
reconciled from several places. That is what makes a daily check such as "are
player funds fully covered by what we actually hold" a query the platform can
run on its own, rather than a manual close-of-books exercise.
That check is the segregated funds coverage test. Per currency, what players
are owed is the sum of every player cash, bonus and held account plus the open
stakes in liability:unsettled_bets. What covers it is an explicit list of
asset accounts: the provider receivables, the provider reserves and the bank
client accounts. The bank operating account is deliberately not on the list, and
a new asset account counts for nothing until someone adds it on purpose. The
test is "assets at least equal obligations", never equality: a surplus is
correct and is exactly what the operator may take as profit (see
Wallet & Payments).
Common operations
Every action below is one or more ledger movements — never a balance edited in place. The account paths involved are what you would see if you pulled the raw entries on the ledger forensics screen.
| Operation | What moves |
|---|---|
| Deposit | asset:receivable:psp[provider] → liability:player:<id>:cash |
| Bet placed / spin made | liability:player:<id>:cash (or :bonus) → liability:unsettled_bets |
| Bet settles, lost | liability:unsettled_bets → revenue:stakes |
| Bet settles, won | liability:unsettled_bets → revenue:stakes, and revenue:wins → liability:player:<id>:cash |
| Bet cashed out early (in full or in part) | The cashed-out share of the stake: liability:unsettled_bets → revenue:stakes; the cash-out value: revenue:wins → liability:player:<id>:cash |
| Withdrawal | liability:player:<id>:cash → asset:receivable:psp[provider] (any sub-unit remainder → expense:rounding, in the same entry) |
| Payout cancelled, failed or rejected | asset:receivable:psp[provider] → liability:player:<id>:cash |
| Provider fee on a deposit, withdrawal or chargeback | expense:psp_fees[provider] → asset:receivable:psp[provider], as an extra line of the same entry |
| Provider settles inbound | asset:receivable:psp[provider] → asset:bank:client[bank] |
| Operator funds a short provider | asset:bank:client[bank] → asset:receivable:psp[provider] |
| Acquirer withholds reserve | asset:receivable:psp[provider] → asset:reserve:psp[provider], as extra lines of the settlement entry |
| Reserve released / forfeited | asset:reserve:psp[provider] → asset:receivable:psp[provider] / an expense |
| Take profit | asset:bank:client[bank] → asset:bank:operating[bank], only up to the currency's surplus |
| Fund the client account | asset:bank:operating[bank] → asset:bank:client[bank] |
| Capital in / out | equity:capital ↔ asset:bank:operating[bank] |
| Crypto deposit held by a responsible-gaming check | asset:receivable:psp[provider] → liability:player:<id>:held |
| Held deposit released / returned / written off | :held → :cash / asset:receivable:psp[provider] / expense:adjustments |
| Bonus granted | revenue:promo_cost → liability:player:<id>:bonus |
| Bonus converts to cash | liability:player:<id>:bonus → liability:player:<id>:cash |
| Bonus forfeited or expired | principal liability:player:<id>:bonus → expense:adjustments; any winnings from it → revenue:wins |
| Cash reward granted | revenue:promo_cost → liability:player:<id>:cash |
| Locked bonus unlocks | revenue:promo_cost → liability:player:<id>:cash |
| Staff freezes funds | liability:player:<id>:cash → liability:player:<id>:held |
| Staff releases funds | liability:player:<id>:held → liability:player:<id>:cash |
| Manual adjustment | expense:adjustments ↔ liability:player:<id>:cash |
| Game provider commission accrues | expense:game_provider_commission:<provider> → liability:payable:game_provider:<provider> |
| Affiliate commission accrues | expense:affiliate_commission → liability:payable:affiliate |
| Game provider is paid | liability:payable:game_provider:<provider> → asset:receivable:psp[provider] |
A deposit credits the player's cash account and debits the receivable at the provider that processed it. Nothing about the money is guessed at — the ledger records exactly which provider is now holding it, so a per-provider reconciliation is a balance read, not an investigation.
Placing a bet or spinning a game does not yet touch revenue. The stake
moves out of the player's cash (or bonus) account into
liability:unsettled_bets — a holding account for stakes on bets and rounds
that have not been decided. This matters for your reported numbers: a player
with ten open bets worth 500 has not generated 500 of revenue, because that
money is still sitting in a liability account, not in revenue:stakes. Only
when a bet or round settles does the stake move again, into revenue. This
keeps gross gaming revenue honest — it reflects outcomes that actually
happened, not action that is still in flight.
Settlement is the point the stake is finally recognised. On a loss, the
stake moves from liability:unsettled_bets into revenue:stakes — that is
the whole movement; the house keeps what was already taken. On a win, that
same stake-to-revenue movement happens, and separately the payout moves from
revenue:wins to the player's cash account. A win and a loss both realise
the stake as revenue; a win additionally pays a win out. If a round or bet is
voided or rolled back before it settles, the stake is refunded straight back
to the player out of liability:unsettled_bets — it never touched revenue at
all.
A withdrawal is the mirror of a deposit: cash leaves the player's account and the receivable at the provider grows, reflecting that money now owed back out.
A bonus grant funds the player's bonus account from revenue:promo_cost
— the cost is recognised as promotional spend the moment it is granted, not
when it is used. Once the player has wagered through it, converting the
bonus to spendable cash is a movement between the player's own two accounts;
no house account is involved, because the money was already the operator's
cost, not a fresh cost at conversion time.
Cash rewards and locked bonus. A cash reward is credited straight to the
player's cash account from revenue:promo_cost, with no wagering, and is
counted as promotional cost when granted. A locked bonus is different: until
it unlocks it is a promise, not money, and has no ledger balance at all. As
the player wagers, a share of the play earns release, and each release is a
movement from revenue:promo_cost into the player's cash — so its cost lands
in the books when it is paid, and what is still locked is reported on the Money
page as outstanding rather than as a liability. A refund of a round reverses
what that round had earned.
Staff freezing and releasing player funds. This is the one operation where money is restricted without the player doing anything. When a staff member freezes funds — for a fraud investigation, a chargeback dispute, a court order — the requested amount physically moves out of the player's cash account into their held account. It is a real movement, not a flag on the existing balance, which is what makes it hold up under concurrent activity: the moment the freeze posts, that money cannot be staked or withdrawn, because it is no longer in cash at all.
If a freeze is requested for more than the player currently has available, the platform funds what it can and records the shortfall rather than rejecting the request outright — a fraud hold failing because the player already spent part of the balance is exactly the case that most needs catching. As more money arrives in the player's cash account, a sweep tops the freeze up automatically until the full requested amount is covered: every deposit that credits a player checks their active holds and closes any shortfall it can, so staff do not have to remember to revisit a partially-funded freeze by hand. (Other paths that credit a player — a casino or sports win, a jackpot payout, a bonus conversion, a manual staff credit — do not yet trigger this check; a deposit is today's only automatic trigger.)
Every freeze and every release requires a written reason from the staff member performing it, and both are written to the immutable staff audit log in the same database transaction as the money movement — so a freeze that somehow failed to record cannot leave money moved with no explanation on file, and a reason can never be skipped by mistake.
A manual adjustment or correction is a movement through
expense:adjustments, in whichever direction the correction requires. The
ledger is append-only, so a correction is never an edit to the original
entry — it is a new movement that carries a link back to the one it corrects,
leaving both the original mistake and its fix permanently visible in order.
Game provider commission is the share of revenue you owe the companies whose games you run. You configure a rate per provider — a percentage of revenue, agreed commercially — and the platform books what is owed on its own, hourly, without anyone raising an invoice.
Two things about it are worth knowing before you set a rate. First, it is charged on gross gaming revenue, not per bet: for each provider the platform totals the stakes on their settled rounds for the period, subtracts what those rounds paid out, and applies the rate to the difference. A month the players ran hot against one provider owes that provider nothing, and the shortfall is carried into the following month rather than written off — so commission tracks what you actually earned, not how much was wagered.
Second, a rate carries the date it takes effect, and each period is billed at the rate that was in force when that period opened. Commission is owed from when the revenue was earned, not from when someone gets round to invoicing it. That cuts both ways, and the practical consequence matters: if a provider has no rate configured at the moment a month begins, that month accrues nothing for that provider, permanently — setting a rate mid-month does not reach back and pick it up. The fix is to enter the rate with an effective date on or before the start of the period it should cover. The commission screen shows "no rate in force" as its own state, distinct from a rate of 0%, precisely so this is visible rather than silent.
Rates are never edited in place. Changing one adds a new rate at a new effective date, so the history of what you agreed and when stays intact and past periods keep the figure they were billed at. Every rate change and every payout request requires a written reason and is recorded in the staff audit log.
What has accrued is booked as a period cost against your profit and loss and sits in a payable account per provider, and paying it is a deliberate staff action rather than something that happens automatically — the platform tells you what is owed and moves the money out of the payable when you request it, but it does not initiate the transfer to the provider for you. A nightly check compares each provider's payable against what has been accrued and paid, and raises an alarm if the two ever disagree.
Balances are derived, not stored
There is no balance column. A balance is computed as:
latest checkpoint + everything credited since − everything debited since
...in a single database statement. That last detail is not pedantry: an earlier design read the checkpoint, the credits and the debits as three separate queries, and a transaction committing between them could return a balance that had never actually existed at any point in time. One statement sees one consistent snapshot.
Checkpoints are per-account snapshots, taken hourly, so a balance read sums only the ledger lines posted since the last checkpoint — an indexed read, however long the account's history — rather than re-summing everything. Each line carries the sequence number of its entry, which is what makes that read cheap, and the database refuses a line without one. A checkpoint locks the account while it takes its snapshot, so a posting that commits at the same moment can never land behind the snapshot and be skipped by every later read. Correspondingly, every correction, hold release or top-up is posted as an entry of its own rather than appended to an earlier entry, because an appended line would inherit an old sequence number that a checkpoint had already passed. Checkpoints are an optimization; the ledger remains the source of truth. Because every balance read trusts them, a nightly job recomputes every account from genesis, ignoring checkpoints, and fails if any checkpoint-based balance disagrees.
Idempotency: paying exactly once
Every movement carries a reference — a mandatory, globally unique key. Re-posting the same reference returns the original movement instead of moving money again.
The strictness here is deliberate. Replay is a full-field match: if a retry arrives with the same reference but a different amount, memo or reason, it is rejected as a conflict, not silently absorbed as a duplicate. A provider that retries a callback with changed figures gets an error, not a quiet overwrite.
References derived from external input are namespaced by provider, because the key space is global across the whole platform and two vendors could otherwise collide on the same round id — casino round references included, so one provider's rollback can never void another provider's stake on a matching round id. The reference is stored on each ledger line, and a replay always reuses the key stored with the original round or intent rather than re-deriving it.
Money arithmetic
All monetary calculation uses fixed-precision decimal arithmetic with pinned rounding — never floating point. The rounding mode and decimal places are set once, globally, so two parts of the system cannot round a split payout differently and disagree by a cent.
Money crosses the API boundary as a string, not a number, so no amount is ever degraded by a language's float representation in transit.
Amounts are stored at full precision (18 decimal places) and rounded only at the edges, never on a ledger movement — rounding the two legs of an entry independently would break the sum. The two edges are display and the provider or payout boundary, and both round down to the currency's own precision (2 places for USD and EUR, 6 for USDT and USDC, 8 for BTC, 18 for ETH), so the screen never shows more than a player can actually withdraw. The sub-unit remainder this leaves on a withdrawal is booked to the rounding account in the same entry, not discarded. A currency the platform does not know is refused rather than assumed to have two decimals.
Crypto play keeps this property: a crypto wallet and its ledger entries stay in the coin. A round shown to a game provider in a fiat shadow currency uses a rate frozen on that round, and the coin amount moved is derived from it — there is no fiat balance and no entry that mixes currencies. See Wallet & Payments.
The system audits itself
Every fifteen minutes, a job sums every account balance in every currency. If any currency does not sum to zero, the job fails and publishes an imbalance metric that alerting fires on. The same run flags any liability account with a negative balance beyond what the permitted exceptions explain, and any account path or entry type outside the known vocabulary. Nobody has to remember to check. The backoffice ledger-invariants card shows the latest run of this job — its status, time and any error — rather than recomputing the whole ledger on every page view; the checkpoint drift check stays available on demand behind its own button.
A nightly job recomputes every balance from genesis against its checkpoint (see above).
A second daily job cross-checks affiliate commission owed against what the ledger says, catching drift between the accrual records and the money.
Corrections
The ledger is append-only. Nothing is edited or deleted. A staff correction is a new movement through the adjustments account, carrying a link back to the movement it corrects — so the original mistake and its fix are both permanently visible, in order.
Multi-tenancy
Every major record carries an operator id, and the intended deployment is one database per operator. The schema supports many operators in one database without change — a player's email is unique per operator, not globally — but whether every read path is operator-scoped has not been formally audited. If you intend to run genuinely multi-tenant, that audit is required work.
Scope & Roadmap
Implemented. Double-entry movements with both sides named; the house account paths above, including a holding account for stakes not yet settled and a held account for staff-frozen player funds; derived balances computed atomically; checkpoints; mandatory unique idempotency references with strict-conflict semantics; pinned decimal arithmetic; append-only corrections with back-links; a fifteen-minute self-audit that fails loudly; per-currency accounts; hourly checkpoints with a nightly from-genesis drift check; staff fund freeze and release, including partial funding and an automatic top-up of the shortfall triggered by a deposit. Game provider and affiliate commission booked as period costs against payables; cash rewards and locked bonus released into cash; full-precision storage rounded down only at the display and payout edges, with the withdrawal remainder booked to a rounding account; checkpoints taken under an account lock; provider-namespaced casino round references; provider fees, rolling-reserve withholding and settlements posted into the provider receivable; bank client and operating accounts with owner capital, and a segregated-funds test that counts only player money; held crypto deposits; early cash-out of bets.
Partial. Multi-tenancy is modelled but not audited end to end. The shortfall top-up runs automatically on a deposit but not yet on the other paths that credit a player (a win, a jackpot payout, a bonus conversion, a manual staff credit) — see above. Settlements posted before the operator bank existed landed in the operator's own equity account and stay there as posted, since the ledger is append-only; only new settlements land in a bank client account. The only bank adapter is a simulation, so bank movements on a production deployment are not yet backed by a real bank.
Not built. Conversion of a player's balance between currencies is reporting-only: figures are converted for display and limit checks — and a money check with no usable rate is refused rather than guessed (see Wallet & Payments) — but no player movement converts one wallet into another. The one ledger conversion that exists is an affiliate changing payout currency, which posts two same-currency legs through the FX account (see Affiliate Payouts). There is no automated alarm for rounds that never reach a terminal state, though the imbalance check would catch the accounting consequences of one going wrong. (A negative obligation is guarded directly: the ledger refuses any movement that would push a player wallet, payable or jackpot pool below zero, outside the two exceptions above, and a fifteen-minute check re-confirms every liability from scratch as a backstop.) There is no data-retention or GDPR erasure tooling — and gambling record-keeping obligations and the right to erasure genuinely conflict, so that needs a deliberate documented policy rather than a feature.