feat: regenerate cycles when membership fee type changes (same interval)
- 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
cd915531c2
commit
2d1d650c28
6 changed files with 550 additions and 17 deletions
|
|
@ -189,34 +189,35 @@ defmodule Mv.Membership.Member do
|
|||
where [changing(:membership_fee_type_id)]
|
||||
end
|
||||
|
||||
# Trigger cycle generation when membership_fee_type_id changes
|
||||
# Note: Cycle generation runs asynchronously to not block the action,
|
||||
# Trigger cycle regeneration when membership_fee_type_id changes
|
||||
# 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
|
||||
change after_action(fn changeset, member, _context ->
|
||||
fee_type_changed =
|
||||
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
||||
|
||||
if fee_type_changed && member.membership_fee_type_id && member.join_date do
|
||||
generate_fn = fn ->
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
{:ok, _cycles} ->
|
||||
regenerate_fn = fn ->
|
||||
case regenerate_cycles_on_type_change(member) do
|
||||
:ok ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
require Logger
|
||||
|
||||
Logger.warning(
|
||||
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
|
||||
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if Application.get_env(:mv, :sql_sandbox, false) do
|
||||
# Run synchronously in test environment for DB sandbox compatibility
|
||||
generate_fn.()
|
||||
regenerate_fn.()
|
||||
else
|
||||
# Run asynchronously in other environments
|
||||
Task.start(generate_fn)
|
||||
Task.start(regenerate_fn)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -646,10 +647,13 @@ defmodule Mv.Membership.Member do
|
|||
false
|
||||
end
|
||||
end)
|
||||
|> Enum.sort_by(fn cycle ->
|
||||
interval = Map.get(cycle, :membership_fee_type).interval
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
end, {:desc, Date})
|
||||
|> Enum.sort_by(
|
||||
fn cycle ->
|
||||
interval = Map.get(cycle, :membership_fee_type).interval
|
||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||
end,
|
||||
{:desc, Date}
|
||||
)
|
||||
|> List.first()
|
||||
else
|
||||
nil
|
||||
|
|
@ -681,6 +685,75 @@ defmodule Mv.Membership.Member do
|
|||
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.
|
||||
# JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||
defp normalize_visibility_config(config) when is_map(config) do
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue