ash_double_entry and updating transfers

I see a unit test that is capable of updating a transfer. However I cannot get it to work and see this error.
* ** (CaseClauseError) no case clause matching: {:error, "Cannot destroy a transfer atomically if balances must be destroyed manually, or if balance is being updated"}
* ** (CaseClauseError) no case clause matching: {:error, "Cannot destroy a transfer atomically if balances must be destroyed manually, or if balance is being updated"}
I am using Postgres (16.1) and saw this in the docs:
transfer do
# configure the other resources it will interact with
account_resource YourApp.Ledger.Account
balance_resource YourApp.Ledger.Balance

# you only need this if you are using `postgres`
# and so cannot add the `references` block shown below

# destroy_balances? true
end
transfer do
# configure the other resources it will interact with
account_resource YourApp.Ledger.Account
balance_resource YourApp.Ledger.Balance

# you only need this if you are using `postgres`
# and so cannot add the `references` block shown below

# destroy_balances? true
end
cascading destroys

If you are not using a data layer capable of automatic cascade deletion, you must add destroy_balances? true to the transfer resource! We do this with the references block in ash_postgres as shown above.
cascading destroys

If you are not using a data layer capable of automatic cascade deletion, you must add destroy_balances? true to the transfer resource! We do this with the references block in ash_postgres as shown above.
I've played with both of these settings but still come up against the error I posted above. The mention of these two settings is confusing. Postgres is capable of "automatic cascade deletion", no? What am I missing? Note: There's no ash_double_entry tag.
17 Replies
ZachDaniel
ZachDaniel9h ago
Hm....something does seem strange there. Is that case clause error happening somewhere internally? In what way are you updating the transfer? Might need to see a repro
Failz
FailzOP9h ago
ZachDaniel
ZachDaniel9h ago
Latest version of everything?
Failz
FailzOP8h ago
vscode ➜ /workspaces/yakgo.money (main ✗) $ mix hex.outdated
Dependency Current Latest Status
ash 3.5.12 3.5.12 Up-to-date
ash_admin 0.13.5 0.13.5 Up-to-date
ash_authentication 4.8.7 4.8.7 Up-to-date
ash_authentication_phoenix 2.7.0 2.7.0 Up-to-date
ash_double_entry 1.0.14 1.0.14 Up-to-date
ash_money 0.2.0 0.2.0 Up-to-date
ash_paper_trail 0.5.3 0.5.3 Up-to-date
ash_phoenix 2.3.2 2.3.2 Up-to-date
ash_postgres 2.5.22 2.5.22 Up-to-date
bandit 1.6.11 1.6.11 Up-to-date
bcrypt_elixir 3.3.2 3.3.2 Up-to-date
dns_cluster 0.1.3 0.2.0 Update not possible
ecto_sql 3.12.1 3.12.1 Up-to-date
esbuild 0.9.0 0.9.0 Up-to-date
ex_money_sql 1.11.0 1.11.0 Up-to-date
finch 0.19.0 0.19.0 Up-to-date
floki 0.37.1 0.37.1 Up-to-date
gettext 0.26.2 0.26.2 Up-to-date
igniter 0.6.2 0.6.2 Up-to-date
jason 1.4.4 1.4.4 Up-to-date
live_debugger 0.2.3 0.2.3 Up-to-date
phoenix 1.7.21 1.7.21 Up-to-date
phoenix_ecto 4.6.4 4.6.4 Up-to-date
phoenix_html 4.2.1 4.2.1 Up-to-date
phoenix_live_dashboard 0.8.7 0.8.7 Up-to-date
phoenix_live_reload 1.6.0 1.6.0 Up-to-date
phoenix_live_view 1.0.12 1.0.12 Up-to-date
picosat_elixir 0.2.3 0.2.3 Up-to-date
postgrex 0.20.0 0.20.0 Up-to-date
sourceror 1.10.0 1.10.0 Up-to-date
swoosh 1.19.1 1.19.1 Up-to-date
tailwind 0.2.4 0.3.1 Update not possible
telemetry_metrics 1.1.0 1.1.0 Up-to-date
telemetry_poller 1.2.0 1.2.0 Up-to-date
tidewave 0.1.6 0.1.7 Update possible
vscode ➜ /workspaces/yakgo.money (main ✗) $ mix hex.outdated
Dependency Current Latest Status
ash 3.5.12 3.5.12 Up-to-date
ash_admin 0.13.5 0.13.5 Up-to-date
ash_authentication 4.8.7 4.8.7 Up-to-date
ash_authentication_phoenix 2.7.0 2.7.0 Up-to-date
ash_double_entry 1.0.14 1.0.14 Up-to-date
ash_money 0.2.0 0.2.0 Up-to-date
ash_paper_trail 0.5.3 0.5.3 Up-to-date
ash_phoenix 2.3.2 2.3.2 Up-to-date
ash_postgres 2.5.22 2.5.22 Up-to-date
bandit 1.6.11 1.6.11 Up-to-date
bcrypt_elixir 3.3.2 3.3.2 Up-to-date
dns_cluster 0.1.3 0.2.0 Update not possible
ecto_sql 3.12.1 3.12.1 Up-to-date
esbuild 0.9.0 0.9.0 Up-to-date
ex_money_sql 1.11.0 1.11.0 Up-to-date
finch 0.19.0 0.19.0 Up-to-date
floki 0.37.1 0.37.1 Up-to-date
gettext 0.26.2 0.26.2 Up-to-date
igniter 0.6.2 0.6.2 Up-to-date
jason 1.4.4 1.4.4 Up-to-date
live_debugger 0.2.3 0.2.3 Up-to-date
phoenix 1.7.21 1.7.21 Up-to-date
phoenix_ecto 4.6.4 4.6.4 Up-to-date
phoenix_html 4.2.1 4.2.1 Up-to-date
phoenix_live_dashboard 0.8.7 0.8.7 Up-to-date
phoenix_live_reload 1.6.0 1.6.0 Up-to-date
phoenix_live_view 1.0.12 1.0.12 Up-to-date
picosat_elixir 0.2.3 0.2.3 Up-to-date
postgrex 0.20.0 0.20.0 Up-to-date
sourceror 1.10.0 1.10.0 Up-to-date
swoosh 1.19.1 1.19.1 Up-to-date
tailwind 0.2.4 0.3.1 Update not possible
telemetry_metrics 1.1.0 1.1.0 Up-to-date
telemetry_poller 1.2.0 1.2.0 Up-to-date
tidewave 0.1.6 0.1.7 Update possible
where does :skip_balance_updates come from? I don't see this in my IO.inspect output
changeset.context[:ash_double_entry][:skip_balance_updates]
changeset.context[:ash_double_entry][:skip_balance_updates]
ZachDaniel
ZachDaniel8h ago
You're right, it looks like possibly something that was supposed to be setting this context was removed and so is preventing essentially the cascading balance updates required actually, nvm the amounts shouldn't be changing on future transfers, that stays the same okay, I have a thought can you try main of ash_double_entry? Its just returning the wrong format {:error, ...} should be {:not_atomic, ...} The action can be done, just not atomically (because it requires multiple queries)
Failz
FailzOP8h ago
do I need to set either of these?
# balance.ex
postgres do
table "balances"
repo YourApp.Repo

references do
reference :transfer, on_delete: :delete
end
end

# or
# transfer.ex
transfer do
account_resource App.Ledger.Account
balance_resource App.Ledger.Balance
destroy_balances? true
end
# balance.ex
postgres do
table "balances"
repo YourApp.Repo

references do
reference :transfer, on_delete: :delete
end
end

# or
# transfer.ex
transfer do
account_resource App.Ledger.Account
balance_resource App.Ledger.Balance
destroy_balances? true
end
ZachDaniel
ZachDaniel8h ago
Just the references
Failz
FailzOP8h ago
now seeing the :not_atomic coming back
vscode ➜ /workspaces/yakgo.money (main ✗) $ iex -S mix
Erlang/OTP 27 [erts-15.2.3] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [jit]

Interactive Elixir (1.18.3) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> transfer = App.Ledger.get_transfer!("01JW3TNT6M4PW7QWSTY5KSPT19")
[debug] %Postgrex.Query{ref: nil, name: "ecto_1154", statement: "SELECT l0.\"id\", l0.\"timestamp\", l0.\"amount\", l0.\"updated_at\", l0.\"inserted_at\", l0.\"from_account_id\", l0.\"to_account_id\" FROM \"ledger_transfers\" AS l0 WHERE (l0.\"id\"::bytea::bytea = $1::bytea::bytea)", param_oids: nil, param_formats: nil, param_types: nil, columns: nil, result_oids: nil, result_formats: nil, result_types: nil, types: nil, cache: :reference} uses unknown oid(s) 16792forcing us to reload type information from the database. This is expected behaviour whenever you migrate your database.
[debug] QUERY OK source="ledger_transfers" db=0.2ms queue=3.0ms idle=315.9ms
SELECT l0."id", l0."timestamp", l0."amount", l0."updated_at", l0."inserted_at", l0."from_account_id", l0."to_account_id" FROM "ledger_transfers" AS l0 WHERE (l0."id"::bytea::bytea = $1::bytea::bytea) ["01JW3TNT6M4PW7QWSTY5KSPT19"]
↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:786
%App.Ledger.Transfer{
balances: #Ash.NotLoaded<:relationship, field: :balances>,
to_account: #Ash.NotLoaded<:relationship, field: :to_account>,
from_account: #Ash.NotLoaded<:relationship, field: :from_account>,
__meta__: #Ecto.Schema.Metadata<:loaded, "ledger_transfers">,
id: "01JW3TNT6M4PW7QWSTY5KSPT19",
amount: Money.new(:USD, "20"),
inserted_at: ~U[2025-05-25 13:38:50.709890Z],
updated_at: ~U[2025-05-25 13:38:50.709890Z],
timestamp: ~U[2025-05-25 13:38:50.709837Z],
from_account_id: "0197078c-7b11-7b5f-a00b-1ec0996ba0b8",
to_account_id: "0197078f-1092-7c1e-88e9-1cf553432e7a"
}
iex(2)> App.Ledger.update_transfer(transfer, %{amount: Money.new(10, :USD)})
changeset: #Ash.Changeset<
domain: App.Ledger,
action_type: :update,
action: :update,
attributes: %{amount: Money.new(:USD, "10")},
relationships: %{},
errors: [],
data: %App.Ledger.Transfer{
balances: #Ash.NotLoaded<:relationship, field: :balances>,
to_account: #Ash.NotLoaded<:relationship, field: :to_account>,
from_account: #Ash.NotLoaded<:relationship, field: :from_account>,
__meta__: #Ecto.Schema.Metadata<:loaded, "ledger_transfers">,
id: "01JW3TNT6M4PW7QWSTY5KSPT19",
amount: Money.new(:USD, "20"),
inserted_at: ~U[2025-05-25 13:38:50.709890Z],
updated_at: ~U[2025-05-25 13:38:50.709890Z],
timestamp: ~U[2025-05-25 13:38:50.709837Z],
from_account_id: "0197078c-7b11-7b5f-a00b-1ec0996ba0b8",
to_account_id: "0197078f-1092-7c1e-88e9-1cf553432e7a"
},
valid?: true
>
opts: []
context: %Ash.Resource.Change.Context{
actor: nil,
tenant: nil,
authorize?: true,
tracer: nil,
bulk?: false
}
{:error,
%Ash.Error.framework{
errors: [
%Ash.Error.Framework.MustBeAtomic{
resource: App.Ledger.Transfer,
action: :update,
reason: "Cannot destroy a transfer atomically if balances must be destroyed manually, or if balance is being updated",
splode: Ash.Error,
bread_crumbs: [],
vars: [],
path: [],
stacktrace: #Splode.Stacktrace<>,
class: :framework
}
]
}}
iex(3)>
vscode ➜ /workspaces/yakgo.money (main ✗) $ iex -S mix
Erlang/OTP 27 [erts-15.2.3] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [jit]

Interactive Elixir (1.18.3) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> transfer = App.Ledger.get_transfer!("01JW3TNT6M4PW7QWSTY5KSPT19")
[debug] %Postgrex.Query{ref: nil, name: "ecto_1154", statement: "SELECT l0.\"id\", l0.\"timestamp\", l0.\"amount\", l0.\"updated_at\", l0.\"inserted_at\", l0.\"from_account_id\", l0.\"to_account_id\" FROM \"ledger_transfers\" AS l0 WHERE (l0.\"id\"::bytea::bytea = $1::bytea::bytea)", param_oids: nil, param_formats: nil, param_types: nil, columns: nil, result_oids: nil, result_formats: nil, result_types: nil, types: nil, cache: :reference} uses unknown oid(s) 16792forcing us to reload type information from the database. This is expected behaviour whenever you migrate your database.
[debug] QUERY OK source="ledger_transfers" db=0.2ms queue=3.0ms idle=315.9ms
SELECT l0."id", l0."timestamp", l0."amount", l0."updated_at", l0."inserted_at", l0."from_account_id", l0."to_account_id" FROM "ledger_transfers" AS l0 WHERE (l0."id"::bytea::bytea = $1::bytea::bytea) ["01JW3TNT6M4PW7QWSTY5KSPT19"]
↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:786
%App.Ledger.Transfer{
balances: #Ash.NotLoaded<:relationship, field: :balances>,
to_account: #Ash.NotLoaded<:relationship, field: :to_account>,
from_account: #Ash.NotLoaded<:relationship, field: :from_account>,
__meta__: #Ecto.Schema.Metadata<:loaded, "ledger_transfers">,
id: "01JW3TNT6M4PW7QWSTY5KSPT19",
amount: Money.new(:USD, "20"),
inserted_at: ~U[2025-05-25 13:38:50.709890Z],
updated_at: ~U[2025-05-25 13:38:50.709890Z],
timestamp: ~U[2025-05-25 13:38:50.709837Z],
from_account_id: "0197078c-7b11-7b5f-a00b-1ec0996ba0b8",
to_account_id: "0197078f-1092-7c1e-88e9-1cf553432e7a"
}
iex(2)> App.Ledger.update_transfer(transfer, %{amount: Money.new(10, :USD)})
changeset: #Ash.Changeset<
domain: App.Ledger,
action_type: :update,
action: :update,
attributes: %{amount: Money.new(:USD, "10")},
relationships: %{},
errors: [],
data: %App.Ledger.Transfer{
balances: #Ash.NotLoaded<:relationship, field: :balances>,
to_account: #Ash.NotLoaded<:relationship, field: :to_account>,
from_account: #Ash.NotLoaded<:relationship, field: :from_account>,
__meta__: #Ecto.Schema.Metadata<:loaded, "ledger_transfers">,
id: "01JW3TNT6M4PW7QWSTY5KSPT19",
amount: Money.new(:USD, "20"),
inserted_at: ~U[2025-05-25 13:38:50.709890Z],
updated_at: ~U[2025-05-25 13:38:50.709890Z],
timestamp: ~U[2025-05-25 13:38:50.709837Z],
from_account_id: "0197078c-7b11-7b5f-a00b-1ec0996ba0b8",
to_account_id: "0197078f-1092-7c1e-88e9-1cf553432e7a"
},
valid?: true
>
opts: []
context: %Ash.Resource.Change.Context{
actor: nil,
tenant: nil,
authorize?: true,
tracer: nil,
bulk?: false
}
{:error,
%Ash.Error.framework{
errors: [
%Ash.Error.Framework.MustBeAtomic{
resource: App.Ledger.Transfer,
action: :update,
reason: "Cannot destroy a transfer atomically if balances must be destroyed manually, or if balance is being updated",
splode: Ash.Error,
bread_crumbs: [],
vars: [],
path: [],
stacktrace: #Splode.Stacktrace<>,
class: :framework
}
]
}}
iex(3)>
I added back in the inspects
ZachDaniel
ZachDaniel8h ago
update_transfer is defined by you right?
Failz
FailzOP8h ago
correct:
defmodule App.Ledger do
use Ash.Domain,
otp_app: :app,
extensions: [AshPhoenix]

resources do
resource App.Ledger.Account do
define :list_accounts, action: :read
define :get_account, action: :read, get_by: [:id]
define :open_account, action: :open
define :update_account, action: :update
define :delete_account, action: :destroy
end

resource App.Ledger.Balance

resource App.Ledger.Transfer do
define :list_transfers, action: :read
define :get_transfer, action: :read, get_by: [:id]
define :create_transfer, action: :transfer
define :update_transfer, action: :update
end
end
end
defmodule App.Ledger do
use Ash.Domain,
otp_app: :app,
extensions: [AshPhoenix]

resources do
resource App.Ledger.Account do
define :list_accounts, action: :read
define :get_account, action: :read, get_by: [:id]
define :open_account, action: :open
define :update_account, action: :update
define :delete_account, action: :destroy
end

resource App.Ledger.Balance

resource App.Ledger.Transfer do
define :list_transfers, action: :read
define :get_transfer, action: :read, get_by: [:id]
define :create_transfer, action: :transfer
define :update_transfer, action: :update
end
end
end
and
defmodule App.Ledger.Transfer do
use Ash.Resource,
domain: Elixir.App.Ledger,
data_layer: AshPostgres.DataLayer,
extensions: [AshDoubleEntry.Transfer]

transfer do
account_resource App.Ledger.Account
balance_resource App.Ledger.Balance
end

postgres do
table "ledger_transfers"
repo App.Repo
end

actions do
defaults [:read]

create :transfer do
accept [:amount, :timestamp, :from_account_id, :to_account_id]
end

update :update do
accept [:amount]
end
end

attributes do
attribute :id, AshDoubleEntry.ULID do
primary_key? true
allow_nil? false
default &AshDoubleEntry.ULID.generate/0
end

attribute :amount, :money do
allow_nil? false
end

timestamps()
end

relationships do
belongs_to :from_account, App.Ledger.Account do
attribute_writable? true
end

belongs_to :to_account, App.Ledger.Account do
attribute_writable? true
end

has_many :balances, App.Ledger.Balance
end
end
defmodule App.Ledger.Transfer do
use Ash.Resource,
domain: Elixir.App.Ledger,
data_layer: AshPostgres.DataLayer,
extensions: [AshDoubleEntry.Transfer]

transfer do
account_resource App.Ledger.Account
balance_resource App.Ledger.Balance
end

postgres do
table "ledger_transfers"
repo App.Repo
end

actions do
defaults [:read]

create :transfer do
accept [:amount, :timestamp, :from_account_id, :to_account_id]
end

update :update do
accept [:amount]
end
end

attributes do
attribute :id, AshDoubleEntry.ULID do
primary_key? true
allow_nil? false
default &AshDoubleEntry.ULID.generate/0
end

attribute :amount, :money do
allow_nil? false
end

timestamps()
end

relationships do
belongs_to :from_account, App.Ledger.Account do
attribute_writable? true
end

belongs_to :to_account, App.Ledger.Account do
attribute_writable? true
end

has_many :balances, App.Ledger.Balance
end
end
ZachDaniel
ZachDaniel8h ago
You need to add require_atomic? false to the update action, if you are going to allow updating the amount of a transfer.
ZachDaniel
ZachDaniel8h ago
Ah, because its doesn't have backwards compatibility configuration set that changes how default update actions work When I set this:
config :ash, :default_actions_require_atomic?, true
config :ash, :default_actions_require_atomic?, true
then it fails in the same way before that config was introduced, default actions had an implicit require_atomic? false Its fine to add require_atomic? false, because the relevant accounts are locked before updating them.
Failz
FailzOP8h ago
you're a step ahead of me, I was trying to think through what gets broken if I set atomic to false. thank you for the help here!
ZachDaniel
ZachDaniel8h ago
With that said, I'm actually wondering if some recent improvements to this code that we've made lets us loosen the restriction here. oh, no it doesn't I remember why the change logic uses the previous balance to do its work So there actually is something you should do to make this safe for concurrency
update :update_balance do
change get_and_lock_for_update()
end
update :update_balance do
change get_and_lock_for_update()
end
that will ensure that your hook always sees the previous balance properly if you have a chance to add a blurb about this to the docs that would be great. I'm updating the tests to use this new pattern and the new configs so its more realistic.
Failz
FailzOP8h ago
looks happy!
ZachDaniel
ZachDaniel8h ago
🥳

Did you find this page helpful?