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) cond do # If no current type, allow any change (first assignment) is_nil(current_type_id) -> changeset # If new type is nil, reject the change (membership_fee_type_id is required) is_nil(new_type_id) -> add_nil_type_error(changeset) # Both types exist - validate intervals match true -> validate_intervals_match(changeset, current_type_id, new_type_id) end end # Validates that intervals match when both types exist defp validate_intervals_match(changeset, current_type_id, new_type_id) do 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} -> # Fail closed: If we can't load the types, reject the change # This prevents inconsistent data states add_type_validation_error(changeset, reason) 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 # Add validation error when types cannot be loaded defp add_type_validation_error(changeset, _reason) do message = "Could not validate membership fee type intervals. " <> "The current or new membership fee type no longer exists. " <> "This may indicate a data consistency issue." Ash.Changeset.add_error( changeset, field: :membership_fee_type_id, message: message ) end # Add validation error when trying to set membership_fee_type_id to nil defp add_nil_type_error(changeset) do message = "Cannot remove membership fee type. A membership fee type is required." 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