An Elixir double-entry library inspired by Ruby's DoubleEntry. Brought to you by CoinJar.
- Postgres 9.4+ (for
JSONB
support) - MySQL 8.0+ (for row locking support)
def deps do
[
{:ex_double_entry, github: "coinjar/ex_double_entry"},
# pick one DB package
{:postgrex, ">= 0.0.0"},
{:myxql, ">= 0.0.0"},
]
end
You will need to copy and run the migration file to create the DB tables.
config :ex_double_entry,
db: :postgres,
db_table_prefix: "ex_double_entry_",
repo: YourProject.Repo,
default_currency: :USD,
# all accounts need to be defined here
accounts: %{
# account identifier: account options
#
# valid options are:
# "positive_only": whether the account can go into negative balance
bank: [],
savings: [positive_only: true],
checking: [],
},
# all transfers need to be defined here
transfers: %{
# transfer code: transfer pairs
#
# for each transfer pair:
# - the first element is the source account
# - the second element is the destination account
deposit: [
{:bank, :savings},
{:bank, :checking},
{:checking, :savings},
],
withdraw: [
{:savings, :checking},
],
}
# creates a new account with 0 balance
ExDoubleEntry.make_account!(
# identifier of the account, in atom
:savings,
# currency can be any arbitrary atom
currency: :USD,
# optional, scope can be any arbitrary string
#
# due to DB index on `NULL` values, scope value can only be `nil` (stored as
# an empty string in the DB) or non-empty strings
scope: "user/1"
)
# looks up an account with its balance
ExDoubleEntry.lookup_account!(
:savings,
currency: :USD,
scope: "user/1"
)
Both functions return an ExDoubleEntry.Account
struct that looks like this:
%ExDoubleEntry.Account{
id: 1,
identifier: :savings,
currency: :USD,
scope: "user/1",
positive_only?: true,
balance: Money.new(0, :USD),
}
There are two transfer modes, transfer
and transfer!
.
Note: ExDoubleEntry relies on the money library for balances and amounts.
# accounts need to exist in the DB otherwise
# `ExDoubleEntry.Account.NotFoundError` is raised
ExDoubleEntry.transfer(
money: Money.new(100_00, :USD),
# accounts need to be defined in the config
from: account_a,
to: account_b,
# transfer code is required, and must be defined in the config
code: :deposit,
# optional, metadata can be any arbitrary map, it gets stored in the DB
# as either a JSON string (MySQL) or a JSONB object (Postgres)
metadata: %{diamond: "hands"}
)
# accounts will be created in the DB if they don't exist
# once accounts are created they will be locked during the transfer
ExDoubleEntry.transfer!(
money: Money.new(100_00, :USD),
from: account_a,
to: account_b,
code: :deposit
)
Transfer itself will already lock the accounts involved. However, if there are
other tasks that need to be performed atomically with the transfer, you can
perform them using lock_accounts
.
Transactions can be nested arbitrarily, since in Ecto, transactions are flattened and are committed or rolled back based on the outer most transaction.
Read more on Ecto's transaction handling here.
ExDoubleEntry.lock_accounts([account_a, account_b], fn ->
ExDoubleEntry.transfer!(
money: Money.new(100, :USD),
from: account_a,
to: account_b,
code: :deposit
)
# perform other tasks that should be committed atomically with the transfer
end)
Licensed under MIT.