MyNEX Docs / Architecture
Go · Fiber · GORM
Backend architecture

One binary, one ledger, sandboxed everything

mynex-backend is a monolith by design: one Go process (cmd/server/main.go), one Postgres database, one Redis instance. Every domain lives in its own internal/<domain>-service package and every balance mutation funnels through internal/ledger. External integrations are never called directly from business logic — they sit behind a handful of interfaces with a sandbox implementation that needs zero external keys.

Package map

Fifteen internal/ packages, grouped by what they own. Arrows show the direction of dependency — everything ultimately funnels balance changes through the ledger.

internal/api — routes · middleware (auth, admin auth, rate limit, idempotency) · handlers auth-service JWT, sessions, 2FA user-service profile, email change kyc-service tiers, verification wallet-service P2P, QR, convert fx-service official/effective boards payment-service +providers/, sweeper oracle-service XAG/USD, median stablecoin-service mint/burn worker blockchain-service ChainClient impls card-service hold → capture partner-service rails, tng-my notification-service email/SMS/in-app admin-service — dashboard, RBAC gates, audit log, maker-checker, WebSocket hub (facade over internal/events) internal/ledger double-entry Post() · FOR UPDATE locking · Reconcile internal/models GORM models, AutoMigrate internal/core config, database, apperr, utils internal/events + pinguard pub/sub bus · PIN guard
Domain services never mutate a balance directly — they build entries and hand them to ledger.Post.

Service responsibilities

PackageOwnsDoes not own
ledgerDouble-entry accounts (user + system), Post(), row-locking, Reconcile()Business rules for when to post — callers decide
auth-serviceRegister/login/logout, JWT issuance + rotation, sessions, 2FA (TOTP), login historyTransaction PIN (that's pinguard) and admin auth (separate handler, same JWT secret)
user-serviceProfile read/update, email-change confirmation flowKYC documents/tiers (kyc-service)
kyc-serviceDocument submission/upload, simulated verification heuristics, tier resolution (basic/standard/enhanced)Card/partner spend limits — those packages read the tier but define their own ceilings
wallet-serviceWallet CRUD, P2P transfer, QR generate/scan, FX convert execution, withdrawals, saved cardsTop-up settlement state machine (payment-service)
fx-serviceOfficial/effective rate boards, quote issuance (30s TTL), currency bridgingNAQD/USD pricing — supplied by oracle-service via a chained rate provider
payment-serviceTop-up settlement state machine, provider registry, settlement + recurring-payment sweepers, webhook handlingCard authorization (card-service uses its own hold/capture design)
oracle-serviceXAG/USD price sourcing, median aggregation, staleness fallback chainMinting/burning — that's stablecoin-service
stablecoin-serviceMint/burn/withdraw quotes, NAQDOperation state machine, maker-checker threshold, the async worker, reserve/collateral accountingChain calls themselves (delegates to blockchain-service's ChainClient)
blockchain-serviceChainClient interface + solana/polygon/sandbox implementations, custody addresses, key decryptionDeciding when to mint/burn — stablecoin-service's worker calls it
card-serviceCard lifecycle (virtual/physical state machines), CardProcessor interface + sandbox impl, hold/capture ledger design, PAN encryption
partner-servicePartnerRail interface + TnG sandbox rail, quote/execute/refund, per-rail settlement sub-accounts, recovery sweep
notification-serviceIn-app notifications, best-effort email (real SMTP or logged mock), SMS (Africa's Talking real, others stubbed)Push notifications — implemented but never wired to a caller (no device-token model yet)
admin-serviceDashboard stats, user/KYC/withdrawal review, audit log, wallet-adjustment maker-checker, WebSocket hubDomain logic — it calls into the same services users' endpoints call
eventsOne process-global, in-memory pub/sub bus (Publish/Subscribe), drop-if-full semanticsDurable delivery — anything needing that persists separately (notifications, audit log)
pinguardTransaction PIN hashing (Argon2id, same params as passwords), 3-attempt/15-minute lockout in RedisAccount password auth (auth-service)

Request lifecycle

A typical money-moving request passes through a fixed middleware chain before it ever reaches a handler. The chain is assembled in cmd/server/main.go and internal/api/routes/routes.go.

Client mobile / admin recover + logger main.go CORS AllowedOrigins Rate limiter Redis, 100/min per client+path Auth middleware JWT + Redis blacklist check Idempotency per-route only, money-moving POSTs Handler → Service → Ledger
Idempotency middleware is attached per-route (a third handler argument), not globally — see the API Reference for which endpoints require it.
Admin requests
use a separate gate, middleware.AdminAuthMiddleware(db, redis, cfg, requiredRole), applied per-route/group with a required role — a five-tier hierarchy (support < finance < compliance < admin < super_admin), distinct from the simpler 3-tier RequireRole middleware that exists in the codebase but is only wired to one dead route group.
Public routes
are mounted directly on the v1 group before v1.Use(AuthMiddleware) runs — auth endpoints, FX rates, partner rail listing, NAQD price/oracle reads, and the payment/card webhooks (which are signature-verified inline instead of JWT-gated).

Sandbox-first: the provider interface philosophy

REVAMP_BLUEPRINT.md §2 states it as a rule: external integrations are Go interfaces with a production implementation and a deterministic sandbox implementation selected by config — never an if env == "dev" branch inside business logic. Verified against code: this rule holds for all four interfaces below.

PaymentProvider ChainClient CardProcessor PartnerRail sandbox (default) deterministic, no external keys sandboxChain (default) in-memory balances, idempotent SandboxProcessor (only impl) Luhn-valid PANs, HMAC webhooks TnGSandboxRail (only impl) deterministic simulated failures stripe · local_bank real when keys configured solanaChain · polygonChain CHAIN_ENV=live, custodial treasury real BIN-sponsor processor not built — interface slot only real TnG production API not built — interface slot only Selection is entirely config-driven — PAYMENTS_PROVIDER / CHAIN_ENV env vars, never a code-level dev/prod branch.
Card and partner interfaces currently have exactly one implementation each (the sandbox) — the production slot is designed-for but unbuilt.

Background workers

Five workers keep state machines moving without a live request driving them. All are pure-DB-state (safe across restarts) except the admin stats ticker, which is in-memory and simply restarts fresh.

WorkerStartsIntervalJob
Settlement sweeper go launch in main.go PAYMENTS_SWEEPER_INTERVAL (30s), batch PAYMENTS_SWEEPER_BATCH_SIZE (50) Re-polls stale pending/processing top-ups and Apple/Google Pay charges via SettlementEngine; fails a settlement after PAYMENTS_MAX_SETTLEMENT_ATTEMPTS (20) poll attempts.
Recurring sweeper go launch in main.go RECURRING_SWEEPER_INTERVAL (1h) Executes due recurring P2P mandates, advances next_payment_date, auto-disables after RECURRING_MAX_CONSECUTIVE_FAILURES (3) consecutive failures.
NAQD worker started inside SetupNAQDRoutes (route setup, not main.go) NAQD_WORKER_POLL_INTERVAL (2s), stuck-after NAQD_WORKER_STUCK_AFTER (2m) Crash-recovery sweep (requeues stuck processing rows) then drains pending NAQDOperation rows: posts ledger entries, calls the chain client, reverses + fails on chain error.
Partner recovery sweep synchronous, one-shot call in main.go at boot not recurring — runs once Re-drives any PartnerOperation stuck processing from before a restart (age threshold PARTNER_RECOVERY_SWEEP_AGE_SECONDS, 120s).
Admin stats ticker lazily, via sync.Once, on the first admin WebSocket connection 5s, fixed Publishes stats.tick with live totals (users, pending KYC, transactions today, volume today). Never runs if no admin opens the dashboard WS.
Worth knowing

The NAQD worker and the admin stats ticker are not started in main.go the way the two payment sweepers and the partner recovery sweep are — they're side effects of route setup / first WebSocket use. Functionally harmless (route setup runs exactly once, at boot), but worth knowing if you're tracing "what starts when."