A financial ledger service built with FastAPI and PostgreSQL, implementing double-entry bookkeeping as an isolated microservice.
Learning-focused. This repository exists primarily as a study reference for financial ledger design — double-entry bookkeeping, immutable audit trails, event-driven architecture, and incremental balance strategies.
A financial ledger is the authoritative, append-only record of all financial events in a system. Every balance is the result of explicit debit and credit entries — nothing is ever overwritten or deleted.
This project implements that model as a standalone service:
- Every financial event (sale, fee, settlement, chargeback) is recorded as a set of balanced debit/credit entries
- Account balances are maintained incrementally via database triggers — no aggregation at query time
- A World Account represents the external world, keeping double-entry intact when money enters or leaves the system
- The service consumes events from an upstream system and never reads its database directly
- All event handlers are idempotent; duplicate events are safely ignored
For the full design — data model, chart of accounts, transaction flows, balance strategy — see .docs/open-ledger-full-spec.md.
- Python 3.14 - pre-requisite
- Docker - pre-requisite
- Docker Compose - pre-requisite
- Poetry - pre-requisite
- Ruff
- FastAPI
- Uvicorn
- Alembic - database migration tool
- Testcontainers - integration testing
Please pay attention on pre-requisites resources that you must install/configure.
Represents any participant in the ledger — seller, buyer, payment facilitator, or any other financial actor. Each entity gets its own isolated chart of accounts upon creation. Entities can be organized hierarchically (e.g. sub-merchants under a payfac) via parent_entity_id.
Each entity has its own chart of accounts — a set of accounts that record every financial movement. Accounts are never shared across entities. Account types follow standard bookkeeping: asset, liability, revenue, expense, and equity. Balances are maintained incrementally in the same DB transaction as each entry — never recomputed from history. Two special system accounts exist globally:
9.9.999— World Account: used whenever money crosses the system boundary (deposits, withdrawals, settlements).9.9.998— Transfer Account: used for internal transfers between entities. The sum across all entities is always zero.
Represents a business day in the ledger. New entries are accepted by default; creating a period record with closed or locked status blocks any new entries for that date. The status progresses one-way: open → closed → locked. Once locked, the period is immutable for audit purposes.
The header record for a financial event. Every transaction belongs to one entity and groups one or more debit/credit entries that must balance (Σ debits = Σ credits). Transactions are identified by an idempotency_key that prevents duplicate processing of the same event. Once committed, a transaction is never modified — corrections are posted as new reversing transactions.
A single debit or credit line within a transaction. Each entry references an account from the entity's chart of accounts, records the amount and currency, and carries an entry_type of either debit or credit. Entries are immutable; they are never updated or deleted after the transaction commits.
A point-in-time snapshot of an account's balance at a given date. Snapshots are generated by a nightly job (Plan 09) and stored in account_balance_snapshots. Their purpose is to eliminate the need to replay the entire entry history when computing the opening balance for a statement — the service first looks for a snapshot dated before start_date; only if none exists does it fall back to summing all committed entries. Snapshots are never mutated; a new row is written each night.
Represents a future cash inflow — typically the net amount a seller is entitled to receive after fees are deducted from a sale. A receivable is created atomically with its originating sale transaction (same DB commit): when the POST /entities/{id}/transactions body includes a receivable field, both records are written together or not at all.
Lifecycle:
pending ──settlement completed──▶ settled
──transaction reversed──▶ cancelled
pending— created at the time of the sale; awaiting settlement.settled— the funds were transferred to the seller;actual_settlement_dateis set.cancelled— the originating transaction was reversed (e.g. chargeback); no funds will be paid.
Status transitions are one-way and validated by the service — attempting to settle a cancelled receivable raises an error.
All write endpoints require an Idempotency-Key header.
Moves a receivable from Receivables into Receivables Anticipated and books the anticipation fee as an expense. Does not change the receivable status.
Records the cash transfer from the clearing network and marks the receivable as settled.
Record inbound and outbound transfers between the entity's checking account and an external clearing network. The World account (9.9.9xx) is resolved from the clearing_network field (STR → 9.9.901, CIP-PIX → 9.9.902, COMPE → 9.9.903, default 9.9.999).
Moves funds between two entities inside the ledger in a single atomic write — both transactions are flushed and committed together; neither can exist without the other.
Why top-level? A transfer belongs to two entities simultaneously, so it cannot be a sub-resource of either one. POST /transfers is the only endpoint that crosses entity boundaries in a single commit.
Accounting entries:
| Entity | Account | Type | Amount |
|---|---|---|---|
| Sender | 9.9.998 Transfer |
debit | amount |
| Sender | 1.1.001 Checking |
credit | amount |
| Receiver | 1.1.001 Checking |
debit | amount |
| Receiver | 9.9.998 Transfer |
credit | amount |
Invariant: Σ 9.9.998 across all entities = 0 always. The debit in the sender is exactly offset by the credit in the receiver, so the net effect on the Transfer account system-wide is zero.
Example — PIX between two BaaS customers: When customer A sends a PIX to customer B (both accounts within the same ledger), the ledger books the Transfer debit on A and the Transfer credit on B atomically. Upstream systems only need to post one POST /transfers call; the ledger guarantees both sides land or neither does.
Idempotency: a single Idempotency-Key header is split internally into transfer:send:{key} and transfer:recv:{key}. Replaying the same key is safe — the service detects both keys already exist and returns the original pair.
Cancels a pending transaction before entries are posted. No balance changes occur; status becomes voided. Attempting to void a committed transaction returns HTTP 422.
Creates a new reversal transaction that mirrors every entry of the original (debit↔credit), restoring all balances. If the original had a linked receivable, it is cancelled. Recommended idempotency key format: "reversal:{original_transaction_id}".
pending ──void──▶ voided
(committed on creation — immutable; use reverse to undo)
pending ──settlement──▶ settled
──reverse─────▶ cancelled
Returns the entity's net asset position: the sum of current_balance across all asset-type accounts. Balances are maintained incrementally by the application layer — no entry aggregation happens at query time.
The optional ?as_of=YYYY-MM-DD parameter returns the historical balance at a past date using the same snapshot-first resolution strategy as the statement endpoint. Future dates are rejected with HTTP 422.
The response includes a breakdown of each asset account's individual balance alongside the aggregated total. Both balance (full decimal precision) and rounded (2 decimal places) are returned for convenience.
Returns a period statement (start_date / end_date) with a per-transaction running balance and a summary of opening_balance, total_in, total_out, and closing_balance. Each entry lists the movements (all debit/credit lines of that transaction) and the balance_after.
The running balance tracks the entity's net asset position — only asset-type account entries move it (debit = +, credit = −). Revenue, expense, and equity entries appear in movements but do not affect the running total.
Opening balance resolution: the service looks for an account_balance_snapshot dated before start_date; if found, it uses that value directly. If not, it falls back to summing all committed entries before start_date.
| Variable | Description | Available Values | Default Value | Required |
|---|---|---|---|---|
| ENV | The application environment | dev / test / qa / prod |
dev |
Yes |
| PYTHONPATH | Python interpreter path guidance | ref | . |
Yes |
| DATABASE_USER | The database user | a valid user |
postgres |
Yes |
| DATABASE_PASS | The database user password | a valid password |
postgres |
Yes |
| DATABASE_PORT | The database port | a valid port number |
5433 |
Yes |
| DATABASE_NAME | The database name | a valid database name |
open_ledger_db |
Yes |
| DATABASE_ENDPOINT | The database endpoint | a valid database endpoint |
localhost |
Yes |
Note: When you run the install command (using docker or locally), a .env file will be created automatically based on env.template
| Command | Docker | Locally | Description |
|---|---|---|---|
| install | make docker/install |
make local/install |
to install |
| tests | make docker/tests |
make local/tests |
to run the tests with coverage |
| lint | make docker/lint |
make local/lint |
to run static code analysis using ruff |
| lint/fix | make docker/lint/fix |
make local/lint/fix |
to fix files using ruff |
| run | make docker/run |
make local/run |
to run the project |
| migration apply | - | make migration/apply |
to apply new version of migrations |
| migration revision | - | make migration/revision message="text a new message" |
to create a new revision of migrations |
| migration downgrade | - | make migration/downgrade |
to downgrade a version of migrations |
| build image | make docker/image/build |
- | to build the production docker image |
| build image distroless | make docker/image/build/distroless |
- | to build the distroless production image |
| push image | make docker/image/push |
- | to push the docker image |
| push image distroless | make docker/image/push/distroless |
- | to push the distroless image |
Helpful commands
Please, check all available commands in the Makefile for more information.
Uvicorn is an ASGI web server implementation for Python and you can configure it overriding these values on the settings.conf file.
| Variable | Description | Available Values | Default Value | Required |
|---|---|---|---|---|
| UVICORN_HOST | The host of the application | a valid host address |
0.0.0.0 |
Yes |
| UVICORN_PORT | The application port | a valid port number |
8000 |
Yes |
| UVICORN_WORKERS | The number of uvicorn workers | a valid number |
dev |
No |
| UVICORN_ACCESS_LOG | Enable or disable the access log | True / False |
True |
No |
| UVICORN_LOG_LEVEL | Set the log level | critical / error / warning / info / debug / trace' |
info |
No |
Note: The default value of these configs are available on run.py.
This project provides two production-ready Docker images with a multi-stage build approach:
Based on python:3.14-slim-bookworm, this is a minimal image without Poetry or development dependencies.
make docker/image/buildAn ultra-minimal image using Chainguard for maximum security and smallest footprint.
make docker/image/build/distrolessWhy Chainguard?
- Minimal attack surface: No shell, package managers, or unnecessary tools
- Reduced CVEs: Significantly fewer vulnerabilities compared to traditional base images
- Smaller size: Only essential runtime components included
- Signed images: Cryptographically signed with Sigstore for supply chain security
- SBOM included: Software Bill of Materials for compliance and auditing
Trade-offs:
- No shell access for debugging (use ephemeral debug containers if needed)
- Free tier only provides
:latesttag (specific version tags like3.14.xrequire paid tier)
Running the distroless image:
docker run -p 8000:8000 --env-file .env open-ledger:latest-distrolessFor more information about Chainguard images, visit the official documentation.
This project uses a simple way to configure the log with logging.conf to show the logs on the container output console.
This project uses a simple way to manage the settings with settings.conf and ConfigParser using a config class.