Skip to content

Latest commit

 

History

History
164 lines (134 loc) · 4.07 KB

README.md

File metadata and controls

164 lines (134 loc) · 4.07 KB

ExDoubleEntry

Build Status

An Elixir double-entry library inspired by Ruby's DoubleEntry. Brought to you by CoinJar.

Supported Databases

  • Postgres 9.4+ (for JSONB support)
  • MySQL 8.0+ (for row locking support)

Installation

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 to create the DB tables.

Configuration

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

# 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),
}

Transfers

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
)

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.

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.