From cd915531c2937d16404517861c2e67d0a46d33d7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 12 Dec 2025 20:08:03 +0100 Subject: [PATCH] feat: add validation for same-interval membership fee type changes --- lib/membership/member.ex | 5 + .../changes/validate_same_interval.ex | 119 ++++++++++ .../changes/validate_same_interval_test.exs | 220 ++++++++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 lib/membership_fees/changes/validate_same_interval.ex create mode 100644 test/membership_fees/changes/validate_same_interval_test.exs diff --git a/lib/membership/member.ex b/lib/membership/member.ex index b76fb64..4c90e05 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -178,6 +178,11 @@ defmodule Mv.Membership.Member do where [changing(:user)] end + # Validate that membership fee type changes only allow same-interval types + change Mv.MembershipFees.Changes.ValidateSameInterval do + where [changing(:membership_fee_type_id)] + end + # Auto-calculate membership_fee_start_date when membership_fee_type_id is set # and membership_fee_start_date is not already set change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do diff --git a/lib/membership_fees/changes/validate_same_interval.ex b/lib/membership_fees/changes/validate_same_interval.ex new file mode 100644 index 0000000..0d067ce --- /dev/null +++ b/lib/membership_fees/changes/validate_same_interval.ex @@ -0,0 +1,119 @@ +defmodule Mv.MembershipFees.Changes.ValidateSameInterval do + @moduledoc """ + Validates that membership fee type changes only allow same-interval types. + + Prevents changing from yearly to monthly, etc. (MVP constraint). + + ## Usage + + In a Member action: + + update :update_member do + # ... + change Mv.MembershipFees.Changes.ValidateSameInterval + end + + The change module only executes when `membership_fee_type_id` is being changed. + If the new type has a different interval than the current type, a validation error is returned. + """ + use Ash.Resource.Change + + @impl true + def change(changeset, _opts, _context) do + if changing_membership_fee_type?(changeset) do + validate_interval_match(changeset) + else + changeset + end + end + + # Check if membership_fee_type_id is being changed + defp changing_membership_fee_type?(changeset) do + Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id) + end + + # Validate that the new type has the same interval as the current type + defp validate_interval_match(changeset) do + current_type_id = get_current_type_id(changeset) + new_type_id = get_new_type_id(changeset) + + # If no current type, allow any change (first assignment) + if is_nil(current_type_id) do + changeset + else + # If new type is nil, that's allowed (removing type) + if is_nil(new_type_id) do + changeset + else + # Both types exist - validate intervals match + case get_intervals(current_type_id, new_type_id) do + {:ok, current_interval, new_interval} -> + if current_interval == new_interval do + changeset + else + add_interval_mismatch_error(changeset, current_interval, new_interval) + end + + {:error, _reason} -> + # If we can't load the types, allow the change (fail open) + # The database constraint will catch invalid foreign keys + changeset + end + end + end + end + + # Get current type ID from changeset data + defp get_current_type_id(changeset) do + case changeset.data do + %{membership_fee_type_id: type_id} -> type_id + _ -> nil + end + end + + # Get new type ID from changeset + defp get_new_type_id(changeset) do + case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do + {:ok, type_id} -> type_id + :error -> nil + end + end + + # Get intervals for both types + 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 + {{:ok, current_type}, {:ok, new_type}} -> + {:ok, current_type.interval, new_type.interval} + + _ -> + {:error, :type_not_found} + end + end + + # Add validation error for interval mismatch + defp add_interval_mismatch_error(changeset, current_interval, new_interval) do + current_interval_name = format_interval(current_interval) + new_interval_name = format_interval(new_interval) + + message = + "Cannot change membership fee type: current type uses #{current_interval_name} interval, " <> + "new type uses #{new_interval_name} interval. Only same-interval changes are allowed." + + Ash.Changeset.add_error( + changeset, + field: :membership_fee_type_id, + message: message + ) + end + + # Format interval atom to human-readable string + defp format_interval(:monthly), do: "monthly" + defp format_interval(:quarterly), do: "quarterly" + defp format_interval(:half_yearly), do: "half-yearly" + defp format_interval(:yearly), do: "yearly" + defp format_interval(interval), do: to_string(interval) +end + diff --git a/test/membership_fees/changes/validate_same_interval_test.exs b/test/membership_fees/changes/validate_same_interval_test.exs new file mode 100644 index 0000000..7b7a433 --- /dev/null +++ b/test/membership_fees/changes/validate_same_interval_test.exs @@ -0,0 +1,220 @@ +defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do + @moduledoc """ + Tests for ValidateSameInterval change module. + """ + use Mv.DataCase, async: true + + alias Mv.Membership.Member + alias Mv.MembershipFees.MembershipFeeType + alias Mv.MembershipFees.Changes.ValidateSameInterval + + # 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" + } + + attrs = Map.merge(default_attrs, attrs) + + Member + |> Ash.Changeset.for_create(:create_member, attrs) + |> Ash.create!() + end + + describe "validate_interval_match/1" do + test "allows change to type with same interval" do + yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"}) + yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"}) + + member = create_member(%{membership_fee_type_id: yearly_type1.id}) + + changeset = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type2.id + }) + |> ValidateSameInterval.change(%{}, %{}) + + assert changeset.valid? + end + + test "prevents change to type with different interval" do + yearly_type = create_fee_type(%{interval: :yearly}) + monthly_type = create_fee_type(%{interval: :monthly}) + + member = create_member(%{membership_fee_type_id: yearly_type.id}) + + changeset = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: monthly_type.id + }) + |> ValidateSameInterval.change(%{}, %{}) + + refute changeset.valid? + assert %{errors: errors} = changeset + assert Enum.any?(errors, fn error -> + error.field == :membership_fee_type_id and + error.message =~ "yearly" and + error.message =~ "monthly" + end) + end + + test "allows first assignment of membership fee type" do + yearly_type = create_fee_type(%{interval: :yearly}) + member = create_member(%{}) # No fee type assigned + + changeset = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type.id + }) + |> ValidateSameInterval.change(%{}, %{}) + + assert changeset.valid? + end + + test "allows removal of membership fee type" do + yearly_type = create_fee_type(%{interval: :yearly}) + member = create_member(%{membership_fee_type_id: yearly_type.id}) + + changeset = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: nil + }) + |> ValidateSameInterval.change(%{}, %{}) + + assert changeset.valid? + end + + test "does nothing when membership_fee_type_id is not changed" do + yearly_type = create_fee_type(%{interval: :yearly}) + member = create_member(%{membership_fee_type_id: yearly_type.id}) + + changeset = + member + |> Ash.Changeset.for_update(:update_member, %{ + first_name: "New Name" + }) + |> ValidateSameInterval.change(%{}, %{}) + + assert changeset.valid? + end + + test "error message is clear and helpful" do + yearly_type = create_fee_type(%{interval: :yearly}) + quarterly_type = create_fee_type(%{interval: :quarterly}) + + member = create_member(%{membership_fee_type_id: yearly_type.id}) + + changeset = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: quarterly_type.id + }) + |> ValidateSameInterval.change(%{}, %{}) + + error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id)) + assert error.message =~ "yearly" + assert error.message =~ "quarterly" + assert error.message =~ "same-interval" + end + + test "handles all interval types correctly" do + intervals = [:monthly, :quarterly, :half_yearly, :yearly] + + for interval1 <- intervals, + interval2 <- intervals, + interval1 != interval2 do + type1 = + create_fee_type(%{ + interval: interval1, + name: "Type #{interval1} #{System.unique_integer([:positive])}" + }) + + type2 = + create_fee_type(%{ + interval: interval2, + name: "Type #{interval2} #{System.unique_integer([:positive])}" + }) + + member = create_member(%{membership_fee_type_id: type1.id}) + + changeset = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: type2.id + }) + |> ValidateSameInterval.change(%{}, %{}) + + refute changeset.valid?, + "Should prevent change from #{interval1} to #{interval2}" + end + end + end + + describe "integration with update_member action" do + test "validation works when updating member via update_member action" do + yearly_type = create_fee_type(%{interval: :yearly}) + monthly_type = create_fee_type(%{interval: :monthly}) + + member = create_member(%{membership_fee_type_id: yearly_type.id}) + + # Try to update member with different interval type + assert {:error, %Ash.Error.Invalid{} = error} = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: monthly_type.id + }) + |> Ash.update() + + # Check that error is about interval mismatch + error_message = extract_error_message(error) + assert error_message =~ "yearly" + assert error_message =~ "monthly" + assert error_message =~ "same-interval" + end + + test "allows update when interval matches" do + yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"}) + yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"}) + + member = create_member(%{membership_fee_type_id: yearly_type1.id}) + + # Update member with same-interval type + assert {:ok, updated_member} = + member + |> Ash.Changeset.for_update(:update_member, %{ + membership_fee_type_id: yearly_type2.id + }) + |> Ash.update() + + assert updated_member.membership_fee_type_id == yearly_type2.id + end + + defp extract_error_message(%Ash.Error.Invalid{errors: errors}) do + errors + |> Enum.filter(&(&1.field == :membership_fee_type_id)) + |> Enum.map(& &1.message) + |> Enum.join(" ") + end + end +end