goledger

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: MIT Imports: 1 Imported by: 0

README

go-ledger

coverage Go Report Card

A production-grade, multi-tenant, white-label double-entry payment ledger, built in public.

Money never appears or disappears. It only moves. go-ledger takes that 500-year-old rule of double-entry bookkeeping and enforces it in code: every transaction is a set of two or more postings whose amounts sum to zero, per currency. Balances are never stored as mutable state; they're derived by summing an immutable, append-only history of postings. If a transaction doesn't balance, it never persists. The service is multi-tenant and white-label from the ground up: any number of tenants, each with their own accounts, keys, and policy, isolated from each other by Postgres row-level security.

Try it live at go.sohag.pro, read The Ledger Book for the whole build story, or jump to the 5-minute tour below.

The Ledger Book

The whole build is collected into a book: every weekly essay, the senior-level interview questions that stress-test each design decision, and the architecture decision records, in one volume. Free to read, in whatever format you like.

Both live at the repo root and rebuild from the blog and Q&A sources. Design decisions themselves live in docs/adr; start with ADR-001: why double-entry.

Run it in one command

The fastest way to see a real, populated ledger is Docker Compose. Two profiles are built for newcomers; only run one at a time, both publish localhost:8080 and localhost:5432:

docker compose --profile demo up

Seeded, public admin: a demo tenant with four personal-finance accounts and about 285 backdated transactions, admin panels unlocked with no key needed. The whole database resets every four hours, so treat it as a sandbox, not storage.

docker compose --profile local up

Empty, production-like: a fresh tenant with no data, and a random bootstrap admin key printed once to the app logs on first boot. Grab it from there (see Get your admin access below).

Either way, once it's up:

  • Console: http://localhost:8080/console, a browser front end over the public API: tenants, keys, accounts, transactions, webhooks, policy, and reporting.
  • Playground: http://localhost:8080/playground, an interactive Scalar API explorer generated from the live OpenAPI spec.
  • Health check: curl localhost:8080/healthz returns {"status":"ok"}.

Run from source

Requires Go 1.26+ and a reachable Postgres. Point it at yours with DATABASE_URL, then:

make run

If you'd rather not set DATABASE_URL up front, build and run the binary directly from a terminal: with no DATABASE_URL set and a TTY attached, it walks you through an interactive first-run setup (host, port, database, user, password, or a full connection string), tests the connection, and optionally saves it to a .env file for next time.

make build
./bin/go-ledger

Run a release binary

Once a version is tagged, cross-platform binaries for the server and ledgerctl (darwin, linux, windows) are attached to that GitHub Release. If no release is published yet, build from source (above) or use Docker Compose. When a release binary is available, download the one for your platform, then:

./go-ledger

Same interactive first-run setup as above. Use ledgerctl alongside it for admin tasks (issuing keys, managing tenants) without going through the REST API or the console.

Get your admin access

  • Demo: nothing to do, admin panels in the console are public. The demo key glk_demo_public_key_reset_every_4h also works directly against the API if you'd rather use curl.

  • Local or a real deployment: on first boot, if no admin-scoped key exists yet for the default tenant, the server generates one, stores only its hash, and prints the plaintext to the logs exactly once with a "save this now" notice. Copy it from there. If you missed it (or you're bringing up a tenant that isn't the default one), mint a fresh admin key with ledgerctl:

    export DATABASE_URL=postgres://ledger:ledger@localhost:5432/ledger?sslmode=disable
    ledgerctl key issue --tenant <default-tenant-id> --name admin --scopes admin
    

    Either way, paste the key into the console's "Admin API key" field (top of the page) to unlock the admin panels there, or send it as Authorization: Bearer <api-key> on any /v1 request.

A 5-minute tour

Every step below has a console click path and the equivalent curl/ledgerctl. All examples assume localhost:8080 and an admin key in $ADMIN_KEY.

1. Create a tenant. Console: Tenants panel, "Create tenant". API:

curl -X POST localhost:8080/v1/admin/tenants \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "acme-corp"}'

2. Issue a key for the new tenant. Console: Keys panel, "Issue key". API:

curl -X POST localhost:8080/v1/admin/keys \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tenant_id": "<tenant-id>", "name": "acme-api", "scopes": ["read", "post"]}'

or with ledgerctl:

ledgerctl key issue --tenant <tenant-id> --name acme-api --scopes read,post

The plaintext key is shown exactly once; save it as $KEY.

3. Create an account. Console: Accounts panel, "New account". API:

curl -X POST localhost:8080/v1/accounts \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Checking", "type": "asset", "currency": "USD"}'

4. Post a balanced transaction. Postings must sum to zero per currency; here a $10 deposit debits the asset account and credits an income account. Console: Transactions panel, "New transaction". API:

curl -X POST localhost:8080/v1/transactions \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "currency": "USD",
    "postings": [
      {"account_id": "<checking-account-id>", "amount": 1000, "description": "deposit"},
      {"account_id": "<income-account-id>", "amount": -1000, "description": "deposit"}
    ]
  }'

The Idempotency-Key header is required: retrying with the same key returns the original transaction instead of posting twice.

5. Read the balance. Console: click into the account. API:

curl localhost:8080/v1/accounts/<checking-account-id>/balance -H "Authorization: Bearer $KEY"

6. Add a webhook to get notified of future transactions. Console: Webhooks panel, "Add webhook". API:

curl -X POST localhost:8080/v1/admin/webhooks \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tenant_id": "<tenant-id>", "url": "https://example.com/hooks/ledger", "event_types": ["transaction.created"]}'

or with ledgerctl:

ledgerctl webhook add --tenant <tenant-id> --url https://example.com/hooks/ledger --events transaction.created

The signing secret (to verify the X-Ledger-Signature header on each delivery) is shown exactly once.

7. View the trial balance, the double-entry proof that every currency nets to zero across the tenant. Console: Reports panel. API:

curl localhost:8080/v1/reports/trial-balance -H "Authorization: Bearer $KEY"

Architecture

One place moves money (the domain services), reached identically over REST and gRPC. Balances are never stored: they are summed from an append-only posting history, and the zero-sum invariant is enforced both in the domain type and by a Postgres CHECK trigger on the postings table, so no write path can break it. Every post also writes a transactional outbox row in the same transaction; a single-leader chainer drains that into a hash-chained, tamper-evident audit_log, which is also the event stream webhooks are delivered from.

Clients                      Service                      Storage
--------                     --------                     --------
REST (huma) -.
gRPC         :- keys+scopes -> domain services - SERIALIZABLE -> Postgres
console/CLI -'   +throttle      (internal/ledger)   +retry        . RLS per tenant
                                post.convert.reverse              . append-only postings
                                approve.report                    . balances = SUM(amount)
                                      |                            . CHECK trigger: sum = 0
                                      | each post also writes an outbox row (same tx)
                                      v
        audit_outbox --> chainer --> audit_log --> webhook fan-out --> subscribers
          (per event)   (1 leader)  (hash chain,   (1 leader, signed,   (HTTPS,
                                     tamper-evident) at-least-once)       dedup by id)

Observability: OpenTelemetry traces . slog (JSON) . Prometheus metrics

Features

  • Tenants with a status lifecycle: active, suspended, closed, each fully isolated by Postgres row-level security so one tenant can never see another's data even through a bug.
  • API keys with scopes, expiry, and rotation: read, post, approve, admin, an optional expiry, and a rotate flow that keeps the old key live for an overlap window.
  • Multi-currency accounts and FX conversion, env-configured rates or an optional free live rate feed with a staleness guard, four-leg atomic conversion through per-currency clearing accounts, and USD-hub triangulation for cross pairs. See ADR-014, ADR-022, and ADR-026.
  • Approval workflows: over-threshold transactions are held as pending intent (no postings, balances untouched) until an approver replays them against current balances, with an optional per-key four-eyes control. See ADR-025.
  • Account hierarchy and rollup reporting: nested accounts with balances rolled up over each subtree. See ADR-023.
  • Transaction reversal, posting the exact inverse of an existing transaction, idempotent against re-reversal.
  • External reference and value date: an optional reconciliation id (unique per tenant) and an effective_at distinct from post time.
  • Mandatory idempotency keys on every posting endpoint, with a bounded, server-configured replay window. See ADR-012.
  • Webhooks: signed (HMAC over a timestamped body, so a receiver can reject replays), retried with backoff, at-least-once delivery of ledger events to a tenant's registered URLs, fanned out off the same tamper-evident event stream, with an SSRF egress guard. See ADR-027.
  • Per-tenant policy: an optional max transaction amount, daily volume cap, and currency allowlist, enforced per currency.
  • Row-level security (RLS) at the Postgres layer as the backstop for tenant isolation, not just application-layer checks.
  • PII crypto-shredding: an irreversible per-tenant erasure of posting-description encryption keys, reconciling the right to erasure with an append-only ledger. See ADR-018.
  • Tamper-evident audit chain: every posting is hash-chained so any retroactive edit is cryptographically detectable, safe under multiple app instances via a transactional outbox and single chainer. See ADR-012 and ADR-017.
  • Reporting and disputes: a trial balance endpoint (the balance proof), account statements, CSV export, and a disputes workflow over posted transactions.

Project structure

go-ledger/
├── cmd/
│   ├── server/        # main.go: wiring, config, migrations, graceful shutdown. No business logic.
│   └── ledgerctl/      # operator CLI over the admin surface: tenants, keys, rates, webhooks
├── internal/          # the heart of the project; domain and service code, unimportable from outside
│   ├── domain/         # the double-entry model: Transaction, Posting, Account, Validate()
│   ├── api/            # REST handlers (huma), OpenAPI generation, the Scalar playground
│   ├── web/             # the /console operator console (thin client, no server-side logic of its own)
│   └── postgres/       # repository layer, goose migrations, sqlc-generated queries
├── docs/
│   └── adr/            # architecture decision records: why double-entry, schema, auth, FX, RLS, PII...
├── api/
│   └── openapi.yaml    # committed OpenAPI spec, regenerated with `make openapi`
├── Makefile            # run, build, test, lint, dev, migrations
├── docker-compose.yml   # demo, local, dev, and load-test profiles
├── Dockerfile           # multi-stage build, distroless base
├── .golangci.yml        # lint config (gofumpt, gosec, and friends)
└── .air.toml            # hot reload for local dev

Development

Command What it does
make run Run the server
make build Build binary to bin/
make test Run tests with race detector
make cover Run coverage and enforce the coverage floors
make lint Run golangci-lint
make dev Hot reload via air
make migrate-up Apply all Postgres migrations (needs DATABASE_URL)
make openapi Regenerate the committed OpenAPI spec

Every significant design decision gets an architecture decision record before the code that implements it; see docs/adr. Read one before proposing an alternative it already considered and rejected.

Contributing

See CONTRIBUTING.md for prerequisites, running the test suite (including the Postgres integration tests), migrations, and the ADR-first convention this project follows.

License

MIT

Documentation

Overview

Package goledger embeds repo-root release artifacts (the compiled book) so the server can serve them without copying the files into a package subtree. The embed directive can only reach files at or below its own directory, so this lives at the module root where the artifacts do.

Index

Constants

This section is empty.

Variables

View Source
var LedgerBookPDF []byte

LedgerBookPDF is the compiled book, embedded from the repo root at build time. The landing page serves it inline so it opens in the browser's native PDF viewer instead of downloading. The ebook build regenerates the file; the next `go build` picks up the new bytes, so the served copy never drifts.

Functions

This section is empty.

Types

This section is empty.

Directories

Path Synopsis
cmd
genopenapi command
Command genopenapi writes the OpenAPI spec to api/openapi.yaml.
Command genopenapi writes the OpenAPI spec to api/openapi.yaml.
ledgerctl command
Command ledgerctl is the operator CLI over the admin surface (Task 2.2b, audit A3.2/A2.3): onboarding a tenant and issuing/rotating/revoking its api keys with no raw SQL.
Command ledgerctl is the operator CLI over the admin surface (Task 2.2b, audit A3.2/A2.3): onboarding a tenant and issuing/rotating/revoking its api keys with no raw SQL.
server command
Command server runs the go-ledger HTTP API.
Command server runs the go-ledger HTTP API.
verify-restore command
Command verify-restore checks a restored ledger database against the ledger's own invariants (ADR-016, "Automated restore-and-verify").
Command verify-restore checks a restored ledger database against the ledger's own invariants (ADR-016, "Automated restore-and-verify").
examples
grpc-client command
Command grpc-client is a small example that exercises the go-ledger gRPC API: it creates two accounts, posts a balanced transaction between them, and reads a balance back.
Command grpc-client is a small example that exercises the go-ledger gRPC API: it creates two accounts, posts a balanced transaction between them, and reads a balance back.
internal
admin
Package admin is the operator-facing service behind the /v1/admin REST surface and the ledgerctl CLI (Task 2.2b, audit A3.2/A2.3): onboarding a tenant and issuing, rotating, or revoking its API keys, with no raw SQL.
Package admin is the operator-facing service behind the /v1/admin REST surface and the ledgerctl CLI (Task 2.2b, audit A3.2/A2.3): onboarding a tenant and issuing, rotating, or revoking its API keys, with no raw SQL.
api
Package api defines the go-ledger HTTP API using huma, which generates the OpenAPI 3.1 spec directly from the typed operation handlers.
Package api defines the go-ledger HTTP API using huma, which generates the OpenAPI 3.1 spec directly from the typed operation handlers.
audit
Package audit runs the single background chainer that builds each tenant's tamper-evident audit hash chain from the audit_outbox transactional outbox (ADR-017).
Package audit runs the single background chainer that builds each tenant's tamper-evident audit hash chain from the audit_outbox transactional outbox (ADR-017).
auth
Package auth resolves a bearer API key to a domain.APIKey, backed by a short-TTL in-memory cache so the request hot path does not hit the database on every call.
Package auth resolves a bearer API key to a domain.APIKey, backed by a short-TTL in-memory cache so the request hot path does not hit the database on every call.
crypto
Package crypto implements envelope encryption for posting descriptions (Task 6.2, audit A9.3): the technique that lets a data-subject erasure request destroy a tenant's ability to read its PII (free-text posting descriptions) WITHOUT mutating any money row or breaking the tamper-evident audit hash chain (ADR-012, ADR-017).
Package crypto implements envelope encryption for posting descriptions (Task 6.2, audit A9.3): the technique that lets a data-subject erasure request destroy a tenant's ability to read its PII (free-text posting descriptions) WITHOUT mutating any money row or breaking the tamper-evident audit hash chain (ADR-012, ADR-017).
domain
Package domain holds the double-entry accounting model: Money, Account, Transaction, and Posting.
Package domain holds the double-entry accounting model: Money, Account, Transaction, and Posting.
fx
Package fx resolves FX rates for cross-currency conversion (ADR-014) and seeds the append-only fx_rates table from the FX_RATES environment variable at boot.
Package fx resolves FX rates for cross-currency conversion (ADR-014) and seeds the append-only fx_rates table from the FX_RATES environment variable at boot.
grpcserver
Package grpcserver is the gRPC adapter for the ledger.
Package grpcserver is the gRPC adapter for the ledger.
ledger
Package ledger holds the application services that sit between transport (REST, gRPC) and the persistence port.
Package ledger holds the application services that sit between transport (REST, gRPC) and the persistence port.
metrics
Package metrics holds the Prometheus collectors for the ledger and exposes an HTTP handler to scrape them.
Package metrics holds the Prometheus collectors for the ledger and exposes an HTTP handler to scrape them.
observability
Package observability wires OpenTelemetry tracing and metrics for the ledger and correlates slog output with the active trace.
Package observability wires OpenTelemetry tracing and metrics for the ledger and correlates slog output with the active trace.
opsmetrics
Package opsmetrics runs the periodic background collector that keeps internal/metrics's operational gauges (audit outbox backlog and lag, webhook delivery backlog, and the balance-invariant canary) fresh from the database (Task 5.6a, audit A6.1).
Package opsmetrics runs the periodic background collector that keeps internal/metrics's operational gauges (audit outbox backlog and lag, webhook delivery backlog, and the balance-invariant canary) fresh from the database (Task 5.6a, audit A6.1).
paging
Package paging holds the shared keyset cursor used by both the REST and gRPC adapters to page account statements and audit logs, so the two protocols speak the identical opaque cursor format.
Package paging holds the shared keyset cursor used by both the REST and gRPC adapters to page account statements and audit logs, so the two protocols speak the identical opaque cursor format.
postgres
Package postgres is the Postgres storage adapter for the ledger.
Package postgres is the Postgres storage adapter for the ledger.
seed
Package seed populates the demo ledger with realistic, backdated data.
Package seed populates the demo ledger with realistic, backdated data.
verify
Package verify checks a restored ledger database against the ledger's own invariants (ADR-016, "Automated restore-and-verify").
Package verify checks a restored ledger database against the ledger's own invariants (ADR-016, "Automated restore-and-verify").
web
Package web serves the embedded static landing page and its assets.
Package web serves the embedded static landing page and its assets.
webhook
Package webhook runs the single background worker that fans posted transactions out to tenant-configured webhook subscribers, signs each delivery, and retries it with exponential backoff until it succeeds or is given up on as dead (Task 4.1, audit A7.1).
Package webhook runs the single background worker that fans posted transactions out to tenant-configured webhook subscribers, signs each delivery, and retries it with exponential backoff until it succeeds or is given up on as dead (Task 4.1, audit A7.1).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL