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. SeeAIM_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.applySellmutatesgold_holdingsdirectly), so the ledger over-states every customer who has sold; and sell orders once reached no terminal state, becausemarkPayoutSent/markCompletedhad zero callers. The second has since been fixed:PayoutsService.applyPayoutOutcomeToOrderdrives a SELL order toCOMPLETEDorPAYOUT_FAILEDfrom the provider's verdict, and is called from four live sites on the webhook and reconciler paths (the twoOrdersServicemethods 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, andaccounts.aimgold.orgis 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_ORIGINstays unset and the API's CORS stays shut.accounts-api.aimgold.orgremains unpublished with no DNS record. Two controls survive that decision: theaccountingcompose profile keeps both services out of every defaultdocker compose up/pull/build/config, andACCOUNTING_PORTAL_MODEdefaults toreadonlyin three layers: compose substitutesreadonlywhen the variable is unset or empty (${ACCOUNTING_PORTAL_MODE:-readonly}), the Joi schema defaults an absent value toreadonlyand rejects an unrecognised one at boot (a typo crash-loops the service rather than silently unlocking writes), and behind both,app.config.tstreats anything but the exact stringreadwriteas read-only. What did not happen is the edge access controlAIM_GOLD_ACCOUNT_ADMIN_SECURITY.md§10 asked for first — its item 1, "Restrict at the edge — IP allow-list or VPN-only" (deploy/Caddyfilesketches 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, noServerheader) but admits every client:remote_ipandACCOUNTING_ALLOWED_CIDRSappear 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.ymlgains anaccount_adminjob runningnpm 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 assertingproject.config.tsleaves every statutory field empty.