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 is liability: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: and expense: — 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.

results matching ""

    No results matching ""