diff --git a/config/config.exs b/config/config.exs index eedcc98..91d1cc6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -20,6 +20,9 @@ if config_env() == :test do config :ash, :disable_async?, true end +config :ash, :known_types, [AshMoney.Types.Money] +config :logger, level: :error + config :spark, :formatter, remove_parens?: true, "Ash.Resource": [] diff --git a/documentation/dsls/DSL:-AshDoubleEntry.Balance.md b/documentation/dsls/DSL:-AshDoubleEntry.Balance.md index 9ce7cda..5cfd225 100644 --- a/documentation/dsls/DSL:-AshDoubleEntry.Balance.md +++ b/documentation/dsls/DSL:-AshDoubleEntry.Balance.md @@ -22,6 +22,7 @@ An extension for creating a double entry ledger balance. See the getting started | [`account_resource`](#balance-account_resource){: #balance-account_resource .spark-required} | `module` | | The resource used for accounts | | [`pre_check_identities_with`](#balance-pre_check_identities_with){: #balance-pre_check_identities_with } | `module` | | A domain to use to precheck generated identities. Required by certain data layers. | | [`money_composite_type?`](#balance-money_composite_type?){: #balance-money_composite_type? } | `boolean` | `true` | Whether the balance is stored as a composite type. | +| [`data_layer_can_add_money?`](#balance-data_layer_can_add_money?){: #balance-data_layer_can_add_money? } | `boolean` | `true` | Whether or not the data layer supports adding money. | diff --git a/lib/balance/balance.ex b/lib/balance/balance.ex index b59c64f..098d25f 100644 --- a/lib/balance/balance.ex +++ b/lib/balance/balance.ex @@ -24,6 +24,11 @@ defmodule AshDoubleEntry.Balance do type: :boolean, doc: "Whether the balance is stored as a composite type.", default: true + ], + data_layer_can_add_money?: [ + type: :boolean, + doc: "Whether or not the data layer supports adding money.", + default: true ] ] } diff --git a/lib/balance/changes/adjust_balance.ex b/lib/balance/changes/adjust_balance.ex new file mode 100644 index 0000000..31b7b38 --- /dev/null +++ b/lib/balance/changes/adjust_balance.ex @@ -0,0 +1,45 @@ +defmodule AshDoubleEntry.Balance.Changes.AdjustBalance do + @moduledoc false + use Ash.Resource.Change + + def change(changeset, _, _) do + amount_delta = changeset.arguments.delta + + new_balance = + if changeset.data.account_id == changeset.arguments.from_account_id do + Money.sub!(changeset.data.balance, amount_delta) + else + Money.add!(changeset.data.balance, amount_delta) + end + + Ash.Changeset.force_change_attribute(changeset, :balance, new_balance) + end + + def atomic(changeset, opts, _) do + amount_delta = changeset.arguments.delta + + if Ash.Expr.expr?(amount_delta) do + raise """ + Amount delta is dynamic. The balance adjustment logic does not support this. + + Expected a literal money value, got an expression: #{inspect(amount_delta)} + """ + end + + if opts[:can_add_money?] do + {:atomic, + %{ + balance: + expr( + if account_id == ^changeset.arguments.from_account_id do + ^atomic_ref(:balance) - ^amount_delta + else + ^atomic_ref(:balance) + ^amount_delta + end + ) + }} + else + {:not_atomic, "Data layer cannot add money, so balance cannot be adjusted atomically"} + end + end +end diff --git a/lib/balance/transformers/add_structure.ex b/lib/balance/transformers/add_structure.ex index 2c0fe97..2999b30 100644 --- a/lib/balance/transformers/add_structure.ex +++ b/lib/balance/transformers/add_structure.ex @@ -3,6 +3,7 @@ defmodule AshDoubleEntry.Balance.Transformers.AddStructure do @moduledoc false use Spark.Dsl.Transformer import Spark.Dsl.Builder + import Ash.Expr def before?(Ash.Resource.Transformers.CachePrimaryKey), do: true def before?(Ash.Resource.Transformers.BelongsToAttribute), do: true @@ -51,6 +52,32 @@ defmodule AshDoubleEntry.Balance.Transformers.AddStructure do upsert?: true, upsert_identity: :unique_references ) + |> Ash.Resource.Builder.add_action(:update, :adjust_balance, + changes: [ + Ash.Resource.Builder.build_action_change( + {Ash.Resource.Change.Filter, + filter: + expr( + account_id in [^arg(:from_account_id), ^arg(:to_account_id)] and + transfer_id > ^arg(:transfer_id) + )} + ), + Ash.Resource.Builder.build_action_change( + {AshDoubleEntry.Balance.Changes.AdjustBalance, + can_add_money?: AshDoubleEntry.Balance.Info.balance_data_layer_can_add_money?(dsl)} + ) + ], + arguments: [ + Ash.Resource.Builder.build_action_argument(:from_account_id, :uuid, allow_nil?: false), + Ash.Resource.Builder.build_action_argument(:to_account_id, :uuid, allow_nil?: false), + Ash.Resource.Builder.build_action_argument(:delta, AshMoney.Types.Money, + allow_nil?: false + ), + Ash.Resource.Builder.build_action_argument(:transfer_id, AshDoubleEntry.ULID, + allow_nil?: false + ) + ] + ) |> Ash.Resource.Builder.add_identity(:unique_references, [:account_id, :transfer_id], pre_check_with: pre_check_with(dsl) ) diff --git a/lib/transfer/changes/verify_transfer.ex b/lib/transfer/changes/verify_transfer.ex index 14755ef..808340f 100644 --- a/lib/transfer/changes/verify_transfer.ex +++ b/lib/transfer/changes/verify_transfer.ex @@ -80,77 +80,55 @@ defmodule AshDoubleEntry.Transfer.Changes.VerifyTransfer do amount_delta ) - unless changeset.action.type == :destroy do - changeset.resource - |> AshDoubleEntry.Transfer.Info.transfer_balance_resource!() - |> Ash.Changeset.for_create( - :upsert_balance, - %{ - account_id: from_account.id, - transfer_id: result.id, - balance: new_from_account_balance, - account: from_account - }, - Ash.Context.to_opts(context, - domain: changeset.domain, - skip_unknown_inputs: [:account_id, :transfer_id, :balance, :account] - ) - ) - |> Ash.create!() + balance_resource = + AshDoubleEntry.Transfer.Info.transfer_balance_resource!(changeset.resource) - changeset.resource - |> AshDoubleEntry.Transfer.Info.transfer_balance_resource!() - |> Ash.Changeset.for_create( + unless changeset.action.type == :destroy do + Ash.bulk_create!( + [ + %{ + account_id: from_account.id, + transfer_id: result.id, + balance: new_from_account_balance + }, + %{ + account_id: to_account.id, + transfer_id: result.id, + balance: new_to_account_balance + } + ], + balance_resource, :upsert_balance, - %{ - account_id: to_account.id, - transfer_id: result.id, - balance: new_to_account_balance - }, Ash.Context.to_opts(context, domain: changeset.domain, - skip_unknown_inputs: [:account_id, :transfer_id, :balance] + upsert_fields: [:balance], + return_errors?: true, + stop_on_error?: true ) ) - |> Ash.create!() end - # Turn this into a bulk update when we support it in Ash core - changeset.resource - |> AshDoubleEntry.Transfer.Info.transfer_balance_resource!() - |> Ash.Query.filter(account_id in ^[from_account.id, to_account.id]) - |> Ash.Query.filter(transfer_id > ^result.id) - |> Ash.stream!(Ash.Context.to_opts(context, domain: changeset.domain)) - |> Stream.map(fn balance -> - amount_delta = - if changeset.action.type == :destroy do - Money.mult!(amount_delta, -1) - else - amount_delta - end - - if balance.account_id == from_account.id do - %{ - account_id: balance.account_id, - transfer_id: balance.transfer_id, - balance: Money.sub!(balance.balance, amount_delta) - } + amount_delta = + if changeset.action.type == :destroy do + Money.mult!(amount_delta, -1) else - %{ - account_id: balance.account_id, - transfer_id: balance.transfer_id, - balance: Money.add!(balance.balance, amount_delta) - } + amount_delta end - end) - |> Ash.bulk_create!( - AshDoubleEntry.Transfer.Info.transfer_balance_resource!(changeset.resource), - :upsert_balance, + + Ash.bulk_update!( + balance_resource, + :adjust_balance, + %{ + from_account_id: from_account.id, + to_account_id: to_account.id, + transfer_id: result.id, + delta: amount_delta + }, Ash.Context.to_opts(context, domain: changeset.domain, + strategy: [:atomic, :stream, :atomic_batches], return_errors?: true, - stop_on_error?: true, - upsert_fields: [:balance] + stop_on_error?: true ) ) @@ -174,16 +152,15 @@ defmodule AshDoubleEntry.Transfer.Changes.VerifyTransfer do Ash.Changeset.before_action(changeset, fn changeset -> balance_resource |> Ash.Query.filter(transfer_id == ^changeset.data.id) - |> Ash.stream!(Ash.Context.to_opts(context, authorize?: false, domain: changeset.domain)) - |> Enum.each(fn balance -> - balance - |> Ash.Changeset.for_destroy( - destroy_action, - %{}, - Ash.Context.to_opts(context, authorize?: false, domain: changeset.domain) + |> Ash.bulk_destroy!( + destroy_action, + %{}, + Ash.Context.to_opts(context, + authorize?: false, + domain: changeset.domain, + strategy: [:stream, :atomic, :atomic_batches] ) - |> Ash.destroy!() - end) + ) changeset end) diff --git a/mix.exs b/mix.exs index 7d08299..d33742f 100644 --- a/mix.exs +++ b/mix.exs @@ -116,8 +116,8 @@ defmodule AshDoubleEntry.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ash, ash_version("~> 3.0.0-rc.7")}, - {:ash_money, "~> 0.1.6-rc.0"}, + {:ash, ash_version("~> 3.0.0-rc")}, + {:ash_money, path: "../ash_money"}, {:ex_money_sql, "~> 1.10"}, # dev/test dependencies {:git_ops, "~> 2.5", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index 752639c..8922003 100644 --- a/mix.lock +++ b/mix.lock @@ -15,11 +15,11 @@ "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"}, - "ex_cldr": {:hex, :ex_cldr, "2.37.5", "9da6d97334035b961d2c2de167dc6af8cd3e09859301a5b8f49f90bd8b034593", [:mix], [{:cldr_utils, "~> 2.21", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "74ad5ddff791112ce4156382e171a5f5d3766af9d5c4675e0571f081fe136479"}, - "ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.15.1", "e92ba17c41e7405b7784e0e65f406b5f17cfe313e0e70de9befd653e12854822", [:mix], [{:ex_cldr, "~> 2.34", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "31df8bd37688340f8819bdd770eb17d659652078d34db632b85d4a32864d6a25"}, - "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.32.4", "5562148dfc631b04712983975093d2aac29df30b3bf2f7257e0c94b85b72e91b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.37", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, ">= 2.14.2", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "6fd5a82f0785418fa8b698c0be2b1845dff92b77f1b3172c763d37868fb503d2"}, - "ex_doc": {:git, "https://github.com/elixir-lang/ex_doc.git", "a99e02654fb6910660921df72838a05e44f2411a", []}, - "ex_money": {:hex, :ex_money, "5.15.4", "9c933cd65e943d9f48cb9c6878d3eaa535baade94ffc8be7630e619c99658d95", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.31", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:gringotts, "~> 1.1", [hex: :gringotts, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "196a28df6b9195d566a4ba0860d2f87b45738032b9d6f87d1ca067987a4de5bb"}, + "ex_cldr": {:hex, :ex_cldr, "2.38.0", "80399ccbfd996ea02f245394db83d7ce6113ed88e1e50e4695238cd9a0c258d5", [:mix], [{:cldr_utils, "~> 2.25", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "8758000c97bdf4b2583c3fedd7cfa35896567a7f8351248b2faa33ba73841cc7"}, + "ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.16.1", "29317f533cb5ec046d04523256cca4090291e9157028f28731395149b06ff8b2", [:mix], [{:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "095d5e973bf0ee066dd1153990d10cb6fa6d8ff0e028295bdce7a7821c70a0e4"}, + "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.33.1", "49dc6e77e6d9ad22660aaa2480a7408ad3aedfbe517e4e83e5fe3a7bf5345770", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.16", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "c003bfaa3fdee6bab5195f128b94038c2ce1cf4879a759eef431dd075d9a5dac"}, + "ex_doc": {:git, "https://github.com/elixir-lang/ex_doc.git", "f8075a37b44b406bc489ee9cb1374f6e3d3bb8ee", []}, + "ex_money": {:hex, :ex_money, "5.16.1", "99c07552948ab437ce8c7a43c80d59571145ba7c42a466e4c8ce687c71fa0f57", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.33", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:gringotts, "~> 1.1", [hex: :gringotts, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "c4c894a0a1423df31b3f0442692b1cb8d51ba826ff25778382803a059a04ae4f"}, "ex_money_sql": {:hex, :ex_money_sql, "1.11.0", "1b9b2f920d5d9220fa6dd4d8aae258daf562deaed2fb037b38b1f7ba4d0a344c", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_money, "~> 5.7", [hex: :ex_money, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.15", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "629e0541ae9f87122d34650f8c8febbc7349bbc6f881cf7a51b4d0779886107d"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, diff --git a/test/ash_double_entry_test.exs b/test/ash_double_entry_test.exs index 663cba9..fa070d1 100644 --- a/test/ash_double_entry_test.exs +++ b/test/ash_double_entry_test.exs @@ -1,14 +1,47 @@ defmodule AshDoubleEntryTest do use ExUnit.Case - import ExUnit.CaptureLog require Ash.Query + defmodule RequiresPositiveBalance do + use Ash.Resource.Validation + + def validate(changeset, _, _) do + account_id = Ash.Changeset.get_attribute(changeset, :account_id) + + if is_nil(account_id) do + :ok + else + account = Ash.get!(AshDoubleEntryTest.Account, account_id, authorize?: false) + + if account.allow_zero_balance do + {:error, "Account must require positive balance"} + else + :ok + end + end + end + + def atomic(_changeset, _, _) do + {:atomic, [:account], expr(account.allow_zero_balance == false), + expr( + error(Ash.Error.Changes.InvalidRelationship, + relationship: :account, + message: "Account must require positive balance" + ) + )} + end + end + defmodule Account do use Ash.Resource, domain: AshDoubleEntryTest.Domain, - data_layer: Ash.DataLayer.Mnesia, + data_layer: Ash.DataLayer.Ets, extensions: [AshDoubleEntry.Account] + ets do + private? true + end + account do pre_check_identities_with AshDoubleEntryTest.Domain transfer_resource AshDoubleEntryTest.Transfer @@ -26,9 +59,13 @@ defmodule AshDoubleEntryTest do defmodule Transfer do use Ash.Resource, domain: AshDoubleEntryTest.Domain, - data_layer: Ash.DataLayer.Mnesia, + data_layer: Ash.DataLayer.Ets, extensions: [AshDoubleEntry.Transfer] + ets do + private? true + end + transfer do account_resource Account balance_resource AshDoubleEntryTest.Balance @@ -42,9 +79,13 @@ defmodule AshDoubleEntryTest do defmodule Balance do use Ash.Resource, domain: AshDoubleEntryTest.Domain, - data_layer: Ash.DataLayer.Mnesia, + data_layer: Ash.DataLayer.Ets, extensions: [AshDoubleEntry.Balance] + ets do + private? true + end + balance do pre_check_identities_with AshDoubleEntryTest.Domain transfer_resource Transfer @@ -55,24 +96,10 @@ defmodule AshDoubleEntryTest do defaults [:destroy] end - changes do - change after_action(&validate_balance/3) - end - - defp validate_balance(_changeset, result, _context) do - account = result |> Ash.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 + validations do + validate compare(:balance, greater_than_or_equal_to: 0), + where: [RequiresPositiveBalance], + message: "balance cannot be negative" end end @@ -86,17 +113,6 @@ defmodule AshDoubleEntryTest do end end - setup do - Ash.DataLayer.Mnesia.start(Domain) - - on_exit(fn -> - capture_log(fn -> - :mnesia.stop() - :mnesia.delete_schema([node()]) - end) - end) - end - describe "opening accounts" do test "an account can be opened" do assert %{identifier: "account_one"} =