# Ledgex
A double-entry accounting system for Elixir applications, ported from Ruby's [double_entry](https://github.com/envato/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](https://hex.pm/packages/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`:
```elixir
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:
```bash
mix deps.get
mix ledgex.gen.migration
mix ecto.migrate
```
## Quick Start
```elixir
# 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:
```elixir
# 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:
```elixir
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:
```elixir
# 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
```elixir
# 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
```elixir
Ledgex.transfer!(
Money.new(25000, :USD),
from: checking,
to: savings,
code: :save,
metadata: %{
note: "Monthly savings",
category: "savings",
tags: ["automated", "recurring"]
}
)
```
### Multi-Account Operations
```elixir
# 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
```elixir
# 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
```elixir
# 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
```elixir
# 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
```elixir
# 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
```elixir
# 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:
- **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](https://github.com/envato/double_entry) by Envato.