mirror of
https://github.com/ash-project/ash_double_entry.git
synced 2024-09-20 13:33:55 +12:00
205 lines
6.5 KiB
Markdown
205 lines
6.5 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
|
|
|
|
|
|
### Define an Ash api to use them through
|
|
|
|
```elixir
|
|
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
|
|
|
|
```elixir
|
|
Account
|
|
|> Ash.Changeset.for_create(:open, %{identifier: "account_one", currency: "USD"})
|
|
|> Api.create!()
|
|
```
|
|
|
|
#### Create transfers between accounts
|
|
|
|
```elixir
|
|
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
|
|
|
|
```elixir
|
|
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](https://ash-hq.org).
|