From a99f56969d3941f5b1f292ca96f6d8477519beef Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 12 Dec 2025 16:21:36 +0100 Subject: [PATCH] feat: include inactive members in batch cycle generation - Remove exit_date filter from generate_cycles_for_all_members query - Inactive members now get cycles generated up to their exit_date - Add tests for inactive member processing and exit_date boundary - Document exit_date == cycle_start behavior (cycle still generated) --- lib/mv/membership_fees/cycle_generator.ex | 23 ++++-- .../cycle_generator_edge_cases_test.exs | 70 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index 2f904d9..f87961a 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -83,12 +83,18 @@ defmodule Mv.MembershipFees.CycleGenerator do end @doc """ - Generates membership fee cycles for all active members. + Generates membership fee cycles for all members with a fee type assigned. - Active members are those who: + This includes both active and inactive (left) members. Inactive members + will have cycles generated up to their exit_date if they don't have cycles + for that period yet. This allows for catch-up generation of missing cycles. + + Members processed are those who: - Have a membership_fee_type assigned - Have a join_date set - - Either have no exit_date or exit_date >= today + + The exit_date boundary is respected during generation (not in the query), + so inactive members will get cycles up to their exit date. ## Parameters @@ -107,12 +113,13 @@ defmodule Mv.MembershipFees.CycleGenerator do today = Keyword.get(opts, :today, Date.utc_today()) batch_size = Keyword.get(opts, :batch_size, 10) - # Query active members with fee type assigned + # Query ALL members with fee type assigned (including inactive/left members) + # The exit_date boundary is applied during cycle generation, not here. + # This allows catch-up generation for members who left but are missing cycles. query = Member |> Ash.Query.filter(not is_nil(membership_fee_type_id)) |> Ash.Query.filter(not is_nil(join_date)) - |> Ash.Query.filter(is_nil(exit_date) or exit_date >= ^today) case Ash.read(query) do {:ok, members} -> @@ -234,7 +241,11 @@ defmodule Mv.MembershipFees.CycleGenerator do defp determine_end_date(member, today) do if member.exit_date && Date.compare(member.exit_date, today) == :lt do - # Member has left - use the cycle that contains the exit date + # Member has left - use the exit date as boundary + # Note: If exit_date == cycle_start, the cycle IS still generated. + # This means the member is considered a member on the first day of that cycle. + # Example: exit_date = 2025-01-01, yearly interval + # -> The 2025 cycle (starting 2025-01-01) WILL be generated member.exit_date else today diff --git a/test/mv/membership_fees/cycle_generator_edge_cases_test.exs b/test/mv/membership_fees/cycle_generator_edge_cases_test.exs index f9c534f..3d59f36 100644 --- a/test/mv/membership_fees/cycle_generator_edge_cases_test.exs +++ b/test/mv/membership_fees/cycle_generator_edge_cases_test.exs @@ -454,4 +454,74 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do assert 2024 in cycle_years end end + + describe "inactive member processing" do + test "inactive members receive cycles up to exit_date via generate_cycles_for_all_members" do + setup_settings(true) + fee_type = create_fee_type(%{interval: :yearly}) + + # Create an inactive member (left in 2023) WITHOUT fee type initially + # This simulates a member that was created before the fee system existed + member = + create_member(%{ + join_date: ~D[2021-03-15], + exit_date: ~D[2023-06-15] + }) + + # Now assign fee type (simulating a retroactive assignment) + member = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: fee_type.id, + membership_fee_start_date: ~D[2021-01-01] + }) + |> Ash.update!() + + # Run batch generation with a "today" date after the member left + today = ~D[2024-06-15] + {:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today) + + # The inactive member should have been processed + assert results.total >= 1 + + # Check the member's cycles + cycles = get_member_cycles(member.id) + cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq() + + # Should have 2021, 2022, 2023 (exit year included) + assert 2021 in cycle_years + assert 2022 in cycle_years + assert 2023 in cycle_years + + # Should NOT have 2024 (after exit) + refute 2024 in cycle_years + end + + test "exit_date on cycle_start still generates that cycle" do + setup_settings(true) + fee_type = create_fee_type(%{interval: :yearly}) + + # Member exits exactly on cycle start (2024-01-01) + member = + create_member(%{ + join_date: ~D[2022-03-15], + exit_date: ~D[2024-01-01], + membership_fee_type_id: fee_type.id, + membership_fee_start_date: ~D[2022-01-01] + }) + + # Check cycles + cycles = get_member_cycles(member.id) + cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() + + # 2024 should be included because exit_date == cycle_start means + # the member was still a member on that day + assert 2022 in cycle_years + assert 2023 in cycle_years + assert 2024 in cycle_years + + # 2025 should NOT be included + refute 2025 in cycle_years + end + end end