Skip to content

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 (sessions table), 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 /price broadcast.
  • Portfolio — holdings summary, paginated history, and a passbook backed by gold_holdings plus the customer's own ledger lines. Because AccountingPostingGate refuses every posting (see Ledger / accounting below), ledger_entries is structurally empty and the passbook returns entries_available: false rather than an empty array that would read as "you have never transacted". GET /v1/portfolio/statement still 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, verified bank_account_id and is refused with 503 while DIGITAL_SELL_ENABLED is not true. 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 FIXED or DYNAMIC; a DYNAMIC row is derived from the live 24K rate × karat with making charge and discount applied (libs/common/src/money/product-pricing.ts), and the response carries priceBasis: LIVE|FIXED so a client never assumes a stored snapshot is live. Line totals, GST and delivery are integer paise end to end. idempotencyKey is required and de-duplicated by a partial unique index on (user_id, idempotency_key); an optional expectedTotal is compared before the insert, so a 409 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 under libs/payments/src/adapters/razorpay/; no business code names a provider, and libs/payments/src/gateway-neutrality.spec.ts fails the build if a provider token appears outside the adapter directory.
  • Bank accountsGET/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 identityGET /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 readinessGET /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.
  • ObservabilityGET /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 AccountingPostingGate currently refuses every posting: ACCOUNTING_POSTING_DISABLED when the flag is off, ACCOUNTING_POSTING_BLOCKED_ENGINE_NOT_WIRED when it is on (libs/finance/src/accounting/posting-gate.service.ts). ledger_entries is therefore empty by design, and turning ACCOUNTING_AUTO_POSTING_ENABLED on 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 under CUSTOMER_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-onlyACCOUNTING_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 call POST /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/kyc collects PAN, Aadhaar, PIN code, an identity document and a selfie and posts them to POST /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 formPOST /v1/support/tickets, unauthenticated, returning a quotable reference. The form itself no longer composes a mailto:, 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/:slug returns available with a version (src/lib/useLegalDocument.ts); a draft or an unreachable backend resolves to draft or unknown and never falls through to "approved".
  • a /pricing fee schedule driven entirely by GET /v1/legal/pricing, with per-item PENDING / NOT_CHARGED / PUBLISHED states 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 a 409 PRICE_CHANGED triggers a re-quote and a retry with the same idempotency key. Products carry pricingMode, priceBasis, purityKarat, makingChargePct and ratePerGram, 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/productionlib/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.jsondeploy/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.