feat: regenerate cycles when membership fee type changes (same interval)
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:
Moritz 2025-12-15 11:00:08 +01:00
parent 7994303166
commit 06324d77c5
Signed by: moritz
GPG key ID: 1020A035E5DD0824
6 changed files with 550 additions and 17 deletions

View file

@ -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