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