mirror of
https://github.com/ash-project/ash_double_entry.git
synced 2024-09-20 05:23:22 +12:00
5.4 KiB
5.4 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