Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Ledger Service

A double-entry bookkeeping REST API built with Node.js, TypeScript, Express, and PostgreSQL.

Overview

Every financial movement is recorded as a pair of entries — one debit, one credit — that must always balance. The service enforces this invariant at both the application and database level.

Tech Stack

  • Runtime: Node.js + TypeScript
  • Framework: Express 5
  • Database: PostgreSQL 16
  • Metrics: Prometheus (prom-client)
  • Logging: Winston (structured JSON)
  • Validation: Zod
  • Container: Docker + Docker Compose

Architecture

src/
├── config/        # Environment config
├── controllers/   # Request handlers
├── db/            # Migrations, seed
│   └── migrations/
├── errors/        # AppError, error handler middleware
├── lib/           # DB pool, logger, metrics
├── middleware/    # auth, requestId, requestLogger, metrics, validate
├── models/        # DB query functions
├── routes/        # Express routers
└── services/      # Business logic (ledger, balance, audit)

Getting Started

Prerequisites: Docker and Docker Compose

# Clone and start
git clone <repo>
cd ledger_service_ts
docker compose up

The service starts on http://localhost:3001.

On boot it:

  1. Verifies the database connection
  2. Runs all migrations (idempotent)
  3. Seeds the system account (00000000-0000-0000-0000-000000000000)

API

All endpoints except /health and /metrics require the X-Actor-ID header.

Method Path Description
GET /health Health check
GET /metrics Prometheus metrics
POST /accounts Create an account
GET /accounts/:id Get an account
GET /accounts/:id/balance Get account balance
GET /accounts/:id/entries List entries (cursor-paginated)
POST /transactions Post a transaction
GET /transactions/:id Get a transaction
GET /audit Query audit log

Key Design Decisions

DB Isolation Level — SERIALIZABLE

All transaction writes execute under SERIALIZABLE isolation.

REPEATABLE READ prevents dirty reads and non-repeatable reads but does not protect against write skew. Write skew is the exact failure mode that matters here: two concurrent requests both read a balance as sufficient, both pass the balance check, and both proceed to debit — leaving the account overdrawn. SERIALIZABLE detects this cycle and aborts one of the transactions with a serialization failure, which the service surfaces as a retryable error. The performance cost over REPEATABLE READ is minimal for a write-heavy ledger workload where transactions are short and operate on a small number of rows.

Double-Entry Invariant

Enforced at two layers:

  • Application: debits and credits are summed and compared before any DB write. Unbalanced transactions are rejected immediately with a 422 before a database transaction is even opened.
  • Database: a DEFERRABLE INITIALLY DEFERRED constraint trigger on the entries table fires at COMMIT time, rejecting any transaction where debits ≠ credits. The deferred firing is intentional — it allows all entries for a transaction to be inserted in a single batch before the check runs, rather than requiring them to be pre-balanced at each individual insert.

Idempotency

POST /transactions requires an Idempotency-Key header. Duplicate requests with the same key return the original result (200 OK) without creating a new transaction. Uniqueness is enforced by a UNIQUE constraint on transactions.idempotency_key — application-level deduplication alone is insufficient under concurrent requests.

Balance Reads

Balance is always derived by summing committed entries (SELECT SUM(amount) ... WHERE direction = 'CREDIT' minus debits). No cached balance field exists. Because the balance check inside a transaction runs under SERIALIZABLE isolation, it sees a consistent snapshot and cannot observe a concurrent uncommitted debit. Balance reads outside a transaction (GET /accounts/:id/balance) reflect only committed entries and are therefore always correct, though they may lag by milliseconds under very high concurrency — acceptable for a query endpoint.

Audit Failure Strategy

Two cases apply:

Successful transactions — the audit record is written inside the same database transaction as the ledger entries. If the audit insert fails, the whole transaction rolls back. The ledger and audit log stay consistent by construction — no separate decision is needed.

Rejected transactions (e.g. insufficient funds) — the ledger transaction rolls back by definition, so the audit record must be written in a separate connection after the rollback. If that write fails, the rejection response is still returned to the caller. No money moved, so this is a missing record problem, not a correctness problem. Blocking the caller on a secondary audit write would make the audit system a liveness dependency over an operation that already failed.

In a production system this would be supplemented by an async audit queue with a dead-letter path, so no audit record is silently dropped.

Audit Log Immutability

A BEFORE UPDATE/DELETE trigger on audit_log raises an exception for any modification attempt. This is enforced at the database level independently of the application.

System Account

The system account (00000000-0000-0000-0000-000000000000) is the external source of funds. To credit a user account, debit the system account. Its balance is allowed to go negative — it represents an external funding source, not a user wallet.

Amounts

All amounts are stored as integers in the smallest currency unit (e.g. cents for USD). 10000 = $100.00. Floating-point types are never used for money.

Environment Variables

Variable Description
DATABASE_URL PostgreSQL connection string
PORT Server port (default 3000)

Configured via .env file.

Database Schema

accounts          — id, currency, created_at
transactions      — id, idempotency_key, status, currency, created_at
entries           — id, transaction_id, account_id, direction, amount, created_at
audit_log         — id, actor, action, entity_type, entity_id, payload, created_at

Data Persistence

PostgreSQL data is stored in a named Docker volume (pgdata). Data survives container restarts and docker compose down. To reset all data:

docker compose down -v

Extending to Multi-Currency

The schema already stores currency on both transactions and accounts. Cross-currency support would require:

  1. Exchange rate source — a fx_rates table (or external oracle) storing rates with a timestamp. Rates must be snapshotted into the transaction payload at execution time so the audit record is self-contained and not affected by later rate changes.

  2. Conversion entries — introduce a system-owned FX account per currency pair. A USD→EUR transfer becomes four entries: debit user USD account, credit USD-FX account, debit EUR-FX account, credit recipient EUR account. The double-entry invariant still holds within each currency leg.

  3. Invariant relaxation — the current trigger checks that ∑ debits = ∑ credits globally per transaction. For multi-currency transactions this check would need to be scoped per currency, or the FX accounts absorb the spread as a fee entry.

  4. Precision — different currencies have different decimal places (JPY has 0, KWD has 3). The integer representation already handles this correctly as long as amounts are stored in the currency's smallest unit consistently.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages