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.
ledger.Post.Service responsibilities
| Package | Owns | Does not own |
|---|---|---|
ledger | Double-entry accounts (user + system), Post(), row-locking, Reconcile() | Business rules for when to post — callers decide |
auth-service | Register/login/logout, JWT issuance + rotation, sessions, 2FA (TOTP), login history | Transaction PIN (that's pinguard) and admin auth (separate handler, same JWT secret) |
user-service | Profile read/update, email-change confirmation flow | KYC documents/tiers (kyc-service) |
kyc-service | Document 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-service | Wallet CRUD, P2P transfer, QR generate/scan, FX convert execution, withdrawals, saved cards | Top-up settlement state machine (payment-service) |
fx-service | Official/effective rate boards, quote issuance (30s TTL), currency bridging | NAQD/USD pricing — supplied by oracle-service via a chained rate provider |
payment-service | Top-up settlement state machine, provider registry, settlement + recurring-payment sweepers, webhook handling | Card authorization (card-service uses its own hold/capture design) |
oracle-service | XAG/USD price sourcing, median aggregation, staleness fallback chain | Minting/burning — that's stablecoin-service |
stablecoin-service | Mint/burn/withdraw quotes, NAQDOperation state machine, maker-checker threshold, the async worker, reserve/collateral accounting | Chain calls themselves (delegates to blockchain-service's ChainClient) |
blockchain-service | ChainClient interface + solana/polygon/sandbox implementations, custody addresses, key decryption | Deciding when to mint/burn — stablecoin-service's worker calls it |
card-service | Card lifecycle (virtual/physical state machines), CardProcessor interface + sandbox impl, hold/capture ledger design, PAN encryption | — |
partner-service | PartnerRail interface + TnG sandbox rail, quote/execute/refund, per-rail settlement sub-accounts, recovery sweep | — |
notification-service | In-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-service | Dashboard stats, user/KYC/withdrawal review, audit log, wallet-adjustment maker-checker, WebSocket hub | Domain logic — it calls into the same services users' endpoints call |
events | One process-global, in-memory pub/sub bus (Publish/Subscribe), drop-if-full semantics | Durable delivery — anything needing that persists separately (notifications, audit log) |
pinguard | Transaction PIN hashing (Argon2id, same params as passwords), 3-attempt/15-minute lockout in Redis | Account 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.
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.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.
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.
| Worker | Starts | Interval | Job |
|---|---|---|---|
| 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. |
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."