Merge branch 'main' into bugfix/274_required_custom_fields
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
08f563a412
76 changed files with 13847 additions and 1688 deletions
360
test/membership/member_cycle_calculations_test.exs
Normal file
360
test/membership/member_cycle_calculations_test.exs
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||
@moduledoc """
|
||||
Tests for Member cycle status calculations.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
|
||||
# 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,
|
||||
status: :unpaid
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
describe "current_cycle_status" do
|
||||
test "returns status of current cycle for member with active cycle" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
# Create a cycle that is active today (2024-01-01 to 2024-12-31)
|
||||
# Assuming today is in 2024
|
||||
today = Date.utc_today()
|
||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: cycle_start,
|
||||
status: :paid
|
||||
})
|
||||
|
||||
member = Ash.load!(member, :current_cycle_status)
|
||||
assert member.current_cycle_status == :paid
|
||||
end
|
||||
|
||||
test "returns nil for member without current cycle" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
# Create a cycle in the past (not current)
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2020-01-01],
|
||||
status: :paid
|
||||
})
|
||||
|
||||
member = Ash.load!(member, :current_cycle_status)
|
||||
assert member.current_cycle_status == nil
|
||||
end
|
||||
|
||||
test "returns nil for member without cycles" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
member = Ash.load!(member, :current_cycle_status)
|
||||
assert member.current_cycle_status == nil
|
||||
end
|
||||
|
||||
test "returns status of current cycle for monthly interval" do
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
# Create a cycle that is active today (current month)
|
||||
today = Date.utc_today()
|
||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: cycle_start,
|
||||
status: :unpaid
|
||||
})
|
||||
|
||||
member = Ash.load!(member, :current_cycle_status)
|
||||
assert member.current_cycle_status == :unpaid
|
||||
end
|
||||
end
|
||||
|
||||
describe "last_cycle_status" do
|
||||
test "returns status of last completed cycle" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
# Create cycles: 2022 (completed), 2023 (completed), 2024 (current)
|
||||
today = Date.utc_today()
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2022-01-01],
|
||||
status: :paid
|
||||
})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :unpaid
|
||||
})
|
||||
|
||||
# Current cycle
|
||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: cycle_start,
|
||||
status: :paid
|
||||
})
|
||||
|
||||
member = Ash.load!(member, :last_cycle_status)
|
||||
# Should return status of 2023 (last completed)
|
||||
assert member.last_cycle_status == :unpaid
|
||||
end
|
||||
|
||||
test "returns nil for member without completed cycles" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
# Only create current cycle (not completed yet)
|
||||
today = Date.utc_today()
|
||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: cycle_start,
|
||||
status: :paid
|
||||
})
|
||||
|
||||
member = Ash.load!(member, :last_cycle_status)
|
||||
assert member.last_cycle_status == nil
|
||||
end
|
||||
|
||||
test "returns nil for member without cycles" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
member = Ash.load!(member, :last_cycle_status)
|
||||
assert member.last_cycle_status == nil
|
||||
end
|
||||
|
||||
test "returns status of last completed cycle for monthly interval" do
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
today = Date.utc_today()
|
||||
# Create cycles: last month (completed), current month (not completed)
|
||||
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
|
||||
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: last_month_start,
|
||||
status: :paid
|
||||
})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: current_month_start,
|
||||
status: :unpaid
|
||||
})
|
||||
|
||||
member = Ash.load!(member, :last_cycle_status)
|
||||
# Should return status of last month (last completed)
|
||||
assert member.last_cycle_status == :paid
|
||||
end
|
||||
end
|
||||
|
||||
describe "overdue_count" do
|
||||
test "counts only unpaid cycles that have ended" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
today = Date.utc_today()
|
||||
|
||||
# Create cycles:
|
||||
# 2022: unpaid, ended (overdue)
|
||||
# 2023: paid, ended (not overdue)
|
||||
# 2024: unpaid, current (not overdue)
|
||||
# 2025: unpaid, future (not overdue)
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2022-01-01],
|
||||
status: :unpaid
|
||||
})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :paid
|
||||
})
|
||||
|
||||
# Current cycle
|
||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: cycle_start,
|
||||
status: :unpaid
|
||||
})
|
||||
|
||||
# Future cycle (if we're not at the end of the year)
|
||||
next_year = today.year + 1
|
||||
|
||||
if today.month < 12 or today.day < 31 do
|
||||
next_year_start = Date.new!(next_year, 1, 1)
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: next_year_start,
|
||||
status: :unpaid
|
||||
})
|
||||
end
|
||||
|
||||
member = Ash.load!(member, :overdue_count)
|
||||
# Should only count 2022 (unpaid and ended)
|
||||
assert member.overdue_count == 1
|
||||
end
|
||||
|
||||
test "returns 0 when no overdue cycles" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
# Create only paid cycles
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2022-01-01],
|
||||
status: :paid
|
||||
})
|
||||
|
||||
member = Ash.load!(member, :overdue_count)
|
||||
assert member.overdue_count == 0
|
||||
end
|
||||
|
||||
test "returns 0 for member without cycles" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
member = Ash.load!(member, :overdue_count)
|
||||
assert member.overdue_count == 0
|
||||
end
|
||||
|
||||
test "counts overdue cycles for monthly interval" do
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
today = Date.utc_today()
|
||||
|
||||
# Create cycles: two months ago (unpaid, ended), last month (paid, ended), current month (unpaid, not ended)
|
||||
two_months_ago_start =
|
||||
Date.add(today, -65) |> CalendarCycles.calculate_cycle_start(:monthly)
|
||||
|
||||
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
|
||||
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: two_months_ago_start,
|
||||
status: :unpaid
|
||||
})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: last_month_start,
|
||||
status: :paid
|
||||
})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: current_month_start,
|
||||
status: :unpaid
|
||||
})
|
||||
|
||||
member = Ash.load!(member, :overdue_count)
|
||||
# Should only count two_months_ago (unpaid and ended)
|
||||
assert member.overdue_count == 1
|
||||
end
|
||||
|
||||
test "counts multiple overdue cycles" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
# Create multiple unpaid, ended cycles
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2020-01-01],
|
||||
status: :unpaid
|
||||
})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2021-01-01],
|
||||
status: :unpaid
|
||||
})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2022-01-01],
|
||||
status: :unpaid
|
||||
})
|
||||
|
||||
member = Ash.load!(member, :overdue_count)
|
||||
assert member.overdue_count == 3
|
||||
end
|
||||
end
|
||||
|
||||
describe "calculations with multiple cycles" do
|
||||
test "all calculations work correctly with multiple cycles" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
today = Date.utc_today()
|
||||
|
||||
# Create cycles: 2022 (unpaid, ended), 2023 (paid, ended), 2024 (unpaid, current)
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2022-01-01],
|
||||
status: :unpaid
|
||||
})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
status: :paid
|
||||
})
|
||||
|
||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: cycle_start,
|
||||
status: :unpaid
|
||||
})
|
||||
|
||||
member =
|
||||
Ash.load!(member, [:current_cycle_status, :last_cycle_status, :overdue_count])
|
||||
|
||||
assert member.current_cycle_status == :unpaid
|
||||
assert member.last_cycle_status == :paid
|
||||
assert member.overdue_count == 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -6,7 +6,6 @@ defmodule Mv.Membership.MemberTest do
|
|||
@valid_attrs %{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
paid: true,
|
||||
email: "john@example.com",
|
||||
phone_number: "+49123456789",
|
||||
join_date: ~D[2020-01-01],
|
||||
|
|
@ -42,14 +41,6 @@ defmodule Mv.Membership.MemberTest do
|
|||
assert error_message(errors, :email) =~ "is not a valid email"
|
||||
end
|
||||
|
||||
test "Paid is optional but must be boolean if specified" do
|
||||
attrs = Map.put(@valid_attrs, :paid, nil)
|
||||
attrs2 = Map.put(@valid_attrs, :paid, "yes")
|
||||
assert {:ok, _member} = Membership.create_member(Map.delete(attrs, :paid))
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs2)
|
||||
assert error_message(errors, :paid) =~ "is invalid"
|
||||
end
|
||||
|
||||
test "Phone number is optional but must have a valid format if specified" do
|
||||
attrs = Map.put(@valid_attrs, :phone_number, "abc")
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
|
|
@ -58,12 +49,12 @@ defmodule Mv.Membership.MemberTest do
|
|||
assert {:ok, _member} = Membership.create_member(attrs2)
|
||||
end
|
||||
|
||||
test "Join date is optional but must not be in the future" do
|
||||
test "Join date cannot be in the future" do
|
||||
attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1))
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :join_date) =~ "cannot be in the future"
|
||||
attrs2 = Map.delete(@valid_attrs, :join_date)
|
||||
assert {:ok, _member} = Membership.create_member(attrs2)
|
||||
|
||||
assert {:error,
|
||||
%Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidAttribute{field: :join_date}]}} =
|
||||
Membership.create_member(attrs)
|
||||
end
|
||||
|
||||
test "Exit date is optional but must not be before join date if both are specified" do
|
||||
|
|
|
|||
453
test/membership/member_type_change_integration_test.exs
Normal file
453
test/membership/member_type_change_integration_test.exs
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||
@moduledoc """
|
||||
Integration tests for membership fee type changes and cycle regeneration.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# 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",
|
||||
join_date: ~D[2023-01-15]
|
||||
}
|
||||
|
||||
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,
|
||||
status: :unpaid
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
describe "type change cycle regeneration" do
|
||||
test "future unpaid cycles are regenerated with new amount" do
|
||||
today = Date.utc_today()
|
||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
|
||||
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
|
||||
|
||||
# Create member without fee type first to avoid auto-generation
|
||||
member = create_member(%{})
|
||||
|
||||
# Manually assign fee type (this will trigger cycle generation)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type1.id
|
||||
})
|
||||
|> Ash.update!()
|
||||
|
||||
# Cycle generation runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
||||
# Create cycles: one in the past (paid), one current (unpaid)
|
||||
# Note: Future cycles are not automatically generated by CycleGenerator,
|
||||
# so we only test with current cycle
|
||||
past_cycle_start = CalendarCycles.calculate_cycle_start(~D[2023-01-01], :yearly)
|
||||
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||
|
||||
# Past cycle (paid) - should remain unchanged
|
||||
# Check if it already exists (from auto-generation), if not create it
|
||||
case MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|
||||
|> Ash.read_one() do
|
||||
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
||||
# Update to paid
|
||||
existing_cycle
|
||||
|> Ash.Changeset.for_update(:update, %{status: :paid})
|
||||
|> Ash.update!()
|
||||
|
||||
_ ->
|
||||
create_cycle(member, yearly_type1, %{
|
||||
cycle_start: past_cycle_start,
|
||||
status: :paid,
|
||||
amount: Decimal.new("100.00")
|
||||
})
|
||||
end
|
||||
|
||||
# Current cycle (unpaid) - should be regenerated
|
||||
# Delete if exists (from auto-generation), then create with old amount
|
||||
case MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
||||
|> Ash.read_one() do
|
||||
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
||||
Ash.destroy!(existing_cycle)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
_current_cycle =
|
||||
create_cycle(member, yearly_type1, %{
|
||||
cycle_start: current_cycle_start,
|
||||
status: :unpaid,
|
||||
amount: Decimal.new("100.00")
|
||||
})
|
||||
|
||||
# Change membership fee type (same interval, different amount)
|
||||
assert {:ok, _updated_member} =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type2.id
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# Cycle regeneration runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
||||
# Verify past cycle is unchanged
|
||||
past_cycle_after =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|
||||
|> Ash.read_one!()
|
||||
|
||||
assert past_cycle_after.status == :paid
|
||||
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
|
||||
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
|
||||
|
||||
# Verify current cycle was deleted and regenerated
|
||||
# Check that cycle with new type exists (regenerated)
|
||||
new_current_cycle =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
||||
|> Ash.read_one!()
|
||||
|
||||
# Verify it has the new type and amount
|
||||
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
|
||||
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
|
||||
assert new_current_cycle.status == :unpaid
|
||||
|
||||
# Verify old cycle with old type doesn't exist anymore
|
||||
old_current_cycles =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(
|
||||
member_id == ^member.id and cycle_start == ^current_cycle_start and
|
||||
membership_fee_type_id == ^yearly_type1.id
|
||||
)
|
||||
|> Ash.read!()
|
||||
|
||||
assert Enum.empty?(old_current_cycles)
|
||||
end
|
||||
|
||||
test "paid cycles remain unchanged" do
|
||||
today = Date.utc_today()
|
||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
|
||||
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
|
||||
|
||||
# Create member without fee type first to avoid auto-generation
|
||||
member = create_member(%{})
|
||||
|
||||
# Manually assign fee type (this will trigger cycle generation)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type1.id
|
||||
})
|
||||
|> Ash.update!()
|
||||
|
||||
# Cycle generation runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
||||
# Get the current cycle and mark it as paid
|
||||
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||
|
||||
# Find current cycle and mark as paid
|
||||
paid_cycle =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
||||
|> Ash.read_one!()
|
||||
|> Ash.Changeset.for_update(:mark_as_paid)
|
||||
|> Ash.update!()
|
||||
|
||||
# Change membership fee type
|
||||
assert {:ok, _updated_member} =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type2.id
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# Cycle regeneration runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
||||
# Verify paid cycle is unchanged (not deleted and regenerated)
|
||||
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, paid_cycle.id)
|
||||
assert cycle_after.status == :paid
|
||||
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
|
||||
assert cycle_after.membership_fee_type_id == yearly_type1.id
|
||||
end
|
||||
|
||||
test "suspended cycles remain unchanged" do
|
||||
today = Date.utc_today()
|
||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
|
||||
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
|
||||
|
||||
# Create member without fee type first to avoid auto-generation
|
||||
member = create_member(%{})
|
||||
|
||||
# Manually assign fee type (this will trigger cycle generation)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type1.id
|
||||
})
|
||||
|> Ash.update!()
|
||||
|
||||
# Cycle generation runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
||||
# Get the current cycle and mark it as suspended
|
||||
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||
|
||||
# Find current cycle and mark as suspended
|
||||
suspended_cycle =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
||||
|> Ash.read_one!()
|
||||
|> Ash.Changeset.for_update(:mark_as_suspended)
|
||||
|> Ash.update!()
|
||||
|
||||
# Change membership fee type
|
||||
assert {:ok, _updated_member} =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type2.id
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# Cycle regeneration runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
||||
# Verify suspended cycle is unchanged (not deleted and regenerated)
|
||||
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, suspended_cycle.id)
|
||||
assert cycle_after.status == :suspended
|
||||
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
|
||||
assert cycle_after.membership_fee_type_id == yearly_type1.id
|
||||
end
|
||||
|
||||
test "only cycles that haven't ended yet are deleted" do
|
||||
today = Date.utc_today()
|
||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
|
||||
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
|
||||
|
||||
# Create member without fee type first to avoid auto-generation
|
||||
member = create_member(%{})
|
||||
|
||||
# Manually assign fee type (this will trigger cycle generation)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type1.id
|
||||
})
|
||||
|> Ash.update!()
|
||||
|
||||
# Cycle generation runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
||||
# Create cycles: one in the past (unpaid, ended), one current (unpaid, not ended)
|
||||
past_cycle_start =
|
||||
CalendarCycles.calculate_cycle_start(
|
||||
Date.add(today, -365),
|
||||
:yearly
|
||||
)
|
||||
|
||||
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||
|
||||
# Past cycle (unpaid) - should remain unchanged (cycle_start < today)
|
||||
# Delete existing cycle if it exists (from auto-generation)
|
||||
case MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|
||||
|> Ash.read_one() do
|
||||
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
||||
Ash.destroy!(existing_cycle)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
past_cycle =
|
||||
create_cycle(member, yearly_type1, %{
|
||||
cycle_start: past_cycle_start,
|
||||
status: :unpaid,
|
||||
amount: Decimal.new("100.00")
|
||||
})
|
||||
|
||||
# Current cycle (unpaid) - should be regenerated (cycle_start >= today)
|
||||
# Delete existing cycle if it exists (from auto-generation)
|
||||
case MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
||||
|> Ash.read_one() do
|
||||
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
||||
Ash.destroy!(existing_cycle)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
_current_cycle =
|
||||
create_cycle(member, yearly_type1, %{
|
||||
cycle_start: current_cycle_start,
|
||||
status: :unpaid,
|
||||
amount: Decimal.new("100.00")
|
||||
})
|
||||
|
||||
# Change membership fee type
|
||||
assert {:ok, _updated_member} =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type2.id
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# Cycle regeneration runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
||||
# Verify past cycle is unchanged
|
||||
{:ok, past_cycle_after} = Ash.get(MembershipFeeCycle, past_cycle.id)
|
||||
assert past_cycle_after.status == :unpaid
|
||||
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
|
||||
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
|
||||
|
||||
# Verify current cycle was regenerated
|
||||
# Check that cycle with new type exists
|
||||
new_current_cycle =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
||||
|> Ash.read_one!()
|
||||
|
||||
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
|
||||
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
|
||||
|
||||
# Verify old cycle with old type doesn't exist anymore
|
||||
old_current_cycles =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(
|
||||
member_id == ^member.id and cycle_start == ^current_cycle_start and
|
||||
membership_fee_type_id == ^yearly_type1.id
|
||||
)
|
||||
|> Ash.read!()
|
||||
|
||||
assert Enum.empty?(old_current_cycles)
|
||||
end
|
||||
|
||||
test "member calculations update after type change" do
|
||||
today = Date.utc_today()
|
||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
|
||||
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
|
||||
|
||||
# Create member with join_date = today to avoid past cycles
|
||||
# This ensures no overdue cycles exist
|
||||
member = create_member(%{join_date: today})
|
||||
|
||||
# Manually assign fee type (this will trigger cycle generation)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type1.id
|
||||
})
|
||||
|> Ash.update!()
|
||||
|
||||
# Cycle generation runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
||||
# Get current cycle start
|
||||
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||
|
||||
# Ensure only one cycle exists (the current one)
|
||||
# Delete all cycles except the current one
|
||||
existing_cycles =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!()
|
||||
|
||||
Enum.each(existing_cycles, fn cycle ->
|
||||
if cycle.cycle_start != current_cycle_start do
|
||||
Ash.destroy!(cycle)
|
||||
end
|
||||
end)
|
||||
|
||||
# Ensure current cycle exists and is unpaid
|
||||
case MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
||||
|> Ash.read_one() do
|
||||
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
||||
# Update to unpaid if it's not
|
||||
if existing_cycle.status != :unpaid do
|
||||
existing_cycle
|
||||
|> Ash.Changeset.for_update(:mark_as_unpaid)
|
||||
|> Ash.update!()
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Create if it doesn't exist
|
||||
create_cycle(member, yearly_type1, %{
|
||||
cycle_start: current_cycle_start,
|
||||
status: :unpaid,
|
||||
amount: Decimal.new("100.00")
|
||||
})
|
||||
end
|
||||
|
||||
# Load calculations before change
|
||||
member = Ash.load!(member, [:current_cycle_status, :overdue_count])
|
||||
assert member.current_cycle_status == :unpaid
|
||||
assert member.overdue_count == 0
|
||||
|
||||
# Change membership fee type
|
||||
assert {:ok, updated_member} =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type2.id
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# Cycle regeneration runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
||||
# Reload member with calculations
|
||||
updated_member = Ash.load!(updated_member, [:current_cycle_status, :overdue_count])
|
||||
|
||||
# Calculations should still work (cycle was regenerated)
|
||||
assert updated_member.current_cycle_status == :unpaid
|
||||
assert updated_member.overdue_count == 0
|
||||
end
|
||||
end
|
||||
end
|
||||
98
test/membership/membership_fee_settings_test.exs
Normal file
98
test/membership/membership_fee_settings_test.exs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
defmodule Mv.Membership.MembershipFeeSettingsTest do
|
||||
@moduledoc """
|
||||
Tests for membership fee settings in the Settings resource.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Membership.Setting
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
describe "membership fee settings" do
|
||||
test "default values are correct" do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
assert settings.include_joining_cycle == true
|
||||
end
|
||||
|
||||
test "settings can be read" do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
assert %Setting{} = settings
|
||||
end
|
||||
|
||||
test "settings can be written via update_membership_fee_settings" do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
{:ok, updated} =
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
include_joining_cycle: false
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
assert updated.include_joining_cycle == false
|
||||
end
|
||||
|
||||
test "default_membership_fee_type_id can be nil (optional)" do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
{:ok, updated} =
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: nil
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
assert updated.default_membership_fee_type_id == nil
|
||||
end
|
||||
|
||||
test "default_membership_fee_type_id validation: must exist if set" do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
# Create a valid fee type
|
||||
{:ok, fee_type} =
|
||||
Ash.create(MembershipFeeType, %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :yearly
|
||||
})
|
||||
|
||||
# Setting a valid fee type should work
|
||||
{:ok, updated} =
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
assert updated.default_membership_fee_type_id == fee_type.id
|
||||
end
|
||||
|
||||
test "default_membership_fee_type_id validation: fails if not found" do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
# Use a non-existent UUID
|
||||
fake_uuid = Ecto.UUID.generate()
|
||||
|
||||
assert {:error, error} =
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: fake_uuid
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
assert error_on_field?(error, :default_membership_fee_type_id)
|
||||
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)
|
||||
end
|
||||
|
||||
defp error_on_field?(_, _), do: false
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue