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

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
  ]
end

Then generate and run the migration:

mix deps.get
mix ledgex.gen.migration
mix ecto.migrate

Quick 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 struct

Core 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_ref

Tagged 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
end

Database Schema

The mix ledgex.gen.migration task generates a migration that creates four tables:

You can review and customize the generated migration before running mix ecto.migrate.

Architecture Highlights

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

License

MIT License - see LICENSE file

Credits

Ported from double_entry by Envato.