Compare commits

..

1 commit

Author SHA1 Message Date
cad8cac9a8
feat: regenerate cycles when membership fee type changes (same interval)
Some checks failed
continuous-integration/drone/push Build is failing
- Implemented regenerate_cycles_on_type_change helper in Member resource
- Cycles that haven't ended yet (cycle_end >= today) are deleted and regenerated
- Paid and suspended cycles remain unchanged (not deleted)
- CycleGenerator reloads member with new membership_fee_type_id
- Adjusted tests to work with current cycles only (no future cycles)
- All integration tests passing

Phase 4 completed: Cycle regeneration on type change
2025-12-15 11:35:48 +01:00
6 changed files with 31 additions and 52 deletions

View file

@ -647,13 +647,10 @@ defmodule Mv.Membership.Member do
false false
end end
end) end)
|> Enum.sort_by( |> Enum.sort_by(fn cycle ->
fn cycle -> interval = Map.get(cycle, :membership_fee_type).interval
interval = Map.get(cycle, :membership_fee_type).interval Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) end, {:desc, Date})
end,
{:desc, Date}
)
|> List.first() |> List.first()
else else
nil nil

View file

@ -83,7 +83,8 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
defp get_intervals(current_type_id, new_type_id) do defp get_intervals(current_type_id, new_type_id) do
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} do case {Ash.get(MembershipFeeType, current_type_id),
Ash.get(MembershipFeeType, new_type_id)} do
{{:ok, current_type}, {:ok, new_type}} -> {{:ok, current_type}, {:ok, new_type}} ->
{:ok, current_type.interval, new_type.interval} {:ok, current_type.interval, new_type.interval}
@ -115,3 +116,4 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
defp format_interval(:yearly), do: "yearly" defp format_interval(:yearly), do: "yearly"
defp format_interval(interval), do: to_string(interval) defp format_interval(interval), do: to_string(interval)
end end

View file

@ -56,7 +56,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
description "Mark cycle as paid" description "Mark cycle as paid"
require_atomic? false require_atomic? false
accept [:notes] accept [:notes]
change fn changeset, _context -> change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :paid) Ash.Changeset.force_change_attribute(changeset, :status, :paid)
end end
@ -66,7 +65,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
description "Mark cycle as suspended" description "Mark cycle as suspended"
require_atomic? false require_atomic? false
accept [:notes] accept [:notes]
change fn changeset, _context -> change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :suspended) Ash.Changeset.force_change_attribute(changeset, :status, :suspended)
end end
@ -76,7 +74,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
description "Mark cycle as unpaid (for error correction)" description "Mark cycle as unpaid (for error correction)"
require_atomic? false require_atomic? false
accept [:notes] accept [:notes]
change fn changeset, _context -> change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :unpaid) Ash.Changeset.force_change_attribute(changeset, :status, :unpaid)
end end

View file

@ -118,7 +118,6 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
# Current cycle # Current cycle
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly) cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{ create_cycle(member, fee_type, %{
cycle_start: cycle_start, cycle_start: cycle_start,
status: :paid status: :paid
@ -180,7 +179,6 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
# Current cycle # Current cycle
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly) cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{ create_cycle(member, fee_type, %{
cycle_start: cycle_start, cycle_start: cycle_start,
status: :unpaid status: :unpaid
@ -188,10 +186,8 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
# Future cycle (if we're not at the end of the year) # Future cycle (if we're not at the end of the year)
next_year = today.year + 1 next_year = today.year + 1
if today.month < 12 or today.day < 31 do if today.month < 12 or today.day < 31 do
next_year_start = Date.new!(next_year, 1, 1) next_year_start = Date.new!(next_year, 1, 1)
create_cycle(member, fee_type, %{ create_cycle(member, fee_type, %{
cycle_start: next_year_start, cycle_start: next_year_start,
status: :unpaid status: :unpaid
@ -269,7 +265,6 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
}) })
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly) cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{ create_cycle(member, fee_type, %{
cycle_start: cycle_start, cycle_start: cycle_start,
status: :unpaid status: :unpaid
@ -284,3 +279,4 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
end end
end end
end end

View file

@ -116,12 +116,11 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
:ok :ok
end end
_current_cycle = _current_cycle = create_cycle(member, yearly_type1, %{
create_cycle(member, yearly_type1, %{ cycle_start: current_cycle_start,
cycle_start: current_cycle_start, status: :unpaid,
status: :unpaid, amount: Decimal.new("100.00")
amount: Decimal.new("100.00") })
})
# Change membership fee type (same interval, different amount) # Change membership fee type (same interval, different amount)
assert {:ok, _updated_member} = assert {:ok, _updated_member} =
@ -139,7 +138,6 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
MembershipFeeCycle MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start) |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one!() |> Ash.read_one!()
assert past_cycle_after.status == :paid assert past_cycle_after.status == :paid
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00")) assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
assert past_cycle_after.membership_fee_type_id == yearly_type1.id assert past_cycle_after.membership_fee_type_id == yearly_type1.id
@ -159,10 +157,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Verify old cycle with old type doesn't exist anymore # Verify old cycle with old type doesn't exist anymore
old_current_cycles = old_current_cycles =
MembershipFeeCycle MembershipFeeCycle
|> Ash.Query.filter( |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start and membership_fee_type_id == ^yearly_type1.id)
member_id == ^member.id and cycle_start == ^current_cycle_start and
membership_fee_type_id == ^yearly_type1.id
)
|> Ash.read!() |> Ash.read!()
assert Enum.empty?(old_current_cycles) assert Enum.empty?(old_current_cycles)
@ -284,12 +279,10 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
Process.sleep(100) Process.sleep(100)
# Create cycles: one in the past (unpaid, ended), one current (unpaid, not ended) # Create cycles: one in the past (unpaid, ended), one current (unpaid, not ended)
past_cycle_start = past_cycle_start = CalendarCycles.calculate_cycle_start(
CalendarCycles.calculate_cycle_start( Date.add(today, -365),
Date.add(today, -365), :yearly
:yearly )
)
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly) current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Past cycle (unpaid) - should remain unchanged (cycle_start < today) # Past cycle (unpaid) - should remain unchanged (cycle_start < today)
@ -304,12 +297,11 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
:ok :ok
end end
past_cycle = past_cycle = create_cycle(member, yearly_type1, %{
create_cycle(member, yearly_type1, %{ cycle_start: past_cycle_start,
cycle_start: past_cycle_start, status: :unpaid,
status: :unpaid, amount: Decimal.new("100.00")
amount: Decimal.new("100.00") })
})
# Current cycle (unpaid) - should be regenerated (cycle_start >= today) # Current cycle (unpaid) - should be regenerated (cycle_start >= today)
# Delete existing cycle if it exists (from auto-generation) # Delete existing cycle if it exists (from auto-generation)
@ -323,12 +315,11 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
:ok :ok
end end
_current_cycle = _current_cycle = create_cycle(member, yearly_type1, %{
create_cycle(member, yearly_type1, %{ cycle_start: current_cycle_start,
cycle_start: current_cycle_start, status: :unpaid,
status: :unpaid, amount: Decimal.new("100.00")
amount: Decimal.new("100.00") })
})
# Change membership fee type # Change membership fee type
assert {:ok, _updated_member} = assert {:ok, _updated_member} =
@ -360,10 +351,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Verify old cycle with old type doesn't exist anymore # Verify old cycle with old type doesn't exist anymore
old_current_cycles = old_current_cycles =
MembershipFeeCycle MembershipFeeCycle
|> Ash.Query.filter( |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start and membership_fee_type_id == ^yearly_type1.id)
member_id == ^member.id and cycle_start == ^current_cycle_start and
membership_fee_type_id == ^yearly_type1.id
)
|> Ash.read!() |> Ash.read!()
assert Enum.empty?(old_current_cycles) assert Enum.empty?(old_current_cycles)
@ -451,3 +439,4 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
end end
end end
end end

View file

@ -70,7 +70,6 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
refute changeset.valid? refute changeset.valid?
assert %{errors: errors} = changeset assert %{errors: errors} = changeset
assert Enum.any?(errors, fn error -> assert Enum.any?(errors, fn error ->
error.field == :membership_fee_type_id and error.field == :membership_fee_type_id and
error.message =~ "yearly" and error.message =~ "yearly" and
@ -80,8 +79,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
test "allows first assignment of membership fee type" do test "allows first assignment of membership fee type" do
yearly_type = create_fee_type(%{interval: :yearly}) yearly_type = create_fee_type(%{interval: :yearly})
# No fee type assigned member = create_member(%{}) # No fee type assigned
member = create_member(%{})
changeset = changeset =
member member