Ledgex
A double-entry accounting system for Elixir applications, ported from Ruby's double_entry gem.
Ledgex provides a robust framework for tracking financial transactions using double-entry bookkeeping principles. Each transfer creates two ledger entries ensuring balanced records and data integrity.
Features
- Dynamic Account Management: Define accounts at runtime, stored in ETS for fast access
- Scoped Accounts: Per-user or per-entity account isolation
- Balance Tracking: Current and historical balance calculations with efficient caching
- Currency Support: Integration with the Money library
- Transfer Validation: Configurable allowed transfers with business codes
- Metadata Support: Attach arbitrary data to transfers via JSON
- Concurrency Safe: Database-level locking prevents race conditions
- Flexible Queries: Historical snapshots, date ranges, and code filtering
- Idiomatic Elixir: ETS tables, tagged tuples, functional patterns
Installation
Add to mix.exs:
def deps do
[
{:ledgex, "~> 1.0"},
{:money, "~> 1.12"},
{:decimal, "~> 2.0"},
{:postgrex, ">= 0.0.0"} # or {:myxql, ">= 0.0.0"} for MySQL
]
endThen generate and run the migration:
mix deps.get
mix ledgex.gen.migration
mix ecto.migrateQuick Start
# 1. Configure the repo (in application.ex)
Ledgex.Config.put(:repo, MyApp.Repo)
# 2. Define accounts (can be done anytime, even at runtime!)
Ledgex.Account.define!(identifier: :cash, currency: "USD")
Ledgex.Account.define!(
identifier: :checking,
scope_identity_fun: &(&1.id), # Scoped per-user
currency: "USD"
)
Ledgex.Account.define!(
identifier: :savings,
scope_identity_fun: &(&1.id),
positive_only: true, # Can't go negative
currency: "USD"
)
# 3. Define allowed transfers
Ledgex.Transfer.define!(from: :cash, to: :checking, code: :deposit)
Ledgex.Transfer.define!(from: :checking, to: :savings, code: :save)
Ledgex.Transfer.define!(from: :savings, to: :checking, code: :withdraw)
# 4. Get account references (simple tuples!)
cash = Ledgex.account_ref(:cash)
user_checking = Ledgex.account_ref(:checking, user)
user_savings = Ledgex.account_ref(:savings, user)
# 5. Transfer money
{:ok, {from_line, to_line}} = Ledgex.transfer(
Money.new(10000, :USD), # $100.00
from: cash,
to: user_checking,
code: :deposit
)
# Or use bang version
Ledgex.transfer!(
Money.new(5000, :USD),
from: user_checking,
to: user_savings,
code: :save
)
# 6. Check balances
balance = Ledgex.balance(user_checking) # Returns Money structCore Concepts
Account References
Account references are simple tuples: {%Account{}, scope_identity}. This functional approach makes pattern matching easy:
# Unscoped account
cash_ref = Ledgex.account_ref(:cash)
# => {%Ledgex.Account{identifier: :cash, ...}, nil}
# Scoped account
checking_ref = Ledgex.account_ref(:checking, user)
# => {%Ledgex.Account{identifier: :checking, ...}, 123}
# Pattern match on refs
{account, scope_id} = checking_refTagged Tuples
Ledgex uses idiomatic Elixir error handling with tagged tuples:
case Ledgex.transfer(amount, from: acc1, to: acc2, code: :transfer) do
{:ok, {from_line, to_line}} ->
# Success! Process lines
{:error, :negative_amount} ->
# Handle negative amount error
{:error, :not_allowed} ->
# Handle unauthorized transfer
{:error, :same_account} ->
# Handle same-account transfer
end
# Or use bang version to raise on error
Ledgex.transfer!(amount, from: acc1, to: acc2, code: :transfer)Dynamic Configuration
Unlike traditional configuration, Ledgex allows runtime changes:
# Add accounts dynamically
{:ok, account} = Ledgex.Account.define(
identifier: :new_account,
currency: "EUR"
)
# Remove accounts (careful!)
Ledgex.Account.delete(:old_account)
# List all accounts
accounts = Ledgex.Account.list()
# Same for transfers
Ledgex.Transfer.define!(from: :new_account, to: :cash, code: :exchange)
Ledgex.Transfer.delete(:old_account, :cash, :old_code)Usage Examples
Basic Transfer
# Define what you need
Ledgex.Account.define!(identifier: :revenue, currency: "USD")
Ledgex.Account.define!(identifier: :bank, currency: "USD")
Ledgex.Transfer.define!(from: :revenue, to: :bank, code: :collect)
# Transfer
revenue = Ledgex.account_ref(:revenue)
bank = Ledgex.account_ref(:bank)
Ledgex.transfer!(
Money.new(50000, :USD),
from: revenue,
to: bank,
code: :collect
)With Metadata
Ledgex.transfer!(
Money.new(25000, :USD),
from: checking,
to: savings,
code: :save,
metadata: %{
note: "Monthly savings",
category: "savings",
tags: ["automated", "recurring"]
}
)Multi-Account Operations
# Lock accounts to ensure atomicity
Ledgex.lock([acc1, acc2, acc3], fn ->
# All transfers happen atomically
Ledgex.transfer!(Money.new(1000, :USD), from: acc1, to: acc2, code: :transfer)
Ledgex.transfer!(Money.new(500, :USD), from: acc2, to: acc3, code: :transfer)
# Other database operations...
update_user_stats(user)
end)Balance Queries
# Current balance
current = Ledgex.balance(account)
# Historical balance
historical = Ledgex.balance(account,
at: ~U[2024-01-01 00:00:00Z]
)
# Balance for time range
range_balance = Ledgex.balance(account,
from: ~U[2024-01-01 00:00:00Z],
to: ~U[2024-12-31 23:59:59Z]
)
# Balance filtered by code
deposits = Ledgex.balance(account, code: :deposit)
withdrawals = Ledgex.balance(account, code: :withdraw)
# Multiple codes
inflows = Ledgex.balance(account, codes: [:deposit, :income, :refund])Per-User Accounts
# Define scoped account
Ledgex.Account.define!(
identifier: :wallet,
scope_identity_fun: &(&1.id),
currency: "USD"
)
# Each user has their own wallet
user1_wallet = Ledgex.account_ref(:wallet, user1) # {Account, 1}
user2_wallet = Ledgex.account_ref(:wallet, user2) # {Account, 2}
# Balances are separate
Ledgex.balance(user1_wallet) # User 1's balance
Ledgex.balance(user2_wallet) # User 2's balance (independent)Account Constraints
# Positive-only (e.g., savings, can't overdraft)
Ledgex.Account.define!(
identifier: :savings,
positive_only: true,
currency: "USD"
)
# Negative-only (e.g., debt, should stay negative/zero)
Ledgex.Account.define!(
identifier: :debt,
negative_only: true,
currency: "USD"
)Validation & Integrity
# Validate all ledger lines
Ledgex.Validation.LineCheck.perform!()
# Validate and automatically fix issues
Ledgex.Validation.LineCheck.perform!(auto_fix: true)
# Validate from specific line ID
Ledgex.Validation.LineCheck.perform!(from_line_id: 12345)Testing
# In test_helper.exs or data_case.ex
setup do
# Clear state between tests
Ledgex.Account.clear()
Ledgex.Transfer.clear()
# Configure for testing
Ledgex.Config.put(:repo, MyApp.Test.Repo)
Ledgex.Config.put(:transactional_fixtures, true)
:ok
endDatabase Schema
The mix ledgex.gen.migration task generates a migration that creates four tables:
- ledgex_lines: Core ledger entries with running balances
- ledgex_account_balances: Cached balances for locking
- ledgex_line_checks: Audit log for validation
- ledgex_line_metadata: Optional metadata table
You can review and customize the generated migration before running mix ecto.migrate.
Architecture Highlights
- ETS Tables: Fast concurrent reads for account/transfer lookups
- Database Locking: Pessimistic locks prevent race conditions
- Running Balances: Stored in each line for efficient queries
- Sorted Locking: Accounts locked in order to prevent deadlocks
- Tagged Tuples: Standard Elixir error handling pattern
- Pattern Matching: Account refs are simple tuples for easy matching
Comparison to Ruby double_entry
Ledgex faithfully ports double_entry while embracing Elixir idioms:
| Feature | Ruby double_entry | Ledgex |
|---|---|---|
| Storage | Class variables | ETS tables |
| Config | DSL callbacks | Direct function calls |
| Errors | Raise exceptions | Tagged tuples + bangs |
| Account refs | OOP objects | Functional tuples |
| State | Agent/GenServer | ETS (faster) |
| API | Object methods | Module functions |
Performance
- ETS: Constant-time account/transfer lookups
- Running Balances: No need to sum on every query
- Efficient Indexes: Optimized for common query patterns
- Connection Pooling: Uses your app's Ecto repo
License
MIT License - see LICENSE file
Credits
Ported from double_entry by Envato.