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, 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

Installation

1. Add the dependency

DoubleEntryLedger is not published on Hex yet, so point Mix at the GitHub repository:

def deps do
  [
    {:double_entry_ledger, git: "https://github.com/csommerauer/double_entry_ledger.git", branch: "main"}
  ]
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"),
  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"

config :double_entry_ledger, Oban,
  repo: DoubleEntryLedger.Repo,
  prefix: "double_entry_ledger",
  engine: Oban.Engines.Basic,
  queues: [double_entry_ledger: 5]

Set a strong idempotency_secret — it is used to hash incoming keys. Production systems should override the repo credentials, event queue settings, and Oban concurrency.

3. Run the migrations

Copy the migrations in priv/repo/migrations into your host application (adjust timestamps to keep ordering), create the schema if necessary, and migrate:

cp -R deps/double_entry_ledger/priv/repo/migrations/*.exs priv/repo/migrations/
mix ecto.create
mix ecto.migrate

The migrations create the ledger schema, instances, accounts, transactions, entries, commands, command queue items, pending transaction lookup, journal events and links, idempotency keys, and Oban jobs.

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

{:ok, equity} =
  AccountStore.create(instance.address, %{
    address: "equity:capital",
    type: :equity,
    currency: :USD,
    name: "Owners' Equity"
  })

Process a transaction synchronously

alias DoubleEntryLedger.Apis.CommandApi

event = %{
  "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, command} = CommandApi.process_from_params(event)

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 an event for asynchronous processing

async_event = Map.put(event, "source_idempk", "initial-capital-async")
{:ok, queued_command} = CommandApi.create_from_params(async_event)
# 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

Documentation & Further Reading

Generate fresh API docs locally with:

mix docs

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

Development

License

DoubleEntryLedger is released under the MIT License.