feat: regenerate cycles when membership fee type changes (same interval)

- 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
This commit is contained in:
Moritz 2025-12-15 11:00:08 +01:00
parent cd915531c2
commit 2d1d650c28
Signed by: moritz
GPG key ID: 1020A035E5DD0824
6 changed files with 550 additions and 17 deletions

View file

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

View file

@ -0,0 +1,453 @@
defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
@moduledoc """
Integration tests for membership fee type changes and cycle regeneration.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CalendarCycles
require Ash.Query
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-01-15]
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "type change cycle regeneration" do
test "future unpaid cycles are regenerated with new amount" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Wait for cycle generation
Process.sleep(100)
# Create cycles: one in the past (paid), one current (unpaid)
# Note: Future cycles are not automatically generated by CycleGenerator,
# so we only test with current cycle
past_cycle_start = CalendarCycles.calculate_cycle_start(~D[2023-01-01], :yearly)
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Past cycle (paid) - should remain unchanged
# Check if it already exists (from auto-generation), if not create it
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} ->
# Update to paid
existing_cycle
|> Ash.Changeset.for_update(:update, %{status: :paid})
|> Ash.update!()
{:error, _} ->
create_cycle(member, yearly_type1, %{
cycle_start: past_cycle_start,
status: :paid,
amount: Decimal.new("100.00")
})
end
# Current cycle (unpaid) - should be regenerated
# Delete if exists (from auto-generation), then create with old amount
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} ->
Ash.destroy!(existing_cycle)
{:error, _} ->
:ok
end
_current_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
# Change membership fee type (same interval, different amount)
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Wait for async cycle regeneration (in test it runs synchronously)
Process.sleep(100)
# Verify past cycle is unchanged
past_cycle_after =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one!()
assert past_cycle_after.status == :paid
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
# Verify current cycle was deleted and regenerated
# Check that cycle with new type exists (regenerated)
new_current_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
# Verify it has the new type and amount
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
assert new_current_cycle.status == :unpaid
# Verify old cycle with old type doesn't exist anymore
old_current_cycles =
MembershipFeeCycle
|> Ash.Query.filter(
member_id == ^member.id and cycle_start == ^current_cycle_start and
membership_fee_type_id == ^yearly_type1.id
)
|> Ash.read!()
assert Enum.empty?(old_current_cycles)
end
test "paid cycles remain unchanged" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Wait for cycle generation
Process.sleep(100)
# Get the current cycle and mark it as paid
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Find current cycle and mark as paid
paid_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
|> Ash.Changeset.for_update(:mark_as_paid)
|> Ash.update!()
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Wait for async cycle regeneration
Process.sleep(100)
# Verify paid cycle is unchanged (not deleted and regenerated)
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, paid_cycle.id)
assert cycle_after.status == :paid
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
assert cycle_after.membership_fee_type_id == yearly_type1.id
end
test "suspended cycles remain unchanged" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Wait for cycle generation
Process.sleep(100)
# Get the current cycle and mark it as suspended
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Find current cycle and mark as suspended
suspended_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
|> Ash.Changeset.for_update(:mark_as_suspended)
|> Ash.update!()
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Wait for async cycle regeneration
Process.sleep(100)
# Verify suspended cycle is unchanged (not deleted and regenerated)
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, suspended_cycle.id)
assert cycle_after.status == :suspended
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
assert cycle_after.membership_fee_type_id == yearly_type1.id
end
test "only cycles that haven't ended yet are deleted" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Wait for cycle generation
Process.sleep(100)
# Create cycles: one in the past (unpaid, ended), one current (unpaid, not ended)
past_cycle_start =
CalendarCycles.calculate_cycle_start(
Date.add(today, -365),
:yearly
)
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Past cycle (unpaid) - should remain unchanged (cycle_start < today)
# Delete existing cycle if it exists (from auto-generation)
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} ->
Ash.destroy!(existing_cycle)
{:error, _} ->
:ok
end
past_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: past_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
# Current cycle (unpaid) - should be regenerated (cycle_start >= today)
# Delete existing cycle if it exists (from auto-generation)
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} ->
Ash.destroy!(existing_cycle)
{:error, _} ->
:ok
end
_current_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Wait for async cycle regeneration
Process.sleep(100)
# Verify past cycle is unchanged
{:ok, past_cycle_after} = Ash.get(MembershipFeeCycle, past_cycle.id)
assert past_cycle_after.status == :unpaid
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
# Verify current cycle was regenerated
# Check that cycle with new type exists
new_current_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
# Verify old cycle with old type doesn't exist anymore
old_current_cycles =
MembershipFeeCycle
|> Ash.Query.filter(
member_id == ^member.id and cycle_start == ^current_cycle_start and
membership_fee_type_id == ^yearly_type1.id
)
|> Ash.read!()
assert Enum.empty?(old_current_cycles)
end
test "member calculations update after type change" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member with join_date = today to avoid past cycles
# This ensures no overdue cycles exist
member = create_member(%{join_date: today})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Wait for cycle generation
Process.sleep(100)
# Get current cycle start
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Ensure only one cycle exists (the current one)
# Delete all cycles except the current one
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(existing_cycles, fn cycle ->
if cycle.cycle_start != current_cycle_start do
Ash.destroy!(cycle)
end
end)
# Ensure current cycle exists and is unpaid
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} ->
# Update to unpaid if it's not
if existing_cycle.status != :unpaid do
existing_cycle
|> Ash.Changeset.for_update(:mark_as_unpaid)
|> Ash.update!()
end
{:error, _} ->
# Create if it doesn't exist
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
end
# Load calculations before change
member = Ash.load!(member, [:current_cycle_status, :overdue_count])
assert member.current_cycle_status == :unpaid
assert member.overdue_count == 0
# Change membership fee type
assert {:ok, updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Wait for async cycle regeneration
Process.sleep(100)
# Reload member with calculations
updated_member = Ash.load!(updated_member, [:current_cycle_status, :overdue_count])
# Calculations should still work (cycle was regenerated)
assert updated_member.current_cycle_status == :unpaid
assert updated_member.overdue_count == 0
end
end
end