feat: add status management actions to MembershipFeeCycle

This commit is contained in:
Moritz 2025-12-12 19:40:17 +01:00
parent f39fd49af3
commit 48d98b97b2
Signed by: moritz
GPG key ID: 1020A035E5DD0824
2 changed files with 186 additions and 234 deletions

View file

@ -51,6 +51,33 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
primary? true
accept [:status, :notes]
end
update :mark_as_paid do
description "Mark cycle as paid"
require_atomic? false
accept [:notes]
change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :paid)
end
end
update :mark_as_suspended do
description "Mark cycle as suspended"
require_atomic? false
accept [:notes]
change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :suspended)
end
end
update :mark_as_unpaid do
description "Mark cycle as unpaid (for error correction)"
require_atomic? false
accept [:notes]
change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :unpaid)
end
end
end
attributes do

View file

@ -1,6 +1,6 @@
defmodule Mv.MembershipFees.MembershipFeeCycleTest do
@moduledoc """
Tests for MembershipFeeCycle resource.
Tests for MembershipFeeCycle resource, focusing on status management actions.
"""
use Mv.DataCase, async: true
@ -8,275 +8,200 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
setup do
# Create a member for testing
{:ok, member} =
Ash.create(Member, %{
# 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
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "status defaults" do
test "status defaults to :unpaid when creating a cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
# Create a fee type for testing
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
%{member: member, fee_type: fee_type}
end
describe "create MembershipFeeCycle" do
test "can create cycle with valid attributes", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:ok, %MembershipFeeCycle{} = cycle} = Ash.create(MembershipFeeCycle, attrs)
assert cycle.cycle_start == ~D[2025-01-01]
assert Decimal.equal?(cycle.amount, Decimal.new("100.00"))
assert cycle.member_id == member.id
assert cycle.membership_fee_type_id == fee_type.id
end
test "can create cycle with notes", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
notes: "First payment cycle"
}
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
assert cycle.notes == "First payment cycle"
end
test "requires cycle_start", %{member: member, fee_type: fee_type} do
attrs = %{
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
assert error_on_field?(error, :cycle_start)
end
test "requires amount", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
assert error_on_field?(error, :amount)
end
test "requires member_id", %{fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
membership_fee_type_id: fee_type.id
}
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
assert error_on_field?(error, :member_id) or error_on_field?(error, :member)
end
test "requires membership_fee_type_id", %{member: member} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id
}
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
assert error_on_field?(error, :membership_fee_type_id) or
error_on_field?(error, :membership_fee_type)
end
test "status defaults to :unpaid", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
assert cycle.status == :unpaid
end
test "validates status enum values - unpaid", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
assert cycle.status == :unpaid
end
test "validates status enum values - paid", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-02-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :paid
}
describe "mark_as_paid" do
test "sets status to :paid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
assert cycle.status == :paid
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid)
assert updated.status == :paid
end
test "validates status enum values - suspended", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-03-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :suspended
}
test "can set notes when marking as paid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
assert cycle.status == :suspended
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Payment received via bank transfer"},
action: :mark_as_paid
)
assert updated.status == :paid
assert updated.notes == "Payment received via bank transfer"
end
test "rejects invalid status values", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :cancelled
}
test "can change from suspended to paid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :suspended})
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
assert error_on_field?(error, :status)
end
test "rejects negative amount", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-04-01],
amount: Decimal.new("-50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
assert error_on_field?(error, :amount)
end
test "accepts zero amount", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-05-01],
amount: Decimal.new("0.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
assert Decimal.equal?(cycle.amount, Decimal.new("0.00"))
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid)
assert updated.status == :paid
end
end
describe "uniqueness constraint" do
test "cannot create duplicate cycle for same member and cycle_start", %{
member: member,
fee_type: fee_type
} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
describe "mark_as_suspended" do
test "sets status to :suspended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs)
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
# Should fail due to uniqueness constraint
assert is_struct(error, Ash.Error.Invalid)
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_suspended)
assert updated.status == :suspended
end
test "can create cycles for same member with different cycle_start", %{
member: member,
fee_type: fee_type
} do
attrs1 = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
test "can set notes when marking as suspended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
attrs2 = %{
cycle_start: ~D[2025-02-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Waived due to special circumstances"},
action: :mark_as_suspended
)
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs1)
assert {:ok, _cycle2} = Ash.create(MembershipFeeCycle, attrs2)
assert updated.status == :suspended
assert updated.notes == "Waived due to special circumstances"
end
test "can create cycles for different members with same cycle_start", %{fee_type: fee_type} do
{:ok, member1} =
Ash.create(Member, %{
first_name: "Member",
last_name: "One",
email: "member.one.#{System.unique_integer([:positive])}@example.com"
})
test "can change from paid to suspended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
{:ok, member2} =
Ash.create(Member, %{
first_name: "Member",
last_name: "Two",
email: "member.two.#{System.unique_integer([:positive])}@example.com"
})
attrs1 = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member1.id,
membership_fee_type_id: fee_type.id
}
attrs2 = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member2.id,
membership_fee_type_id: fee_type.id
}
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs1)
assert {:ok, _cycle2} = Ash.create(MembershipFeeCycle, attrs2)
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_suspended)
assert updated.status == :suspended
end
end
# Helper to check if an error occurred on a specific field
defp error_on_field?(%Ash.Error.Invalid{} = error, field) do
Enum.any?(error.errors, fn e ->
case e do
%{field: ^field} -> true
%{fields: fields} when is_list(fields) -> field in fields
_ -> false
end
end)
describe "mark_as_unpaid" do
test "sets status to :unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert updated.status == :unpaid
end
defp error_on_field?(_, _), do: false
test "can set notes when marking as unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Payment was reversed"}, action: :mark_as_unpaid)
assert updated.status == :unpaid
assert updated.notes == "Payment was reversed"
end
test "can change from suspended to unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :suspended})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert updated.status == :unpaid
end
end
describe "status transitions" do
test "all status transitions are allowed" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# unpaid -> paid
cycle1 = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, c1} = Ash.update(cycle1, %{}, action: :mark_as_paid)
assert c1.status == :paid
# paid -> suspended
assert {:ok, c2} = Ash.update(c1, %{}, action: :mark_as_suspended)
assert c2.status == :suspended
# suspended -> unpaid
assert {:ok, c3} = Ash.update(c2, %{}, action: :mark_as_unpaid)
assert c3.status == :unpaid
# unpaid -> suspended
assert {:ok, c4} = Ash.update(c3, %{}, action: :mark_as_suspended)
assert c4.status == :suspended
# suspended -> paid
assert {:ok, c5} = Ash.update(c4, %{}, action: :mark_as_paid)
assert c5.status == :paid
# paid -> unpaid
assert {:ok, c6} = Ash.update(c5, %{}, action: :mark_as_unpaid)
assert c6.status == :unpaid
end
end
end