fix: CycleGenerator generates from last cycle, not filling gaps

- Change algorithm to start from last existing cycle instead of start_date
- Deleted cycles (gaps) are no longer automatically filled
- Add test to verify gaps remain unfilled
- Update documentation to clarify gap handling behavior
This commit is contained in:
Moritz 2025-12-12 16:33:39 +01:00
parent 272a8a8afc
commit 0b986db635
2 changed files with 95 additions and 14 deletions

View file

@ -8,11 +8,20 @@ defmodule Mv.MembershipFees.CycleGenerator do
## Algorithm
1. Load member with relationships (membership_fee_type, membership_fee_cycles)
2. Determine membership_fee_start_date (calculate if nil)
3. Find the last existing cycle start date (or use membership_fee_start_date)
4. Generate all cycle starts from last to today (or left_at)
5. Filter out existing cycles (idempotency)
6. Create new cycles with the current amount from membership_fee_type
2. Determine the generation start point:
- If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
- If cycles exist: Start from the cycle AFTER the last existing one
3. Generate all cycle starts from the determined start point to today (or `exit_date`)
4. Create new cycles with the current amount from `membership_fee_type`
## Important: Gap Handling
**Gaps are NOT filled.** If a cycle was explicitly deleted (e.g., 2023 was deleted
but 2022 and 2024 exist), the generator will NOT recreate the deleted cycle.
It always continues from the LAST existing cycle, regardless of any gaps.
This behavior ensures that manually deleted cycles remain deleted and prevents
unwanted automatic recreation of intentionally removed cycles.
## Concurrency
@ -215,21 +224,36 @@ defmodule Mv.MembershipFees.CycleGenerator do
amount = fee_type.amount
existing_cycles = member.membership_fee_cycles || []
# Determine start date
start_date = determine_start_date(member, interval)
# Determine start point based on existing cycles
# Note: We do NOT fill gaps - only generate from the last existing cycle onwards
start_date = determine_generation_start(member, existing_cycles, interval)
# Determine end date (today or exit_date, whichever is earlier)
end_date = determine_end_date(member, today)
# Generate all cycle starts from start_date to end_date
all_cycle_starts = generate_cycle_starts(start_date, end_date, interval)
# Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
create_cycles(cycle_starts, member.id, fee_type.id, amount)
else
{:ok, []}
end
end
# Filter out existing cycles
existing_starts = MapSet.new(existing_cycles, & &1.cycle_start)
missing_starts = Enum.reject(all_cycle_starts, &MapSet.member?(existing_starts, &1))
# No existing cycles: start from membership_fee_start_date
defp determine_generation_start(member, [], interval) do
determine_start_date(member, interval)
end
# Create missing cycles
create_cycles(missing_starts, member.id, fee_type.id, amount)
# Has existing cycles: start from the cycle AFTER the last one
# This ensures gaps (deleted cycles) are NOT filled
defp determine_generation_start(_member, existing_cycles, interval) do
last_cycle_start =
existing_cycles
|> Enum.map(& &1.cycle_start)
|> Enum.max(Date)
CalendarCycles.next_cycle_start(last_cycle_start, interval)
end
defp determine_start_date(member, interval) do

View file

@ -174,6 +174,63 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
assert second_cycles == []
end
test "does not fill gaps when cycles were deleted" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create member without fee type first to control which cycles exist
member =
create_member_without_cycles(%{
join_date: ~D[2020-03-15],
membership_fee_start_date: ~D[2020-01-01]
})
# Manually create cycles for 2020, 2021, 2022, 2023
for year <- [2020, 2021, 2022, 2023] do
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: Date.new!(year, 1, 1),
member_id: member.id,
membership_fee_type_id: fee_type.id,
amount: fee_type.amount,
status: :unpaid
})
|> Ash.create!()
end
# Delete the 2021 cycle (create a gap)
cycle_2021 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^~D[2021-01-01])
|> Ash.read_one!()
Ash.destroy!(cycle_2021)
# Now assign fee type to member (this triggers generation)
# Since cycles already exist (2020, 2022, 2023), the generator will
# start from the last existing cycle (2023) and go forward
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Verify gap was NOT filled and new cycles were generated from last existing
all_cycles = get_member_cycles(member.id)
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort()
# 2021 should NOT exist (gap was not filled)
refute 2021 in all_cycle_years, "Gap at 2021 should NOT be filled"
# 2020, 2022, 2023 should exist (original cycles)
assert 2020 in all_cycle_years
assert 2022 in all_cycle_years
assert 2023 in all_cycle_years
# 2024 and 2025 should exist (generated after last existing cycle 2023)
assert 2024 in all_cycle_years
assert 2025 in all_cycle_years
end
test "sets correct amount from membership fee type" do
setup_settings(true)
amount = Decimal.new("75.50")