Build a robust, scalable backend for a multi-currency FX trading application using NestJS and PostgreSQL. Core Philosophy: Financial integrity is paramount. We favor Accuracy and Auditability over raw speed. The system uses a Double-Entry Ledger Lite model (Append-Only Logs) rather than mutable balances to prevent race conditions and ensure perfect transaction history.
- Framework: NestJS (Modular Monolith architecture).
- Database: PostgreSQL (Required for JSONB, Locking, and Decimal precision).
- ORM: TypeORM.
- Language: TypeScript (Strict Mode).
- Environment: Dockerized (Postgres + App).
- Math:
decimal.jsorbig.js. STRICT RULE: NEVER USE NATIVE FLOATING POINT MATH. - Validation:
class-validatorandclass-transformer(Whitelist enabled). - Documentation: Swagger (
@nestjs/swagger).
The database design is the most critical component. The AI must implement this schema exactly to support locking and auditing.
- Table:
users id(UUID, PK)email(VARCHAR, Unique, Index)password_hash(VARCHAR)is_email_verified(BOOLEAN, Default: false)kyc_status(ENUM: 'PENDING', 'VERIFIED', 'REJECTED') — Trading is blocked if not VERIFIED.created_at(TIMESTAMP)
This table exists primarily to provide a row to LOCK (SELECT ... FOR UPDATE). It does not store the balance.
- Table:
wallets id(UUID, PK)user_id(FK -> users)currency(VARCHAR(3)) — E.g., 'NGN', 'USD'created_at(TIMESTAMP)- Constraint: Unique(
user_id,currency)
This is an append-only table. Balance is derived from the latest entry's snapshot or a summation of history.
- Table:
ledger_entries id(UUID, PK)wallet_id(FK -> wallets)amount(DECIMAL(20, 8)) — Signed value (+Credit, -Debit)balance_before(DECIMAL(20, 8)) — Snapshot for auditbalance_after(DECIMAL(20, 8)) — Snapshot for fast readtransaction_type(ENUM: 'DEPOSIT', 'WITHDRAWAL', 'TRADE_DEBIT', 'TRADE_CREDIT')transaction_reference(UUID) — Links this entry to thetransactionstablecreated_at(TIMESTAMP)
Tracks the intent and status of a user action.
- Table:
transactions id(UUID, PK)user_id(FK -> users)type(ENUM: 'FUNDING', 'CONVERSION')status(ENUM: 'PENDING', 'COMPLETED', 'FAILED')source_currency(VARCHAR)target_currency(VARCHAR)source_amount(DECIMAL)target_amount(DECIMAL)rate_used(DECIMAL)quote_id(UUID, Nullable) — Links to the specific FX quote usedcreated_at(TIMESTAMP)
- Table:
fx_quotes id(UUID, PK)quote_id(UUID, Index) — Public ID sent to frontenduser_id(FK)pair(VARCHAR) — 'NGN/USD'rate(DECIMAL)expires_at(TIMESTAMP) — Quotes are valid for 60 seconds only.
- Register: Creates User. Triggers mock Email Event.
- Verify: Accepts OTP (Mock implementation). Updates
is_email_verified. - Guard:
KycGuardmust protect allWalletendpoints.
- Goal: Prevent rate slippage.
- Flow:
- User requests
GET /fx/quote?from=NGN&to=USD&amount=5000. - System fetches live rate (mock or API).
- System calculates output.
- System saves
Quoteto DB withexpires_at = NOW() + 60s. - Returns
quote_idandrateto user.
- Feature: Fund Wallet (Mock)
- Input:
amount,currency. - Logic:
- Start DB Transaction.
UpsertWallet (Create if not exists).- Lock Wallet Row.
- Get Last Ledger Entry (or 0).
- Create new Ledger Entry (+Amount).
- Create Transaction Record (COMPLETED).
- Commit.
- Feature: Execute Trade (The Critical Path)
- Input:
quote_id. - Logic (Step-by-Step):
- Validation: Check if
quote_idexists and is valid (not expired, not used). - Start DB Transaction (Isolation: READ COMMITTED).
- Locking:
SELECT * FROM wallets WHERE user_id=X AND currency=Source FOR UPDATE.SELECT * FROM wallets WHERE user_id=X AND currency=Target FOR UPDATE. (Handle Deadlock risk by sorting currency locks alphabetically or checking existence first).
- Balance Check: Fetch latest
balance_afterfromledger_entriesfor Source Wallet. Ifbalance < amount, THROWInsufficientFundsException. - Execution:
- Debit Source: Insert Ledger Entry (
-amount, type:TRADE_DEBIT). - Credit Target: Insert Ledger Entry (
+amount * rate, type:TRADE_CREDIT). - Update Transaction: Set status to
COMPLETED. - Invalidate Quote: Set quote status to
USED.
- Debit Source: Insert Ledger Entry (
- Commit.
-
Idempotency: The
POST /tradeendpoint MUST accept anIdempotency-Keyheader.- Store key in Redis or a
idempotency_logstable. - If key exists, return the previous result without re-executing.
- Store key in Redis or a
-
Decimal Precision:
- Use a custom TypeORM
ValueTransformerto convert string from DB toDecimal.jsobject in code. - API responses must return money as Strings (e.g.,
"100.50"), not Numbers.
- Use a custom TypeORM
-
Config Validation: Use
Joito validate.env(DB_HOST, API_KEYS) on startup. Fail hard if missing. -
Exception Handling: Global Filter to catch HTTP exceptions and return uniform JSON:
{ statusCode, message, error, timestamp }.
src/
├── common/
│ ├── decorators/ # @User(), @IdempotencyKey()
│ ├── filters/ # GlobalExceptionFilter
│ ├── guards/ # JwtGuard, KycGuard
│ ├── interceptors/ # ResponseTransformInterceptor
│ └── utils/ # decimal-math.util.ts
├── config/ # typeorm.config.ts, env.validation.ts
├── database/
│ ├── entities/ # User, Wallet, LedgerEntry, Transaction, Quote
│ └── migrations/ # Timestamped migrations
├── modules/
│ ├── auth/
│ ├── wallet/
│ ├── exchange/
│ └── health/ # Health checks
└── main.ts
- Setup NestJS with TypeORM and Docker Compose (Postgres).
- Create Entities with the exact relations defined above.
- Implement
DecimalTransformerfor TypeORM. - Implement
WalletService.executeTrade()usingQueryRunnerfor manual transaction control. - Implement Pessimistic Locking (
lock: { mode: 'pessimistic_write' }). - Add Unit Test for "Double Spend": Try to run two trades of the same funds concurrently; one MUST fail.
End of Specification