DoubleEntryLedger

Elixir CI

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

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. Each journal event stores direct foreign keys to its command and related transaction or account. 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. Journal-event relationships are written synchronously with the journal event; there is no internal linking job in 0.5.0.

Balances & Audit Trails

Each transaction updates Account projections plus immutable BalanceHistoryEntry snapshots, enabling temporal queries and reconciliation. Instances can be validated with Instance.validate_account_balances/1, ensuring posted and pending debits/credits remain equal. Direct command_id, transaction_id, and account_id foreign keys on journal events 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 a dedicated Postgres schema (double_entry_ledger by default, overridable via config :double_entry_ledger, schema_prefix: …), so migrations never clash with your application schema.

Requirements

Installation

1. Add the dependency

def deps do
[
{:double_entry_ledger, "~> 0.5.0"}
]
end

Run mix deps.get after updating mix.exs.

2. Configure the application

Most consumers point DoubleEntryLedger at their own Ecto repo so the library shares one connection pool (and one Ecto sandbox in tests):

# config/config.exs
import Config
config :double_entry_ledger,
repo: MyApp.Repo,
idempotency_secret: System.fetch_env!("LEDGER_IDEMPOTENCY_SECRET"),
start_command_queue: true,
insert_path: :legacy,
batch_enabled: false,
batch_size: 8,
max_batch_retries: 3,
max_retries: 5,
retry_interval: 200
config :double_entry_ledger, :command_queue,
poll_interval: 5_000,
pending_fetch_limit: 64,
max_retries: 5,
base_retry_delay: 30,
max_retry_delay: 3_600,
stale_processing_after: 300, # seconds a command may sit in :processing before recovery
processor_name: "command_queue"

In this "BYO-repo" mode the library does not start its own repo. The command queue needs the consumer's repo to be running, so add DoubleEntryLedger.children/0 to your supervision tree after your repo.

Set a strong idempotency_secret — it hashes incoming keys. Set start_command_queue: false to disable background processing (useful in tests or when embedding the ledger without the queue). retry_interval is read at runtime; max_retries is captured into each OCC worker at compile time (Occ.Processor.__using__/1), so changing it needs a recompile. The :command_queue list is read with Application.compile_env/3 and is likewise compile-time.

insert_path: :legacy and batch_enabled: false are the conservative defaults. To opt into the new paths, set insert_path: :insert_all and/or batch_enabled: true in the consuming application's configuration. Set batch_size to control how many compatible transaction commands are processed together and max_batch_retries to control retries after a stale account write. Account commands continue through the single-command path. pending_fetch_limit controls how many queue IDs an instance processor fetches per database read; it is independent of batch_size. When batching is enabled, set it to at least batch_size and preferably to a multiple of batch_size so each database fetch can be divided into full batches. Dependency configuration files are not loaded by a host application, so the INSERT_PATH, BATCH, and BATCH_SIZE environment-variable helpers in this repository's config/runtime.exs only apply when running this repository directly. A consuming release must translate any environment variables into :double_entry_ledger configuration in its own config/runtime.exs.

Standalone mode (omit :repo): the library ships its own DoubleEntryLedger.Repo and supervises it automatically. Configure it as a normal Ecto repo per env:

config :double_entry_ledger, ecto_repos: [DoubleEntryLedger.Repo]
config :double_entry_ledger, DoubleEntryLedger.Repo,
database: "double_entry_ledger_repo",
username: "postgres",
password: "postgres",
hostname: "localhost",
pool_size: 10

This is useful for running DEL on its own (tests, demos), but for production apps prefer the BYO-repo form above.

3. Run the migrations

mix double_entry_ledger.install
mix ecto.migrate

This 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 schema versions 2–5, including the FK fixes, negative_limit, trace context, query indexes, direct journal-event foreign keys, queue instance IDs, and widened balance columns.

Historical background-job migration: v0.1.0 included a migration for the then-used job runner. An already-applied migration and its tables may remain; 0.5.0 does not drop them because the host application may use that job runner independently. If you still need to execute or roll back that migration, declare the original dependency in your application rather than relying on DEL to provide it.

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. Add the command queue to your supervision tree

In BYO-repo mode, add DoubleEntryLedger.children/0 to your supervision tree so DEL's command queue starts after your repo:

# lib/my_app/application.ex
children =
[
MyApp.Repo,
# …other children…
] ++ DoubleEntryLedger.children()

In standalone mode the library supervises the command queue itself and consumers do not need to call DoubleEntryLedger.children/0.

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_for_instance/2 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.Instance
alias DoubleEntryLedger.Stores.{AccountStore, TransactionStore, JournalEventStore, CommandStore}
AccountStore.get_by_id(cash.id).available
{:ok, {history, _meta}} = AccountStore.list_balance_history(cash.id)
{:ok, {transactions, _meta}} = TransactionStore.list_for_instance(instance.id)
{:ok, {events, _meta}} = JournalEventStore.list_for_account(cash.id)
CommandStore.get_by_id(command.id)

Each list function accepts an optional second-argument map of Flop params (cursor, filters, ordering) — see the store moduledocs for the allow-listed filter fields.

Use Instance.validate_account_balances(instance) to assert the ledger still balances, or PendingTransactionLookup to inspect open holds.

Background Processing

Deployment scope: one node per ledger

The command queue is designed and tested for a single node processing a given ledger. Run it that way in production.

Every write that completes, retries, or dead-letters a command is fenced by command_queue_items.processor_version, so a stale processor can never overwrite work after ownership has moved, and commands stranded in :processing by a node that died are recovered automatically. Those guarantees hold under concurrency and are what make a crash or restart safe.

What is not yet guaranteed across several nodes:

Cluster-wide safety needs an ownership lease per ledger, held in PostgreSQL with a fencing token and a database-clock expiry, so that only the current owner may claim work for that ledger. That is not implemented.

Documentation & Further Reading

Generate fresh API docs locally with:

mix docs

Extras are bundled in pages/ when you run mix docs.

Development

Migrating from 0.4.x to 0.5.0

⚠️ 0.5.0 contains breaking schema and API changes. Migration 5 is not compatible with a mixed 0.4.x/0.5.0 rolling deployment. Stop 0.4.x command processing, apply the upgrade migration, deploy 0.5.0, and then resume processing.

Generate and run an upgrade migration from schema version 4:

mix double_entry_ledger.install --from 4
mix ecto.migrate

The migration performs these changes:

  1. Replaces journal_event_*_links tables with direct command_id, transaction_id, and account_id columns on journal_events. Update custom queries and preloads to use the direct associations.
  2. Adds and backfills command_queue_items.instance_id, then makes it required. Custom code building queue-item changesets must provide instance_id.
  3. Widens account and balance-history integer balance/limit columns to bigint. A later downgrade can fail if stored values exceed the old integer range.
  4. Moves commands.inserted_at plus command-queue insertion, update, processing-start, and processing-completion timestamp generation to PostgreSQL so processing times do not depend on application-node clocks.
  5. Adds and backfills command_queue_items.queue_position from a PostgreSQL sequence, then uses it as the stable queue-processing order. Existing rows receive dense positions in (inserted_at, command_id) order; PostgreSQL uses the sequence for rows inserted after the backfill.
  6. Adds the transient command_queue_items.retry_delay_seconds column and the queue-trigger rule that turns it into next_retry_after on the PostgreSQL clock, so retry timing no longer depends on application-node clocks.

The queue-position backfill rewrites every command_queue_items row, including processed and dead-letter history, while holding an ACCESS EXCLUSIVE table lock. Its runtime therefore scales with total queue history, not only currently processable work. Plan an appropriate maintenance window before applying it to a large table.

Command.processing_start_changeset/3 and CommandQueueItem.processing_start_changeset/3 were removed; callers must claim through CommandQueue.Scheduling.claim_command_for_processing/3, which enforces queue status and the retry deadline in one atomic UPDATE.

The JournalEventAccountLink, JournalEventCommandLink, JournalEventTransactionLink, and legacy journal-event link worker have been removed. DEL no longer depends on or supervises a third-party job runner, and journal-event relationships are now written synchronously. Remove the obsolete DEL job-runner configuration; applications using a job runner for their own work must declare, migrate, configure, and supervise it independently. Existing job-runner tables are intentionally left untouched.

Batching remains opt-in. Configure insert_path, batch_enabled, and batch_size in the consuming application; this dependency's config/runtime.exs is not loaded by the host release.

Migrating from 0.3.x to 0.4.0

⚠️ 0.4.0 contains breaking changes. Read this whole section before bumping the dependency — at minimum you'll update store call sites and supervision. See CHANGELOG.md for the canonical migration notes per item.

Breaking changes at a glance

  1. Store list functions renamed and re-shaped. list_all_* and get_all_accounts_* are gone; replacements are list_for_* under Flop. Returns are now {:ok, {[item], %Flop.Meta{}}}. Update every call site — see the Function rename map and the Before/After example below.

  2. Pagination is cursor-based via Flop. (id, page, per_page)(id, flop_params_map). No consumer config :flop, repo: … required — DEL ships its own backend.

  3. Supervision shift in BYO-repo mode. If you opt into BYO-repo via config :double_entry_ledger, repo: MyApp.Repo, DEL no longer supervises the command queue from its own tree. Add DoubleEntryLedger.children/0 to your app's supervisor. Standalone consumers (no :repo set) are unaffected.

New: bring your own repo

0.4.0 adds config :double_entry_ledger, repo: MyApp.Repo so the library shares the host's connection pool (and one Ecto sandbox in tests) instead of shipping its own DoubleEntryLedger.Repo. When :repo is omitted, the library runs in standalone mode as before. See Configuration for the full setup.

Before (0.3.x)

transactions = TransactionStore.list_all_for_instance_id(instance.id, 1, 40)

After (0.4.x)

{:ok, {transactions, meta}} = TransactionStore.list_for_instance(instance)
# Next page — cursor pagination
{:ok, {next, _meta}} =
TransactionStore.list_for_instance(instance, %{first: 40, after: meta.end_cursor})
# With a filter (allow-listed field)
{:ok, {pending, _meta}} =
TransactionStore.list_for_instance(instance, %{
filters: [%{field: :status, op: :==, value: :pending}]
})

Function rename map

0.3.x 0.4.0
InstanceStore.list_all/0 InstanceStore.list/1
AccountStore.get_all_accounts_by_instance_id/1 AccountStore.list_for_instance/2
AccountStore.get_all_accounts_by_instance_address/1 AccountStore.list_for_instance_address/2
AccountStore.get_accounts_by_instance_id_and_type/2 AccountStore.list_for_instance/2 with filters: [%{field: :type, op: :==, value: type}]
AccountStore.get_balance_history_by_id/3 AccountStore.list_balance_history/2
AccountStore.get_balance_history_by_address/4 AccountStore.list_balance_history_by_address/3
AccountStore.get_balance_history_by_account/3 AccountStore.list_balance_history/2
TransactionStore.list_all_for_instance_id/3 TransactionStore.list_for_instance/2
TransactionStore.list_all_for_instance_address/3 TransactionStore.list_for_instance_address/2
TransactionStore.list_all_for_instance_id_and_account_id/4 TransactionStore.list_for_instance_and_account/3
TransactionStore.list_all_for_instance_address_and_account_address/4 TransactionStore.list_for_instance_and_account_address/3
CommandStore.list_all_for_instance_id/3 CommandStore.list_for_instance/2
CommandStore.list_all_for_transaction_id/1 CommandStore.list_for_transaction/2
JournalEventStore.list_all_for_instance_id/3 JournalEventStore.list_for_instance/2
JournalEventStore.list_all_for_account_id/3 JournalEventStore.list_for_account/2
JournalEventStore.list_all_for_account_address/2 JournalEventStore.list_for_account_address/3
JournalEventStore.list_all_for_transaction_id/1 JournalEventStore.list_for_transaction/2

All list_for_* functions that take a parent scope accept either the parent struct (Instance.t(), Account.t(), Transaction.t()) or its UUID string.

License

DoubleEntryLedger is released under the MIT License.