defmodule Mv.MembershipFees.CycleEndBackfill do @moduledoc """ One-time backfill for the denormalized `cycle_end` column on `membership_fee_cycles`. For every cycle whose `cycle_end` is still `NULL`, sets it to `CalendarCycles.calculate_cycle_end(cycle_start, interval)` using the cycle's own fee-type interval. Idempotent: rows that already have a `cycle_end` are left untouched, so it is safe to re-run. Invoked from the `cycle_end` migration. Kept as a module function (rather than inline migration SQL) so the exact calendar arithmetic is reused from `CalendarCycles` and can be tested directly. """ import Ecto.Query alias Mv.MembershipFees.CalendarCycles alias Mv.Repo @doc """ Backfills `cycle_end` for all cycles missing it. Returns `{:ok, updated_count}`. """ @spec run() :: {:ok, non_neg_integer()} def run do rows = from(c in "membership_fee_cycles", join: t in "membership_fee_types", on: c.membership_fee_type_id == t.id, where: is_nil(c.cycle_end), select: {c.id, c.cycle_start, t.interval} ) |> Repo.all() count = Enum.reduce(rows, 0, fn {id, cycle_start, interval}, acc -> cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, normalize_interval(interval)) from(c in "membership_fee_cycles", where: c.id == ^id) |> Repo.update_all(set: [cycle_end: cycle_end]) acc + 1 end) {:ok, count} end # The raw query returns the enum column as a string. defp normalize_interval(interval) when is_atom(interval), do: interval defp normalize_interval(interval) when is_binary(interval), do: String.to_existing_atom(interval) end