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.
98 lines
3.4 KiB
Elixir
98 lines
3.4 KiB
Elixir
defmodule Mv.MembershipFees.CycleEndTest do
|
|
@moduledoc """
|
|
Tests for the denormalized `cycle_end` column on membership fee cycles:
|
|
the create-time change that sets it, and the one-time backfill helper.
|
|
"""
|
|
use Mv.DataCase, async: false
|
|
|
|
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2]
|
|
|
|
alias Mv.MembershipFees.CalendarCycles
|
|
alias Mv.MembershipFees.CycleEndBackfill
|
|
alias Mv.MembershipFees.MembershipFeeCycle
|
|
|
|
setup do
|
|
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
|
end
|
|
|
|
defp create_member(fee_type, actor) do
|
|
member_fixture_with_actor(%{membership_fee_type_id: fee_type.id}, actor)
|
|
end
|
|
|
|
describe "create sets cycle_end" do
|
|
for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do
|
|
test "create sets cycle_end from cycle_start + #{interval} interval", %{actor: actor} do
|
|
interval = unquote(interval)
|
|
fee_type = create_fee_type(%{interval: interval}, actor)
|
|
member = create_member(fee_type, actor)
|
|
cycle_start = ~D[2024-02-01]
|
|
|
|
cycle =
|
|
MembershipFeeCycle
|
|
|> Ash.Changeset.for_create(:create, %{
|
|
cycle_start: cycle_start,
|
|
amount: Decimal.new("50.00"),
|
|
member_id: member.id,
|
|
membership_fee_type_id: fee_type.id
|
|
})
|
|
|> Ash.create!(actor: actor)
|
|
|
|
assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "create with interval argument" do
|
|
test "uses the passed interval and skips the fee-type lookup", %{actor: actor} do
|
|
# Fee type carries :yearly, but the caller passes :monthly explicitly.
|
|
# If the interval argument is honored (no per-row fee-type lookup), the
|
|
# stored cycle_end reflects the argument, not the fee type's interval.
|
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
|
member = create_member(fee_type, actor)
|
|
cycle_start = ~D[2024-02-01]
|
|
|
|
cycle =
|
|
MembershipFeeCycle
|
|
|> Ash.Changeset.for_create(:create, %{
|
|
cycle_start: cycle_start,
|
|
amount: Decimal.new("50.00"),
|
|
member_id: member.id,
|
|
membership_fee_type_id: fee_type.id,
|
|
interval: :monthly
|
|
})
|
|
|> Ash.create!(actor: actor)
|
|
|
|
assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, :monthly)
|
|
end
|
|
end
|
|
|
|
describe "backfill" do
|
|
test "fills cycle_end for pre-existing rows matching calculate_cycle_end/2", %{actor: actor} do
|
|
fee_type = create_fee_type(%{interval: :quarterly}, actor)
|
|
member = create_member(fee_type, actor)
|
|
|
|
cycle =
|
|
MembershipFeeCycle
|
|
|> Ash.Changeset.for_create(:create, %{
|
|
cycle_start: ~D[2024-04-01],
|
|
amount: Decimal.new("50.00"),
|
|
member_id: member.id,
|
|
membership_fee_type_id: fee_type.id
|
|
})
|
|
|> Ash.create!(actor: actor)
|
|
|
|
# Simulate a pre-existing row that predates the cycle_end column.
|
|
Repo.query!("ALTER TABLE membership_fee_cycles ALTER COLUMN cycle_end DROP NOT NULL")
|
|
|
|
Repo.query!("UPDATE membership_fee_cycles SET cycle_end = NULL WHERE id = $1", [
|
|
Ecto.UUID.dump!(cycle.id)
|
|
])
|
|
|
|
assert {:ok, count} = CycleEndBackfill.run()
|
|
assert count >= 1
|
|
|
|
reloaded = Ash.get!(MembershipFeeCycle, cycle.id, actor: actor)
|
|
assert reloaded.cycle_end == CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly)
|
|
end
|
|
end
|
|
end
|