Wallet is a digital wallet API that manages integer-cent balances, purchase transactions with royalty splits, and async financial reporting. The system prioritises correctness under concurrency over raw throughput — every balance mutation uses pessimistic locking and SQL-side arithmetic to prevent double-spending and race conditions.
graph LR
Client -->|HTTP| API[NestJS API :3000]
API -->|Drizzle ORM| PG[(PostgreSQL 16)]
API -->|ioredis - idempotency cache| Redis[(Redis 7)]
API -->|BullMQ| Redis
Redis -->|Job| Worker[Report Processor]
Worker -->|Drizzle ORM| PG
| Module | Path | Responsibility |
|---|---|---|
| AppModule | src/app.module.ts |
Root module — imports all domains, registers global middleware |
| DbModule | src/common/database/db.module.ts |
Drizzle ORM connection via postgres driver |
| LoggerModule | src/common/logger/logger.module.ts |
Pino logger (JSON in prod, pino-pretty in dev) |
| WalletsModule | src/wallets/wallets.module.ts |
Deposit funds into user wallets |
| PurchasesModule | src/purchases/purchases.module.ts |
Purchase items with royalty splits across 3 wallets |
| ReportsModule | src/reports/reports.module.ts |
Async financial report generation via BullMQ |
- Express receives the HTTP request
- CorrelationIdMiddleware extracts or generates
X-Request-Id, attaches it to the Pino logging context - UserIdGuard (per-controller) validates the
X-User-Idheader as a UUID and attaches it toreq.userId - ValidationPipe (global) transforms and validates the request body against the DTO class (
whitelist,forbidNonWhitelisted,transform) - Controller delegates to the service layer
- Service executes business logic within a database transaction
- Response is serialised and returned with the correlation ID
erDiagram
users ||--o| wallets : "has one"
wallets ||--o{ ledger : "has many"
wallets ||--o{ purchases : "buyer"
wallets ||--o{ purchases : "author"
purchases ||--o{ ledger : "generates"
users ||--o{ reports : "requests"
users {
uuid id PK
timestamp created_at
}
wallets {
uuid id PK
uuid user_id FK
integer balance "CHECK >= 0"
timestamp updated_at
}
purchases {
uuid id PK
uuid idempotency_key UK
uuid buyer_wallet_id FK
uuid author_wallet_id FK
integer item_price "CHECK > 0"
enum status "pending/completed/failed"
}
ledger {
uuid id PK
uuid wallet_id FK
uuid purchase_id FK
enum type "deposit/purchase/royalty_author/royalty_platform"
enum direction "credit/debit"
integer amount "CHECK > 0"
}
reports {
uuid id PK
uuid requested_by FK
enum status "queued/processing/completed/failed"
jsonb result
timestamp completed_at
}
ledger_totals {
enum type PK "ledger_type"
bigint total "running sum"
}
ledger ||--o{ ledger_totals : "aggregated into"
Each deposit runs inside a transaction that locks the wallet row with SELECT FOR UPDATE. The balance update uses SQL-side arithmetic (balance + $amount) to eliminate read-then-write races.
A purchase involves three wallets: buyer, author, and platform. All three are locked in a single query ordered by id ASC (FOR UPDATE) to enforce consistent lock acquisition and prevent deadlocks. Within the same transaction:
- Buyer balance is decremented by the item price
- Author receives floor royalty plus any accrued centi-cents that crossed 100 (see ADR-010)
- Platform receives the remainder (
price - totalAuthorCents) - A purchase record and three ledger entries are inserted
If PostgreSQL detects a deadlock (40P01), the service catches it and returns 409 Conflict with a retry hint.
Each purchase carries a client-owned Idempotency-Key header (UUID). Before entering the transaction, the service checks a Redis edge cache then the database:
Redis SETNX (fast path): SET idempotency:<key> 'processing' EX 86400 NX
- NX returns
null(key exists): read cached value — return completed purchase or409 - NX returns
'OK'(new request): proceed to DB check below - Redis unavailable: log warning, fall through to DB (degraded but not broken)
- Transaction failure: sentinel is deleted so clients can retry
DB check (cold-start / Redis miss):
- Completed + same payload — populate Redis, return cached result (safe replay)
- Completed + different payload —
409 Conflict(payload drift) - Still in flight —
409 Conflict
The idempotency_key column has a unique constraint — concurrent inserts with the same key trigger 23505 unique_violation, also surfaced as 409.
Financial reports are generated asynchronously:
POST /reports/financial— inserts a report row with statusqueued, enqueues a BullMQ job, returns{ jobId, status }- ReportsProcessor (BullMQ worker) picks up the job, sets status to
processing, reads pre-aggregated totals fromledger_totals(O(1), no table scan), and stores the JSONB result GET /reports/financial/:jobId— polls the report status and result, scoped to the requesting user
If Redis is down when enqueuing, the report is immediately marked failed rather than left orphaned in queued.
Architectural decisions are recorded as ADRs:
- ADR-001: NestJS over Fastify
- ADR-002: PostgreSQL + Drizzle ORM
- ADR-003: Integer Cents over NUMERIC
- ADR-004: Pessimistic Locking over Optimistic
- ADR-005: Platform Receives Royalty Remainder
- ADR-006: BullMQ for Async Report Generation
- ADR-007: Idempotency Key Owned by Client
- ADR-008: Running Totals for Reporting
- ADR-009: Redis Idempotency Cache
- ADR-010: Centi-cent Fractional Accrual
- Ownership scoping — all queries filter by the authenticated user's ID; accessing another user's resource returns
404(not403) to prevent identifier enumeration - UserIdGuard — validates
X-User-Idas a UUID on every protected endpoint; rejects with401if missing or invalid - ValidationPipe —
whitelist: truestrips unknown properties,forbidNonWhitelisted: truerejects them with400 - No raw errors — all exceptions use NestJS built-in HTTP exceptions; stack traces are never leaked to clients
- Idempotency key validation — the
Idempotency-Keyheader is validated as a UUID before processing