Skip to content

marcieltorres/open-ledger

Repository files navigation

Open Ledger

GitHub stars GitHub forks GitHub issues GitHub license codecov

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.

What is this?

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.

Technology and Resources

Please pay attention on pre-requisites resources that you must install/configure.

Ledger Entities

Entity

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.

Chart of Accounts

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.

Accounting Period

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: openclosedlocked. Once locked, the period is immutable for audit purposes.

Transaction

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.

Transaction Entry

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.

Account Balance Snapshot

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.

Receivable

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_date is 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.


Lifecycle Operations

All write endpoints require an Idempotency-Key header.

Anticipation

Moves a receivable from Receivables into Receivables Anticipated and books the anticipation fee as an expense. Does not change the receivable status.

Settlement

Records the cash transfer from the clearing network and marks the receivable as settled.

Deposit / Withdrawal

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).

Transfer

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.

Void

Cancels a pending transaction before entries are posted. No balance changes occur; status becomes voided. Attempting to void a committed transaction returns HTTP 422.

Reverse

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}".

Transaction Status Lifecycle

pending ──void──▶ voided
         (committed on creation — immutable; use reverse to undo)

Receivable Status Lifecycle

pending ──settlement──▶ settled
        ──reverse─────▶ cancelled

Balance & Statement

Current balance

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.

Statement

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.


How to install, run and test

Environment variables

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 settings

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.

Production Docker Images

This project provides two production-ready Docker images with a multi-stage build approach:

Standard Production Image (production)

Based on python:3.14-slim-bookworm, this is a minimal image without Poetry or development dependencies.

make docker/image/build

Distroless Production Image (production-distroless)

An ultra-minimal image using Chainguard for maximum security and smallest footprint.

make docker/image/build/distroless

Why 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 :latest tag (specific version tags like 3.14.x require paid tier)

Running the distroless image:

docker run -p 8000:8000 --env-file .env open-ledger:latest-distroless

For more information about Chainguard images, visit the official documentation.

Logging

This project uses a simple way to configure the log with logging.conf to show the logs on the container output console.

Settings

This project uses a simple way to manage the settings with settings.conf and ConfigParser using a config class.

Releases

No releases published

Packages

 
 
 

Contributors

Languages