pgledger
repository·main·Indexed 19 days ago
https://github.com/pgr0ss/pgledgerA reusable double-entry bookkeeping ledger implementation built entirely within PostgreSQL using tables, functions, and views. It ensures atomic transactional guarantees by allowing ledger operations to be part of the same transaction as application data. Features include prefixed ULID identifiers, support for multi-currency exchanges via liquidity accounts, and the ability to reconstruct historical account balances using recorded previous and current balances.
What's inside pgledger
- pgledger is a double-entry bookkeeping ledger implementation built entirely within PostgreSQL. Instead of using application-level logic, the entire ledger system is implemented using PostgreSQL tables, functions, and views. This design allows you to interact with the ledger by calling SQL functions and querying SQL views, ensuring that ledger entries can be part of the same atomic transactions as the rest of your application data.
Understand prefixed ULID identifiers
mainAll table IDs in
pgledgerare prefixed ULIDs. This provides several benefits:- Type Safety: Prefixes (e.g.,
pgla_for accounts,pglt_for transfers) make it easy to distinguish ID types. - Ordering: ULIDs are monotonically increasing, ensuring IDs are generated in sorted order.
- Compatibility: ULIDs can be converted to UUIDs for future storage optimizations.
- Format: They are URL-safe and more compact than standard UUID strings.
Example IDs:
- Account:
pgla_01JTVST7XAES5BXHWZN4KR4VEZ - Transfer:
pglt_01JTVR1WKXEKCRG7N6YD7XCZA6
- Type Safety: Prefixes (e.g.,
Manage multi-currency exchanges
mainEach account in
pgledgeris restricted to a single currency. To handle exchanges between different currencies (e.g., USD to EUR), you must use a multi-step approach involving liquidity/system accounts to ensure the ledger remains balanced (debits and credits sum to zero for every currency).An exchange typically requires:
- Two user accounts (one for each currency).
- Two system/liquidity accounts (one for each currency).
- Two simultaneous transfers (one per currency) created via
pgledger_create_transfers.
-- Setup accounts select id from pgledger_create_account('user1.USD', 'USD'); select id from pgledger_create_account('user1.EUR', 'EUR'); select id from pgledger_create_account('liquidity.USD', 'USD'); select id from pgledger_create_account('liquidity.EUR', 'EUR'); -- Perform exchange using two simultaneous transfers select * from pgledger_create_transfers( ($user1_usd, $liquidity_usd, '10.00'), ($liquidity_eur, $user1_eur, '9.26') );Look up historical account balances
mainBecause every entry row inpgledgerrecords both theaccount_previous_balanceand theaccount_current_balance, you can reconstruct the state of an account at any point in time. To find a historical balance, locate the most recent entry row where the timestamp is prior to your target time.Use `event_at` for historical accuracy
mainThe
pgledger_create_transferfunction accepts an optionalevent_attimestamp.- If omitted,
event_atdefaults tonow()(matchingcreated_at). - Use
event_atto record when a real-world event actually occurred (e.g., a bank webhook timestamp) rather than when the record was inserted into the database.
When querying ledger history for a specific time period, you should filter by
event_atinstead ofcreated_atto ensure the results align with real-world timelines.-- Recording a transfer with an explicit event timestamp select * from pgledger_create_transfer($account_1_id, $account_2_id, 12.34, '2025-07-01T12:34:56Z'); -- Or using named parameter syntax select * from pgledger_create_transfer($account_1_id, $account_2_id, 12.34, event_at => '2025-07-01T12:34:56Z'); -- Querying entries based on when the event happened select * from pgledger_entries_view where account_id = $account_id and event_at >= '2025-06-01' and event_at < '2025-07-01' order by event_at;- If omitted,
Basic Usage: Create accounts and transfers
mainThe ledger is managed via SQL functions for mutations (appending to the ledger) and views for querying.
To set up accounts, use
pgledger_create_account(name, currency). To move funds, usepgledger_create_transfer(from_account_id, to_account_id, amount).To query the current state, use the
pgledger_accounts_viewto see balances andpgledger_entries_viewto see the history of entries for a specific account.-- Set up accounts select id from pgledger_create_account('account_1', 'USD'); select id from pgledger_create_account('account_2', 'USD'); -- Create transfers select * from pgledger_create_transfer($account_1_id, $account_2_id, 12.34); -- See updated balances select name, balance, version from pgledger_accounts_view where id = $account_2_id; -- See ledger entries select created_at, account_version, amount, account_previous_balance, account_current_balance from pgledger_entries_view where account_id = $account_2_id order by id;Install pgledger
mainTo install pgledger, you must execute a specific sequence of SQL files in your PostgreSQL database. The installation requires helper functions for ULID/UUID conversion which are vendored within the repository.
Follow these steps in order:
- Run the vendored
scoville/pgsql-ulidhelper files:vendor/scoville-pgsql-ulid/ulid-to-uuid.sqlvendor/scoville-pgsql-ulid/uuid-to-ulid.sql
- Run the main implementation file:
pgledger.sql
- Run the vendored
Set up the pgledger development environment
mainThe
pgledgerimplementation is written in SQL. To develop or run tests, you need several tools for dependency management, testing, task running, SQL linting, and database hosting.On MacOS, you can install the primary dependencies using Homebrew:
- Install
miseanddocker-desktopvia Homebrew. - Use
mise installto set up tool dependencies. - Use
docker compose upto start a PostgreSQL instance. - Use
just checkto execute the full suite of tests and linters.
brew install mise docker-desktop mise install docker compose up just check- Install
Run pgledger with Docker Compose
mainYou can use the provided
docker-compose.ymlto spin up a PostgreSQL instance configured forpgledger. The setup includes a container namedpgledger_postgresthat exposes port5432and uses default credentials for development.Default Credentials
- User:
pgledger - Password:
pgledger - Database Port:
5432
Configuration via Environment Variables
You can control the PostgreSQL version using the
POSTGRES_VERSIONenvironment variable. If not provided, it defaults to version18.Volume Mappings
./psqlrcis mapped to/root/.psqlrcinside the container (useful for custom CLI settings).- The current directory
.is mapped to/codeinside the container.
docker-compose up- User:
Compose ledger queries using CTEs and Joins
mainSince
pgledgeris built on standard SQL, you can use Common Table Expressions (CTEs) to join the results of mutation functions (likepgledger_create_transfer) with other views (likepgledger_accounts_view) to retrieve enriched data, such as account names instead of just IDs, in a single atomic operation.with transfer as ( select * from pgledger_create_transfer('pgla_01KBE8WV6PE2BSZHVKDD5TSEBZ', 'pgla_01KBE8WV6QESATM17SHW189Q0H', 10) ) select t.id, fa.name as from_account_name, ta.name as to_account_name, t.amount from transfer t join pgledger_accounts_view fa on t.from_account_id = fa.id join pgledger_accounts_view ta on t.to_account_id = ta.id;