A payment transaction engine built on double-entry bookkeeping principles. Every transfer produces debit and credit journal entries against an append-only ledger, giving full financial traceability from day one.
- Double-entry bookkeeping — every transaction writes a debit and credit journal entry atomically
- Idempotent transfers — duplicate requests with the same key return the cached result instead of double-processing
- Deadlock-safe locking — account rows are locked in deterministic UUID order before any balance mutation
- Append-only audit trail — journal entries are never updated or deleted
- JWT authentication — all endpoints require a signed Bearer token
- Structured logging — JSON logs with request ID, subject, method, path, status, and duration
- Graceful shutdown — in-flight requests drain before the server exits
- Go —
net/http, no framework - PostgreSQL — via
pgx/v5connection pool - JWT —
golang-jwt/jwt/v5
Prerequisites: Go 1.25+, Docker
# Start PostgreSQL
docker compose up -d
# Apply migrations
psql postgresql://ledger:ledger@localhost:5432/ledger -f migrations/001_create_accounts.sql
psql postgresql://ledger:ledger@localhost:5432/ledger -f migrations/002_create_transactions.sql
psql postgresql://ledger:ledger@localhost:5432/ledger -f migrations/003_create_journal_entries.sql
psql postgresql://ledger:ledger@localhost:5432/ledger -f migrations/004_create_idempotency_keys.sql
# Run
JWT_SECRET=your_secret go run ./cmd/app| Variable | Default | Description |
|---|---|---|
DB_HOST |
localhost |
PostgreSQL host |
DB_PORT |
5432 |
PostgreSQL port |
DB_USER |
ledger |
PostgreSQL user |
DB_PASSWORD |
ledger |
PostgreSQL password |
DB_NAME |
ledger |
PostgreSQL database name |
JWT_SECRET |
changeme |
HMAC secret for JWT |
APP_PORT |
8080 |
HTTP listen port |
All requests require Authorization: Bearer <token>.
POST /v1/accounts
Creates a new account with zero balance. Returns the account object.
GET /v1/accounts/{id}
Returns account by UUID.
POST /v1/transactions
Content-Type: application/json
{
"idempotency_key": "unique-key",
"source_account_id": "<uuid>",
"destination_account_id": "<uuid>",
"amount": 1000
}
Transfers amount (integer, smallest currency unit) from source to destination. Idempotent — repeating the same idempotency_key returns the original result. Idempotency keys expire after 24 hours.
GET /v1/transactions/{id}
Returns transaction by UUID.
| Code | Status | Description |
|---|---|---|
MISSING_IDEMPOTENCY_KEY |
400 | idempotency_key field is empty |
INVALID_AMOUNT |
400 | Amount is zero or negative |
SAME_ACCOUNT |
400 | Source and destination are the same |
ACCOUNT_NOT_FOUND |
404 | One or both accounts do not exist |
INSUFFICIENT_BALANCE |
422 | Source account has insufficient funds |
IDEMPOTENCY_CONFLICT |
409 | Key claimed but result not yet committed |
OPTIMISTIC_LOCK_CONFLICT |
409 | Concurrent modification detected |
UNAUTHORIZED |
401 | Missing or invalid JWT |
accounts(id, balance, version, created_at)
transactions(id, source_account_id, destination_account_id, amount, status, created_at)
journal_entries(id, transaction_id, account_id, amount, direction, created_at)
idempotency_keys(key, response, expires_at)balance has a CHECK (balance >= 0) constraint at the database level. Expired idempotency keys are cleaned up hourly by a background goroutine.