From 54608d5c761fdfe7f6e19118bcde67cd46112844 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Fri, 18 Aug 2023 23:49:09 -0400 Subject: [PATCH] improvement: wrap up initial implementaiton, add guides --- README.md | 13 +- .../get-started-with-double-entry.md | 155 ++++++++++++++++++ lib/account/transformers/add_structure.ex | 55 +++---- lib/ash_double_entry.ex | 1 + lib/balance/transformers/add_structure.ex | 10 +- lib/transfer/changes/set_amounts.ex | 34 ---- lib/transfer/changes/verify_transfer.ex | 35 ++-- lib/transfer/transformers/add_structure.ex | 16 +- mix.exs | 8 +- mix.lock | 2 +- test/ash_double_entry_test.exs | 2 - 11 files changed, 210 insertions(+), 121 deletions(-) create mode 100644 documentation/tutorials/get-started-with-double-entry.md delete mode 100644 lib/transfer/changes/set_amounts.ex diff --git a/README.md b/README.md index 29ee2e2..7c2a7aa 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # AshDoubleEntry -**TODO: Add description** +An extensible double entry system built using [Ash](ash-hq.org) resources. + +See the [getting-started-guide](github.com/ash-project/ash_double_entry.git) to +setup the project! ## Installation -If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `ash_double_entry` to your list of dependencies in `mix.exs`: +The package can be installed by adding `ash_double_entry` to your list of dependencies in `mix.exs`: ```elixir def deps do @@ -14,8 +16,3 @@ def deps do ] end ``` - -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at . - diff --git a/documentation/tutorials/get-started-with-double-entry.md b/documentation/tutorials/get-started-with-double-entry.md new file mode 100644 index 0000000..0623f97 --- /dev/null +++ b/documentation/tutorials/get-started-with-double-entry.md @@ -0,0 +1,155 @@ +# 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 diff --git a/lib/account/transformers/add_structure.ex b/lib/account/transformers/add_structure.ex index 3069d58..eb017ca 100644 --- a/lib/account/transformers/add_structure.ex +++ b/lib/account/transformers/add_structure.ex @@ -8,29 +8,19 @@ defmodule AshDoubleEntry.Account.Transformers.AddStructure do def transform(dsl) do dsl |> add_primary_read_action() - |> add_balance_as_of_ulid_calculation() - |> add_balance_as_of_calculation() - |> Ash.Resource.Builder.add_attribute(:identifier, :string, allow_nil?: false) - |> Ash.Resource.Builder.add_aggregate(:balance, :first, [:balances], - field: :balance, - default: Decimal.new(0), - sort: [transfer_id: :desc] + |> Ash.Resource.Builder.add_attribute(:id, :uuid, + primary_key?: true, + writable?: false, + generated?: true, + allow_nil?: false, + default: &Ash.UUID.generate/0 ) + |> Ash.Resource.Builder.add_attribute(:identifier, :string, allow_nil?: false) |> Ash.Resource.Builder.add_attribute( :currency, :string, allow_nil?: false ) - |> Ash.Resource.Builder.add_attribute( - :must_be_positive, - :boolean, - allow_nil?: false, - default: true - ) - |> Ash.Resource.Builder.add_attribute(:inserted_at, :utc_datetime_usec, - allow_nil?: false, - default: &DateTime.utc_now/0 - ) |> Ash.Resource.Builder.add_action(:create, :open, accept: Enum.uniq( @@ -44,15 +34,25 @@ defmodule AshDoubleEntry.Account.Transformers.AddStructure do ) ] ) - |> Ash.Resource.Builder.add_identity(:unique_identifier, [:identifier], - pre_check_with: pre_check_with(dsl) + |> Ash.Resource.Builder.add_attribute(:inserted_at, :utc_datetime_usec, + allow_nil?: false, + default: &DateTime.utc_now/0 + ) + |> Ash.Resource.Builder.add_aggregate(:balance, :first, [:balances], + field: :balance, + default: Decimal.new(0), + sort: [transfer_id: :desc] ) |> Ash.Resource.Builder.add_relationship( :has_many, :balances, AshDoubleEntry.Account.Info.account_balance_resource!(dsl), - destination_attribute: :account_id, - source_attribute: :id + destination_attribute: :account_id + ) + |> add_balance_as_of_ulid_calculation() + |> add_balance_as_of_calculation() + |> Ash.Resource.Builder.add_identity(:unique_identifier, [:identifier], + pre_check_with: pre_check_with(dsl) ) end @@ -92,19 +92,6 @@ defmodule AshDoubleEntry.Account.Transformers.AddStructure do ) end - defbuilder add_balance_aggregate(dsl) do - Ash.Resource.Builder.add_aggregate( - dsl, - :balance, - :first, - [:balances], - field: :balance, - query: [ - sort: [ulid: :desc] - ] - ) - end - defbuilder add_primary_read_action(dsl) do if Ash.Resource.Info.primary_action(dsl, :read) do {:ok, dsl} diff --git a/lib/ash_double_entry.ex b/lib/ash_double_entry.ex index 29380b8..ee0cd5b 100644 --- a/lib/ash_double_entry.ex +++ b/lib/ash_double_entry.ex @@ -1,2 +1,3 @@ defmodule AshDoubleEntry do + @moduledoc false end diff --git a/lib/balance/transformers/add_structure.ex b/lib/balance/transformers/add_structure.ex index 5109922..b2fcc4c 100644 --- a/lib/balance/transformers/add_structure.ex +++ b/lib/balance/transformers/add_structure.ex @@ -7,7 +7,6 @@ defmodule AshDoubleEntry.Balance.Transformers.AddStructure do def transform(dsl) do dsl - |> add_primary_read_action() |> Ash.Resource.Builder.add_attribute(:id, :uuid, primary_key?: true, writable?: false, @@ -15,6 +14,7 @@ defmodule AshDoubleEntry.Balance.Transformers.AddStructure do allow_nil?: false, default: &Ash.UUID.generate/0 ) + |> Ash.Resource.Builder.add_attribute(:balance, :decimal, allow_nil?: false) |> Ash.Resource.Builder.add_relationship( :belongs_to, :transfer, @@ -30,15 +30,15 @@ defmodule AshDoubleEntry.Balance.Transformers.AddStructure do allow_nil?: false, attribute_writable?: true ) - |> Ash.Resource.Builder.add_attribute(:balance, :decimal, allow_nil?: false) - |> Ash.Resource.Builder.add_identity(:unique_references, [:account_id, :transfer_id], - pre_check_with: pre_check_with(dsl) - ) + |> add_primary_read_action() |> Ash.Resource.Builder.add_action(:create, :upsert_balance, accept: [:balance, :account_id, :transfer_id], upsert?: true, upsert_identity: :unique_references ) + |> Ash.Resource.Builder.add_identity(:unique_references, [:account_id, :transfer_id], + pre_check_with: pre_check_with(dsl) + ) end defbuilder add_primary_read_action(dsl) do diff --git a/lib/transfer/changes/set_amounts.ex b/lib/transfer/changes/set_amounts.ex deleted file mode 100644 index 07b4595..0000000 --- a/lib/transfer/changes/set_amounts.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule AshDoubleEntry.Transfer.Changes.SetAmounts do - use Ash.Resource.Change - - def change(changeset, _, _) do - case Ash.Changeset.fetch_argument(changeset, :amount) do - {:ok, amount} when not is_nil(amount) -> - changeset - |> Ash.Changeset.force_change_new_attribute(:to_amount, amount) - |> Ash.Changeset.force_change_new_attribute(:from_amount, amount) - - _ -> - cond do - is_nil(Ash.Changeset.get_attribute(changeset, :to_amount)) -> - {:error, - Ash.Error.Changes.Required.exception( - field: :to_amount, - type: :attribute, - resource: changeset.resource - )} - - is_nil(Ash.Changeset.get_attribute(changeset, :from_amount)) -> - {:error, - Ash.Error.Changes.Required.exception( - field: :from_amount, - type: :attribute, - resource: changeset.resource - )} - - true -> - changeset - end - end - end -end diff --git a/lib/transfer/changes/verify_transfer.ex b/lib/transfer/changes/verify_transfer.ex index 17f06d4..55ee0b3 100644 --- a/lib/transfer/changes/verify_transfer.ex +++ b/lib/transfer/changes/verify_transfer.ex @@ -13,8 +13,7 @@ defmodule AshDoubleEntry.Transfer.Changes.VerifyTransfer do |> Ash.Changeset.before_action(fn changeset -> from_account_id = Ash.Changeset.get_attribute(changeset, :from_account_id) to_account_id = Ash.Changeset.get_attribute(changeset, :to_account_id) - from_amount = Ash.Changeset.get_attribute(changeset, :from_amount) - to_amount = Ash.Changeset.get_attribute(changeset, :to_amount) + amount = Ash.Changeset.get_attribute(changeset, :amount) timestamp = Ash.Changeset.get_attribute(changeset, :timestamp) timestamp = @@ -26,27 +25,21 @@ defmodule AshDoubleEntry.Transfer.Changes.VerifyTransfer do ulid = AshDoubleEntry.ULID.generate(timestamp) accounts = - try do - changeset.resource - |> AshDoubleEntry.Transfer.Info.transfer_account_resource!() - |> Ash.Query.filter(id in ^[from_account_id, to_account_id]) - |> Ash.Query.for_read(:lock_accounts) - |> Ash.Query.load(balance_as_of_ulid: %{ulid: ulid}) - |> changeset.api.read!(authorize?: false, tracer: context[:tracer]) - rescue - e -> - IO.puts(Exception.format(:error, e, __STACKTRACE__)) - [] - end + changeset.resource + |> AshDoubleEntry.Transfer.Info.transfer_account_resource!() + |> Ash.Query.filter(id in ^[from_account_id, to_account_id]) + |> Ash.Query.for_read(:lock_accounts) + |> Ash.Query.load(balance_as_of_ulid: %{ulid: ulid}) + |> changeset.api.read!(authorize?: false, tracer: context[:tracer]) from_account = Enum.find(accounts, &(&1.id == from_account_id)) to_account = Enum.find(accounts, &(&1.id == to_account_id)) new_from_account_balance = - Decimal.sub(from_account.balance_as_of_ulid, from_amount) + Decimal.sub(from_account.balance_as_of_ulid, amount) new_to_account_balance = - Decimal.add(to_account.balance_as_of_ulid, to_amount) + Decimal.add(to_account.balance_as_of_ulid, amount) changeset.resource |> AshDoubleEntry.Transfer.Info.transfer_balance_resource!() @@ -80,8 +73,7 @@ defmodule AshDoubleEntry.Transfer.Changes.VerifyTransfer do |> Ash.Changeset.set_context(%{ from_account: from_account, to_account: to_account, - from_amount: from_amount, - to_amount: to_amount + amount: amount }) end) |> Ash.Changeset.after_action(fn changeset, result -> @@ -98,17 +90,17 @@ defmodule AshDoubleEntry.Transfer.Changes.VerifyTransfer do %{ account_id: balance.account_id, transfer_id: balance.transfer_id, - balance: Decimal.sub(balance.balance, changeset.context.from_amount) + balance: Decimal.sub(balance.balance, changeset.context.amount) } else %{ account_id: balance.account_id, transfer_id: balance.transfer_id, - balance: Decimal.add(balance.balance, changeset.context.to_amount) + balance: Decimal.add(balance.balance, changeset.context.amount) } end end) - |> changeset.api.bulk_create( + |> changeset.api.bulk_create!( AshDoubleEntry.Transfer.Info.transfer_balance_resource!(changeset.resource), :upsert_balance, context_to_opts(context, @@ -119,7 +111,6 @@ defmodule AshDoubleEntry.Transfer.Changes.VerifyTransfer do ) {:ok, result} - # do verification logic end) end diff --git a/lib/transfer/transformers/add_structure.ex b/lib/transfer/transformers/add_structure.ex index 1095b5d..d116afb 100644 --- a/lib/transfer/transformers/add_structure.ex +++ b/lib/transfer/transformers/add_structure.ex @@ -2,6 +2,8 @@ defmodule AshDoubleEntry.Transfer.Transformers.AddStructure do use Spark.Dsl.Transformer def before?(Ash.Resource.Transformers.CachePrimaryKey), do: true + def before?(Ash.Resource.Transformers.BelongsToSourceField), do: true + def before?(Ash.Resource.Transformers.BelongsToAttribute), do: true def before?(_), do: false def transform(dsl) do @@ -12,8 +14,7 @@ defmodule AshDoubleEntry.Transfer.Transformers.AddStructure do default: &AshDoubleEntry.ULID.generate/0, generated?: false ) - |> Ash.Resource.Builder.add_attribute(:from_amount, :decimal, allow_nil?: false) - |> Ash.Resource.Builder.add_attribute(:to_amount, :decimal, allow_nil?: false) + |> Ash.Resource.Builder.add_attribute(:amount, :decimal, allow_nil?: false) |> Ash.Resource.Builder.add_attribute(:timestamp, :utc_datetime_usec, allow_nil?: false, default: &DateTime.utc_now/0 @@ -34,19 +35,12 @@ defmodule AshDoubleEntry.Transfer.Transformers.AddStructure do AshDoubleEntry.Transfer.Info.transfer_account_resource!(dsl), attribute_writable?: true ) - |> Ash.Resource.Builder.add_change({AshDoubleEntry.Transfer.Changes.VerifyTransfer, []}) |> Ash.Resource.Builder.add_action(:create, :transfer, - accept: [:to_amount, :from_amount, :timestamp, :from_account_id, :to_account_id], - allow_nil_input: [:to_amount, :from_amount], - arguments: [ - Ash.Resource.Builder.build_action_argument(:amount, :decimal) - ], - changes: [ - Ash.Resource.Builder.build_action_change({AshDoubleEntry.Transfer.Changes.SetAmounts, []}) - ] + accept: [:amount, :timestamp, :from_account_id, :to_account_id] ) |> Ash.Resource.Builder.add_action(:read, :read_transfers, pagination: Ash.Resource.Builder.build_pagination(keyset?: true) ) + |> Ash.Resource.Builder.add_change({AshDoubleEntry.Transfer.Changes.VerifyTransfer, []}) end end diff --git a/mix.exs b/mix.exs index fcaf150..f111497 100644 --- a/mix.exs +++ b/mix.exs @@ -1,10 +1,12 @@ defmodule AshDoubleEntry.MixProject do use Mix.Project + @version "0.1.0" + def project do [ app: :ash_double_entry, - version: "0.1.0", + version: @version, elixir: "~> 1.15", start_permanent: Mix.env() == :prod, consolidate_protocols: Mix.env() != :test, @@ -33,9 +35,7 @@ defmodule AshDoubleEntry.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ash, github: "ash-project/ash"} - # {:dep_from_hexpm, "~> 0.3.0"}, - # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + {:ash, "~> 2.14"} ] end end diff --git a/mix.lock b/mix.lock index 962d66f..2c66d7e 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,5 @@ %{ - "ash": {:git, "https://github.com/ash-project/ash.git", "6daae630f446441a314af162dae23154502e9fb8", []}, + "ash": {:hex, :ash, "2.14.2", "111829b1db52e43c28c0660b2da8f1bfc76199dd8e15289f86f5998c07fb8d36", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 1.1.20 and < 2.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e6e5924ccb3f8b0ab5de15b22006a4a18c0f002aadadd7aa9dfa39d53cff0b9a"}, "comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"}, diff --git a/test/ash_double_entry_test.exs b/test/ash_double_entry_test.exs index 4545ed4..2017c13 100644 --- a/test/ash_double_entry_test.exs +++ b/test/ash_double_entry_test.exs @@ -15,8 +15,6 @@ defmodule AshDoubleEntryTest do end attributes do - uuid_primary_key :id - attribute :allow_zero_balance, :boolean do default true end