defmodule Mv.MembershipFees.CycleGeneratorTest do @moduledoc """ Tests for the CycleGenerator module. """ use Mv.DataCase, async: false alias Mv.MembershipFees.CycleGenerator alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeType alias Mv.Membership.Member require Ash.Query setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() %{actor: system_actor} end # Helper to create a membership fee type defp create_fee_type(attrs, actor) do default_attrs = %{ name: "Test Fee Type #{System.unique_integer([:positive])}", amount: Decimal.new("50.00"), interval: :yearly } attrs = Map.merge(default_attrs, attrs) MembershipFeeType |> Ash.Changeset.for_create(:create, attrs) |> Ash.create!(actor: actor) end # Helper to create a member without triggering cycle generation defp create_member_without_cycles(attrs, actor) do default_attrs = %{ first_name: "Test", last_name: "User", email: "test#{System.unique_integer([:positive])}@example.com" } attrs = Map.merge(default_attrs, attrs) {:ok, member} = Mv.Membership.create_member(attrs, actor: actor) member end # Helper to set up settings with specific include_joining_cycle value defp setup_settings(include_joining_cycle, actor) do {:ok, settings} = Mv.Membership.get_settings() settings |> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle}) |> Ash.update!(actor: actor) end # Helper to get cycles for a member defp get_member_cycles(member_id, actor) do MembershipFeeCycle |> Ash.Query.filter(member_id == ^member_id) |> Ash.Query.sort(cycle_start: :asc) |> Ash.read!(actor: actor) end describe "generate_cycles_for_member/2" do test "generates cycles from start date to today", %{actor: actor} do setup_settings(true, actor) fee_type = create_fee_type(%{interval: :yearly}, actor) # Create member WITHOUT fee type first to avoid auto-generation member = create_member_without_cycles( %{ join_date: ~D[2022-03-15], membership_fee_start_date: ~D[2022-01-01] }, actor ) # Assign fee type member = member |> then(fn m -> {:ok, updated} = Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id}, actor: actor) updated end) # Explicitly generate cycles with fixed "today" date to avoid date dependency today = ~D[2024-06-15] {:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) # Verify cycles were generated all_cycles = get_member_cycles(member.id, actor) cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq() # With include_joining_cycle=true and join_date=2022-03-15, # start_date should be 2022-01-01 # Should have cycles for 2022, 2023, 2024 assert 2022 in cycle_years assert 2023 in cycle_years assert 2024 in cycle_years end test "generates cycles from last existing cycle", %{actor: actor} do setup_settings(true, actor) fee_type = create_fee_type(%{interval: :yearly}, actor) # Create member without fee type first to avoid auto-generation member = create_member_without_cycles( %{ join_date: ~D[2022-03-15], membership_fee_start_date: ~D[2022-01-01] }, actor ) # Manually create a cycle for 2022 MembershipFeeCycle |> Ash.Changeset.for_create(:create, %{ cycle_start: ~D[2022-01-01], member_id: member.id, membership_fee_type_id: fee_type.id, amount: fee_type.amount, status: :paid }) |> Ash.create!(actor: actor) # Now assign fee type to member member = member |> then(fn m -> {:ok, updated} = Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id}, actor: actor) updated end) # Generate cycles with specific "today" date today = ~D[2024-06-15] {:ok, new_cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) # Should generate only 2023 and 2024 (2022 already exists) new_cycle_years = Enum.map(new_cycles, & &1.cycle_start.year) |> Enum.sort() assert 2022 not in new_cycle_years end test "respects left_at boundary (stops generation)", %{actor: actor} do setup_settings(true, actor) fee_type = create_fee_type(%{interval: :yearly}, actor) member = create_member_without_cycles( %{ join_date: ~D[2022-03-15], exit_date: ~D[2023-06-15], membership_fee_type_id: fee_type.id, membership_fee_start_date: ~D[2022-01-01] }, actor ) # Generate cycles with specific "today" date far in the future today = ~D[2025-06-15] {:ok, cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) # With exit_date in 2023, should only generate 2022 and 2023 cycles cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() # Should not have 2024 or 2025 cycles assert 2024 not in cycle_years assert 2025 not in cycle_years end test "skips existing cycles (idempotent)", %{actor: actor} do setup_settings(true, actor) fee_type = create_fee_type(%{interval: :yearly}, actor) member = create_member_without_cycles( %{ join_date: ~D[2023-03-15], membership_fee_type_id: fee_type.id, membership_fee_start_date: ~D[2023-01-01] }, actor ) today = ~D[2024-06-15] # First generation {:ok, _first_cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) # Second generation (should be idempotent) {:ok, second_cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) # Second call should return empty list (no new cycles) assert second_cycles == [] end test "does not fill gaps when cycles were deleted", %{actor: actor} do setup_settings(true, actor) fee_type = create_fee_type(%{interval: :yearly}, actor) # 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] }, actor ) # 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!(actor: actor) 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!(actor: actor) Ash.destroy!(cycle_2021, actor: actor) # 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 |> then(fn m -> {:ok, updated} = Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id}, actor: actor) updated end) # Verify gap was NOT filled and new cycles were generated from last existing all_cycles = get_member_cycles(member.id, actor) 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", %{actor: actor} do setup_settings(true, actor) amount = Decimal.new("75.50") fee_type = create_fee_type(%{interval: :yearly, amount: amount}, actor) member = create_member_without_cycles( %{ join_date: ~D[2024-03-15], membership_fee_type_id: fee_type.id, membership_fee_start_date: ~D[2024-01-01] }, actor ) # Verify cycles were generated with correct amount all_cycles = get_member_cycles(member.id, actor) refute Enum.empty?(all_cycles), "Expected cycles to be generated" # All cycles should have the correct amount Enum.each(all_cycles, fn cycle -> assert Decimal.equal?(cycle.amount, amount) end) end test "handles NULL membership_fee_start_date by calculating from join_date", %{actor: actor} do setup_settings(true, actor) fee_type = create_fee_type(%{interval: :quarterly}, actor) # Create member without membership_fee_start_date - it will be auto-calculated # and cycles will be auto-generated member = create_member_without_cycles( %{ join_date: ~D[2024-02-15], membership_fee_type_id: fee_type.id # No membership_fee_start_date - should be calculated }, actor ) # Verify cycles were auto-generated all_cycles = get_member_cycles(member.id, actor) # With include_joining_cycle=true and join_date=2024-02-15 (quarterly), # start_date should be 2024-01-01 (Q1 start) # Should have Q1, Q2, Q3, Q4 2024 cycles (based on current date) refute Enum.empty?(all_cycles), "Expected cycles to be generated" cycle_starts = Enum.map(all_cycles, & &1.cycle_start) |> Enum.sort(Date) first_cycle_start = List.first(cycle_starts) # First cycle should start in Q1 2024 (2024-01-01) assert first_cycle_start == ~D[2024-01-01] end test "returns error when member has no membership_fee_type", %{actor: actor} do # Create member without fee type - no auto-generation will occur member = create_member_without_cycles( %{ join_date: ~D[2024-03-15] # No membership_fee_type_id }, actor ) {:error, reason} = CycleGenerator.generate_cycles_for_member(member.id) assert reason == :no_membership_fee_type end test "returns error when member has no join_date", %{actor: actor} do fee_type = create_fee_type(%{interval: :yearly}, actor) # Create member without join_date - no auto-generation will occur # (after_action hook checks for join_date) member = create_member_without_cycles( %{ membership_fee_type_id: fee_type.id # No join_date }, actor ) {:error, reason} = CycleGenerator.generate_cycles_for_member(member.id) assert reason == :no_join_date end test "returns error when member not found" do fake_id = Ash.UUID.generate() {:error, reason} = CycleGenerator.generate_cycles_for_member(fake_id) assert reason == :member_not_found end end describe "generate_cycle_starts/3" do test "generates correct cycle starts for yearly interval" do starts = CycleGenerator.generate_cycle_starts(~D[2022-01-01], ~D[2024-06-15], :yearly) assert starts == [~D[2022-01-01], ~D[2023-01-01], ~D[2024-01-01]] end test "generates correct cycle starts for quarterly interval" do starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-09-15], :quarterly) assert starts == [~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01]] end test "generates correct cycle starts for monthly interval" do starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-03-15], :monthly) assert starts == [~D[2024-01-01], ~D[2024-02-01], ~D[2024-03-01]] end test "generates correct cycle starts for half_yearly interval" do starts = CycleGenerator.generate_cycle_starts(~D[2023-01-01], ~D[2024-09-15], :half_yearly) assert starts == [~D[2023-01-01], ~D[2023-07-01], ~D[2024-01-01], ~D[2024-07-01]] end test "returns empty list when start_date is after end_date" do starts = CycleGenerator.generate_cycle_starts(~D[2025-01-01], ~D[2024-06-15], :yearly) assert starts == [] end test "includes cycle when end_date is on cycle start" do starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-01-01], :yearly) assert starts == [~D[2024-01-01]] end end describe "generate_cycles_for_all_members/1" do test "generates cycles for multiple members", %{actor: actor} do setup_settings(true, actor) fee_type = create_fee_type(%{interval: :yearly}, actor) # Create multiple members _member1 = create_member_without_cycles( %{ join_date: ~D[2024-01-15], membership_fee_type_id: fee_type.id, membership_fee_start_date: ~D[2024-01-01] }, actor ) _member2 = create_member_without_cycles( %{ join_date: ~D[2024-02-15], membership_fee_type_id: fee_type.id, membership_fee_start_date: ~D[2024-01-01] }, actor ) today = ~D[2024-06-15] {:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today) assert is_map(results) assert Map.has_key?(results, :success) assert Map.has_key?(results, :failed) assert Map.has_key?(results, :total) end end describe "lock mechanism" do test "prevents concurrent generation for same member", %{actor: actor} do setup_settings(true, actor) fee_type = create_fee_type(%{interval: :yearly}, actor) member = create_member_without_cycles( %{ join_date: ~D[2022-03-15], membership_fee_type_id: fee_type.id, membership_fee_start_date: ~D[2022-01-01] }, actor ) today = ~D[2024-06-15] # Run two concurrent generations task1 = Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end) task2 = Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end) result1 = Task.await(task1) result2 = Task.await(task2) # Both should succeed assert match?({:ok, _, _}, result1) assert match?({:ok, _, _}, result2) # One should have created cycles, the other should have empty list (idempotent) {:ok, cycles1, _} = result1 {:ok, cycles2, _} = result2 # Combined should not have duplicates all_cycles = cycles1 ++ cycles2 unique_starts = all_cycles |> Enum.map(& &1.cycle_start) |> Enum.uniq() assert length(all_cycles) == length(unique_starts) end end end