defmodule Mv.MembershipFees.CycleGenerator do @moduledoc """ Module for generating membership fee cycles for members. This module provides functions to automatically generate membership fee cycles based on a member's fee type, start date, and exit date. ## Algorithm 1. Load member with relationships (membership_fee_type, membership_fee_cycles) 2. Determine membership_fee_start_date (calculate if nil) 3. Find the last existing cycle start date (or use membership_fee_start_date) 4. Generate all cycle starts from last to today (or left_at) 5. Filter out existing cycles (idempotency) 6. Create new cycles with the current amount from membership_fee_type ## Concurrency Uses PostgreSQL advisory locks to prevent race conditions when generating cycles for the same member concurrently. ## Examples # Generate cycles for a single member {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member) # Generate cycles for all active members {:ok, results} = CycleGenerator.generate_cycles_for_all_members() """ alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate alias Mv.Membership.Member alias Mv.Repo require Ash.Query require Logger @type generate_result :: {:ok, [MembershipFeeCycle.t()]} | {:error, term()} @doc """ Generates membership fee cycles for a single member. Uses an advisory lock to prevent concurrent generation for the same member. ## Parameters - `member` - The member struct or member ID - `opts` - Options: - `:today` - Override today's date (useful for testing) ## Returns - `{:ok, cycles}` - List of newly created cycles - `{:error, reason}` - Error with reason ## Examples {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member) {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member_id) {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member, today: ~D[2024-12-31]) """ @spec generate_cycles_for_member(Member.t() | String.t(), keyword()) :: generate_result() def generate_cycles_for_member(member_or_id, opts \\ []) def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do case load_member(member_id) do {:ok, member} -> generate_cycles_for_member(member, opts) {:error, reason} -> {:error, reason} end end def generate_cycles_for_member(%Member{} = member, opts) do today = Keyword.get(opts, :today, Date.utc_today()) # Use advisory lock to prevent concurrent generation with_advisory_lock(member.id, fn -> do_generate_cycles(member, today) end) end @doc """ Generates membership fee cycles for all active members. Active members are those who: - Have a membership_fee_type assigned - Have a join_date set - Either have no exit_date or exit_date >= today ## Parameters - `opts` - Options: - `:today` - Override today's date (useful for testing) - `:batch_size` - Number of members to process in parallel (default: 10) ## Returns - `{:ok, results}` - Map with :success and :failed counts - `{:error, reason}` - Error with reason """ @spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()} def generate_cycles_for_all_members(opts \\ []) do today = Keyword.get(opts, :today, Date.utc_today()) batch_size = Keyword.get(opts, :batch_size, 10) # Query active members with fee type assigned query = Member |> Ash.Query.filter(not is_nil(membership_fee_type_id)) |> Ash.Query.filter(not is_nil(join_date)) |> Ash.Query.filter(is_nil(exit_date) or exit_date >= ^today) case Ash.read(query) do {:ok, members} -> results = process_members_in_batches(members, batch_size, today) {:ok, build_results_summary(results)} {:error, reason} -> {:error, reason} end end defp process_members_in_batches(members, batch_size, today) do members |> Enum.chunk_every(batch_size) |> Enum.flat_map(&process_batch(&1, today)) end defp process_batch(batch, today) do batch |> Task.async_stream(fn member -> {member.id, generate_cycles_for_member(member, today: today)} end) |> Enum.map(fn {:ok, result} -> result end) end defp build_results_summary(results) do success_count = Enum.count(results, fn {_id, result} -> match?({:ok, _}, result) end) failed_count = Enum.count(results, fn {_id, result} -> match?({:error, _}, result) end) %{success: success_count, failed: failed_count, total: length(results)} end # Private functions defp load_member(member_id) do Member |> Ash.Query.filter(id == ^member_id) |> Ash.Query.load([:membership_fee_type, :membership_fee_cycles]) |> Ash.read_one() |> case do {:ok, nil} -> {:error, :member_not_found} {:ok, member} -> {:ok, member} {:error, reason} -> {:error, reason} end end defp with_advisory_lock(member_id, fun) do # Convert UUID to integer for advisory lock (use hash) lock_key = :erlang.phash2(member_id) Repo.transaction(fn -> # Acquire advisory lock for this transaction Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) case fun.() do {:ok, result} -> result {:error, reason} -> Repo.rollback(reason) end end) end defp do_generate_cycles(member, today) do # Reload member with relationships to ensure fresh data case load_member(member.id) do {:ok, member} -> cond do is_nil(member.membership_fee_type_id) -> {:error, :no_membership_fee_type} is_nil(member.join_date) -> {:error, :no_join_date} true -> generate_missing_cycles(member, today) end {:error, reason} -> {:error, reason} end end defp generate_missing_cycles(member, today) do fee_type = member.membership_fee_type interval = fee_type.interval amount = fee_type.amount existing_cycles = member.membership_fee_cycles || [] # Determine start date start_date = determine_start_date(member, interval) # Determine end date (today or exit_date, whichever is earlier) end_date = determine_end_date(member, today) # Generate all cycle starts from start_date to end_date all_cycle_starts = generate_cycle_starts(start_date, end_date, interval) # Filter out existing cycles existing_starts = MapSet.new(existing_cycles, & &1.cycle_start) missing_starts = Enum.reject(all_cycle_starts, &MapSet.member?(existing_starts, &1)) # Create missing cycles create_cycles(missing_starts, member.id, fee_type.id, amount) end defp determine_start_date(member, interval) do if member.membership_fee_start_date do member.membership_fee_start_date else # Calculate from join_date using global settings include_joining_cycle = get_include_joining_cycle() SetMembershipFeeStartDate.calculate_start_date( member.join_date, interval, include_joining_cycle ) end end defp determine_end_date(member, today) do if member.exit_date && Date.compare(member.exit_date, today) == :lt do # Member has left - use the cycle that contains the exit date member.exit_date else today end end defp get_include_joining_cycle do case Mv.Membership.get_settings() do {:ok, %{include_joining_cycle: include}} -> include {:error, _} -> true end end @doc """ Generates all cycle start dates from a start date to an end date. ## Parameters - `start_date` - The first cycle start date - `end_date` - The date up to which cycles should be generated - `interval` - The billing interval ## Returns List of cycle start dates. ## Examples iex> generate_cycle_starts(~D[2024-01-01], ~D[2024-12-31], :quarterly) [~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01], ~D[2024-10-01]] """ @spec generate_cycle_starts(Date.t(), Date.t(), atom()) :: [Date.t()] def generate_cycle_starts(start_date, end_date, interval) do # Ensure start_date is aligned to cycle boundary aligned_start = CalendarCycles.calculate_cycle_start(start_date, interval) generate_cycle_starts_acc(aligned_start, end_date, interval, []) end defp generate_cycle_starts_acc(current_start, end_date, interval, acc) do if Date.compare(current_start, end_date) == :gt do # Current cycle start is after end date - stop Enum.reverse(acc) else # Include this cycle and continue to next next_start = CalendarCycles.next_cycle_start(current_start, interval) generate_cycle_starts_acc(next_start, end_date, interval, [current_start | acc]) end end defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do cycles = Enum.map(cycle_starts, fn cycle_start -> attrs = %{ cycle_start: cycle_start, member_id: member_id, membership_fee_type_id: fee_type_id, amount: amount, status: :unpaid } case Ash.create(MembershipFeeCycle, attrs) do {:ok, cycle} -> {:ok, cycle} {:error, reason} -> {:error, {cycle_start, reason}} end end) errors = Enum.filter(cycles, &match?({:error, _}, &1)) if Enum.empty?(errors) do {:ok, Enum.map(cycles, fn {:ok, cycle} -> cycle end)} else Logger.warning("Some cycles failed to create: #{inspect(errors)}") # Return successfully created cycles anyway successful = Enum.filter(cycles, &match?({:ok, _}, &1)) |> Enum.map(fn {:ok, c} -> c end) {:ok, successful} end end end