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:
parent
272a8a8afc
commit
0b986db635
2 changed files with 95 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue