Ash Double Entry is implemented as a set of Ash resource extensions. You build the resources yourself, and the extensions add the attributes, relationships, actions and validations required for them to constitute a double entry system.
Double entry is a accounting principle that ensures every financial transaction is recorded in at least two accounts, maintaining the accounting equation of Assets = Liabilities + Equity. This means that every transaction has a debit (or money going out) and a credit (or money coming in), recorded in dedicated debit and credit accounts (hence the name "double entry"). The total debits must always equal the total credits, ensuring that books always balance and allowing for errors to be quickly detected.
AshDoubleEntry implements double entry accounting through three core resources:
- AshDoubleEntry.Account DSL, which represents accounts in your ledger (such as bank accounts, revenue accounts, expense accounts, etc.).
- AshDoubleEntry.Transfer DSL, which represents transactions between accounts, always linking a
from_accountandto_accountwith an amount. - AshDoubleEntry.Balance DSL, which tracks the balance of each account at the point of each transfer for recordkeeping.
- Account balances are updated automatically as transfers are introduced.
- Arbitrary custom validations and behavior by virtue of modifying your own resources.
- Transactions can be entered in the past, and all future balances are updated (and therefore validated).
Follow the setup guide for AshMoney, located here. If you are using with AshPostgres, be sure to include the :ex_money_sql dependency in your mix.exs.
{:ash_double_entry, "~> 1.0.3"}defmodule YourApp.Ledger.Account do
use Ash.Resource,
domain: YourApp.Ledger,
data_layer: AshPostgres.DataLayer,
extensions: [AshDoubleEntry.Account]
postgres do
table "accounts"
repo YourApp.Repo
end
account do
# configure the other resources it will interact with
transfer_resource YourApp.Ledger.Transfer
balance_resource YourApp.Ledger.Balance
# accept custom attributes in the autogenerated `open` create action
open_action_accept [:account_number]
end
attributes do
# Add custom attributes
attribute :account_number, :string do
allow_nil? false
end
end
end- Adds the following attributes:
:id, a:uuidprimary key:currency, a:stringrepresenting the currency of the account.:inserted_at, a:utc_datetime_usectimestamp:identifier, a:stringand a unique identifier for the account
- Adds the following actions:
- A primary read called
:read, unless a primary read action already exists. - A create action called
open, that acceptsidentifier,currency, and the attributes inopen_action_accept - A read action called
:lock_accountsthat can be used to lock a list of accounts while in a transaction(for data layers that support it)
- A primary read called
- Adds a
has_manyrelationship calledbalances, referring to all related balances of an account - Adds an aggregate called
balance, referring to the latest balance as adecimalfor that account - Adds the following calculations:
- A
balance_as_of_ulidcalculation that takes an argument calledulid, which corresponds to a transfer id and returns the balance. - A
balance_as_ofcalculation that takes autc_datetime_usecand returns the balance as of that datetime. - Adds an identity called
unique_identifierthat ensuresidentifieris unique.
defmodule YourApp.Ledger.Transfer do
use Ash.Resource,
domain: YourApp.Ledger,
data_layer: AshPostgres.DataLayer,
extensions: [AshDoubleEntry.Transfer]
postgres do
table "transfers"
repo YourApp.Repo
end
transfer do
# configure the other resources it will interact with
account_resource YourApp.Ledger.Account
balance_resource YourApp.Ledger.Balance
# you only need this if you are using `postgres`
# and so cannot add the `references` block shown below
# destroy_balances? true
end
end- Adds the following attributes
:id, aAshDoubleEntry.ULIDprimary key which is sortable based on thetimestampof the transfer.:amount, aAshMoney.Types.Moneyrepresenting the amount and currency of the transfer:timestamp, a:utc_datetime_usecrepresenting when the transfer occurred:inserted_at, a:utc_datetime_usectimestamp
- Adds the following relationships
:from_account, abelongs_torelationship of the account the transfer is from:to_account, abelongs_torelationship of the account the transfer is to
- Adds a
:readaction called:read_transferswith keyset pagination enabled. Required for streaming transfers, used for validating balances. - Adds a change that runs on all create and update actions that reifies the balances table. It inserts a balance for the transfer, and updates any affected future balances.
defmodule YourApp.Ledger.Balance do
use Ash.Resource,
domain: YourApp.Ledger,
data_layer: AshPostgres.DataLayer,
extensions: [AshDoubleEntry.Balance]
postgres do
table "balances"
repo YourApp.Repo
references do
reference :transfer, on_delete: :delete
end
end
balance do
# configure the other resources it will interact with
transfer_resource YourApp.Ledger.Transfer
account_resource YourApp.Ledger.Account
end
actions do
read :read do
primary? true
# configure keyset pagination for streaming
pagination keyset?: true, required?: false
end
end
endIf you are not using a data layer capable of automatic cascade deletion, you must add
destroy_balances? trueto thetransferresource! We do this with thereferencesblock inash_postgresas shown above.
- Adds the following attributes:
:id, a:uuidprimary key:balance, the balance as a decimal of the account at the time of the related transfer
- Adds the following relationships:
:transfera:belongs_torelationship, pointing to the transfer that this balance is as of.:accounta:belongs_torelationship, pointing to the account the balance is for
- Adds the following actions:
- a primary read action called
:read, if a priamry read action doesn't exist - configure primary read action to have keyset pagination enabled
- a create action called
:upsert_balance, which will create or update the relevant balance, bytransfer_idandaccount_id
- a primary read action called
- Adds an identity that ensures that
account_idandtransfer_idare unique
defmodule YourApp.Ledger do
use Ash.Domain
resources do
resource YourApp.Ledger.Account
resource YourApp.Ledger.Balance
resource YourApp.Ledger.Transfer
end
endAnd add the domain to your config
config :your_app, ash_domains: [..., YourApp.Ledger]
mix ash_postgres.generate_migrations --name add_double_entry_ledger
mix ash_postgres.migrate
YourApp.Ledger.Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_one"})
|> Ash.create!()YourApp.Ledger.Transfer
|> Ash.Changeset.for_create(:transfer, %{
amount: Money.new!(20, :USD),
from_account_id: account_one.id,
to_account_id: account_two.id
})
|> Ash.create!()YourApp.Ledger.Transfer
|> Ash.Changeset.for_create(:transfer, %{
amount: Money.new!(:USD, 20),
from_account_id: account_one.id,
to_account_id: account_two.id
})
|> Ash.create!()
|> Ash.Changeset.for_update(:update, %{amount: Money.new!(:USD, 10)})
|> Ash.update!()If
config :ash, :default_actions_require_atomic?is set totrue, theTransferresourceupdateactions must definechange get_and_lock_for_update()andrequire_atomic? false. It's OK to addrequire_atomic? falsebecause the relevant accounts are locked before updating them.
YourApp.Ledger.Account
|> YourApp.Ledger.get!(account_id, load: :balance_as_of)
|> Map.get(:balance_as_of)
# => Money.new!(20, :USD)There are tons of things you can do with your resources. You can add code interfaces to give yourself a nice functional api. You can add custom attributes, aggregates, calculations, relationships, validations, changes, all the great things built into Ash.Resource! See the docs for more: AshHq.