mirror of
https://github.com/ash-project/ash_double_entry.git
synced 2024-09-20 05:23:22 +12:00
156 lines
5.4 KiB
Markdown
156 lines
5.4 KiB
Markdown
|
# 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?
|
||
|
|
||
|
1. Account balances are updated automatically as transfers are introduced.
|
||
|
2. Arbitrary custom validations and behavior by virtue of modifying your own resources.
|
||
|
3. Transactions can be entered in the past, and all future balances are updated (and therefore validated).
|
||
|
|
||
|
## Setup
|
||
|
|
||
|
### Define your account resource
|
||
|
|
||
|
#### Example
|
||
|
|
||
|
```elixir
|
||
|
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 accepts `identifier`, `currency`, and the attributes in `open_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)
|
||
|
- Adds a `has_many` relationship called `balances`, referring to all related balances of an account
|
||
|
- Adds an aggregate called `balance`, referring to the latest balance as a `decimal` for that account
|
||
|
- Adds the following calculations:
|
||
|
- A `balance_as_of_ulid` calculation that takes an argument called `ulid`, which corresponds to a transfer id and returns the balance as a
|
||
|
decimal.
|
||
|
- A `balance_as_of` calculation that takes a `utc_datetime_usec` and returns the balance as of that datetime.
|
||
|
- Adds an identity called `unique_identifier` that ensures `identifier` is unique.
|
||
|
|
||
|
### Define your transfer resource
|
||
|
|
||
|
#### Example
|
||
|
|
||
|
```elixir
|
||
|
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`, a `AshDoubleEntry.ULID` primary key which is sortable based on the `timestamp` 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`, a `belongs_to` relationship of the account the transfer is from
|
||
|
- `:to_account`, a `belongs_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
|
||
|
|
||
|
```elixir
|
||
|
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, by `transfer_id` and `account_id`
|
||
|
- Adds an identity that ensures that `account_id` and `transfer_id` are unique
|