# ExDoubleEntry
[![Build Status](https://github.com/coinjar/ex_double_entry/actions/workflows/ci.yml/badge.svg)](https://github.com/coinjar/ex_double_entry/actions)
An Elixir double-entry library inspired by Ruby's [DoubleEntry](https://github.com/envato/double_entry). Brought to you by [CoinJar](https://coinjar.com).
![](https://i.imgur.com/QqrlYZ9.png)
## Supported Databases
- Postgres 9.4+ (for `JSONB` support)
- MySQL 8.0+ (for row locking support)
## Installation
```elixir
def deps do
[
{:ex_double_entry, github: "coinjar/ex_double_entry"},
# pick one DB package
{:postgrex, ">= 0.0.0"},
{:myxql, ">= 0.0.0"},
]
end
```
### DB Migration
You will need to copy and run the [migration file](priv/repo/migrations/001_ex_double_entry_tables.exs) to create the DB tables.
## Configuration
```elixir
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},
],
}
```
## Usage
### Accounts & Balances
```elixir
# 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:
```elixir
%ExDoubleEntry.Account{
id: 1,
identifier: :savings,
currency: :USD,
scope: "user/1",
positive_only?: true,
balance: Money.new(0, :USD),
}
```
### Transfers
There are two transfer modes, `transfer` and `transfer!`.
Note: ExDoubleEntry relies on the [money](https://github.com/elixirmoney/money)
library for balances and amounts.
```elixir
# 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
)
```
### Locking
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](https://hexdocs.pm/ecto/Ecto.Repo.html#c:transaction/2).
```elixir
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)
```
## License
Licensed under [MIT](LICENSE.md).