MyNEX Docs / Data Flows
7 flows
End-to-end sequences

How money actually moves, step by step

Seven flows, each traced through the real code paths: what the client sends, which service handles it, when the ledger posts, and what happens on restart or failure.

a. Top-up settlement

Every top-up method (topup/mvr|card|crypto|apple-pay|google-pay) goes through the same SettlementEngine state machine: authorize → pending → (webhook or sweeper poll) → completed | failed. All state lives in the PaymentSettlement row, not in memory — a server restart mid-settlement loses nothing; the sweeper simply picks the row back up on its next tick.

Client Wallet API Payment Provider Ledger / DB POST /wallet/topup/card + Idempotency-Key Authorize(amount) provider_ref, status = pending create PaymentSettlement + Transaction (pending); publish txn.created 202 { transaction.status: "pending" } Asynchronously: either the provider POSTs a signed webhook to /webhooks/payments/:provider (public, HMAC/Stripe-sig verified), or the settlement sweeper polls GetStatus() every PAYMENTS_SWEEPER_INTERVAL (30s, up to PAYMENTS_MAX_SETTLEMENT_ATTEMPTS=20 tries). Both call the same complete()/fail(). complete(): ledger.Post(external_funding → user) + ApplyToWallet, same DB tx committed; publish txn.completed GET /wallet/transactions → status: completed
Restart safety: NextPollAt and settlement status live in Postgres, so the sweeper resumes exactly where it left off after a crash.
  1. Client requests a top-up. POST /wallet/topup/<method> with an Idempotency-Key — a retry of the same key replays the original response rather than double-charging.
  2. Immediate authorize. The handler calls the configured PaymentProvider (sandbox by default, or Stripe/local_bank if configured) — this is a synchronous call, so failures here return an error to the client right away.
  3. Pending row created. A PaymentSettlement row and a Transaction row are both written as pending, and txn.created is published on the event bus. The client gets back 202-shaped data with transaction.status = "pending" — it does not wait for settlement.
  4. Settlement happens off the request path. Either the provider calls the public webhook, or the sweeper's next tick polls GetStatus. Sandbox provider settles deterministically after PAYMENTS_SANDBOX_SETTLE_DELAY (5s default).
  5. Ledger posts atomically with the status flip. complete() runs ledger.Post (external_funding → user) and ApplyToWallet inside the same DB transaction as marking the settlement completed — the wallet balance and the ledger can never disagree about a given top-up.
  6. Give-up path. After PAYMENTS_MAX_SETTLEMENT_ATTEMPTS (20) failed polls, the settlement is marked failed and txn.failed is published — no money moves.

b. P2P transfer

Synchronous, single-request flow: PIN check, ordered account locks, one ledger posting, done. Unlike top-ups, P2P does not currently publish txn.* eventsdocs/wiring/payments.md itself notes this as an explicit gap ("Not covered by this wave: ProcessP2PTransfer, ProcessBillPayment, ProcessNFCPayment, ProcessQRPayment"), and it's still true in the current code — confirmed by the complete event-emitter grep in the Security page.

Client Payment API pinguard + Redis Ledger / DB POST /payment/transfer/p2p {recipient, amount, pin} + Idempotency-Key Verify(userID, pin) 3 fails → lock 15m (PIN_LOCKED) · else ok SELECT ... FOR UPDATE on sender + recipient accounts, ordered by account ID Post(KindP2P): sender −(amount+fee), recipient +amount, fees +fee ApplyToWallet both wallets; commit 200 { transaction, receipt } — no txn.* event published today
Sender and recipient account locks are taken in ascending account-ID order regardless of who initiated the transfer — the deadlock-avoidance rule from the ledger design.
  1. PIN gate first. pinguard.Verify runs before anything else touches money — wrong PIN 3 times locks the user out for 15 minutes (PIN_LOCKED), independent of account password.
  2. Fee computed. 0.5% of the amount, floored at 1 MVR / 0.25 USD, rounded to 2dp (calculateTransferFee).
  3. One ledger posting, three legs. Sender debited amount + fee, recipient credited amount, fees system account credited fee — see Ledger → Worked examples.
  4. Synchronous, not async. Unlike a top-up, there's no pending state — the transfer either fully succeeds inside one request or rolls back entirely (ErrOverdraftINSUFFICIENT_BALANCE if the sender can't cover it).

c. NAQD mint

Mint is asynchronous: the API call only creates a queued NAQDOperation; a background worker does the actual ledger + chain work. Large mints route through maker-checker before the worker will touch them.

Client NAQD API oracle-service Worker Chain POST /naqd/mint {amount_naqd | spend, pin} resolvePrice() Fallback chain: live feed (CoinGecko + Metals-API, median) → last-known-good (≤15m old) → admin manual rate → ORACLE_STALE. Each degrade → system.alert. silverPriceUSD create NAQDOperation — usdEquivalent ≥ NAQD_APPROVAL_THRESHOLD_USD (5000)? yes → status=requires_approval · no → status=pending 200 { operation_id, status, quote } If requires_approval: an admin calls POST /admin/naqd/operations/:id/approve first — this flips it to pending with no ledger cost either way. ClaimNextPending: guarded UPDATE pending→processing ledger.Post: user −fiat / naqd_reserve +fiat, user +NAQD / naqd_reserve −NAQD Mint(custody→user, reference) tx_hash → finishOK: completed, publish naqd.minted
On chain failure the worker posts a compensating reversal (reference-REVERSAL) and publishes system.alert rather than leaving the user's fiat debited with no NAQD delivered.
  1. Quote resolution. Rate = silverPriceUSD / 31.1034768 (grams per troy ounce). Either amount-first (amount_naqd → USD cost) or spend-first (USD/MVR amount → NAQD out), no mint fee.
  2. Oracle fallback chain, in order: live median of CoinGecko + Metals-API (if METALS_API_KEY set) → last known-good price if within ORACLE_STALENESS_THRESHOLD (15m) → most recent admin-set manual rate → ORACLE_STALE error. Every degraded step publishes system.alert.
  3. Maker-checker gate. If the USD-equivalent value is ≥ NAQD_APPROVAL_THRESHOLD_USD (default 5000), the operation is created as requires_approvalbefore anything touches the ledger, so a reject costs nothing to undo.
  4. Worker claims and executes. A guarded UPDATE ... WHERE status='pending' prevents two worker ticks from double-claiming the same operation. Ledger posts first, then the chain call — on CHAIN_ENV=sandbox (default) this is in-memory and idempotent per reference.
  5. Client polls GET /naqd/operations/:id for the status transition pending → processing → completed, with tx_hash once the chain step lands.

d. NAQD on-chain withdrawal

Self-custody withdrawal: a user's custodial (ledger-tracked) NAQD balance moves to a wallet address they control. This is the only path that ever moves tokens to a user-supplied external address — the platform never generates or holds per-user chain keys.

Client NAQD API Worker + Ledger Chain (custody addr) POST /naqd/withdraw-onchain {amount, chain, address, pin} PIN verify; create NAQDOperation(withdraw_onchain) 200 { operation_id, status: pending|requires_approval } ClaimNextPending → ledger.Post (burn-shaped: user −NAQD, naqd_reserve +NAQD) Transfer(custody → user-supplied external address, amount) tx receipt (Solana: pending → poll TxStatus) finishOK: completed, publish naqd.burned (withdraw folded into the burn event)
Custodial-first: the pre-revamp per-user chain wallet model was deleted entirely — ChainClient.CustodyAddress() is one platform-owned address per chain.
Known gap on live chains

The sandbox chain is idempotent per (kind, reference), so a crash-and-retry never double-sends. Solana and Polygon have no equivalent dedup primitive yet — if the process crashes between a live chain call succeeding and the DB recording the tx hash, the worker's recovery sweep will resubmit. This is documented in blockchain-service/README.md's "Known limitation" section and is real, not hypothetical, for anyone running CHAIN_ENV=live today. See Security → Known limitations.

e. Card auth → hold → clearing → capture

Card authorizations arrive as signed webhooks (the sandbox processor's SimulateAuth stands in for a real network). The defining design choice: a hold only ever moves Wallet.LockedBalance — the ledger is untouched until the hold actually clears. See Ledger → The card-hold design for why.

Card network (sandbox) card-service Wallet.LockedBalance Ledger / DB POST /webhooks/cards/sandbox {event: auth, amount} (HMAC X-Signature) verify signature · check per-txn/daily/monthly limits by KYC tier · FX-convert if cross-currency AdjustLockedBalance(+holdAmount) — no ledger entries 200 OK — later — merchant clears the transaction — webhook {event: clearing, captureAmount} Post(KindCardAuth): user −capture, card_settlement +capture; ApplyToWallet(−capture) release FULL original hold (unused remainder freed, not re-held) Reversal (hold released, never captured): LockedBalance −= holdAmount only. No ledger entries — nothing to reverse. Refund (after capture): a fresh, independent Post(KindCardAuth) with reference "card-refund-<random>" — [user +amount, card_settlement −amount] + ApplyToWallet(+amount). The original clearing entry is never edited. SimulateAuth is documented on the CardProcessor interface as sandbox-only — a real processor wouldn't implement it.
Auth and clearing are two separate webhook events; the hold amount and the captured amount can legitimately differ (partial capture).

f. TnG reload (partner rail)

First partner corridor: Touch 'n Go eWallet reload (Malaysia), currently a sandbox rail implementation. A quote bridges through USD (MVR/MYR has no direct rate, so it resolves MVR→USD→MYR), then execution posts to a per-rail settlement sub-account.

Client partner-service fx-service Ledger / DB TnG sandbox rail POST /partners/quote {rail: tng-my, target, amount, pay_with: MVR} Rate(MVR→USD→MYR), bridged PartnerQuote frozen (60s TTL): fee = 2.00 + 1.5% principal POST /partners/execute {quote_id, pin} + Idempotency-Key PIN verify · Post(KindPartner): user −debit, partner_settlement:tng-my +(debit−fee), fees +fee Execute(idempotencyKey, req) partner_ref (deterministic), status: processing 201 { operation_id, status: processing }; publish partner.operation Async settlement (~ETASeconds, simulated): completeOperation() → status completed, publish partner.operation. On simulated/real rail failure: failAndRefund() reverses the exact posting — full amount incl. fee refunded, status refunded. RecoverStuckOperations sweeps anything still "processing" older than PARTNER_RECOVERY_SWEEP_AGE_SECONDS (120s) at boot, re-deriving the same deterministic outcome.
The rail's own currency (MYR) never enters the ledger — only what the user actually paid with (MVR/USD/NAQD).

g. Admin WebSocket event flow

One process-global, in-memory pub/sub bus feeds every admin dashboard connection. Delivery is best-effort by design — see the callout below for exactly what that means and where the Vue side currently falls short of consuming everything it receives.

Any backend service events.Bus Admin WSHandler Vue admin dashboard GET /admin/ws?token=<jwt> (WSUpgradeMiddleware: blacklist + validate + active check) Subscribe(64) · sync.Once starts 5s stats ticker on first-ever connection {type:"subscribe", channels:[...]} (optional — default is all) Publish("naqd.minted", data) — non-blocking send, drop-if-full per subscriber {type, at, data} JSON forwarded to subscribed channels message → realtime store → dispatch by type 54s ping / 20s client-side heartbeat watchdog, exponential-backoff reconnect
A slow admin dashboard misses events rather than stalling every other service's publisher — see the callout below.
Drop-if-full is deliberate

events.Bus.Publish's own doc comment: "we'd rather a slow admin dashboard silently miss an event than have Publish pay the cost of draining someone else's channel under lock." Anything that needs guaranteed delivery persists separately (in-app notifications, the audit log) and publishes an event in addition, not instead.

Frontend gap: transport works, dispatch is incomplete

All 18 event types (12 from the contract + 6 additive: session.*, user.updated, kyc.approved/rejected/updated) reach the browser — the WebSocket client (reconnect, heartbeat, parsing) is fully built. But today only system.alert (→ toast) is actually consumed by a specific handler; everything else lands only in a generic 60-item feed array. dashboardStore.applyLiveTick(), transactionsStore.prependLive() and partnersStore.refreshOperationsQuiet() all exist to consume stats.tick / txn.* / partner.operation respectively, but none is currently wired to the realtime store's stream. See Admin Console → WebSocket live layer.