A double-entry accounting ledger for fintech, split the way real payment companies split it: a Go core that owns the money math, and a NestJS gateway that owns the API surface.
Every transfer is a balanced journal entry. Debits always equal credits, per currency. Entries are idempotent, balances are materialized, and money is stored as integer minor units so rounding bugs can't happen.
./demo.shThat logs in, creates two accounts, posts a transfer, replays it to prove idempotency, then reads the balance back. Open http://localhost:3000/docs after for the interactive API.
ledger-core (Go) — all accounting rules live here, nowhere else.
- Strict double-entry validation: at least two lines, one-sided legs only, debits == credits per currency
- Idempotent posting via client-supplied keys. Replays return the original entry and never double-post
- Materialized balances per account and currency, updated in the same transaction as the entry
- Money as int64 minor units (paise/cents). 21 supported currencies
- Postgres store (pgx, migrations embedded in the binary) plus an in-memory store for tests and local dev
POST /v1/accounts,POST /v1/entries,GET /v1/accounts/{id}/balance,GET /v1/entries,GET /healthz
api-gateway (NestJS) — validates input, forwards to the core, passes core errors through untouched. It never invents ledger semantics.
- JWT auth (
POST /api/auth/login; demo operatoradmin/changeme) POST /api/transfersbuilds a balanced two-line entry from debit account, credit account, currency, and amount. Auto-generates an idempotency key when you don't supply onePOST /api/accounts,GET /api/accounts/{id}/balance,GET /api/journal- Request validation (class-validator), 100 req/min rate limiting,
Swagger UI at
/docs - WebSocket feed on the
eventsnamespace: every posted entry broadcasts asentry.posted(replays are not rebroadcast)
┌─────────────────┐
│ api-gateway │ NestJS: JWT auth, validation,
│ (NestJS) │ rate limiting, Swagger, live
└────────┬────────┘ WebSocket feed
│ HTTP
┌────────▼────────┐
│ ledger-core │ Go: double-entry validation,
│ (Go) │ idempotent posting, balances
└────────┬────────┘
│ pgx
┌────────▼────────┐
│ Postgres │ journal, lines, materialized
│ │ balances; migrations on boot
└─────────────────┘
| Metric | Value |
|---|---|
| Posting throughput | ~4.1us per posting (in-memory) |
| Go tests | green, go vet clean |
| Gateway tests | 10/10 vitest green |
| Currencies | 21, int64 minor units |
With Docker:
cd deploy
docker compose up --buildWithout Docker (needs Go 1.24+ and Node 24+):
# terminal 1: ledger core (in-memory store)
cd ledger-core && go run ./cmd/ledger-core
# terminal 2: gateway
cd api-gateway && npm install && npm run startcd ledger-core && go test ./... # validation tables, service, HTTP handler tests
go test ./internal/ledger/ -bench=. -run=^$ # posting benchmark
cd api-gateway && npx vitest run # service and auth unit tests| Variable | Default | Purpose |
|---|---|---|
LEDGER_DSN |
(in-memory) | Postgres connection string |
LEDGER_ADDR |
:8081 |
Core listen address |
PORT |
3000 |
Gateway listen port |
LEDGER_CORE_URL |
http://localhost:8081 |
Where the gateway finds the core |
JWT_SECRET |
dev-secret-change-me |
Token signing secret |
LEDGER_ADMIN_USER |
admin |
Demo operator username |
LEDGER_ADMIN_PASSWORD_HASH |
(hash of changeme) |
bcrypt hash for the demo login |
- The demo operator is a single env-configured account, not a users table.
- Rate limiting is per process; run more than one gateway and you need Redis.
- The WebSocket feed is fire-and-forget; clients that disconnect miss entries
and should backfill from
/api/journal. LEDGER_DSNunset means the in-memory store: fast, but everything is lost on restart. Don't run money through it.- The Postgres store compiles and its SQL was reviewed, but it hasn't run
against a live database in this environment.
docker compose upis the way to exercise it before trusting it.