mitgliederverwaltung/lib/membership_fees/changes/validate_same_interval.ex
Moritz 5ed41555e9 Member/Setting/validations: domain, actor, and seeds
- setting.ex: domain/authorize for default_membership_fee_type_id check
- validate_same_interval: require membership_fee_type (no None)
- set_membership_fee_start_date: domain/actor for fee type lookup
- Validations: domain/authorize for cross-resource checks
- helpers.ex, email_sync change, seeds.exs actor/authorize fixes
- Update related tests
2026-02-03 23:52:16 +01:00

154 lines
4.8 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, context)
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, context) do
current_type_id = get_current_type_id(changeset)
new_type_id = get_new_type_id(changeset)
actor = Map.get(context || %{}, :actor)
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, actor)
end
end
# Validates that intervals match when both types exist
defp validate_intervals_match(changeset, current_type_id, new_type_id, actor) do
case get_intervals(current_type_id, new_type_id, actor) 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 (actor required for authorization when resource has policies)
defp get_intervals(current_type_id, new_type_id, actor) do
alias Mv.MembershipFees.MembershipFeeType
opts = if actor, do: [actor: actor], else: []
case {
Ash.get(MembershipFeeType, current_type_id, opts),
Ash.get(MembershipFeeType, new_type_id, opts)
} 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