Every balance change is a double-entry ledger posting
No service updates Wallet.Balance directly. Every money movement — P2P, FX
conversion, NAQD mint/burn, card capture, partner rail execution, admin adjustment —
becomes a set of ledger.Entry rows that must sum to zero per currency,
posted inside a single database transaction with ordered row locks. The wallet's cached
balance is a projection of this ledger, not the source of truth.
Accounts model
Two kinds of ledger account. Both are rows in the same table — a user account is scoped to one wallet, a system account is a platform-owned counterparty.
User accounts
One per (wallet, currency). Created on demand via GetOrCreateUserAccount. AllowNegative = false — the ledger structurally refuses to let a user account go negative; an attempted overdraft rejects the entire posting.
System accounts
Platform-owned counterparties, bootstrapped idempotently for MVR/USD/NAQD on every boot (BootstrapSystemAccounts). AllowNegative = true — these are allowed to run negative (e.g. fx_spread nets the platform's captured margin, which can be transiently negative mid-period).
| System account constant | Name | Used for |
|---|---|---|
SystemFees | fees | P2P/transfer fees, NAQD burn fees, partner rail fees |
SystemFXSpread | fx_spread | Captured spread + conversion fee on every wallet.Convert |
SystemNAQDReserve | naqd_reserve | Fiat backing for outstanding NAQD supply — debited on mint, credited on burn |
SystemCardSettlement | card_settlement | Counterparty for card capture/refund postings |
SystemPartnerSettlement | partner_settlement:<rail> | Per-rail sub-account (e.g. partner_settlement:tng-my) — the platform's liability to that partner |
SystemExternalFunding | external_funding | Counterparty for top-ups (money entering from outside the ledger) and admin wallet adjustments — not in REVAMP_BLUEPRINT.md's original five-account list; added because a top-up needs a debit counterparty from somewhere |
Post() and the zero-sum invariant
func (s *Service) Post(ctx context.Context, tx *gorm.DB, kind, reference string,
entries []Entry, meta map[string]interface{}) (*LedgerTransaction, error)
type Entry struct {
AccountID uint
Currency string
Amount decimal.Decimal // positive = credit, negative = debit
}
- Balance = 0 per currency, enforced. Entries are summed by
Currency; if any currency's sum isn't exactly zero, the whole posting is rejected withErrUnbalanced— a cross-currency operation (like FX convert) is really two independent zero-sum postings sharing one transaction row. - Deadlock-safe locking.
Postcollects every distinctAccountIDtouched, sorts them ascending, then takesSELECT ... FOR UPDATElocks in that order — the same fixed order regardless of which account a caller names first, which is what prevents two concurrent transfers between the same two wallets from deadlocking each other. - Overdraft guard. After locking, each account's resulting balance is computed; if any account with
AllowNegative = falsewould go negative, the entire posting rejects withErrOverdraft(surfaced to the API asINSUFFICIENT_BALANCE). Because only system accounts allow negative, a user wallet cannot be overdrawn by construction. - Wallet balance is separate.
PostwritesLedgerTransaction+LedgerEntryrows only — it never touchesmodels.Wallet. Callers must also callledger.ApplyToWallet(ctx, tx, walletID, delta)in the same transaction, an atomicUPDATE wallets SET balance = balance + ?(never read-modify-write).
Wallet balance as a projection, and reconciliation
Wallet.Balance is a cache, kept in sync by every caller applying the same delta
to both the ledger and the wallet inside one transaction. ledger.Reconcile(db)
is the independent check: it iterates every wallet, sums that wallet's ledger account from
scratch, and reports a Mismatch{WalletID, Currency, WalletBalance, LedgerBalance, Diff}
for anything that disagrees. Run it via go run ./cmd/tools/reconcile — exits
non-zero if any mismatch is found, so it's CI/cron-friendly.
This is the short version. The full write-up — the exact locking/overdraft code quoted, the sqlite test-dialect exception, idempotency, audit trail, and a practical "how to investigate a mismatch" runbook — lives on Reconciliation.
The card-hold design: why holds never touch the ledger
A card authorization hold moves Wallet.LockedBalance, not the ledger. This
is deliberate, and it's the one place in the platform where a "balance" moves outside
ledger.Post:
- Every real card network works this way — authorize reserves, capture moves money. An auth hold might never capture (it can be reversed, or simply expire).
- If a hold posted to the ledger, a wallet with an outstanding uncaptured hold would show a ledger balance lower than its cached
Balanceby design — andReconcilehas no way to distinguish that from an actual bug. Keeping holds out of the ledger keeps the zero-sum invariant meaningful.
| Event | What moves | Ledger entries |
|---|---|---|
| Auth approved | LockedBalance += holdAmount | none |
| Clearing / capture | ApplyToWallet(-captureAmount), then LockedBalance -= original holdAmount (full release, even on partial capture) | KindCardAuth: user −capture, card_settlement +capture |
| Reversal (never captured) | LockedBalance -= holdAmount | none — there was never anything to reverse |
| Refund (after capture) | ApplyToWallet(+amount) | fresh, independent posting — never mutates the original clearing entry |
Why this design keeps Reconcile meaningful rather than just convenient: Reconciliation → card holds as the deliberate exception.
Worked examples
Six representative postings — enough to see the shape of every kind of entry set (single-currency, multi-currency, fee leg, system-account counterparty). They are not the full catalog: every one of the platform's 22 money-moving operations, including all five top-up methods, QR, bill pay, NFC, NAQD burn/withdraw, and every card/partner event, has its own exact ledger table at Money Flows — start there if the operation you're looking for isn't one of the six below. Positive = credit, negative = debit; each currency column sums to zero.
1. P2P transfer with fee
payment-service.ProcessP2PTransfer — fee is 0.5% of the transfer amount, floored at 1 MVR / 0.25 USD, rounded to 2dp. Sender is guarded by pinguard.Verify first.
| Account | Currency | Amount |
|---|---|---|
| Sender wallet | MVR | −100.50 |
| Recipient wallet | MVR | +100.00 |
fees (system) | MVR | +0.50 |
Sender debited amount + fee; three legs, one currency, sums to zero. KindP2P.
2. FX convert
wallet-service.Convert executes a previously-quoted, persisted FXQuote (30s TTL). Board = effective, plus a flat 0.0025 conversion fee on top of the board spread; amounts round half-even to the target currency's exponent.
| Account | Currency | Amount |
|---|---|---|
| User wallet (from) | USD | −100.00 |
fx_spread (system) | USD | +100.00 |
| User wallet (to) | MVR | +1533.60 |
fx_spread (system) | MVR | −1533.60 |
Four legs across two currencies — each currency's pair sums to zero independently, which is exactly what Post's per-currency check requires. fx_spread's net position across the two currencies is the platform's captured margin. KindConvert.
3. NAQD mint
Executed by the stablecoin worker once a pending NAQDOperation is claimed. Example: silver at $32.00/oz → gram price $1.0288 → minting 100 NAQD costs $102.88. No mint fee.
| Account | Currency | Amount |
|---|---|---|
| User wallet (fiat) | USD | −102.88 |
naqd_reserve (system) | USD | +102.88 |
| User wallet (NAQD) | NAQD | +100.00 |
naqd_reserve (system) | NAQD | −100.00 |
After the ledger posts, the worker calls ChainClient.Mint; on chain failure it posts a compensating reversal (reference-REVERSAL) and fails the operation rather than leaving fiat debited with no NAQD delivered. KindNAQDMint.
4. Card capture (clearing)
Only the clearing/capture step touches the ledger — see the hold design above. Cross-currency auths convert at the live effective rate at authorization time (not a persisted quote), bank-rounded.
| Account | Currency | Amount |
|---|---|---|
| Cardholder wallet | USD | −42.17 |
card_settlement (system) | USD | +42.17 |
Reference: card-clearing-<auth_uuid>. A refund after capture is a fresh, independent posting with reference card-refund-<random> — it never edits the original entry. KindCardAuth.
5. Partner execute (TnG reload)
Fee for tng-my: flat 2.00 + 1.5% of principal. Example: RM500 reload → fee 9.50 (in the currency the user paid with). The rail's own currency (MYR) never appears in the ledger — only what the user paid with.
| Account | Currency | Amount |
|---|---|---|
| User wallet | MVR | −509.50 |
partner_settlement:tng-my (system) | MVR | +500.00 |
fees (system) | MVR | +9.50 |
On rail failure, failAndRefund posts the exact reverse — the full amount including fee is refunded, since no service was rendered. KindPartner.
6. Wallet adjustment (admin, maker-checker)
Posted only on POST /admin/adjustments/:id/approve — a different admin from the one who created the request. Counterparty is external_funding.
| Account | Currency | Amount |
|---|---|---|
| User wallet | MVR | +25.00 |
external_funding (system) | MVR | −25.00 |
A negative adjustment that would overdraw the wallet hits the same ErrOverdraft guard as any other posting and rolls the whole approval back — the adjustment stays pending. KindAdjustment.
Money is decimal.Decimal (shopspring/decimal) end to end — never a float. MVR/USD round half-even to 2 decimal places, NAQD to 4, applied only at presentation/settlement boundaries. This is enforced as a non-negotiable engineering standard, not a convention.
See also
| Money Flows | The exhaustive catalog — all 22 operations, every ledger entry table, wallet-projection change, event, and failure path |
| Reconciliation | The invariants proven with quoted code, the reconcile tool, idempotency, audit trail, and a discrepancy-investigation runbook |