MyNEX Docs / The Ledger
internal/ledger
The heart of the platform

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 constantNameUsed for
SystemFeesfeesP2P/transfer fees, NAQD burn fees, partner rail fees
SystemFXSpreadfx_spreadCaptured spread + conversion fee on every wallet.Convert
SystemNAQDReservenaqd_reserveFiat backing for outstanding NAQD supply — debited on mint, credited on burn
SystemCardSettlementcard_settlementCounterparty for card capture/refund postings
SystemPartnerSettlementpartner_settlement:<rail>Per-rail sub-account (e.g. partner_settlement:tng-my) — the platform's liability to that partner
SystemExternalFundingexternal_fundingCounterparty 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

internal/ledger/service.go
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 with ErrUnbalanced — a cross-currency operation (like FX convert) is really two independent zero-sum postings sharing one transaction row.
  • Deadlock-safe locking. Post collects every distinct AccountID touched, sorts them ascending, then takes SELECT ... FOR UPDATE locks 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 = false would go negative, the entire posting rejects with ErrOverdraft (surfaced to the API as INSUFFICIENT_BALANCE). Because only system accounts allow negative, a user wallet cannot be overdrawn by construction.
  • Wallet balance is separate. Post writes LedgerTransaction + LedgerEntry rows only — it never touches models.Wallet. Callers must also call ledger.ApplyToWallet(ctx, tx, walletID, delta) in the same transaction, an atomic UPDATE 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 Balance by design — and Reconcile has no way to distinguish that from an actual bug. Keeping holds out of the ledger keeps the zero-sum invariant meaningful.
EventWhat movesLedger entries
Auth approvedLockedBalance += holdAmountnone
Clearing / captureApplyToWallet(-captureAmount), then LockedBalance -= original holdAmount (full release, even on partial capture)KindCardAuth: user −capture, card_settlement +capture
Reversal (never captured)LockedBalance -= holdAmountnone — 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.

AccountCurrencyAmount
Sender walletMVR−100.50
Recipient walletMVR+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.

AccountCurrencyAmount
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.

AccountCurrencyAmount
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.

AccountCurrencyAmount
Cardholder walletUSD−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.

AccountCurrencyAmount
User walletMVR−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.

AccountCurrencyAmount
User walletMVR+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.

On rounding

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 FlowsThe exhaustive catalog — all 22 operations, every ledger entry table, wallet-projection change, event, and failure path
ReconciliationThe invariants proven with quoted code, the reconcile tool, idempotency, audit trail, and a discrepancy-investigation runbook