DoubleEntryLedger
DoubleEntryLedger is an event sourced, multi-tenant double entry accounting engine for Elixir and PostgreSQL. It provides typed accounts, signed amount APIs, pending/posting flows, an optimistic-concurrency command queue, and a fully auditable journal so you can embed reliable ledgering without rebuilding the fundamentals.
Highlights
- Multi tenant ledger instances with typed accounts (asset/liability/equity/revenue/expense) and Money backed multi currency support.
- Signed amount API converts intent into the correct debit or credit entry and enforces balanced transactions per currency.
-
Immutable
Command,JournalEvent, andBalanceHistoryEntryrecords plus idempotency keys give a complete audit trail. - Background command queue with OCC, exponential retries, per instance processors, and Oban powered linking jobs ensures exactly once processing.
-
Pending vs. posted projections with automatic
availablebalances support holds, authorizations, and delayed settlements. -
Rich stores and APIs (
InstanceStore,AccountStore,TransactionStore,CommandStore,CommandApi,JournalEventStore) keep ledger interactions safe and consistent. -
Everything lives inside the configurable
double_entry_ledgerschema so it coexists peacefully with your application tables.
System Overview
Instances & Accounts
DoubleEntryLedger.Stores.InstanceStore (lib/double_entry_ledger/stores/instance_store.ex) defines isolation boundaries. Each instance owns its own configuration, accounts, and transactions. DoubleEntryLedger.Stores.AccountStore validates account type, currency, addressing format, and maintains embedded posted, pending, and available balance structs. Helpers in DoubleEntryLedger.Types and DoubleEntryLedger.Utils.Currency encapsulate allowed values.
Commands, Journal Events & Transactions
External requests enter through DoubleEntryLedger.Apis.CommandApi (lib/double_entry_ledger/apis/command_api.ex). Requests are normalized into TransactionCommandMap or AccountCommandMap structs, hashed for idempotency, and saved as immutable Command records (lib/double_entry_ledger/schemas/command.ex). Successful processing creates JournalEvent records plus Transaction + Entry rows, and finally typed links (journal_event_*_links). Query stores such as DoubleEntryLedger.Stores.TransactionStore and DoubleEntryLedger.Stores.JournalEventStore expose read models by instance, account, or transaction.
Queues, Workers & OCC
The command queue (lib/double_entry_ledger/command_queue) polls for pending commands via InstanceMonitor, spins up InstanceProcessor processes per instance, and uses CommandQueue.Scheduling to claim, retry, or dead-letter work. The transaction related workers under lib/double_entry_ledger/workers/command_worker implement DoubleEntryLedger.Occ.Processor, translating event maps into Ecto.Multi workflows that retry on Ecto.StaleEntryError. When the command finishes, DoubleEntryLedger.Workers.Oban.JournalEventLinks runs via Oban to build any missing journal links.
Balances & Audit Trails
Each transaction updates Account projections plus immutable BalanceHistoryEntry snapshots, enabling temporal queries and reconciliation. Instances can be validated with InstanceStore.validate_account_balances/1, ensuring posted and pending debits/credits remain equal. Journal events plus JournalEventTransactionLink/JournalEventAccountLink tables provide traceability from the original request to the final projection.
Idempotency & Isolation
Every command requires a source and source_idempk (plus update_idempk for updates). These keys are hashed via DoubleEntryLedger.Command.IdempotencyKey to prevent duplicates, while PendingTransactionLookup enforces a single open update chain for each pending transaction. All tables live inside the configurable schema_prefix (double_entry_ledger by default), so migrations never clash with your application schema.
Requirements
-
Elixir
~> 1.15and OTP 26. -
PostgreSQL 14+ with permission to create the
double_entry_ledgerschema and the Oban jobs table. -
Access to run Mix tasks (
mix ecto.create,mix ecto.migrate,mix test, etc.). -
Recommended:
money,logger_json,oban,jason,credo, anddialyxir(included inmix.exs).
Installation
1. Add the dependency
def deps do
[
{:double_entry_ledger, "~> 0.2.0"}
]
end
Run mix deps.get after updating mix.exs.
2. Configure the application
# config/config.exs
import Config
config :double_entry_ledger,
ecto_repos: [DoubleEntryLedger.Repo],
schema_prefix: "double_entry_ledger",
idempotency_secret: System.fetch_env!("LEDGER_IDEMPOTENCY_SECRET"),
start_command_queue: true,
max_retries: 5,
retry_interval: 200
config :double_entry_ledger, DoubleEntryLedger.Repo,
database: "double_entry_ledger_repo",
username: "postgres",
password: "postgres",
hostname: "localhost",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
config :double_entry_ledger, :command_queue,
poll_interval: 5_000,
max_retries: 5,
base_retry_delay: 30,
max_retry_delay: 3_600,
processor_name: "command_queue"
Set a strong idempotency_secret — it is used to hash incoming keys. Set start_command_queue: false to disable background processing (useful in test or when embedding the ledger without the queue). max_retries and retry_interval are read at runtime, so they can be changed without recompilation. Production systems should override the repo credentials and command queue settings. For Oban configuration, see step 4.
3. Run the migrations
Fresh install (recommended)
mix double_entry_ledger.install
mix ecto.migrateThis generates a migration file for the core ledger tables.
Upgrading from v0.1.0
If you previously copied migration files from v0.1.0, generate an upgrade migration instead:
mix double_entry_ledger.install --from 1
mix ecto.migrate
This applies only the schema changes since v0.1.0 (FK constraint fixes and
negative_limit replacing allowed_negative).
Oban note: v0.1.0 included an Oban migration (2500_add_oban_jobs_table.exs)
bundled with the core migrations. Your existing copied migration continues to
work — leave it in place.
Manual migration
Create a migration and call the migration module directly:
defmodule MyApp.Repo.Migrations.SetupDoubleEntryLedger do
use Ecto.Migration
def up, do: DoubleEntryLedger.Migration.up()
def down, do: DoubleEntryLedger.Migration.down()
end
See DoubleEntryLedger.Migration docs for all options (:version, :from,
:prefix).
4. Set up Oban
The package uses Oban for background processing but does not ship its own
Oban migration — this avoids locking you to a specific Oban version. Ensure
Oban is installed and migrated in your application
(Oban installation guide), then
add the double_entry_ledger queue to your Oban config:
config :my_app, Oban,
repo: MyApp.Repo,
queues: [default: 10, double_entry_ledger: 5]Quickstart
Create a ledger instance and accounts
alias DoubleEntryLedger.Stores.{InstanceStore, AccountStore}
{:ok, instance} =
InstanceStore.create(%{
address: "Acme:Ledger",
description: "Internal ledger for ACME Corp"
})
{:ok, cash} =
AccountStore.create(instance.address, %{
address: "cash:operating",
type: :asset,
currency: :USD,
name: "Operating Cash",
negative_limit: 0 # default; rejects any negative available balance
})
{:ok, equity} =
AccountStore.create(instance.address, %{
address: "equity:capital",
type: :equity,
currency: :USD,
name: "Owners' Equity",
negative_limit: 1_000_00 # allow available to go as low as -1_000_00
})Process a transaction synchronously
alias DoubleEntryLedger.Apis.CommandApi
command = %{
"instance_address" => instance.address,
"action" => "create_transaction",
"source" => "back-office",
"source_idempk" => "initial-capital-1",
"payload" => %{
status: :posted,
entries: [
%{"account_address" => cash.address, "amount" => 1_000_00, "currency" => :USD},
%{"account_address" => equity.address, "amount" => 1_000_00, "currency" => :USD}
]
}
}
{:ok, transaction, processed_command} = CommandApi.process_from_params(command)Provide positive amounts to add value and negative amounts to subtract it—the ledger will derive the correct debit or credit per account type and reject unbalanced transactions.
Queue a command for asynchronous processing
async_command = Map.put(command, "source_idempk", "initial-capital-async")
{:ok, queued_command} = CommandApi.create_from_params(async_command)
# InstanceMonitor will claim it, process it, and update the command_queue_item status.
Inspect queued work with DoubleEntryLedger.Stores.CommandStore.list_all_for_instance_id/3 or check command.command_queue_item.status.
Reserve funds with pending transactions
hold_event = %{
"instance_address" => instance.address,
"action" => "create_transaction",
"source" => "checkout",
"source_idempk" => "order-123",
"payload" => %{
status: :pending,
entries: [
%{"account_address" => cash.address, "amount" => -200_00, "currency" => :USD},
%{"account_address" => equity.address, "amount" => -200_00, "currency" => :USD}
]
}
}
{:ok, pending_tx, _command} = CommandApi.process_from_params(hold_event)
# Later, finalize the hold
CommandApi.process_from_params(%{
"instance_address" => instance.address,
"action" => "update_transaction",
"source" => "checkout",
"source_idempk" => "order-123",
"update_idempk" => "order-123-post",
"payload" => %{status: :posted}
})source + source_idempk uniquely identify the original event, and update_idempk must be unique per update. Only pending transactions can be updated.
Query ledger state
alias DoubleEntryLedger.Stores.{AccountStore, TransactionStore, JournalEventStore, CommandStore}
AccountStore.get_by_id(cash.id).available
AccountStore.get_balance_history(cash.id)
TransactionStore.list_all_for_instance(instance.id)
JournalEventStore.list_all_for_account_id(cash.id)
CommandStore.get_by_id(command.id)
Use InstanceStore.validate_account_balances(instance.address) to assert the ledger still balances, or PendingTransactionLookup to inspect open holds.
Background Processing
DoubleEntryLedger.CommandQueue.InstanceMonitorpolls for commands in:pending,:occ_timeout, or:failedstatus and ensures each instance has anInstanceProcessor.InstanceProcessorclaims work viaCommandQueue.Scheduling.claim_command_for_processing/2, runs the appropriate worker, and marks theCommandQueueItemas:processed. Each worker task is monitored viaProcess.monitor/1; if the task crashes, the processor schedules a retry automatically.-
OCC is handled inside the workers (see
lib/double_entry_ledger/occ). Retries use exponential backoff untilmax_retriesis reached, after which commands are marked as:dead_letter. -
Errors and retry metadata live on the
command_queue_item, so you can inspect processing attempts viaCommandStoreor SQL views. -
Oban handles fan-out tasks (currently the journal-event linking job) via
DoubleEntryLedger.Workers.Oban.JournalEventLinks. Configure the queue size to match your workload.
Documentation & Further Reading
- Ledger internals & synchronous walkthrough
- Asynchronous processing details
- Handling pending transactions and available balances
- Event sourcing architecture notes
Generate fresh API docs locally with:
mix docs
Extras are bundled in pages/ when you run mix docs.
Development
mix deps.get– install dependencies.mix ecto.create && mix ecto.migrate– prepare the database.mix test– run the test suite (aliases automatically create/migrate the test DB).mix credo --strictandmix dialyzer– static analysis.mix docs– regenerate documentation, ormix tidewaveto preview docs via the built-in dev server.
License
DoubleEntryLedger is released under the MIT License.