Skip to content

0014 — Account Admin portal, separate from operations

Status: accepted

Context

digigold-admin answers "is the system working?" — KYC review, retries, provider and payment monitoring, deploys. It is an operations console.

Nobody could answer "are the books right?" Accounting work had no surface at all: apps/admin-api's reports module throws NotImplementedException on four of its five endpoints (daily transactions CSV/XLSX, GSTR-1, TDS quarterly, SafeGold custodian reconciliation), and there is no supplier, purchase, expense, chart-of-accounts, journal, trial-balance, period or filing concept anywhere in the backend.

A reusable accounting platform existed outside the repo with a domain-neutral core (chart of accounts, double-entry engine, GST/TDS centres, reconciliation, exports with CSV-injection defence, an 8-role/25-permission model) and a machine-enforced core↔module boundary.

Decision

Add aim-gold-account-admin/ (Next.js 14, port 3004) built on that core, with a src/modules/aim-gold domain module, and a third backend application aim-digigold-real/apps/accounting-api (port 3003, prefix api/v1/accounting) to serve it.

Three sub-decisions carry the weight:

1. A third Nest app, not a new prefix or a Caddy rewrite. The portal's client hard-codes /api/v1/accounting; both existing apps use setGlobalPrefix('v1'). Changing that prefix breaks the Flutter app, the website and the operations portal at once. Rewriting paths at Caddy would hide the mount point in infrastructure and make a local run behave differently from production. A third app also isolates the new guard, exception filter and response envelope in a module graph the other two never import.

2. A separate authentication boundary. admin-api uses Authorization: Bearer with a stateless 8-hour JWT that cannot be revoked. The accounting API uses x-portal-token — an opaque random token stored only as a SHA-256 hash, resolved from the database on every request. A token minted for one is rejected by the other. Both being "admin portals" is not a reason to share a credential, and the operations API's inability to revoke a session is not a pattern worth reproducing on the surface that reads every customer's financial position.

3. The backend ledger is a subledger; the portal owns the statutory GL. ledger_entries is a real double-entry journal — balanced per asset, idempotent on a UNIQUE dedupe_key, immutable by database trigger. It is not a general ledger: eleven accounts with no type, normal balance, code or hierarchy; no expenses, purchases, bank settlements or COGS; no posting date distinct from created_at; no financial year, entry numbering or period lock; a single undifferentiated GST_PAYABLE; and no postings at all on the sell or payout path. Two independent ledgers would diverge invisibly — both would balance, both would look authoritative — so ingestion is keyed on the backend's own dedupe_key, and an unmappable account lands in Suspense with an exception rather than being dropped.

Consequences

  • Twelve accounting determinations gate every statutory figure. All shipped NOT_CONFIGURED; nothing depending on an unapproved one may post or be presented as statutory. See AIM_GOLD_ACCOUNTING_POLICY_BLOCKERS.md. This is deliberate: a digital-gold platform cannot derive from its own data whether a customer purchase is a sale or a deposit, and guessing produces books that balance and are wrong.
  • Two defects are now visible rather than absorbed. Sells never post to the ledger (PortfolioService.applySell mutates gold_holdings directly), so the ledger over-states every customer who has sold; and sell orders once reached no terminal state, because markPayoutSent/markCompleted had zero callers. The second has since been fixed: PayoutsService.applyPayoutOutcomeToOrder drives a SELL order to COMPLETED or PAYOUT_FAILED from the provider's verdict, and is called from four live sites on the webhook and reconciler paths (the two OrdersService methods remain dead code). The ledger defect is not fixed here — it needs an approved sell-accounting policy before the correct debit and credit are knowable.
  • The portal was not published when this ADR was accepted — the Caddyfile routes were commented out and no DNS record existed. Superseded 2026-08-13 (ea40e89): the owner decided to publish, and accounts.aimgold.org is live. One hostname serves both upstreams — the portal owns /, the accounting API owns /api/v1/accounting/* — so the browser makes no cross-origin request, ACCOUNTING_PORTAL_ORIGIN stays unset and the API's CORS stays shut. accounts-api.aimgold.org remains unpublished with no DNS record. Two controls survive that decision: the accounting compose profile keeps both services out of every default docker compose up/pull/build/config, and ACCOUNTING_PORTAL_MODE defaults to readonly in three layers: compose substitutes readonly when the variable is unset or empty (${ACCOUNTING_PORTAL_MODE:-readonly}), the Joi schema defaults an absent value to readonly and rejects an unrecognised one at boot (a typo crash-loops the service rather than silently unlocking writes), and behind both, app.config.ts treats anything but the exact string readwrite as read-only. What did not happen is the edge access control AIM_GOLD_ACCOUNT_ADMIN_SECURITY.md §10 asked for first — its item 1, "Restrict at the edge — IP allow-list or VPN-only" (deploy/Caddyfile sketches an identity-aware proxy as a third option). The live vhost hardens the response (HSTS, frame-ancestors 'none', connect-src 'self', X-Robots-Tag: noindex, no Server header) but admits every client: remote_ip and ACCOUNTING_ALLOWED_CIDRS appear only inside the superseded commented block. Application login is therefore the sole barrier in front of a surface that reads every customer's financial position.
  • One more app to build, test and deploy. ci.yml gains an account_admin job running npm run verify; the deploy workflows are deliberately untouched.
  • The identity guard test (test/no-inherited-identity.spec.ts) now scans the new portal too, including asserting project.config.ts leaves every statutory field empty.