diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex index 4c47623..6a101e9 100644 --- a/lib/membership_fees/membership_fee_cycle.ex +++ b/lib/membership_fees/membership_fee_cycle.ex @@ -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 diff --git a/test/membership_fees/membership_fee_cycle_test.exs b/test/membership_fees/membership_fee_cycle_test.exs index ca59e26..14bdf4b 100644 --- a/test/membership_fees/membership_fee_cycle_test.exs +++ b/test/membership_fees/membership_fee_cycle_test.exs @@ -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, %{ - first_name: "Test", - last_name: "Member", - email: "test.member.#{System.unique_integer([:positive])}@example.com" - }) + # 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 + } - # 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 - }) + attrs = Map.merge(default_attrs, attrs) - %{member: member, fee_type: fee_type} + MembershipFeeType + |> Ash.Changeset.for_create(:create, attrs) + |> Ash.create!() 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 - } + # 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" + } - 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 + attrs = Map.merge(default_attrs, attrs) - 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" - } + Member + |> Ash.Changeset.for_create(:create_member, attrs) + |> Ash.create!() + end - assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs) - assert cycle.notes == "First payment cycle" - 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 + } - 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 - } + attrs = Map.merge(default_attrs, attrs) - assert {:error, error} = Ash.create(MembershipFeeCycle, attrs) - assert error_on_field?(error, :cycle_start) - end + MembershipFeeCycle + |> Ash.Changeset.for_create(:create, attrs) + |> Ash.create!() + 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 - } + 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}) - assert {:error, error} = Ash.create(MembershipFeeCycle, attrs) - assert error_on_field?(error, :amount) - end + 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!() - 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 + 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 - } + 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 == :unpaid + assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid) + assert updated.status == :paid 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 - } + 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 == :paid + 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 "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 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 {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs) - assert cycle.status == :suspended - 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 - } - - 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 + + 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 - defp error_on_field?(_, _), do: false + 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