mirror of
https://github.com/ash-project/ash_double_entry.git
synced 2024-09-20 13:33:55 +12:00
6.5 KiB
6.5 KiB
Getting Started with Ash Double Entry
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.
What makes it special?
- 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).
Setup
Define your account resource
Example
defmodule YourApp.Account do
use Ash.Resource,
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.Transfer
balance_resource YourApp.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
What does this extension do?
- Adds the following attributes:
:id
, a:uuid
primary key:currency
, a:string
representing the currency of the transfer:inserted_at
, a:utc_datetime_usec
timestamp:identifier
, a:string
and 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_accounts
that 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_many
relationship calledbalances
, referring to all related balances of an account - Adds an aggregate called
balance
, referring to the latest balance as adecimal
for that account - Adds the following calculations:
- A
balance_as_of_ulid
calculation that takes an argument calledulid
, which corresponds to a transfer id and returns the balance as a decimal. - A
balance_as_of
calculation that takes autc_datetime_usec
and returns the balance as of that datetime. - Adds an identity called
unique_identifier
that ensuresidentifier
is unique.
Define your transfer resource
Example
defmodule YourApp.Transfer do
use Ash.Resource,
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.Account
balance_resource YourApp.Balance
end
end
What does this extension do?
- Adds the following attributes
:id
, aAshDoubleEntry.ULID
primary key which is sortable based on thetimestamp
of the transfer.:amount
, a:decimal
representing the amount of the transfer:timestamp
, a:utc_datetime_usec
representing when the transfer occurred:inserted_at
, a:utc_datetime_usec
timestamp
- Adds the following relationships
:from_account
, abelongs_to
relationship of the account the transfer is from:to_account
, abelongs_to
relationship of the account the transfer is to
- Adds a
:read
action called:read_transfers
with 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.
Define your balance resource
Example
defmodule YourApp.Balance do
use Ash.Resource,
data_layer: AshPostgres.DataLayer,
extensions: [AshDoubleEntry.Balance]
postgres do
table "balances"
repo YourApp.Repo
end
balance do
# configure the other resources it will interact with
transfer_resource Transfer
account_resource Account
end
changes do
# add custom behavior. In this case, we're preventing certain balances from being less than zero
change after_action(&validate_balance/2)
end
defp validate_balance(changeset, result) do
account = result |> changeset.api.load!(:account) |> Map.get(:account)
if account.allow_zero_balance == false && Decimal.negative?(result.balance) do
{:error,
Ash.Error.Changes.InvalidAttribute.exception(
value: result.balance,
field: :balance,
message: "balance cannot be negative"
)}
else
{:ok, result}
end
end
end
What does this extension do?
- Adds the following attributes:
:id
, a:uuid
primary key:balance
, the balance as a decimal of the account at the time of the related transfer
- Adds the following relationships:
:transfer
a:belongs_to
relationship, pointing to the transfer that this balance is as of.:account
a:belongs_to
relationship, 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 - a create action caleld
:upsert_balance
, which will create or update the relevant balance, bytransfer_id
andaccount_id
- a primary read action called
- Adds an identity that ensures that
account_id
andtransfer_id
are unique
Define an Ash api to use them through
defmodule YourApp.Ledger do
use Ash.Api
resources do
resource YourApp.Account
resource YourApp.Balance
resource YourApp.Transfer
end
end
Use them
Create an account
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_one", currency: "USD"})
|> Api.create!()
Create transfers between accounts
Transfer
|> Ash.Changeset.for_create(:transfer, %{
amount: Decimal.new(20),
from_account_id: account_one.id,
to_account_id: account_two.id
})
|> Api.create!()
Check an account's balance
Account
|> Api.get!(account_id, load: :balance)
|> Map.get(:balance)
# => Decimal.new("20")
What else can you do?
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.