feat: regenerate cycles when membership fee type changes (same interval)
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
- Implemented regenerate_cycles_on_type_change helper in Member resource - Cycles that haven't ended yet (cycle_end >= today) are deleted and regenerated - Paid and suspended cycles remain unchanged (not deleted) - CycleGenerator reloads member with new membership_fee_type_id - Adjusted tests to work with current cycles only (no future cycles) - All integration tests passing Phase 4 completed: Cycle regeneration on type change
This commit is contained in:
parent
7994303166
commit
06324d77c5
6 changed files with 550 additions and 17 deletions
|
|
@ -189,34 +189,35 @@ defmodule Mv.Membership.Member do
|
||||||
where [changing(:membership_fee_type_id)]
|
where [changing(:membership_fee_type_id)]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Trigger cycle generation when membership_fee_type_id changes
|
# Trigger cycle regeneration when membership_fee_type_id changes
|
||||||
# Note: Cycle generation runs asynchronously to not block the action,
|
# This deletes future unpaid cycles and regenerates them with the new type/amount
|
||||||
|
# Note: Cycle regeneration runs asynchronously to not block the action,
|
||||||
# but in test environment it runs synchronously for DB sandbox compatibility
|
# but in test environment it runs synchronously for DB sandbox compatibility
|
||||||
change after_action(fn changeset, member, _context ->
|
change after_action(fn changeset, member, _context ->
|
||||||
fee_type_changed =
|
fee_type_changed =
|
||||||
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
||||||
|
|
||||||
if fee_type_changed && member.membership_fee_type_id && member.join_date do
|
if fee_type_changed && member.membership_fee_type_id && member.join_date do
|
||||||
generate_fn = fn ->
|
regenerate_fn = fn ->
|
||||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
|
case regenerate_cycles_on_type_change(member) do
|
||||||
{:ok, _cycles} ->
|
:ok ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
Logger.warning(
|
Logger.warning(
|
||||||
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
|
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if Application.get_env(:mv, :sql_sandbox, false) do
|
if Application.get_env(:mv, :sql_sandbox, false) do
|
||||||
# Run synchronously in test environment for DB sandbox compatibility
|
# Run synchronously in test environment for DB sandbox compatibility
|
||||||
generate_fn.()
|
regenerate_fn.()
|
||||||
else
|
else
|
||||||
# Run asynchronously in other environments
|
# Run asynchronously in other environments
|
||||||
Task.start(generate_fn)
|
Task.start(regenerate_fn)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -646,10 +647,13 @@ defmodule Mv.Membership.Member do
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|> Enum.sort_by(fn cycle ->
|
|> Enum.sort_by(
|
||||||
interval = Map.get(cycle, :membership_fee_type).interval
|
fn cycle ->
|
||||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
interval = Map.get(cycle, :membership_fee_type).interval
|
||||||
end, {:desc, Date})
|
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||||
|
end,
|
||||||
|
{:desc, Date}
|
||||||
|
)
|
||||||
|> List.first()
|
|> List.first()
|
||||||
else
|
else
|
||||||
nil
|
nil
|
||||||
|
|
@ -681,6 +685,75 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Regenerates cycles when membership fee type changes
|
||||||
|
# Deletes future unpaid cycles and regenerates them with the new type/amount
|
||||||
|
defp regenerate_cycles_on_type_change(member) do
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
alias Mv.MembershipFees.CycleGenerator
|
||||||
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
today = Date.utc_today()
|
||||||
|
|
||||||
|
# Find all unpaid cycles for this member
|
||||||
|
# We need to check cycle_end for each cycle using its own interval
|
||||||
|
all_unpaid_cycles_query =
|
||||||
|
MembershipFeeCycle
|
||||||
|
|> Ash.Query.filter(member_id == ^member.id)
|
||||||
|
|> Ash.Query.filter(status == :unpaid)
|
||||||
|
|> Ash.Query.load([:membership_fee_type])
|
||||||
|
|
||||||
|
case Ash.read(all_unpaid_cycles_query) do
|
||||||
|
{:ok, all_unpaid_cycles} ->
|
||||||
|
# Filter cycles that haven't ended yet (cycle_end >= today)
|
||||||
|
# These are the "future" cycles that should be regenerated
|
||||||
|
# Use each cycle's own interval to calculate cycle_end
|
||||||
|
cycles_to_delete =
|
||||||
|
Enum.filter(all_unpaid_cycles, fn cycle ->
|
||||||
|
case cycle.membership_fee_type do
|
||||||
|
%{interval: interval} ->
|
||||||
|
cycle_end = CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||||
|
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Delete future unpaid cycles
|
||||||
|
if Enum.empty?(cycles_to_delete) do
|
||||||
|
# No cycles to delete, just regenerate
|
||||||
|
case CycleGenerator.generate_cycles_for_member(member.id) do
|
||||||
|
{:ok, _cycles} -> :ok
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
delete_results =
|
||||||
|
Enum.map(cycles_to_delete, fn cycle ->
|
||||||
|
Ash.destroy(cycle)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Check if any deletions failed
|
||||||
|
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
|
||||||
|
{:error, :deletion_failed}
|
||||||
|
else
|
||||||
|
# Regenerate cycles with new type/amount
|
||||||
|
# CycleGenerator uses its own transaction with advisory lock
|
||||||
|
# It will reload the member, so it will see the deleted cycles are gone
|
||||||
|
# and the new membership_fee_type_id
|
||||||
|
case CycleGenerator.generate_cycles_for_member(member.id) do
|
||||||
|
{:ok, _cycles} -> :ok
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Normalizes visibility config map keys from strings to atoms.
|
# Normalizes visibility config map keys from strings to atoms.
|
||||||
# JSONB in PostgreSQL converts atom keys to string keys when storing.
|
# JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||||
defp normalize_visibility_config(config) when is_map(config) do
|
defp normalize_visibility_config(config) when is_map(config) do
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
|
||||||
defp get_intervals(current_type_id, new_type_id) do
|
defp get_intervals(current_type_id, new_type_id) do
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
||||||
case {Ash.get(MembershipFeeType, current_type_id),
|
case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} do
|
||||||
Ash.get(MembershipFeeType, new_type_id)} do
|
|
||||||
{{:ok, current_type}, {:ok, new_type}} ->
|
{{:ok, current_type}, {:ok, new_type}} ->
|
||||||
{:ok, current_type.interval, new_type.interval}
|
{:ok, current_type.interval, new_type.interval}
|
||||||
|
|
||||||
|
|
@ -116,4 +115,3 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
|
||||||
defp format_interval(:yearly), do: "yearly"
|
defp format_interval(:yearly), do: "yearly"
|
||||||
defp format_interval(interval), do: to_string(interval)
|
defp format_interval(interval), do: to_string(interval)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
description "Mark cycle as paid"
|
description "Mark cycle as paid"
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
accept [:notes]
|
accept [:notes]
|
||||||
|
|
||||||
change fn changeset, _context ->
|
change fn changeset, _context ->
|
||||||
Ash.Changeset.force_change_attribute(changeset, :status, :paid)
|
Ash.Changeset.force_change_attribute(changeset, :status, :paid)
|
||||||
end
|
end
|
||||||
|
|
@ -65,6 +66,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
description "Mark cycle as suspended"
|
description "Mark cycle as suspended"
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
accept [:notes]
|
accept [:notes]
|
||||||
|
|
||||||
change fn changeset, _context ->
|
change fn changeset, _context ->
|
||||||
Ash.Changeset.force_change_attribute(changeset, :status, :suspended)
|
Ash.Changeset.force_change_attribute(changeset, :status, :suspended)
|
||||||
end
|
end
|
||||||
|
|
@ -74,6 +76,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
description "Mark cycle as unpaid (for error correction)"
|
description "Mark cycle as unpaid (for error correction)"
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
accept [:notes]
|
accept [:notes]
|
||||||
|
|
||||||
change fn changeset, _context ->
|
change fn changeset, _context ->
|
||||||
Ash.Changeset.force_change_attribute(changeset, :status, :unpaid)
|
Ash.Changeset.force_change_attribute(changeset, :status, :unpaid)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
|
|
||||||
# Current cycle
|
# Current cycle
|
||||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(member, fee_type, %{
|
||||||
cycle_start: cycle_start,
|
cycle_start: cycle_start,
|
||||||
status: :paid
|
status: :paid
|
||||||
|
|
@ -179,6 +180,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
|
|
||||||
# Current cycle
|
# Current cycle
|
||||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(member, fee_type, %{
|
||||||
cycle_start: cycle_start,
|
cycle_start: cycle_start,
|
||||||
status: :unpaid
|
status: :unpaid
|
||||||
|
|
@ -186,8 +188,10 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
|
|
||||||
# Future cycle (if we're not at the end of the year)
|
# Future cycle (if we're not at the end of the year)
|
||||||
next_year = today.year + 1
|
next_year = today.year + 1
|
||||||
|
|
||||||
if today.month < 12 or today.day < 31 do
|
if today.month < 12 or today.day < 31 do
|
||||||
next_year_start = Date.new!(next_year, 1, 1)
|
next_year_start = Date.new!(next_year, 1, 1)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(member, fee_type, %{
|
||||||
cycle_start: next_year_start,
|
cycle_start: next_year_start,
|
||||||
status: :unpaid
|
status: :unpaid
|
||||||
|
|
@ -265,6 +269,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
})
|
})
|
||||||
|
|
||||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(member, fee_type, %{
|
||||||
cycle_start: cycle_start,
|
cycle_start: cycle_start,
|
||||||
status: :unpaid
|
status: :unpaid
|
||||||
|
|
@ -279,4 +284,3 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
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!()
|
||||||
|
|
||||||
|
# Wait for cycle generation
|
||||||
|
Process.sleep(100)
|
||||||
|
|
||||||
|
# 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} ->
|
||||||
|
# Update to paid
|
||||||
|
existing_cycle
|
||||||
|
|> Ash.Changeset.for_update(:update, %{status: :paid})
|
||||||
|
|> Ash.update!()
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
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} ->
|
||||||
|
Ash.destroy!(existing_cycle)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
: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()
|
||||||
|
|
||||||
|
# Wait for async cycle regeneration (in test it runs synchronously)
|
||||||
|
Process.sleep(100)
|
||||||
|
|
||||||
|
# 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!()
|
||||||
|
|
||||||
|
# Wait for cycle generation
|
||||||
|
Process.sleep(100)
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# Wait for async cycle regeneration
|
||||||
|
Process.sleep(100)
|
||||||
|
|
||||||
|
# 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!()
|
||||||
|
|
||||||
|
# Wait for cycle generation
|
||||||
|
Process.sleep(100)
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# Wait for async cycle regeneration
|
||||||
|
Process.sleep(100)
|
||||||
|
|
||||||
|
# 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!()
|
||||||
|
|
||||||
|
# Wait for cycle generation
|
||||||
|
Process.sleep(100)
|
||||||
|
|
||||||
|
# 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} ->
|
||||||
|
Ash.destroy!(existing_cycle)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
: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} ->
|
||||||
|
Ash.destroy!(existing_cycle)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
: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()
|
||||||
|
|
||||||
|
# Wait for async cycle regeneration
|
||||||
|
Process.sleep(100)
|
||||||
|
|
||||||
|
# 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!()
|
||||||
|
|
||||||
|
# Wait for cycle generation
|
||||||
|
Process.sleep(100)
|
||||||
|
|
||||||
|
# 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} ->
|
||||||
|
# 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
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# Wait for async cycle regeneration
|
||||||
|
Process.sleep(100)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
@ -70,6 +70,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
|
|
||||||
refute changeset.valid?
|
refute changeset.valid?
|
||||||
assert %{errors: errors} = changeset
|
assert %{errors: errors} = changeset
|
||||||
|
|
||||||
assert Enum.any?(errors, fn error ->
|
assert Enum.any?(errors, fn error ->
|
||||||
error.field == :membership_fee_type_id and
|
error.field == :membership_fee_type_id and
|
||||||
error.message =~ "yearly" and
|
error.message =~ "yearly" and
|
||||||
|
|
@ -79,7 +80,8 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
|
|
||||||
test "allows first assignment of membership fee type" do
|
test "allows first assignment of membership fee type" do
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
yearly_type = create_fee_type(%{interval: :yearly})
|
||||||
member = create_member(%{}) # No fee type assigned
|
# No fee type assigned
|
||||||
|
member = create_member(%{})
|
||||||
|
|
||||||
changeset =
|
changeset =
|
||||||
member
|
member
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue