From 06324d77c53845feb1946b9eefc2d47946441f4b Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Dec 2025 11:00:08 +0100 Subject: [PATCH] feat: regenerate cycles when membership fee type changes (same interval) - Implemented regenerate_cycles_on_type_change helper in Member resource - Cycles that haven't ended yet (cycle_end >= today) are deleted and regenerated - Paid and suspended cycles remain unchanged (not deleted) - CycleGenerator reloads member with new membership_fee_type_id - Adjusted tests to work with current cycles only (no future cycles) - All integration tests passing Phase 4 completed: Cycle regeneration on type change --- lib/membership/member.ex | 97 +++- .../changes/validate_same_interval.ex | 4 +- lib/membership_fees/membership_fee_cycle.ex | 3 + .../member_cycle_calculations_test.exs | 6 +- .../member_type_change_integration_test.exs | 453 ++++++++++++++++++ .../changes/validate_same_interval_test.exs | 4 +- 6 files changed, 550 insertions(+), 17 deletions(-) create mode 100644 test/membership/member_type_change_integration_test.exs diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 4c90e05..7f30833 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 @@ -646,10 +647,13 @@ defmodule Mv.Membership.Member do false end end) - |> Enum.sort_by(fn cycle -> - interval = Map.get(cycle, :membership_fee_type).interval - Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) - end, {:desc, Date}) + |> Enum.sort_by( + fn cycle -> + interval = Map.get(cycle, :membership_fee_type).interval + Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) + end, + {:desc, Date} + ) |> List.first() else nil @@ -681,6 +685,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/lib/membership_fees/changes/validate_same_interval.ex b/lib/membership_fees/changes/validate_same_interval.ex index 0d067ce..7bfbeee 100644 --- a/lib/membership_fees/changes/validate_same_interval.ex +++ b/lib/membership_fees/changes/validate_same_interval.ex @@ -83,8 +83,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do defp get_intervals(current_type_id, new_type_id) do alias Mv.MembershipFees.MembershipFeeType - case {Ash.get(MembershipFeeType, current_type_id), - Ash.get(MembershipFeeType, new_type_id)} do + case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} do {{:ok, current_type}, {:ok, new_type}} -> {:ok, current_type.interval, new_type.interval} @@ -116,4 +115,3 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do defp format_interval(:yearly), do: "yearly" defp format_interval(interval), do: to_string(interval) end - diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex index 6a101e9..b437ead 100644 --- a/lib/membership_fees/membership_fee_cycle.ex +++ b/lib/membership_fees/membership_fee_cycle.ex @@ -56,6 +56,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do description "Mark cycle as paid" require_atomic? false accept [:notes] + change fn changeset, _context -> Ash.Changeset.force_change_attribute(changeset, :status, :paid) end @@ -65,6 +66,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do description "Mark cycle as suspended" require_atomic? false accept [:notes] + change fn changeset, _context -> Ash.Changeset.force_change_attribute(changeset, :status, :suspended) end @@ -74,6 +76,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do description "Mark cycle as unpaid (for error correction)" require_atomic? false accept [:notes] + change fn changeset, _context -> Ash.Changeset.force_change_attribute(changeset, :status, :unpaid) end diff --git a/test/membership/member_cycle_calculations_test.exs b/test/membership/member_cycle_calculations_test.exs index 8dcaeed..19b2a7f 100644 --- a/test/membership/member_cycle_calculations_test.exs +++ b/test/membership/member_cycle_calculations_test.exs @@ -118,6 +118,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do # Current cycle cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly) + create_cycle(member, fee_type, %{ cycle_start: cycle_start, status: :paid @@ -179,6 +180,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do # Current cycle cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly) + create_cycle(member, fee_type, %{ cycle_start: cycle_start, status: :unpaid @@ -186,8 +188,10 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do # Future cycle (if we're not at the end of the year) next_year = today.year + 1 + if today.month < 12 or today.day < 31 do next_year_start = Date.new!(next_year, 1, 1) + create_cycle(member, fee_type, %{ cycle_start: next_year_start, status: :unpaid @@ -265,6 +269,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do }) cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly) + create_cycle(member, fee_type, %{ cycle_start: cycle_start, status: :unpaid @@ -279,4 +284,3 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do end end end - 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..8ea151c --- /dev/null +++ b/test/membership/member_type_change_integration_test.exs @@ -0,0 +1,453 @@ +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 diff --git a/test/membership_fees/changes/validate_same_interval_test.exs b/test/membership_fees/changes/validate_same_interval_test.exs index 7b7a433..af777fa 100644 --- a/test/membership_fees/changes/validate_same_interval_test.exs +++ b/test/membership_fees/changes/validate_same_interval_test.exs @@ -70,6 +70,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do refute changeset.valid? assert %{errors: errors} = changeset + assert Enum.any?(errors, fn error -> error.field == :membership_fee_type_id and error.message =~ "yearly" and @@ -79,7 +80,8 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do test "allows first assignment of membership fee type" do yearly_type = create_fee_type(%{interval: :yearly}) - member = create_member(%{}) # No fee type assigned + # No fee type assigned + member = create_member(%{}) changeset = member