A robust, production-grade Ledger/Banking API built with Node.js, TypeScript, and PostgreSQL. This project demonstrates best practices in API design, database transactions, security, and engineering for real-world financial systems.
- OAuth 2.0 Authentication - Google OAuth integration with Redis-backed sessions
- ACID-Compliant Transactions - Atomic transfers with row-level locking to prevent race conditions
- RESTful API Design - Pagination, filtering, sorting, and proper HTTP semantics
- Idempotency Support - Safely retry requests without double-processing
- Rate Limiting - Protect against abuse with configurable limits
- Structured Logging - Request tracing with correlation IDs
- Comprehensive Testing - Integration tests including concurrency scenarios
- Docker Ready - One-command development environment setup
| Layer | Technology |
|---|---|
| Runtime | Node.js 20+ |
| Language | TypeScript 5.x |
| Framework | Express.js |
| Database | PostgreSQL 16 |
| Session Store | Redis 7 |
| Query Builder | Knex.js |
| Validation | Zod |
| Authentication | Passport.js (Google OAuth) |
| Logging | Pino |
| Testing | Jest + Supertest |
| Containerization | Docker + Docker Compose |
- Node.js 20+
- Docker & Docker Compose
- Google OAuth credentials (optional for dev mode)
# Clone and install
git clone <repository-url>
cd production-grade-ledger-transactions-api
npm install
# Start infrastructure (Postgres + Redis)
npm run docker:up
# Run database migrations
npm run db:migrate
# Seed test data (optional)
npm run db:seed
# Start development server
npm run devThe API will be available at http://localhost:3000.
Create a .env file in the project root:
# Server
NODE_ENV=development
PORT=3000
# Database
DATABASE_URL=postgres://postgres:postgres@localhost:5432/ledger_db
# Redis
REDIS_URL=redis://localhost:6379
# Session
SESSION_SECRET=your-super-secret-session-key
# Google OAuth (optional for dev)
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback
# Rate Limiting
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100| Endpoint | Method | Description |
|---|---|---|
/auth/google |
GET | Initiate Google OAuth flow |
/auth/google/callback |
GET | OAuth callback handler |
/auth/me |
GET | Get current user profile |
/auth/logout |
POST | End session |
/auth/dev-login |
POST | Dev-only mock login |
| Endpoint | Method | Description |
|---|---|---|
/api/v1/accounts |
GET | List user's accounts |
/api/v1/accounts |
POST | Create new account |
/api/v1/accounts/:id |
GET | Get account details |
/api/v1/accounts/:id |
PATCH | Update account |
/api/v1/accounts/:id |
DELETE | Deactivate account |
| Endpoint | Method | Description |
|---|---|---|
/api/v1/transactions |
GET | List transactions (paginated) |
/api/v1/transactions/:id |
GET | Get transaction details |
/api/v1/transactions |
POST | Create transaction |
| Parameter | Type | Description |
|---|---|---|
page |
number | Page number (default: 1) |
limit |
number | Items per page (max: 100, default: 20) |
type |
enum | Filter by type: DEPOSIT, WITHDRAWAL, TRANSFER |
status |
enum | Filter by status: PENDING, COMPLETED, FAILED |
accountId |
uuid | Filter by account (from or to) |
startDate |
date | Filter by start date |
endDate |
date | Filter by end date |
sortBy |
enum | Sort field: created_at, amount, type |
sortOrder |
enum | Sort direction: asc, desc |
Transfer:
{
"type": "TRANSFER",
"fromAccountId": "uuid",
"toAccountId": "uuid",
"amount": 10000,
"currency": "USD",
"description": "Payment for services"
}Deposit:
{
"type": "DEPOSIT",
"accountId": "uuid",
"amount": 50000,
"currency": "USD",
"referenceId": "external-ref-123"
}Withdrawal:
{
"type": "WITHDRAWAL",
"accountId": "uuid",
"amount": 25000,
"currency": "USD"
}For safe retries, include the Idempotency-Key header:
curl -X POST /api/v1/transactions \
-H "Idempotency-Key: unique-request-id-123" \
-d '{"type": "TRANSFER", ...}'This section explains key architectural choices made for production-readiness.
Problem: Floating-point arithmetic causes precision errors.
// BAD: Floating point
0.1 + 0.2 = 0.30000000000000004
// GOOD: Integer arithmetic (cents)
10 + 20 = 30 // $0.30Solution: All monetary values are stored as BIGINT in the smallest currency unit (cents for USD). This:
- Eliminates floating-point precision errors
- Supports values up to ~92 quadrillion cents
- Simplifies arithmetic operations
-- Database column definition
balance BIGINT NOT NULL DEFAULT 0Problem: Concurrent transfers can cause race conditions (double-spending).
Scenario: Account has $1000. Two concurrent requests try to transfer $600 each.
Without locking:
- Request A reads balance: $1000 ✓
- Request B reads balance: $1000 ✓
- Request A deducts $600: $400
- Request B deducts $600: $400 (should fail!)
Solution: Use SELECT ... FOR UPDATE to lock rows during transactions.
// Lock accounts in consistent order to prevent deadlocks
const [firstId, secondId] = fromAccountId < toAccountId
? [fromAccountId, toAccountId]
: [toAccountId, fromAccountId];
const firstAccount = await trx('accounts')
.where({ id: firstId })
.forUpdate() // Pessimistic lock
.first();This ensures:
- Only one transaction can modify an account at a time
- Consistent lock ordering prevents deadlocks
- ACID guarantees are maintained
Problem: Network timeouts can cause duplicate transactions if the client retries.
Solution: Implement idempotency keys that uniquely identify requests.
// Check for existing transaction with same key
const existing = await TransactionModel.findByIdempotencyKey(key);
if (existing) {
return existing; // Return cached result
}
// Proceed with new transaction
await db.transaction(async (trx) => {
// ... create transaction with idempotency_key stored
});The idempotency_key column has a unique index for fast lookups.
Defense in depth - the database enforces business rules even if application code fails:
-- Prevent negative balances
ALTER TABLE accounts ADD CONSTRAINT chk_balance_non_negative
CHECK (balance >= 0);
-- Ensure positive transaction amounts
ALTER TABLE transactions ADD CONSTRAINT chk_amount_positive
CHECK (amount > 0);
-- Transfers must have both accounts
ALTER TABLE transactions ADD CONSTRAINT chk_transfer_accounts
CHECK (
(type != 'TRANSFER') OR
(type = 'TRANSFER' AND from_account_id IS NOT NULL AND to_account_id IS NOT NULL)
);Every request gets a unique ID that flows through all log entries:
// Middleware assigns request ID
req.id = req.headers['x-request-id'] || uuidv4();
// All logs include the ID
logger.info({
requestId: req.id,
transactionId,
amount
}, 'Transfer completed');This enables:
- Tracing a single request across all logs
- Debugging production issues efficiently
- Integration with log aggregation tools (ELK, Datadog)
Indexes are designed for common query patterns:
-- Transaction history queries
CREATE INDEX idx_transactions_account_history
ON transactions(from_account_id, created_at DESC);
-- Idempotency key lookups (partial index)
CREATE INDEX idx_transactions_idempotency
ON transactions(idempotency_key)
WHERE idempotency_key IS NOT NULL;
-- Status filtering
CREATE INDEX idx_transactions_status ON transactions(status);Sessions are stored in Redis rather than in-memory or database:
- Horizontal Scalability: Multiple app instances share session state
- Performance: Sub-millisecond session lookups
- TTL Support: Automatic session expiration
# Run all tests
npm test
# Run with coverage
npm run test:coverage
# Watch mode
npm run test:watch- Unit Tests - Validators, utilities
- Integration Tests - API endpoints with database
- Concurrency Tests - Race condition scenarios
# Start all services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down# Build optimized image
docker build -t ledger-api .
# Run container
docker run -p 3000:3000 \
-e DATABASE_URL=postgres://... \
-e REDIS_URL=redis://... \
ledger-apisrc/
├── config/ # Environment and service configuration
├── controllers/ # HTTP request handlers
├── database/
│ ├── migrations/ # Schema changes
│ └── seeds/ # Test data
├── middlewares/ # Express middleware (auth, validation, logging)
├── models/ # Data access layer
├── routes/ # API route definitions
├── services/ # Business logic (TransactionService)
├── types/ # TypeScript type definitions
├── utils/ # Shared utilities (logger, errors)
├── validators/ # Zod schemas
├── __tests__/ # Test files
├── app.ts # Express app setup
└── server.ts # Server entry point
MIT