feat(membership-fees): denormalize cycle_end so fee status filters run in the database
Store the cycle's end date alongside its start so the payment-status filter can be a plain date comparison in SQL instead of loading every cycle and classifying it in memory. The value is fixed when the cycle is created (the fee type's interval is immutable and the update action never changes the start date), so the denormalized column can never drift from the computed end and needs no write-time reconciliation.
This commit is contained in:
parent
a629bfb617
commit
b745b13ca5
7 changed files with 448 additions and 4 deletions
|
|
@ -0,0 +1,68 @@
|
|||
defmodule Mv.MembershipFees.MembershipFeeCycle.Changes.SetCycleEnd do
|
||||
@moduledoc """
|
||||
Ash change that denormalizes `cycle_end` on a membership fee cycle at create time.
|
||||
|
||||
`cycle_end` is computed as `CalendarCycles.calculate_cycle_end(cycle_start, interval)`,
|
||||
where `interval` is read from the cycle's `membership_fee_type`. Because a fee type's
|
||||
`interval` is immutable and the cycle's `cycle_start` is never updated, the stored
|
||||
`cycle_end` is fixed at create time and never drifts.
|
||||
|
||||
When the caller already knows the interval (e.g. the bulk cycle generator, which
|
||||
resolves the fee type once for a whole batch), it may pass it via the `:interval`
|
||||
create argument; the change then uses that value and issues no fee-type query,
|
||||
avoiding an N+1 lookup across a batch of cycles.
|
||||
|
||||
Otherwise the fee-type interval is read with `authorize?: false`: it is an internal
|
||||
lookup of immutable reference data needed to populate a derived column, not a
|
||||
user-facing read, and cycle creation is itself a systemic operation. Reading it under
|
||||
the caller's actor is unreliable here (the actor is not always present in the change
|
||||
context, e.g. when a changeset is built with `for_create/3` and the actor is only
|
||||
supplied to `Ash.create/2`).
|
||||
|
||||
When `cycle_start` or `membership_fee_type_id` are missing the changeset is returned
|
||||
unchanged so the normal `allow_nil?` validations surface the error.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
with {:ok, cycle_start} <- fetch(changeset, :cycle_start),
|
||||
{:ok, fee_type_id} <- fetch(changeset, :membership_fee_type_id),
|
||||
{:ok, interval} <- resolve_interval(changeset, fee_type_id) do
|
||||
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
||||
Ash.Changeset.force_change_attribute(changeset, :cycle_end, cycle_end)
|
||||
else
|
||||
_ -> changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch(changeset, field) do
|
||||
case Ash.Changeset.fetch_change(changeset, field) do
|
||||
{:ok, value} when not is_nil(value) ->
|
||||
{:ok, value}
|
||||
|
||||
_ ->
|
||||
case Map.get(changeset.data, field) do
|
||||
nil -> :error
|
||||
value -> {:ok, value}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_interval(changeset, fee_type_id) do
|
||||
case Ash.Changeset.get_argument(changeset, :interval) do
|
||||
nil -> fetch_interval(fee_type_id)
|
||||
interval -> {:ok, interval}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_interval(fee_type_id) do
|
||||
case Ash.get(MembershipFeeType, fee_type_id, authorize?: false) do
|
||||
{:ok, %{interval: interval}} -> {:ok, interval}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue