Features¶
This lists what the code actually implements today. AIM GOLD is mid-build: account, browsing, product (physical-jewellery) orders, and SIP creation are wired end-to-end; digital gold buy/sell settlement is gated off server-side, and KYC has no live provider. Each section says what works and what is a stub.
Terminology: "gated" = refused by a server-side kill switch or readiness gate, not merely hidden in a client; "stub" = a screen or endpoint that exists but does nothing real yet. The distinction is load-bearing. A client feature flag can only ever narrow what the backend already permits — it is ANDed with the server's answer, never ORed — so flipping one in a build does not open a path the server refuses, and no client flag is the reason buy or sell is off.
What 'gated off' means for buy and sell
SELL fails closed with HTTP 503 unless DIGITAL_SELL_ENABLED is true,
compared case-insensitively — unset, empty, 1 and yes are all off
(libs/common/src/capability/digital-sell.gate.ts);
the assertion is the first statement of the sell path, so no partial work
happens before the refusal. BUY and SIP installment debits fail closed
unless MerchantReadinessService reports zero blocking items and
DIGITAL_GOLD_PAYMENT_ENABLED=true (also case-insensitive)
(libs/common/src/legal/merchant-readiness.service.ts). It is asserted at
the two call sites that reach a gateway: first statement of
PaymentOrdersService.createFromQuote for buy, and
PaymentsService.createIntent for SIP installment debits — the SIP gate
deliberately lives there rather than in SipService. That is an evidence
gate over GSTIN,
provider disclosure and legal content, so a flipped flag alone cannot
bypass it. Clients must consume GET /v1/public/payment-readiness rather
than re-deriving availability, so a checkout can never open that the
backend would refuse.
Customer backend (aim-digigold-real/apps/api)¶
Live, JWT-protected REST API under /v1 (22 mounted controllers). Full
endpoint list in the API Reference. Working capability
groups:
- Auth — phone OTP login (SMS via alots.in), JWT access + rotating refresh
tokens (
sessionstable), server-side MPIN, device-bound biometric tokens, session list/revoke. - Users — profile get/update, account-deletion request/confirm/status (OTP-confirmed, real soft-delete).
- Price — public live gold price (
GET /v1/price/current) + socket.io/pricebroadcast. - Portfolio — holdings summary, paginated history, and a passbook backed
by
gold_holdingsplus the customer's own ledger lines. BecauseAccountingPostingGaterefuses every posting (see Ledger / accounting below),ledger_entriesis structurally empty and the passbook returnsentries_available: falserather than an empty array that would read as "you have never transacted".GET /v1/portfolio/statementstill returns 501. - Orders / quotes — backend-authoritative buy and sell quotes
(
POST /v1/gold/quote,POST /v1/gold/quote/sell,GET /v1/gold/quote/:id) consumed under a row lock, then buy/sell/SIP order creation with owner-scoped idempotency, SafeGold verify/confirm, and gateway payment orders created through a provider-agnostic registry. A buy quote's expiry is clamped inside SafeGold's 8-minute price lock. SELL requires a caller-owned, verifiedbank_account_idand is refused with 503 whileDIGITAL_SELL_ENABLEDis nottrue. SIP plan creation is live but requires an approved KYC (users.safegold_user_id), and its installment debits pass through the same merchant-readiness gate as one-off buy. - Products / product-orders — jewellery catalogue and Cash-on-Delivery
orders (this is the shopping path that fully works today). Products price
either
FIXEDorDYNAMIC; aDYNAMICrow is derived from the live 24K rate × karat with making charge and discount applied (libs/common/src/money/product-pricing.ts), and the response carriespriceBasis: LIVE|FIXEDso a client never assumes a stored snapshot is live. Line totals, GST and delivery are integer paise end to end.idempotencyKeyis required and de-duplicated by a partial unique index on(user_id, idempotency_key); an optionalexpectedTotalis compared before the insert, so a409 PRICE_CHANGED:refusal does not consume the key and the client can re-confirm with the same one. Physical COD requires account standing but deliberately not digital-gold KYC. - Transactions, notifications, referrals — unified transaction feed, notification records (delivery handoff is a TODO), referral codes.
- KYC — submit + status with encrypted PII; Aadhaar-OTP endpoints return 501; webhook receiver verifies HMAC but does not yet parse events.
- Payments / payouts — payment orders and payouts through a
provider-agnostic layer (
PaymentGatewayRegistry/PaymentGatewayResolver) with HMAC-verified webhooks. Razorpay and RazorpayX are one adapter set underlibs/payments/src/adapters/razorpay/; no business code names a provider, andlibs/payments/src/gateway-neutrality.spec.tsfails the build if a provider token appears outside the adapter directory. - Bank accounts —
GET/POST /v1/bank-accounts, payout destinations with penny-drop verification. An unverified account cannot receive a sell payout, because a sell against one would debit the customer's gold and then fail to pay for it. - Legal / merchant identity —
GET /v1/legal/entity,/provider,/pricing,/policies[/:slug]. Every field is published only when it is in the verified-fields allowlist, so an unverified value renders as absent rather than as a claim. - Public readiness —
GET /v1/public/payment-readiness, the states-only contract clients consume instead of re-deriving checkout availability. It returns states, never reasons; the reasons live behind the admin readiness report. - Support — unauthenticated contact and grievance tickets
(
POST /v1/support/tickets, throttled) plus lookup by reference, so a customer with no account can still raise a grievance and quote a reference back. - Observability —
GET /v1/metrics(Prometheus text format; Caddy 404s it on the public hostname) and a Redis-locked reconciliation scheduler. - Ledger / accounting — a full double-entry ledger, posting engine and
policy gate are built, but
AccountingPostingGatecurrently refuses every posting:ACCOUNTING_POSTING_DISABLEDwhen the flag is off,ACCOUNTING_POSTING_BLOCKED_ENGINE_NOT_WIREDwhen it is on (libs/finance/src/accounting/posting-gate.service.ts).ledger_entriesis therefore empty by design, and turningACCOUNTING_AUTO_POSTING_ENABLEDon changes nothing but the log level. Nothing is withheld from a customer sell either: TDS is hard-zero pending a classification decision, and sell posting is blocked underCUSTOMER_SELL_TDS_CLASSIFICATION_PENDING.
Admin backend (aim-digigold-real/apps/admin-api)¶
Live REST API under /v1, global JWT + role guard over three roles
(SUPER_ADMIN, ADMIN, SUPPORT). The API-side RolesGuard is
exact-match, not hierarchical (libs/common/src/guards/roles.guard.ts):
every handler must list SUPER_ADMIN explicitly to admit it, and a handler
with no @Roles decorator is open to any authenticated admin. The portal's
sidebar uses a separate rank ladder (digigold-admin/lib/permissions.ts,
SUPER_ADMIN 30 ≥ ADMIN 20 ≥ SUPPORT 10), so the two layers disagree in
shape — do not reason about one from the other. The admin JWT strategy
re-reads the row from admin_users on every request, so a deactivation or
demotion takes effect on the next call.
Modules: dashboard, users, KYC review, orders, product-orders, products,
payouts, price config/override, reports, config (feature flags, limits, CMS
banners — stored in Redis), audit log, transactions, payment-gateways
(enable / mode / default / failover priority / per-instrument routing —
credentials are deliberately neither readable nor writable through this API,
and disabling the last usable gateway is refused), readiness
(GET /v1/readiness, /v1/readiness/blocking — the evidence gate in front of
checkout, with the outstanding items and their evidence), reconciliation
(read-only 13-check report over a window defaulting to 72h and clamped to
1..720h), and devops (Build Tools — see below). Full list in the
API Reference. Seed the first admin with
npm run seed:admin.
Stubs in this API: four of the five report exports —
daily-transactions, gst, tds, safegold-reconciliation — throw
NotImplementedException; only /v1/reports/aum works.
POST /v1/price/override persists a price_history row and an audit row
and then throws 501, so
the side effects land but the override never reaches the Redis price cache or
the WebSocket broadcast — it does not take effect.
Accounting backend (aim-digigold-real/apps/accounting-api)¶
The third NestJS app (port 3003, global prefix /api/v1/accounting), serving
the accounting, tax and auditor portal. Controllers: auth, portal-users, gold,
health.
It has its own auth boundary. Callers present an opaque, revocable
x-portal-token (SHA-256 hashed at rest) against accounting_portal_users —
never a bearer JWT — so a token minted for the customer or admin API cannot be
replayed here. Role, active flag and the permission array are re-read from the
database on every request. Four global guards run in order: Throttler →
PortalToken → PortalPermissions → PortalReadOnly. 8 roles × 25 permissions
live in accounting_role_permissions; six administration permissions
additionally require the caller's role to literally be super_admin.
Almost everything it exposes is a read: gold buy/sell registers, holdings,
payments and payouts, provider reconciliation, policy determinations,
statutory GST/TDS parameters, month-end close. The deployment defaults to
read-only — ACCOUNTING_PORTAL_MODE unlocks writes only on the exact
string readwrite — with a narrow governance carve-out letting a Super Admin
still perform the six portal-user-administration writes. Every refusal writes
an audit row. Errors use their own envelope, {error:{code,message,field?}},
rather than the customer API's shape.
Bootstrap the single owner with npm run seed:portal-owner; record policy
determinations with npm run configure:accounting. It ships in the same image
as the other NestJS apps and runs behind the accounting compose profile, so
a default docker compose up does not start it.
Admin portal (digigold-admin)¶
Next.js app at admin.aimgold.org. Role-gated sidebar
(lib/permissions.ts); browser never holds the API token — server routes
proxy and inject it (app/api/proxy/[...path]/route.ts).
- Dashboard — AUM/GMV/user-growth metrics and a live price ticker; header buttons for View Monitoring (opens Grafana) and Build Tools.
- Operational pages — users, KYC queue, orders, product-orders, products, transactions, payouts, price, reports, config, audit (each backed by the matching admin-api module).
- Build Tools (
/build-tools) — lists recent GitHub Actions runs per target (backend/website/admin-portal/monitoring), polling every 10 s, with Deploy buttons (SUPER_ADMIN only) that callPOST /v1/devops/deploy. This is how a deploy is triggered from the UI. - View Monitoring — external link to
monitor.aimgold.org(Grafana).
Account Admin portal (aim-gold-account-admin)¶
A second Next.js 14 portal (output: standalone, port 3004), the
accounting/tax/auditor front end, served at accounts.aimgold.org and
path-split by Caddy so the accounting API answers on the same hostname and the
browser makes no cross-origin request. See ADR
0014.
It is structured as a domain-neutral core (src/core) plus the gold business
as one opt-in module (src/modules/aim-gold). It runs standalone against an
in-browser mock and switches to the live backend only when both
NEXT_PUBLIC_ACCOUNTING_API_MODE=api and a non-empty
NEXT_PUBLIC_API_BASE_URL are set. A half-configured build reverts to the
mock rather than becoming a live-looking portal pointed at nothing. Because
NEXT_PUBLIC_* is inlined at build time, a mock image and a connected image
are different builds — this cannot be switched by restarting a container. The
demo/mock mode is permanently read-only.
Fail-closed throughout: an unrecognised role gets zero permissions, an
unavailable figure renders as "not available" rather than zero, and the route
guard renders a skeleton until permissions are known rather than briefly
showing a page the viewer may not be allowed. It sits behind the same
accounting compose profile as the accounting API, and
.github/workflows/account-admin-enable.yml is its only image producer.
Website (aimgold-website)¶
React SPA at aimgold.org, guest-browsable (no route requires login; pages
needing data just fail for guests). Route table in src/App.tsx.
Working: home dashboard, onboarding, OTP+MPIN auth (MPIN verified
client-side against a stored hash), live gold rate, SIP creation
(POST /v1/gold/sip), transactions/passbook, full shop (catalogue,
product detail, cart, COD checkout, my orders), profile + edit
(name/email persist), referral code display, an offline FAQ chatbot, plus:
- notifications (
GET /v1/notifications, with honest loading, error and signed-out states — the empty state appears only after something has actually looked). - KYC submission —
/kyccollects PAN, Aadhaar, PIN code, an identity document and a selfie and posts them toPOST /v1/kyc/documents+/v1/kyc/submit(src/lib/kyc.ts; base64 in JSON, 3 MB cap, deliberately no logging and no local persistence, so document bytes exist only for the lifetime of the request). The provider integration behind it is still absent — Aadhaar-OTP returns 501 and the KYC webhook parses nothing — so approval is a manual admin review. - contact and grievance form —
POST /v1/support/tickets, unauthenticated, returning a quotable reference. The form itself no longer composes amailto:, but raw mail links to the support address and the grievance officer are still rendered as contact details alongside it (ContactPage.tsx,Footer.tsx,GoldOrderStatusPage.tsx). - legal/policy pages that read their approval status from the backend — a
page is labelled approved only when
/v1/legal/policies/:slugreturnsavailablewith a version (src/lib/useLegalDocument.ts); a draft or an unreachable backend resolves todraftorunknownand never falls through to "approved". - a
/pricingfee schedule driven entirely byGET /v1/legal/pricing, with per-itemPENDING/NOT_CHARGED/PUBLISHEDstates and no fee amount written in code, so "Not currently charged" can only come from an explicit approved zero. - a paise-exact cart — line totals, GST and delivery are computed in
integer paise to bit-match the server, checkout sends
expectedTotal, and a409 PRICE_CHANGEDtriggers a re-quote and a retry with the same idempotency key. Products carrypricingMode,priceBasis,purityKarat,makingChargePctandratePerGram, and the cart re-quotes every product on entering checkout.
Stubs / gated (src/config/company.ts): sellRedeem,
physicalDelivery, silver and serverAccountDeletionInWebClient are
hardcoded false. kyc is true and purely descriptive — it records that
the form exists, it does not switch it on. digitalGoldPayment is
envFlag('VITE_DIGITAL_GOLD_PAYMENT_ENABLED'), which accepts only the exact
string true; src/lib/usePaymentReadiness.ts ANDs it with the backend's
checkout_available, never ORs it, so it can only narrow what the backend
already permits. Because
Vite inlines VITE_* at build time, flipping it needs a rebuild, not a
restart. Also still stubbed on the web client: biometric (native-only), bank
accounts, historical price charts.
The digital-gold payment flow is fully built, not missing. /gold/checkout
fetches a backend quote and creates a payment order with a per-attempt
idempotency key; /gold/status/:paymentOrderId polls the backend on a bounded
back-off, because the gateway's browser callback is never treated as success
(src/lib/gold.ts). Quick Savings' final "Continue" navigates to checkout when
readiness says so and otherwise shows the backend's own message verbatim. Both
routes stay mounted while payments are off, so a deep link reaches an honest
"being prepared" state instead of a 404. Availability starts closed and a
network failure yields a customer-safe message rather than "available".
The code has an explicit "no fabricated data" convention, but it no longer
means "nothing is asked": Home and Portfolio read the real holding from
GET /v1/portfolio via src/store/portfolio.ts. The store has four states
(idle / loading / ready / unavailable) and treats a malformed body as a
failure — there is no hardcoded fallback price and no ₹0.00 stand-in for an
unanswered call.
Mobile app (aimgold_app)¶
Flutter app, three flavors (development/staging/production →
lib/flavor_config.dart), go_router navigation, fully server-backed auth
(OTP → tokens, MPIN set/verified server-side, optional biometric via a
server-issued token). API base is host-root; DioClient appends /v1.
Working: OTP sign-in (with Play Services phone hint), MPIN setup/unlock,
biometric enrollment/unlock, complete-profile step, home dashboard, live
price, portfolio (server truth, zero renders honestly), passbook,
transactions, shop + cart + COD checkout, my-orders, KYC status (read),
profile + account edit, support tickets (/v1/support/tickets),
manage devices / sessions, bank accounts
(GET/POST /v1/bank-accounts, showing verification state on every tile
because PayoutsService refuses an unverified destination), legal pages.
Gated / stub: buy/sell/SIP are behind the compile-time
FeatureFlags.digiGoldTransactionsEnabled (--dart-define, defaults false
so shipped builds are safe) — blocked at both UI and repository layers — and,
independently of that flag, the server refuses sell with 503 unless
DIGITAL_SELL_ENABLED=true. Sell is now priced from a server-stored quote
(POST /v1/gold/quote/sell), so the client cannot propose a price, and
POST /v1/gold/sell requires a verified bank_account_id sourced from
GET /v1/bank-accounts. Physical-shop checkout persists its in-flight
idempotency key to secure storage, so an app kill mid-request resumes the same
intent rather than placing a second order. Price-trend charts use hardcoded
series (only the headline rate is live). The /price socket detail screen,
notifications and referral are still PlaceholderScreens. The KYC
PAN/Aadhaar/selfie routes are retired stubs that redirect to the real KYC
status screen — identity submission is deliberately not duplicated in the app
and happens on the website.
Account deletion is implemented but switched off. The OTP + typed-"DELETE"
flow against POST /v1/users/me/deletion/request,
POST /v1/users/me/deletion/confirm and GET /v1/users/me/deletion/status
is built and
tested, but AppCapabilities.accountDeletionSupported is a compile-time
false and the repository fails closed before any network call. The backend
routes are deployed and bearer-guarded, so the original reason (a 404) is
stale; this is an owner decision not to switch on a destructive flow. Customer
copy must therefore say "not yet switched on", never "the backend does not
have it".
Notable gaps to know: socket_io_client and Firebase
(firebase_core/messaging/analytics) are declared in pubspec.yaml but
no Dart code imports them — the live-socket price feed and push
notifications are not wired (price is HTTP polling).
Parts of the client are written against the parked Go backend and 404 in
production. /v1/config and /v1/features (the maintenance / min-version /
server-banner startup gates), DELETE /me, POST /auth/logout-all,
/auth/reauth/* and GET /account/risk all match
aim-gold-backend/internal/server/server.go and have no NestJS counterpart;
ApiEnvelope itself is documented as parsing the Go backend's {data:…}
success envelope, while NestJS returns raw bodies. The startup gates therefore
never fire — the config service swallows the 404 rather than blocking
startup — and screens gate on the compile-time flag rather than any runtime
digigold_transactions_effective value.
The SIP data source in DI is now the real wire implementation; the offline
mock is reachable only under FeatureFlags.mockModeEnabled, which is built
from dart.vm.product and const-folds to false in every release and profile
build regardless of --dart-define. It used to be the other way round — the
mock was registered unconditionally, so the day the transactions flag flipped,
SIP would have quietly answered from a fabricated ACTIVE plan with a made-up
SIP code and nothing whatsoever on the server.
Contract tests (test/unit/nestjs_contract_test.dart,
test/unit/api_contract_test.dart, test/unit/sell_customer_contract_test.dart
and siblings) pin client models to the NestJS API shapes;
test/integration/ holds only buy_flow_test.dart.
Docs site (docs/, mkdocs.yml, docs.Dockerfile)¶
This documentation is itself a deployed service, one of the live Caddy
hostnames. MkDocs Material built with mkdocs build --strict, so a broken
internal link fails the build; served by nginx at docs.aimgold.org; shipped
by .github/workflows/deploy-docs.yml.
Note
mkdocs.yml also carries an exclude_docs block. MkDocs copies every file
under docs_dir into the built site whether or not it appears in nav, so
absence from the navigation hides a page from a reader but does not stop
anyone fetching it by path. Internal registers live outside docs_dir, and
the exclude patterns are the second line of defence.
Deploy & CI/CD (deploy/, .github/workflows/)¶
Push to main → GitHub Actions build → GHCR → SSH pull-deploy to the VPS,
with a verified pg_dump backup taken first (pre-deploy-*.sql.gz, 14 kept).
Triggerable from the admin portal's Build Tools page. Details in
Architecture.
Migrations are never applied by a merge
deploy-backend.yml does not run migrations. It runs migration:show and
refuses to deploy while anything is pending, so shipping code cannot
alter the production schema as a side effect. Schema changes go through the
separate, manually dispatched migrate-production.yml. The safe order for
a release carrying a migration is therefore: deploy backend (it builds the
image, then refuses) → run migrate-production → deploy backend again.
A schema change additionally requires an armed manifest.
deploy/production-migration-authorization.json must name an authorization id,
an approved_commit_sha, an approver and an expiry, and
aim-digigold-real/scripts/verify-migration-authorization.js re-checks all
four bindings before the workflow will run: COMMIT-BOUND, LIST-BOUND (the exact
pending-migration list and order), OPERATOR-BOUND (the triggering operator) and
SINGLE-USE (against a server-side consumed ledger). It fails closed, and the
manifest is disarmed again once the id is spent.
When GitHub Actions is unavailable, the same gates are drivable over SSH:
npm run deploy:preflight | status | backend | website | admin | migrate | all
(repo-root package.json → deploy/scripts/manual-deploy.sh), plus
deploy:dry-run.
There are 21 workflows in total, including a daily scheduled backup
(scheduled-backup.yml) and a restore drill (restore-drill.yml) that
restores into a disposable database in CI. Android publishing
(deploy-android.yml) is workflow_dispatch-only — merging to main does not
ship the app.
Monitoring (monitoring/)¶
Grafana + Prometheus + Loki + Tempo + Alloy, plus blackbox-exporter,
node-exporter and cAdvisor, on the VPS at monitor.aimgold.org. Prometheus
scrapes the stack's own targets and the customer and admin NestJS apps'
/v1/metrics, and blackbox-probes https://api.aimgold.org/v1/health over the
real internet path rather than from inside the compose network. The
accounting API is deliberately not scraped. Alloy ships every container's logs
to Loki with a deliberately tiny label set (container, compose_project),
everything else parsed at query time. Dashboards and alert rules are
provisioned from git.
The alerts reach nobody
12 alert rules are provisioned
(monitoring/grafana/provisioning/alerting/rules.yml) but no alert
transport is configured: ALERT_WEBHOOK_URL is unset and
monitoring/docker-compose.yml defaults it to a reserved .invalid host —
a non-empty nonsense URL, because Grafana crash-loops on an empty webhook
url. The rules evaluate correctly and notify nothing.
deploy-monitoring.yml reports this as
NOTIFICATION_TRANSPORT_UNCONFIGURED and deliberately does not fail the
deploy.
Parked: Go backend (aim-gold-backend) — NOT deployed¶
A complete, well-tested Go 1.25 + Fiber re-implementation of the backend
(auth, users, KYC, orders, payments, payouts, quotes, immutable ledger, admin
RBAC, provider abstraction, background workers, embedded migrations, ~45 test
files). It is provider-independent and fail-closed: every money-moving
endpoint returns FEATURE_NOT_ENABLED until DIGIGOLD_TRANSACTIONS_ENABLED
is set and a real provider is configured. Nothing in deploy/ or CI
references it. It mirrors the NestJS consumer API's module set and folds the
admin API into /api/v1/admin/*. See ADR
0012 and its own 11 topic docs. Note
that the Flutter client still calls several of its routes — see Mobile app
above.