MyNEX Docs / Money Flows
22 operations
The exhaustive reference

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

21 operations top-ups, P2P, QR, bill, NFC, convert, NAQD, card clearing/refund, partner, adj. ledger.Post(kind, ref, entries) Σ entries = 0 per currency, FOR UPDATE locks ascending by ID ApplyToWallet(walletID, delta) same DB tx — atomic UPDATE balance = balance + ? 2 exceptions card auth hold, card reversal — a hold that may never capture Wallet.LockedBalance only no ledger.Post — ledger stays meaningful for Reconcile
The one shape almost every operation shares — with the card-hold exception called out explicitly rather than hidden.
OperationLedger Kindfeesfx_spreadnaqd_reservecard_settlementpartner_settlement:<rail>external_fundingLockedBalance only
Top-up · bank/MVRtopup
Top-up · cardtopup
Top-up · cryptotopup
Top-up · Apple Paytopup
Top-up · Google Paytopup
Withdrawalwithdraw
P2P transferp2p
QR paymentp2p (reused)
Bill paymentbill
NFC paymentnfc
FX conversionconvert
NAQD mintnaqd_mint
NAQD burnnaqd_burn
NAQD on-chain withdrawnaqd_burn (burn-shaped)
Card authorization hold— none —
Card clearing / capturecard_authreleases
Card reversal— none —releases
Card refundcard_auth
Partner execute (TnG)partner
Partner refund (TnG)partner
Admin wallet adjustmentadjustment
Recurring paymentp2p
Two Kind constants are declared but never posted

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

MethodHandlerCounterparty
Bank / MVRTopUpMVRexternal_funding
CardTopUpCardcard_settlement
CryptoTopUpCryptoexternal_funding
Apple PayTopUpApplePaycard_settlement
Google PayTopUpGooglePaycard_settlement

Bank / MVR top-up

Trigger
POST /wallet/topup/mvr (Idempotency-Key) → Service.TopUpMVRSettlementEngine.Authorize
Validation
checkKYCLimit only — no PIN required for any top-up
State machine
pending → processing → completed | failed; money only moves in complete(), never at Authorize time
Events
txn.created at authorize; txn.completed / txn.failed on settlement
AccountCurrencyAmount
User walletMVR+500.00
external_funding (system)MVR−500.00
Σ MVR0.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

AccountCurrencyAmount
User walletUSD+200.00
card_settlement (system)USD−200.00
Σ USD0.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

AccountCurrencyAmount
User walletUSD+50.00
external_funding (system)USD−50.00
Σ USD0.00 ✓

Apple Pay top-up

AccountCurrencyAmount
User walletUSD+75.00
card_settlement (system)USD−75.00
Σ USD0.00 ✓

Google Pay top-up

AccountCurrencyAmount
User walletUSD+60.00
card_settlement (system)USD−60.00
Σ USD0.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

Trigger
POST /wallet/withdraw (Idempotency-Key) → Service.Withdraw
Validation
checkKYCLimit only — no PIN
Kind / counterparty
ledger.KindWithdraw, external_funding
Events
withdrawal.requested only — fired immediately after the debit commits
AccountCurrencyAmount
User walletMVR−300.00
external_funding (system)MVR+300.00
Σ MVR0.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:

wallet-service/service.go — processWithdrawal
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.

Discrepancy: admin withdrawal rejection bypasses the ledger

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

Trigger
POST /payment/transfer/p2p (Idempotency-Key) → ProcessP2PTransfer
Validation
ValidateAmount (>0, ≤1,000,000) · pinguard.Verify (PIN required) · sender≠recipient
Fee formula
0.5% of amount, floored at 1 MVR / 0.25 USD, rounded 2dp (calculateTransferFee)
Kind
ledger.KindP2P
Events
none — only a stub notification (fmt.Printf), a documented gap since docs/wiring/payments.md
AccountCurrencyAmount
Sender walletMVR−100.50
Recipient walletMVR+100.00
fees (system)MVR+0.50
Σ MVR0.00 ✓

Sender debited amount + fee in one leg. Failure: insufficient balance trips ledger.ErrOverdraftINSUFFICIENT_BALANCE, and the entire request rolls back — no partial state, nothing to compensate.

QR payment

Trigger
POST /payment/qr/scan (Idempotency-Key) → ProcessQRPayment
Implementation
Parses the QR payload, then calls ProcessP2PTransfer internally — identical fee formula, PIN gate, and ledger shape as P2P
Kind
ledger.KindP2Pnot KindQR, despite that constant existing
AccountCurrencyAmount
Payer walletMVR−51.00
Merchant/recipient walletMVR+50.00
fees (system)MVR+1.00
Σ MVR0.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

Trigger
POST /payment/bill-pay (Idempotency-Key) → ProcessBillPayment
Validation
pinguard.Verify (PIN required) · pre-check AvailableBalance() < amount
Rail
MVRGatewayProcessor.ProcessPayment — a real outbound HTTP call to the MVR bank gateway, HMAC-signed, separate from the top-up SettlementEngine/provider registry
Idempotency key
Deterministic: GenerateIdempotencyKey(userID, walletID, amount, billerID+":"+billNumber) — no timestamp, so a retry with the same inputs is a true no-op
Kind
ledger.KindBill, counterparty external_funding
Events
none
AccountCurrencyAmount
User walletMVR−120.00
external_funding (system)MVR+120.00
Σ MVR0.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

Trigger
POST /payment/nfc/process (Idempotency-Key) → ProcessNFCPayment
Structure
Same shape as bill payment: PIN gate, balance pre-check, same MVR gateway, same completed-only posting rule
Idempotency key
Deterministic, includes tagID:merchantID:terminalID
Kind
ledger.KindNFC, counterparty external_funding
AccountCurrencyAmount
User walletMVR−35.00
external_funding (system)MVR+35.00
Σ MVR0.00 ✓

FX conversion

Trigger
POST /wallet/convert {quote_id, pin} (Idempotency-Key) → Service.Convert, executing a quote from POST /fx/quote
Validation
pinguard.Verify only — no KYC-limit check, unlike top-up/withdraw
Pricing
board effective, 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)
Kind
ledger.KindConvert, counterparty fx_spread (once per currency leg)
Events
none
AccountCurrencyAmount
User wallet (from)USD−100.00
fx_spread (system)USD+100.00
Σ USD0.00 ✓
User wallet (to)MVR+1533.60
fx_spread (system)MVR−1533.60
Σ MVR0.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

Kind
ledger.KindNAQDMint, counterparty naqd_reserve (both currencies)
Fee
none
Events
naqd.minted on success; system.alert + compensating reversal on chain failure
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
Σ per currency0.00 ✓ each

Burn

Kind
ledger.KindNAQDBurn
Fee
NAQD_BURN_FEE_BPS, default 50bps (0.5%) of the gross fiat value
Events
naqd.burned
AccountCurrencyAmount
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 currency0.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)

Kind
ledger.KindNAQDBurn — burn-shaped, folded into the same Kind by design
Chain call
ChainClient.Transfer (custody → user-supplied address) — not Burn; supply is unchanged, only custody changes
Events
naqd.burned — withdrawal has no dedicated event type
AccountCurrencyAmount
User wallet (NAQD)NAQD−50.00
naqd_reserve (system)NAQD+50.00
Σ NAQD0.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

Trigger
POST /webhooks/cards/sandbox {event:"auth"} (HMAC-signed) → Service.HandleWebhookhandleAuth
Gates
card Active, ecommerce/international/contactless flags, per-txn limit, daily/monthly outstanding-auth sums, available := Balance − LockedBalance ≥ holdAmount
Ledger
none. Only repo.AdjustLockedBalance(walletID, +holdAmount)
Failure
declined → a CardTransaction{Status: declined} row only; no lock is ever placed
What movesLedger entries
Wallet.LockedBalance += 80.00— none —

Clearing / capture

Trigger
{event:"clearing", captureAmount}handleClearing
Kind
ledger.KindCardAuth, reference "card-clearing-<auth_uuid>"
Rule
captureAmount ≤ original hold; releases the full original hold even on partial capture (standard card-network behavior)
AccountCurrencyAmount
Cardholder walletUSD−42.17
card_settlement (system)USD+42.17
Σ USD0.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 movesLedger entries
Wallet.LockedBalance −= 80.00— none — nothing was ever posted

Refund (after capture)

Precondition
original auth must be Status: captured — refunding an uncaptured hold is rejected
Kind
ledger.KindCardAuth, reference "card-refund-<random>" — a fresh posting, never edits the original clearing entry
AccountCurrencyAmount
Cardholder walletUSD+42.17
card_settlement (system)USD−42.17
Σ USD0.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

Trigger
POST /partners/execute {quote_id, pin} (Idempotency-Key) → Service.Execute, against a quote from POST /partners/quote
Validation
pinguard.Verify · checkTierLimit (per debit-currency daily/monthly exposure, KYC-tier scaled)
Fee formula
tng-my: flat 2.00 + 1.5% of principal, in the user's pay_with currency (FeeFlat/FeePct on the rail row)
Kind
ledger.KindPartner, sub-account partner_settlement:tng-my (SystemPartnerSettlement + ":" + railID)
Events
partner.operation on execute and on every status transition; audit log PARTNER_OPERATION_EXECUTED
AccountCurrencyAmount
User walletMVR−509.50
partner_settlement:tng-my (system)MVR+500.00
fees (system)MVR+9.50
Σ MVR0.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

Trigger
failAndRefund — called synchronously on immediate Execute failure, or from the scheduled-completion goroutine on simulated/real rail failure
Kind
ledger.KindPartner, reference "partner-refund-<op_uuid>"
AccountCurrencyAmount
User walletMVR+509.50
partner_settlement:tng-my (system)MVR−500.00
fees (system)MVR−9.50
Σ MVR0.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

Create
POST /admin/wallets/:id/adjust (role finance) → WalletAdjustment{Status: pending}no money moves at this step
Approve
POST /admin/adjustments/:id/approve (role admin) — hard-enforces a different approver (RequestedBy == adminID → 403); guarded UPDATE WHERE status='pending' prevents a double-approve race
Kind / counterparty
ledger.KindAdjustment, reference "ADJ-<adjustment_uuid>", counterparty external_funding for both credit and debit adjustments
Events
system.alert (severity info) on approval
AccountCurrencyAmount
User walletMVR+25.00
external_funding (system)MVR−25.00
Σ MVR0.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

Trigger
RecurringSweeper, interval RECURRING_SWEEPER_INTERVAL (1h) → ProcessDueRecurringPaymentsprocessOneRecurringPayment
PIN
skipped by design — the mandate itself was PIN-authorized once at creation (CreateRecurringPayment); each scheduled execution runs through an internal path documented to "intentionally stay unreachable from any HTTP handler"
Kind
ledger.KindP2P — mirrors a manual P2P transfer exactly, but with zero fee
Constraint
only wallet-to-wallet recipients are supported; a saved card/bank recipient fails loudly rather than silently skipping
AccountCurrencyAmount
Sender walletMVR−45.00
Recipient walletMVR+45.00
Σ MVR0.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.