diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index bc68c44..b80d3c8 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -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 diff --git a/test/mv/membership_fees/cycle_generator_test.exs b/test/mv/membership_fees/cycle_generator_test.exs index 1c2c35e..e3c918c 100644 --- a/test/mv/membership_fees/cycle_generator_test.exs +++ b/test/mv/membership_fees/cycle_generator_test.exs @@ -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")