From 3241dd7d9602cc10a5c035dba5a2044f3ad8f8dc Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 22 Dec 2025 16:39:49 +0100 Subject: [PATCH] Fix cycle end calculation for misaligned cycle_start dates Make cycle generation idempotent by skipping existing cycles --- lib/mv/membership_fees/calendar_cycles.ex | 24 ++++++++++++------- lib/mv/membership_fees/cycle_generator.ex | 28 ++++++++++++++++++++++- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/lib/mv/membership_fees/calendar_cycles.ex b/lib/mv/membership_fees/calendar_cycles.ex index 8a4ef24..9bc3afa 100644 --- a/lib/mv/membership_fees/calendar_cycles.ex +++ b/lib/mv/membership_fees/calendar_cycles.ex @@ -299,11 +299,15 @@ defmodule Mv.MembershipFees.CalendarCycles do end defp quarterly_cycle_end(cycle_start) do - case cycle_start.month do - 1 -> Date.new!(cycle_start.year, 3, 31) - 4 -> Date.new!(cycle_start.year, 6, 30) - 7 -> Date.new!(cycle_start.year, 9, 30) - 10 -> Date.new!(cycle_start.year, 12, 31) + # Ensure cycle_start is aligned to quarter boundary + # This handles cases where cycle_start might not be at the correct quarter start (e.g., month 12) + aligned_start = quarterly_cycle_start(cycle_start) + + case aligned_start.month do + 1 -> Date.new!(aligned_start.year, 3, 31) + 4 -> Date.new!(aligned_start.year, 6, 30) + 7 -> Date.new!(aligned_start.year, 9, 30) + 10 -> Date.new!(aligned_start.year, 12, 31) end end @@ -313,9 +317,13 @@ defmodule Mv.MembershipFees.CalendarCycles do end defp half_yearly_cycle_end(cycle_start) do - case cycle_start.month do - 1 -> Date.new!(cycle_start.year, 6, 30) - 7 -> Date.new!(cycle_start.year, 12, 31) + # Ensure cycle_start is aligned to half-year boundary + # This handles cases where cycle_start might not be at the correct half-year start (e.g., month 10) + aligned_start = half_yearly_cycle_start(cycle_start) + + case aligned_start.month do + 1 -> Date.new!(aligned_start.year, 6, 30) + 7 -> Date.new!(aligned_start.year, 12, 31) end end diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index feb7b53..23889fb 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -386,18 +386,44 @@ defmodule Mv.MembershipFees.CycleGenerator do {:ok, cycle} -> {:ok, cycle, []} + {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidAttribute{private_vars: %{constraint: constraint, constraint_type: :unique}}]}} = error -> + # Cycle already exists (unique constraint violation) - skip it silently + # This makes the function idempotent and prevents errors on server restart + if constraint == "membership_fee_cycles_unique_cycle_per_member_index" do + {:skip, cycle_start} + else + {:error, {cycle_start, error}} + end + {:error, reason} -> {:error, {cycle_start, reason}} end end) - {successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1)) + {successes, skips, errors} = + Enum.reduce(results, {[], [], []}, fn + {:ok, cycle, notifications}, {successes, skips, errors} -> + {[{:ok, cycle, notifications} | successes], skips, errors} + + {:skip, cycle_start}, {successes, skips, errors} -> + {successes, [cycle_start | skips], errors} + + {:error, error}, {successes, skips, errors} -> + {successes, skips, [error | errors]} + end) all_notifications = Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end) if Enum.empty?(errors) do successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end) + + if Enum.any?(skips) do + Logger.debug( + "Skipped #{length(skips)} cycles that already exist for member #{member_id}" + ) + end + {:ok, successful_cycles, all_notifications} else Logger.warning("Some cycles failed to create: #{inspect(errors)}")