View Source 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

Setup AshMoney

Follow the setup guide for AshMoney. If you are using with AshPostgres, be sure to include the :ex_money_sql dependency in your mix.exs.

Define your account resource

Example

defmodule YourApp.Ledger.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.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

What does this extension do?

  • Adds the following attributes:
    • :id, a :uuid primary key
    • :currency, a :string representing the currency of the account.
    • :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.
  • 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

defmodule YourApp.Ledger.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.Ledger.Account
    balance_resource YourApp.Ledger.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 AshMoney.Types.Money representing the amount and currency 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

defmodule YourApp.Ledger.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 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

  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 && Money.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
    • configure primary read action to have keyset pagination enabled
    • 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

defmodule YourApp.Ledger do
  use Ash.Api

  resources do
    resource YourApp.Ledger.Account
    resource YourApp.Ledger.Balance
    resource YourApp.Ledger.Transfer
  end
end

And add the API to your config

config :your_app, ash_apis: [..., YourApp.Ledger]

Generate Migrations

mix ash_postgres.generate_migrations --name add_double_entry_ledger

Run them

mix ash_postgres.migrate

Use them

Create an account

YourApp.Ledger.Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_one"})
|> YourApp.Ledger.create!()

Create transfers between accounts

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
})
|> YourApp.Ledger.create!()

Check an account's balance

YourApp.Ledger.Account
|> YourApp.Ledger.get!(account_id, load: :balance_as_of)
|> Map.get(:balance_as_of)
# => Money.new!(20, :USD)

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.