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 # Helper to create a membership fee type defp create_fee_type(attrs) 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!() end # Helper to create a member without triggering cycle generation defp create_member_without_cycles(attrs) do default_attrs = %{ first_name: "Test", last_name: "User", email: "test#{System.unique_integer([:positive])}@example.com" } attrs = Map.merge(default_attrs, attrs) Member |> Ash.Changeset.for_create(:create_member, attrs) |> Ash.create!() end # Helper to set up settings with specific include_joining_cycle value defp setup_settings(include_joining_cycle) do {:ok, settings} = Mv.Membership.get_settings() settings |> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle}) |> Ash.update!() end # Helper to get cycles for a member defp get_member_cycles(member_id) do MembershipFeeCycle |> Ash.Query.filter(member_id == ^member_id) |> Ash.Query.sort(cycle_start: :asc) |> Ash.read!() end describe "generate_cycles_for_member/2" do test "generates cycles from start date to today" do setup_settings(true) fee_type = create_fee_type(%{interval: :yearly}) # 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] }) # Assign fee type member = member |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) |> Ash.update!() # 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) 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" do setup_settings(true) fee_type = create_fee_type(%{interval: :yearly}) # 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] }) # 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!() # Now assign fee type to member member = member |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) |> Ash.update!() # 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)" do setup_settings(true) fee_type = create_fee_type(%{interval: :yearly}) 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] }) # 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)" do setup_settings(true) fee_type = create_fee_type(%{interval: :yearly}) 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] }) 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" 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") fee_type = create_fee_type(%{interval: :yearly, amount: amount}) 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] }) # Verify cycles were generated with correct amount all_cycles = get_member_cycles(member.id) 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" do setup_settings(true) fee_type = create_fee_type(%{interval: :quarterly}) # 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 }) # Verify cycles were auto-generated all_cycles = get_member_cycles(member.id) # 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" 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 }) {: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" do fee_type = create_fee_type(%{interval: :yearly}) # 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 }) {: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" do setup_settings(true) fee_type = create_fee_type(%{interval: :yearly}) # 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] }) _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] }) 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" do setup_settings(true) fee_type = create_fee_type(%{interval: :yearly}) 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] }) 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