diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 4c90e05..1fd2812 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -189,34 +189,35 @@ defmodule Mv.Membership.Member do where [changing(:membership_fee_type_id)] end - # Trigger cycle generation when membership_fee_type_id changes - # Note: Cycle generation runs asynchronously to not block the action, + # Trigger cycle regeneration when membership_fee_type_id changes + # This deletes future unpaid cycles and regenerates them with the new type/amount + # Note: Cycle regeneration runs asynchronously to not block the action, # but in test environment it runs synchronously for DB sandbox compatibility change after_action(fn changeset, member, _context -> fee_type_changed = Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id) if fee_type_changed && member.membership_fee_type_id && member.join_date do - generate_fn = fn -> - case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do - {:ok, _cycles} -> + regenerate_fn = fn -> + case regenerate_cycles_on_type_change(member) do + :ok -> :ok {:error, reason} -> require Logger Logger.warning( - "Failed to generate cycles for member #{member.id}: #{inspect(reason)}" + "Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}" ) end end if Application.get_env(:mv, :sql_sandbox, false) do # Run synchronously in test environment for DB sandbox compatibility - generate_fn.() + regenerate_fn.() else # Run asynchronously in other environments - Task.start(generate_fn) + Task.start(regenerate_fn) end end @@ -681,6 +682,75 @@ defmodule Mv.Membership.Member do end end + # Regenerates cycles when membership fee type changes + # Deletes future unpaid cycles and regenerates them with the new type/amount + defp regenerate_cycles_on_type_change(member) do + alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.MembershipFees.CycleGenerator + alias Mv.MembershipFees.CalendarCycles + + require Ash.Query + + today = Date.utc_today() + + # Find all unpaid cycles for this member + # We need to check cycle_end for each cycle using its own interval + all_unpaid_cycles_query = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id) + |> Ash.Query.filter(status == :unpaid) + |> Ash.Query.load([:membership_fee_type]) + + case Ash.read(all_unpaid_cycles_query) do + {:ok, all_unpaid_cycles} -> + # Filter cycles that haven't ended yet (cycle_end >= today) + # These are the "future" cycles that should be regenerated + # Use each cycle's own interval to calculate cycle_end + cycles_to_delete = + Enum.filter(all_unpaid_cycles, fn cycle -> + case cycle.membership_fee_type do + %{interval: interval} -> + cycle_end = CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) + Date.compare(today, cycle_end) in [:lt, :eq] + + _ -> + false + end + end) + + # Delete future unpaid cycles + if Enum.empty?(cycles_to_delete) do + # No cycles to delete, just regenerate + case CycleGenerator.generate_cycles_for_member(member.id) do + {:ok, _cycles} -> :ok + {:error, reason} -> {:error, reason} + end + else + delete_results = + Enum.map(cycles_to_delete, fn cycle -> + Ash.destroy(cycle) + end) + + # Check if any deletions failed + if Enum.any?(delete_results, &match?({:error, _}, &1)) do + {:error, :deletion_failed} + else + # Regenerate cycles with new type/amount + # CycleGenerator uses its own transaction with advisory lock + # It will reload the member, so it will see the deleted cycles are gone + # and the new membership_fee_type_id + case CycleGenerator.generate_cycles_for_member(member.id) do + {:ok, _cycles} -> :ok + {:error, reason} -> {:error, reason} + end + end + end + + {:error, reason} -> + {:error, reason} + end + end + # Normalizes visibility config map keys from strings to atoms. # JSONB in PostgreSQL converts atom keys to string keys when storing. defp normalize_visibility_config(config) when is_map(config) do diff --git a/test/membership/member_type_change_integration_test.exs b/test/membership/member_type_change_integration_test.exs new file mode 100644 index 0000000..7b23cf8 --- /dev/null +++ b/test/membership/member_type_change_integration_test.exs @@ -0,0 +1,442 @@ +defmodule Mv.Membership.MemberTypeChangeIntegrationTest do + @moduledoc """ + Integration tests for membership fee type changes and cycle regeneration. + """ + use Mv.DataCase, async: true + + alias Mv.Membership.Member + alias Mv.MembershipFees.MembershipFeeType + alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.MembershipFees.CalendarCycles + + 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 + defp create_member(attrs) do + default_attrs = %{ + first_name: "Test", + last_name: "Member", + email: "test.member.#{System.unique_integer([:positive])}@example.com", + join_date: ~D[2023-01-15] + } + + attrs = Map.merge(default_attrs, attrs) + + Member + |> Ash.Changeset.for_create(:create_member, attrs) + |> Ash.create!() + end + + # Helper to create a cycle + defp create_cycle(member, fee_type, attrs) do + default_attrs = %{ + cycle_start: ~D[2024-01-01], + amount: Decimal.new("50.00"), + member_id: member.id, + membership_fee_type_id: fee_type.id, + status: :unpaid + } + + attrs = Map.merge(default_attrs, attrs) + + MembershipFeeCycle + |> Ash.Changeset.for_create(:create, attrs) + |> Ash.create!() + end + + describe "type change cycle regeneration" do + test "future unpaid cycles are regenerated with new amount" do + today = Date.utc_today() + yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")}) + yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}) + + # Create member without fee type first to avoid auto-generation + member = create_member(%{}) + + # Manually assign fee type (this will trigger cycle generation) + member = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type1.id + }) + |> Ash.update!() + + # Wait for cycle generation + Process.sleep(100) + + # Create cycles: one in the past (paid), one current (unpaid) + # Note: Future cycles are not automatically generated by CycleGenerator, + # so we only test with current cycle + past_cycle_start = CalendarCycles.calculate_cycle_start(~D[2023-01-01], :yearly) + current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly) + + # Past cycle (paid) - should remain unchanged + # Check if it already exists (from auto-generation), if not create it + case MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start) + |> Ash.read_one() do + {:ok, existing_cycle} -> + # Update to paid + existing_cycle + |> Ash.Changeset.for_update(:update, %{status: :paid}) + |> Ash.update!() + + {:error, _} -> + create_cycle(member, yearly_type1, %{ + cycle_start: past_cycle_start, + status: :paid, + amount: Decimal.new("100.00") + }) + end + + # Current cycle (unpaid) - should be regenerated + # Delete if exists (from auto-generation), then create with old amount + case MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start) + |> Ash.read_one() do + {:ok, existing_cycle} -> + Ash.destroy!(existing_cycle) + + {:error, _} -> + :ok + end + + _current_cycle = create_cycle(member, yearly_type1, %{ + cycle_start: current_cycle_start, + status: :unpaid, + amount: Decimal.new("100.00") + }) + + # Change membership fee type (same interval, different amount) + assert {:ok, _updated_member} = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type2.id + }) + |> Ash.update() + + # Wait for async cycle regeneration (in test it runs synchronously) + Process.sleep(100) + + # Verify past cycle is unchanged + past_cycle_after = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start) + |> Ash.read_one!() + assert past_cycle_after.status == :paid + assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00")) + assert past_cycle_after.membership_fee_type_id == yearly_type1.id + + # Verify current cycle was deleted and regenerated + # Check that cycle with new type exists (regenerated) + new_current_cycle = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start) + |> Ash.read_one!() + + # Verify it has the new type and amount + assert new_current_cycle.membership_fee_type_id == yearly_type2.id + assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00")) + assert new_current_cycle.status == :unpaid + + # Verify old cycle with old type doesn't exist anymore + old_current_cycles = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start and membership_fee_type_id == ^yearly_type1.id) + |> Ash.read!() + + assert Enum.empty?(old_current_cycles) + end + + test "paid cycles remain unchanged" do + today = Date.utc_today() + yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")}) + yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}) + + # Create member without fee type first to avoid auto-generation + member = create_member(%{}) + + # Manually assign fee type (this will trigger cycle generation) + member = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type1.id + }) + |> Ash.update!() + + # Wait for cycle generation + Process.sleep(100) + + # Get the current cycle and mark it as paid + current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly) + + # Find current cycle and mark as paid + paid_cycle = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start) + |> Ash.read_one!() + |> Ash.Changeset.for_update(:mark_as_paid) + |> Ash.update!() + + # Change membership fee type + assert {:ok, _updated_member} = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type2.id + }) + |> Ash.update() + + # Wait for async cycle regeneration + Process.sleep(100) + + # Verify paid cycle is unchanged (not deleted and regenerated) + {:ok, cycle_after} = Ash.get(MembershipFeeCycle, paid_cycle.id) + assert cycle_after.status == :paid + assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00")) + assert cycle_after.membership_fee_type_id == yearly_type1.id + end + + test "suspended cycles remain unchanged" do + today = Date.utc_today() + yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")}) + yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}) + + # Create member without fee type first to avoid auto-generation + member = create_member(%{}) + + # Manually assign fee type (this will trigger cycle generation) + member = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type1.id + }) + |> Ash.update!() + + # Wait for cycle generation + Process.sleep(100) + + # Get the current cycle and mark it as suspended + current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly) + + # Find current cycle and mark as suspended + suspended_cycle = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start) + |> Ash.read_one!() + |> Ash.Changeset.for_update(:mark_as_suspended) + |> Ash.update!() + + # Change membership fee type + assert {:ok, _updated_member} = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type2.id + }) + |> Ash.update() + + # Wait for async cycle regeneration + Process.sleep(100) + + # Verify suspended cycle is unchanged (not deleted and regenerated) + {:ok, cycle_after} = Ash.get(MembershipFeeCycle, suspended_cycle.id) + assert cycle_after.status == :suspended + assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00")) + assert cycle_after.membership_fee_type_id == yearly_type1.id + end + + test "only cycles that haven't ended yet are deleted" do + today = Date.utc_today() + yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")}) + yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}) + + # Create member without fee type first to avoid auto-generation + member = create_member(%{}) + + # Manually assign fee type (this will trigger cycle generation) + member = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type1.id + }) + |> Ash.update!() + + # Wait for cycle generation + Process.sleep(100) + + # Create cycles: one in the past (unpaid, ended), one current (unpaid, not ended) + past_cycle_start = CalendarCycles.calculate_cycle_start( + Date.add(today, -365), + :yearly + ) + current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly) + + # Past cycle (unpaid) - should remain unchanged (cycle_start < today) + # Delete existing cycle if it exists (from auto-generation) + case MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start) + |> Ash.read_one() do + {:ok, existing_cycle} -> + Ash.destroy!(existing_cycle) + + {:error, _} -> + :ok + end + + past_cycle = create_cycle(member, yearly_type1, %{ + cycle_start: past_cycle_start, + status: :unpaid, + amount: Decimal.new("100.00") + }) + + # Current cycle (unpaid) - should be regenerated (cycle_start >= today) + # Delete existing cycle if it exists (from auto-generation) + case MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start) + |> Ash.read_one() do + {:ok, existing_cycle} -> + Ash.destroy!(existing_cycle) + + {:error, _} -> + :ok + end + + _current_cycle = create_cycle(member, yearly_type1, %{ + cycle_start: current_cycle_start, + status: :unpaid, + amount: Decimal.new("100.00") + }) + + # Change membership fee type + assert {:ok, _updated_member} = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type2.id + }) + |> Ash.update() + + # Wait for async cycle regeneration + Process.sleep(100) + + # Verify past cycle is unchanged + {:ok, past_cycle_after} = Ash.get(MembershipFeeCycle, past_cycle.id) + assert past_cycle_after.status == :unpaid + assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00")) + assert past_cycle_after.membership_fee_type_id == yearly_type1.id + + # Verify current cycle was regenerated + # Check that cycle with new type exists + new_current_cycle = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start) + |> Ash.read_one!() + + assert new_current_cycle.membership_fee_type_id == yearly_type2.id + assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00")) + + # Verify old cycle with old type doesn't exist anymore + old_current_cycles = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start and membership_fee_type_id == ^yearly_type1.id) + |> Ash.read!() + + assert Enum.empty?(old_current_cycles) + end + + test "member calculations update after type change" do + today = Date.utc_today() + yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")}) + yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}) + + # Create member with join_date = today to avoid past cycles + # This ensures no overdue cycles exist + member = create_member(%{join_date: today}) + + # Manually assign fee type (this will trigger cycle generation) + member = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type1.id + }) + |> Ash.update!() + + # Wait for cycle generation + Process.sleep(100) + + # Get current cycle start + current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly) + + # Ensure only one cycle exists (the current one) + # Delete all cycles except the current one + existing_cycles = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id) + |> Ash.read!() + + Enum.each(existing_cycles, fn cycle -> + if cycle.cycle_start != current_cycle_start do + Ash.destroy!(cycle) + end + end) + + # Ensure current cycle exists and is unpaid + case MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start) + |> Ash.read_one() do + {:ok, existing_cycle} -> + # Update to unpaid if it's not + if existing_cycle.status != :unpaid do + existing_cycle + |> Ash.Changeset.for_update(:mark_as_unpaid) + |> Ash.update!() + end + + {:error, _} -> + # Create if it doesn't exist + create_cycle(member, yearly_type1, %{ + cycle_start: current_cycle_start, + status: :unpaid, + amount: Decimal.new("100.00") + }) + end + + # Load calculations before change + member = Ash.load!(member, [:current_cycle_status, :overdue_count]) + assert member.current_cycle_status == :unpaid + assert member.overdue_count == 0 + + # Change membership fee type + assert {:ok, updated_member} = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type2.id + }) + |> Ash.update() + + # Wait for async cycle regeneration + Process.sleep(100) + + # Reload member with calculations + updated_member = Ash.load!(updated_member, [:current_cycle_status, :overdue_count]) + + # Calculations should still work (cycle was regenerated) + assert updated_member.current_cycle_status == :unpaid + assert updated_member.overdue_count == 0 + end + end +end +