ash_double_entry/test/ash_double_entry_test.exs

449 lines
12 KiB
Elixir
Raw Normal View History

2023-07-22 12:27:52 +12:00
defmodule AshDoubleEntryTest do
use ExUnit.Case
2023-12-06 13:49:23 +13:00
require Ash.Query
2023-07-22 12:27:52 +12:00
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,
2024-04-02 10:21:49 +13:00
domain: AshDoubleEntryTest.Domain,
data_layer: Ash.DataLayer.Ets,
extensions: [AshDoubleEntry.Account]
ets do
private? true
end
account do
2024-04-02 10:21:49 +13:00
pre_check_identities_with AshDoubleEntryTest.Domain
2023-08-06 16:45:07 +12:00
transfer_resource AshDoubleEntryTest.Transfer
balance_resource AshDoubleEntryTest.Balance
open_action_accept [:allow_zero_balance]
end
attributes do
attribute :allow_zero_balance, :boolean do
default true
end
end
end
defmodule Transfer do
use Ash.Resource,
2024-04-02 10:21:49 +13:00
domain: AshDoubleEntryTest.Domain,
data_layer: Ash.DataLayer.Ets,
extensions: [AshDoubleEntry.Transfer]
ets do
private? true
end
transfer do
2023-08-06 16:45:07 +12:00
account_resource Account
balance_resource AshDoubleEntryTest.Balance
end
actions do
2024-04-02 10:21:49 +13:00
defaults [:destroy, update: [:amount]]
end
end
defmodule Balance do
use Ash.Resource,
2024-04-02 10:21:49 +13:00
domain: AshDoubleEntryTest.Domain,
data_layer: Ash.DataLayer.Ets,
extensions: [AshDoubleEntry.Balance]
ets do
private? true
end
balance do
2024-04-02 10:21:49 +13:00
pre_check_identities_with AshDoubleEntryTest.Domain
2023-08-06 16:45:07 +12:00
transfer_resource Transfer
account_resource Account
end
actions do
defaults [:destroy]
end
validations do
validate compare(:balance, greater_than_or_equal_to: 0),
where: [RequiresPositiveBalance],
message: "balance cannot be negative"
end
end
2024-04-02 10:21:49 +13:00
defmodule Domain do
use Ash.Domain
resources do
resource Account
resource Transfer
resource Balance
end
end
describe "opening accounts" do
test "an account can be opened" do
assert %{identifier: "account_one"} =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_one", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
end
test "you cannot open duplicate accounts" do
assert %{identifier: "account_one"} =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_one", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
assert_raise Ash.Error.Invalid, ~r/identifier: has already been taken/, fn ->
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_one", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
end
end
end
describe "transfers" do
test "with no transfers, balance is 0" do
account_balance =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_one", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
|> Ash.load!(:balance_as_of)
2023-12-06 13:49:23 +13:00
|> Map.get(:balance_as_of)
2023-12-06 13:49:23 +13:00
assert Money.equal?(account_balance, Money.new!(:USD, 0))
end
test "transfers between accounts update the balance accordingly" do
account_one =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_one", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
account_two =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_two", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
Transfer
|> Ash.Changeset.for_create(:transfer, %{
2023-12-06 13:49:23 +13:00
amount: Money.new!(:USD, 20),
from_account_id: account_one.id,
to_account_id: account_two.id
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
2023-12-06 13:49:23 +13:00
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_one, :balance_as_of).balance_as_of,
2023-12-06 13:49:23 +13:00
Money.new!(:USD, -20)
)
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_two, :balance_as_of).balance_as_of,
Money.new!(:USD, 20)
)
end
test "destroying transfers update the balances accordingly" do
account_one =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_one", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
account_two =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_two", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
Transfer
|> Ash.Changeset.for_create(:transfer, %{
amount: Money.new!(:USD, 20),
from_account_id: account_one.id,
to_account_id: account_two.id
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
|> Ash.Changeset.for_destroy(:destroy)
2024-04-02 10:21:49 +13:00
|> Ash.destroy!()
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_one, :balance_as_of).balance_as_of,
Money.new!(:USD, 0)
)
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_two, :balance_as_of).balance_as_of,
Money.new!(:USD, 0)
)
end
test "updating transfer's amount update the balances accordingly" do
account_one =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_one", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
account_two =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_two", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
Transfer
|> Ash.Changeset.for_create(:transfer, %{
amount: Money.new!(:USD, 20),
from_account_id: account_one.id,
to_account_id: account_two.id
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
|> Ash.Changeset.for_update(:update, %{amount: Money.new!(:USD, 10)})
2024-04-02 10:21:49 +13:00
|> Ash.update!()
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_one, :balance_as_of).balance_as_of,
Money.new!(:USD, -10)
)
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_two, :balance_as_of).balance_as_of,
Money.new!(:USD, 10)
)
end
test "adding transfer update the balances accordingly" do
now = DateTime.utc_now()
account_one =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_one", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
account_two =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_two", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
Transfer
|> Ash.Changeset.for_create(:transfer, %{
amount: Money.new!(:USD, 20),
from_account_id: account_one.id,
to_account_id: account_two.id,
timestamp: now
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
Transfer
|> Ash.Changeset.for_create(:transfer, %{
amount: Money.new!(:USD, 20),
from_account_id: account_two.id,
to_account_id: account_one.id,
timestamp: DateTime.add(now, -2, :minute)
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_one, :balance_as_of).balance_as_of,
Money.new!(:USD, 0)
)
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_two, :balance_as_of).balance_as_of,
Money.new!(:USD, 0)
)
end
test "destroying a transfer update the balances accordingly" do
now = DateTime.utc_now()
account_one =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_one", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
account_two =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_two", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
transfer_1 =
Transfer
|> Ash.Changeset.for_create(:transfer, %{
amount: Money.new!(:USD, 20),
from_account_id: account_one.id,
to_account_id: account_two.id,
timestamp: now
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
transfer_2 =
Transfer
|> Ash.Changeset.for_create(:transfer, %{
amount: Money.new!(:USD, 20),
from_account_id: account_two.id,
to_account_id: account_one.id,
timestamp: DateTime.add(now, -2, :minute)
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_one, :balance_as_of).balance_as_of,
Money.new!(:USD, 0)
)
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_two, :balance_as_of).balance_as_of,
Money.new!(:USD, 0)
)
2024-04-02 10:21:49 +13:00
transfer_2 |> Ash.destroy!()
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_one, :balance_as_of).balance_as_of,
Money.new!(:USD, -20)
)
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_two, :balance_as_of).balance_as_of,
Money.new!(:USD, 20)
)
2024-04-02 10:21:49 +13:00
transfer_1 |> Ash.destroy!()
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_one, :balance_as_of).balance_as_of,
Money.new!(:USD, 0)
)
assert Money.equal?(
2024-04-02 10:21:49 +13:00
Ash.load!(account_two, :balance_as_of).balance_as_of,
Money.new!(:USD, 0)
)
end
test "balances can be validated" do
account_one =
Account
|> Ash.Changeset.for_create(:open, %{
identifier: "account_one",
currency: "USD",
allow_zero_balance: false
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
account_two =
Account
|> Ash.Changeset.for_create(:open, %{identifier: "account_two", currency: "USD"})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
assert_raise Ash.Error.Invalid, ~r/balance cannot be negative/, fn ->
Transfer
|> Ash.Changeset.for_create(:transfer, %{
2023-12-06 13:49:23 +13:00
amount: Money.new!(:USD, 20),
from_account_id: account_one.id,
to_account_id: account_two.id
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
end
end
test "balances are validated for each future balance" do
now = DateTime.utc_now()
account_one =
Account
|> Ash.Changeset.for_create(:open, %{
identifier: "account_one",
currency: "USD"
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
account_two =
Account
|> Ash.Changeset.for_create(:open, %{
identifier: "account_two",
currency: "USD",
allow_zero_balance: false
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
account_three =
Account
|> Ash.Changeset.for_create(:open, %{
identifier: "account_three",
currency: "USD",
allow_zero_balance: false
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
account_four =
Account
|> Ash.Changeset.for_create(:open, %{
identifier: "account_four",
currency: "USD",
2024-04-02 10:21:49 +13:00
allow_zero_balance: false
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
Transfer
|> Ash.Changeset.for_create(:transfer, %{
2023-12-06 13:49:23 +13:00
amount: Money.new!(:USD, 20),
from_account_id: account_one.id,
to_account_id: account_two.id,
timestamp: DateTime.add(now, 2, :minute)
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
Transfer
|> Ash.Changeset.for_create(:transfer, %{
2023-12-06 13:49:23 +13:00
amount: Money.new!(:USD, 20),
from_account_id: account_two.id,
to_account_id: account_three.id,
timestamp: DateTime.add(now, 3, :minute)
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
assert_raise Ash.Error.Invalid, ~r/balance cannot be negative/, fn ->
Transfer
|> Ash.Changeset.for_create(:transfer, %{
2023-12-06 13:49:23 +13:00
amount: Money.new!(:USD, 20),
from_account_id: account_two.id,
to_account_id: account_four.id,
timestamp: DateTime.add(now, 1, :minute)
})
2024-04-02 10:21:49 +13:00
|> Ash.create!()
end
end
2023-07-22 12:27:52 +12:00
end
end