feat: add validation for same-interval membership fee type changes
This commit is contained in:
parent
673e90d179
commit
cd915531c2
3 changed files with 344 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
119
lib/membership_fees/changes/validate_same_interval.ex
Normal file
119
lib/membership_fees/changes/validate_same_interval.ex
Normal file
|
|
@ -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
|
||||
|
||||
220
test/membership_fees/changes/validate_same_interval_test.exs
Normal file
220
test/membership_fees/changes/validate_same_interval_test.exs
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue