Change validation to fail closed instead of fail open when types cannot be loaded. This prevents inconsistent data states and provides clearer error messages to users.
134 lines
4.1 KiB
Elixir
134 lines
4.1 KiB
Elixir
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, that's allowed (removing type)
|
|
is_nil(new_type_id) ->
|
|
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: type not found"
|
|
|
|
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
|