A production-grade backend for an FX Trading application built with NestJS, TypeORM, and PostgreSQL. Users can register, verify their email, fund multi-currency wallets, and trade/convert currencies using real-time FX rates.
| Layer | Technology |
|---|---|
| Framework | NestJS (Node.js) |
| ORM | TypeORM |
| Database | PostgreSQL |
| Auth | JWT + Passport |
| Nodemailer (Gmail SMTP) | |
| FX Rates | ExchangeRate-API v6 |
| Docs | Swagger (OpenAPI) |
| Testing | Jest |
- Node.js >= 18
- PostgreSQL >= 14
- A free API key from exchangerate-api.com
- Gmail account with App Password enabled
git clone https://github.com/YOUR_USERNAME/fx-trading-app.git
cd fx-trading-app
npm installcp .env.example .envEdit .env with your values:
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=yourpassword
DB_NAME=fx_trading
JWT_SECRET=your_super_secret_key
JWT_EXPIRES_IN=7d
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USER=your_email@gmail.com
MAIL_PASS=your_gmail_app_password
MAIL_FROM=your_email@gmail.com
FX_API_KEY=your_exchangerate_api_key
FX_API_BASE_URL=https://v6.exchangerate-api.com/v6
PORT=3000
NODE_ENV=developmentpsql -U postgres -c "CREATE DATABASE fx_trading;"# Development (auto-sync DB schema)
npm run start:dev
# Production
npm run build && npm run start:prodhttp://localhost:3000/api/docs
npm test
npm run test:covRegister a new user. Sends a 6-digit OTP to the provided email.
Request:
{ "email": "user@example.com", "password": "MyPass123!" }Response:
{ "message": "Registration successful. Please verify your email with the OTP sent.", "email": "user@example.com" }Verify OTP to activate account and receive JWT. Also initializes the user's multi-currency wallet.
Request:
{ "email": "user@example.com", "otp": "482910" }Response:
{ "message": "Email verified successfully. Wallet initialized.", "access_token": "eyJhbG..." }Login with email + password.
Request:
{ "email": "user@example.com", "password": "MyPass123!" }All endpoints below require
Authorization: Bearer <token>header.
Returns all currency balances for the authenticated user.
Response:
[
{ "currency": "NGN", "balance": "50000.00000000" },
{ "currency": "USD", "balance": "32.50000000" }
]Fund wallet in any supported currency.
Request:
{ "amount": 50000, "currency": "NGN", "idempotencyKey": "fund-001" }Convert between any two supported currencies using real-time FX rates.
Request:
{ "fromCurrency": "NGN", "toCurrency": "USD", "amount": 1000 }Response:
{
"message": "Conversion successful",
"from": { "currency": "NGN", "amount": 1000 },
"to": { "currency": "USD", "amount": 0.65 },
"rate": 0.00065,
"transactionId": "uuid-here"
}Trade Naira against foreign currencies (NGN must be one side of the trade).
Request:
{ "fromCurrency": "NGN", "toCurrency": "GBP", "amount": 100000 }Returns real-time FX rates for all supported currencies relative to the base.
Returns list of supported currencies: NGN, USD, EUR, GBP, CAD, AUD, JPY
Returns full transaction history for the authenticated user, newest first.
NGN USD EUR GBP CAD AUD JPY
Each user has a separate wallet_balances row per currency (e.g., one row for NGN, one for USD). This allows:
- Efficient indexed lookups by
(userId, currency) - Simple atomic balance updates without JSON column parsing
- Easy extension to new currencies (just insert a new row)
All balance mutations (fund, convert, trade) use PostgreSQL transactions with FOR UPDATE row-level locks. This prevents:
- Double-spending — two concurrent requests cannot both read the same balance and spend it
- Race conditions — all reads and writes within a transaction see consistent data
- Rates are fetched from ExchangeRate-API before acquiring DB locks (to minimize lock hold time)
- Results are cached in-memory with a 5-minute TTL
- On API failure, a fallback uses the inverted cached rate if available
- If no fallback exists, a
503 Service Unavailableis returned
All mutating wallet endpoints accept an optional idempotencyKey. If a key is reused, the API returns 409 Conflict instead of processing the transaction twice. This is critical for retry safety in network failures.
/wallet/convert— general-purpose currency conversion between any two supported currencies/wallet/trade— explicitly for NGN ↔ foreign currency trades, as per the FX trading context. Non-NGN pairs are rejected with a clear error message.
- Funding is direct — no payment gateway integration (assumed to be handled externally or mocked)
- No spread/fee — conversions use raw FX rates. A fee mechanism can be layered in
WalletService - Wallet initialization — all currency rows are created with 0 balance upon email verification
- OTP expiry — 10 minutes. A resend-OTP endpoint can be added for production
- Scalability — For millions of users, introduce Redis for rate caching, a queue (BullMQ) for async transaction processing, and horizontal scaling behind a load balancer
- ✅ Idempotency keys (duplicate transaction prevention)
- ✅ In-memory FX rate caching (5-min TTL)
- ✅ Pessimistic row locking (race condition prevention)
- ✅ Global exception filter with structured error responses
- ✅ Swagger/OpenAPI documentation
- ✅ Unit tests for wallet and conversion logic
- ✅ Role field on User entity (ready for RBAC extension)
src/
├── auth/ # Registration, OTP, JWT login
│ ├── dto/
│ ├── guards/
│ └── strategies/
├── users/ # User entity + service
├── wallet/ # Fund, convert, trade logic
│ ├── dto/
│ └── entities/
├── fx/ # FX rate fetching + caching
├── transactions/ # Transaction history
└── common/ # Filters, decorators