Every way money moves in MyNEX, one page
The Ledger explains the engine and shows six illustrative
postings. This page is the catalog: every money-moving code path in the platform, each
with its trigger, its validation gates, its exact ledger entry table
(account, currency, signed amount — verified against the real ledger.Entry{}
literals in source), its wallet-projection change, the events it publishes, and what
happens on failure. Every entry table sums to exactly zero per currency, shown explicitly.
Summary matrix — operation × system account touched
Every row below ends in exactly one thing: a call to ledger.Post (zero or more
legs against system accounts) plus a matching ledger.ApplyToWallet, in the same
DB transaction — except the two card-hold rows, which touch Wallet.LockedBalance
only and never call ledger.Post at all (see Ledger
→ card-hold design and Reconciliation → card holds
as the deliberate exception).
| Operation | Ledger Kind | fees | fx_spread | naqd_reserve | card_settlement | partner_settlement:<rail> | external_funding | LockedBalance only |
|---|---|---|---|---|---|---|---|---|
| Top-up · bank/MVR | topup | ● | ||||||
| Top-up · card | topup | ● | ||||||
| Top-up · crypto | topup | ● | ||||||
| Top-up · Apple Pay | topup | ● | ||||||
| Top-up · Google Pay | topup | ● | ||||||
| Withdrawal | withdraw | ● | ||||||
| P2P transfer | p2p | ● | ||||||
| QR payment | p2p (reused) | ● | ||||||
| Bill payment | bill | ● | ||||||
| NFC payment | nfc | ● | ||||||
| FX conversion | convert | ● | ||||||
| NAQD mint | naqd_mint | ● | ||||||
| NAQD burn | naqd_burn | ● | ● | |||||
| NAQD on-chain withdraw | naqd_burn (burn-shaped) | ● | ||||||
| Card authorization hold | — none — | ● | ||||||
| Card clearing / capture | card_auth | ● | releases | |||||
| Card reversal | — none — | releases | ||||||
| Card refund | card_auth | ● | ||||||
| Partner execute (TnG) | partner | ● | ● | |||||
| Partner refund (TnG) | partner | ● | ● | |||||
| Admin wallet adjustment | adjustment | ● | ||||||
| Recurring payment | p2p |
Kind constants are declared but never postedledger.KindQR and ledger.KindFee exist in internal/ledger/models.go
but no call site ever uses them. QR payments literally call ProcessP2PTransfer
internally (only Transaction.PaymentMethod is overwritten to "qr_code"
afterwards) and are posted under KindP2P — see QR payment below.
Fees are never their own transaction; they're always one leg inside another Kind's posting
(P2P, partner, NAQD burn).
Wallet top-up — five methods, one settlement engine
All five top-up routes share payment-service.SettlementEngine — see the full
state-machine sequence at Data Flows → Top-up settlement.
This section is about the ledger shape, not the state machine. The Kind is
ledger.KindTopup in every case — only the system-account counterparty
changes by method, matching the settlement's SystemAccount field set at the call
site in wallet-service.Service.
| Method | Handler | Counterparty |
|---|---|---|
| Bank / MVR | TopUpMVR | external_funding |
| Card | TopUpCard | card_settlement |
| Crypto | TopUpCrypto | external_funding |
| Apple Pay | TopUpApplePay | card_settlement |
| Google Pay | TopUpGooglePay | card_settlement |
Bank / MVR top-up
POST /wallet/topup/mvr (Idempotency-Key) → Service.TopUpMVR → SettlementEngine.AuthorizecheckKYCLimit only — no PIN required for any top-uppending → processing → completed | failed; money only moves in complete(), never at Authorize timetxn.created at authorize; txn.completed / txn.failed on settlement| Account | Currency | Amount |
|---|---|---|
| User wallet | MVR | +500.00 |
external_funding (system) | MVR | −500.00 |
| Σ MVR | 0.00 ✓ | |
Reference: Transaction.Reference (auto "TXN-<uuid>" via BeforeCreate), reused as the ledger transaction reference. Failure path: a failed authorize or a give-up after PAYMENTS_MAX_SETTLEMENT_ATTEMPTS (20) polls marks the transaction/settlement failed — no ledger entries ever existed, so there is nothing to reverse.
Card top-up
| Account | Currency | Amount |
|---|---|---|
| User wallet | USD | +200.00 |
card_settlement (system) | USD | −200.00 |
| Σ USD | 0.00 ✓ | |
Identical shape to bank/MVR — only the counterparty account changes. Sandbox provider settles deterministically after PAYMENTS_SANDBOX_SETTLE_DELAY (5s); a real Stripe integration completes via the signed webhook instead of the sweeper poll.
Crypto top-up
| Account | Currency | Amount |
|---|---|---|
| User wallet | USD | +50.00 |
external_funding (system) | USD | −50.00 |
| Σ USD | 0.00 ✓ | |
Apple Pay top-up
| Account | Currency | Amount |
|---|---|---|
| User wallet | USD | +75.00 |
card_settlement (system) | USD | −75.00 |
| Σ USD | 0.00 ✓ | |
Google Pay top-up
| Account | Currency | Amount |
|---|---|---|
| User wallet | USD | +60.00 |
card_settlement (system) | USD | −60.00 |
| Σ USD | 0.00 ✓ | |
Apple Pay and Google Pay both route through card_settlement, not a dedicated digital-wallet account — the platform treats a tokenized card charge as a card settlement regardless of the wallet UI it came through.
Withdrawal
POST /wallet/withdraw (Idempotency-Key) → Service.WithdrawcheckKYCLimit only — no PINledger.KindWithdraw, external_fundingwithdrawal.requested only — fired immediately after the debit commits| Account | Currency | Amount |
|---|---|---|
| User wallet | MVR | −300.00 |
external_funding (system) | MVR | +300.00 |
| Σ MVR | 0.00 ✓ | |
Unlike top-up, the debit is posted synchronously and up front, before any
"bank payout" happens. The payout itself is honestly not real:
go s.processWithdrawal(...) is a fire-and-forget goroutine that does exactly
this and nothing else:
func (s *Service) processWithdrawal(ctx context.Context, transaction *models.Transaction) {
time.Sleep(5 * time.Second)
transaction.Status = "completed"
now := time.Now()
transaction.ProcessedAt = &now
s.repo.UpdateTransaction(ctx, transaction)
}
No real bank rail is called. This is a known, tracked simplification (Security → Known limitations) — the ledger debit is real money leaving the user's spendable balance, but the "payout" is a timer.
FiberService.ApproveWithdrawal (internal/admin-service/service_fiber.go),
on rejection, refunds the wallet with a raw
tx.Model(&wallet).Update("balance", gorm.Expr("balance + ?", withdrawal.Amount))
— it never calls ledger.Post or ledger.ApplyToWallet. Since
Withdraw() already posted a real KindWithdraw ledger transaction
debiting the user, a rejected withdrawal leaves the ledger showing the money
gone while the cached wallet balance shows it refunded — exactly the kind of
drift ledger.Reconcile exists to catch. See
Reconciliation → known gaps for the triage note.
P2P transfer
POST /payment/transfer/p2p (Idempotency-Key) → ProcessP2PTransferValidateAmount (>0, ≤1,000,000) · pinguard.Verify (PIN required) · sender≠recipientcalculateTransferFee)ledger.KindP2Pfmt.Printf), a documented gap since docs/wiring/payments.md| Account | Currency | Amount |
|---|---|---|
| Sender wallet | MVR | −100.50 |
| Recipient wallet | MVR | +100.00 |
fees (system) | MVR | +0.50 |
| Σ MVR | 0.00 ✓ | |
Sender debited amount + fee in one leg. Failure: insufficient balance trips ledger.ErrOverdraft → INSUFFICIENT_BALANCE, and the entire request rolls back — no partial state, nothing to compensate.
QR payment
POST /payment/qr/scan (Idempotency-Key) → ProcessQRPaymentProcessP2PTransfer internally — identical fee formula, PIN gate, and ledger shape as P2Pledger.KindP2P — not KindQR, despite that constant existing| Account | Currency | Amount |
|---|---|---|
| Payer wallet | MVR | −51.00 |
| Merchant/recipient wallet | MVR | +50.00 |
fees (system) | MVR | +1.00 |
| Σ MVR | 0.00 ✓ | |
This example shows the 1 MVR floor kicking in: 0.5% of 50.00 is 0.25, below the floor, so the fee is 1.00. The only difference from a plain P2P transfer is Transaction.PaymentMethod gets overwritten to "qr_code" after the underlying transfer completes.
Bill payment
POST /payment/bill-pay (Idempotency-Key) → ProcessBillPaymentpinguard.Verify (PIN required) · pre-check AvailableBalance() < amountMVRGatewayProcessor.ProcessPayment — a real outbound HTTP call to the MVR bank gateway, HMAC-signed, separate from the top-up SettlementEngine/provider registryGenerateIdempotencyKey(userID, walletID, amount, billerID+":"+billNumber) — no timestamp, so a retry with the same inputs is a true no-opledger.KindBill, counterparty external_funding| Account | Currency | Amount |
|---|---|---|
| User wallet | MVR | −120.00 |
external_funding (system) | MVR | +120.00 |
| Σ MVR | 0.00 ✓ | |
Fee is always decimal.Zero on the transaction row. The ledger only posts if the gateway responds "completed" — any other gateway status (e.g. "requires_action") saves the transaction row with no ledger posting at all. There is no partial-debit state to clean up.
NFC payment
POST /payment/nfc/process (Idempotency-Key) → ProcessNFCPaymenttagID:merchantID:terminalIDledger.KindNFC, counterparty external_funding| Account | Currency | Amount |
|---|---|---|
| User wallet | MVR | −35.00 |
external_funding (system) | MVR | +35.00 |
| Σ MVR | 0.00 ✓ | |
FX conversion
POST /wallet/convert {quote_id, pin} (Idempotency-Key) → Service.Convert, executing a quote from POST /fx/quotepinguard.Verify only — no KYC-limit check, unlike top-up/withdraweffective, conversion fee 0.25% (conversionFeeRate = 0.0025) baked into the destination leg, rounded half-even (RoundBank) to the target currency's exponent (2dp MVR/USD, 4dp NAQD)ledger.KindConvert, counterparty fx_spread (once per currency leg)| Account | Currency | Amount |
|---|---|---|
| User wallet (from) | USD | −100.00 |
fx_spread (system) | USD | +100.00 |
| Σ USD | 0.00 ✓ | |
| User wallet (to) | MVR | +1533.60 |
fx_spread (system) | MVR | −1533.60 |
| Σ MVR | 0.00 ✓ | |
Four legs, two independent zero-sum checks in one Post call — this is what a cross-currency operation actually is under the ledger's per-currency invariant. fx_spread's net position across both legs is the platform's captured margin. Failure: expired/already-consumed quote → QUOTE_EXPIRED; insufficient balance → ErrOverdraft — both reject before any posting.
NAQD mint / burn / on-chain withdraw
All three are asynchronous — the API only creates a NAQDOperation row; a
background worker posts the ledger and calls the chain. Full lifecycle detail, oracle
fallback, and economics: NAQD Minting & Reserves.
Mint
ledger.KindNAQDMint, counterparty naqd_reserve (both currencies)naqd.minted on success; system.alert + compensating reversal on chain failure| 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 |
| Σ per currency | 0.00 ✓ each | |
Burn
ledger.KindNAQDBurnnaqd.burned| Account | Currency | Amount |
|---|---|---|
| User wallet (NAQD) | NAQD | −100.00 |
naqd_reserve (system) | NAQD | +100.00 |
naqd_reserve (system) | USD | −102.88 |
| User wallet (fiat) | USD | +102.37 |
fees (system) | USD | +0.51 |
| Σ per currency | 0.00 ✓ each | |
naqd_reserve's fiat leg is debited the gross amount (102.88); the user receives the net amount (102.37) after the 0.51 fee, which lands in fees — five legs in one posting.
On-chain withdrawal (self-custody)
ledger.KindNAQDBurn — burn-shaped, folded into the same Kind by designChainClient.Transfer (custody → user-supplied address) — not Burn; supply is unchanged, only custody changesnaqd.burned — withdrawal has no dedicated event type| Account | Currency | Amount |
|---|---|---|
| User wallet (NAQD) | NAQD | −50.00 |
naqd_reserve (system) | NAQD | +50.00 |
| Σ NAQD | 0.00 ✓ | |
Only two legs, NAQD-only — no fiat ever moves, since the coin already existed as a custodial balance; this operation only changes who controls it on-chain. Failure (all three NAQD kinds): the worker's reverseAndFail loads the original LedgerTransaction, negates every entry, and reposts it under KindAdjustment with reference <original-ref>-REVERSAL, then publishes system.alert. See NAQD Minting → mint lifecycle for the full quoted code.
Card lifecycle
The defining design choice for cards: a hold only ever moves
Wallet.LockedBalance. Nothing is posted to the ledger until the hold
actually clears. See Ledger → the card-hold design
and Reconciliation → card holds as the deliberate
exception for why this is correct, not a shortcut.
Authorization hold
POST /webhooks/cards/sandbox {event:"auth"} (HMAC-signed) → Service.HandleWebhook → handleAuthActive, ecommerce/international/contactless flags, per-txn limit, daily/monthly outstanding-auth sums, available := Balance − LockedBalance ≥ holdAmountrepo.AdjustLockedBalance(walletID, +holdAmount)CardTransaction{Status: declined} row only; no lock is ever placed| What moves | Ledger entries |
|---|---|
Wallet.LockedBalance += 80.00 | — none — |
Clearing / capture
{event:"clearing", captureAmount} → handleClearingledger.KindCardAuth, reference "card-clearing-<auth_uuid>"captureAmount ≤ original hold; releases the full original hold even on partial capture (standard card-network behavior)| Account | Currency | Amount |
|---|---|---|
| Cardholder wallet | USD | −42.17 |
card_settlement (system) | USD | +42.17 |
| Σ USD | 0.00 ✓ | |
Alongside the ledger posting: ApplyToWallet(−42.17), then AdjustLockedBalance(−80.00) — the full original hold, not just the 42.17 captured — releases in the same transaction.
Reversal (hold released, never captured)
| What moves | Ledger entries |
|---|---|
Wallet.LockedBalance −= 80.00 | — none — nothing was ever posted |
Refund (after capture)
Status: captured — refunding an uncaptured hold is rejectedledger.KindCardAuth, reference "card-refund-<random>" — a fresh posting, never edits the original clearing entry| Account | Currency | Amount |
|---|---|---|
| Cardholder wallet | USD | +42.17 |
card_settlement (system) | USD | −42.17 |
| Σ USD | 0.00 ✓ | |
No fee logic surfaced anywhere in card-service for any of the four card operations — none charge a platform fee in the current implementation.
Partner rail — Touch 'n Go eWallet reload
POST /partners/execute {quote_id, pin} (Idempotency-Key) → Service.Execute, against a quote from POST /partners/quotepinguard.Verify · checkTierLimit (per debit-currency daily/monthly exposure, KYC-tier scaled)tng-my: flat 2.00 + 1.5% of principal, in the user's pay_with currency (FeeFlat/FeePct on the rail row)ledger.KindPartner, sub-account partner_settlement:tng-my (SystemPartnerSettlement + ":" + railID)partner.operation on execute and on every status transition; audit log PARTNER_OPERATION_EXECUTED| Account | Currency | Amount |
|---|---|---|
| User wallet | MVR | −509.50 |
partner_settlement:tng-my (system) | MVR | +500.00 |
fees (system) | MVR | +9.50 |
| Σ MVR | 0.00 ✓ | |
Fee = 2.00 + 1.5%×500.00 = 9.50. The rail's own currency (MYR) never appears in the ledger — only what the user actually paid with. Execution then calls impl.Execute(...) (TnG sandbox returns a deterministic partnerRef); completion is simulated after rail.ETASeconds (30s default) via a background goroutine, with deterministic (FNV-hashed, not math/rand) simulated failure.
Partner refund
failAndRefund — called synchronously on immediate Execute failure, or from the scheduled-completion goroutine on simulated/real rail failureledger.KindPartner, reference "partner-refund-<op_uuid>"| Account | Currency | Amount |
|---|---|---|
| User wallet | MVR | +509.50 |
partner_settlement:tng-my (system) | MVR | −500.00 |
fees (system) | MVR | −9.50 |
| Σ MVR | 0.00 ✓ | |
The exact negation of the execute posting — the full amount including the fee is refunded, since no service was actually rendered. Idempotent: a second refund attempt on an operation no longer processing is a no-op.
Admin wallet adjustment
POST /admin/wallets/:id/adjust (role finance) → WalletAdjustment{Status: pending} — no money moves at this stepPOST /admin/adjustments/:id/approve (role admin) — hard-enforces a different approver (RequestedBy == adminID → 403); guarded UPDATE WHERE status='pending' prevents a double-approve raceledger.KindAdjustment, reference "ADJ-<adjustment_uuid>", counterparty external_funding for both credit and debit adjustmentssystem.alert (severity info) on approval| Account | Currency | Amount |
|---|---|---|
| User wallet | MVR | +25.00 |
external_funding (system) | MVR | −25.00 |
| Σ MVR | 0.00 ✓ | |
A negative (debit) adjustment posts the mirror image — user account debited, external_funding credited — and hits the same ErrOverdraft guard as any other posting if it would overdraw the wallet; the whole approval transaction (ledger + wallet + audit log row) rolls back and the adjustment stays pending. Reject is a pure status transition (pending → rejected) with no ledger interaction at all.
Recurring payment execution
RecurringSweeper, interval RECURRING_SWEEPER_INTERVAL (1h) → ProcessDueRecurringPayments → processOneRecurringPaymentCreateRecurringPayment); each scheduled execution runs through an internal path documented to "intentionally stay unreachable from any HTTP handler"ledger.KindP2P — mirrors a manual P2P transfer exactly, but with zero fee| Account | Currency | Amount |
|---|---|---|
| Sender wallet | MVR | −45.00 |
| Recipient wallet | MVR | +45.00 |
| Σ MVR | 0.00 ✓ | |
Failure: markRecurringFailure increments ConsecutiveFailures and advances NextPaymentDate regardless (so a permanently-broken mandate doesn't hot-loop the sweeper), auto-disabling the mandate once RECURRING_MAX_CONSECUTIVE_FAILURES (default 3) is reached. No events.Publish anywhere in this path.