mitgliederverwaltung/lib/mv/membership_fees/cycle_generator.ex
Moritz a99f56969d feat: include inactive members in batch cycle generation
- Remove exit_date filter from generate_cycles_for_all_members query
- Inactive members now get cycles generated up to their exit_date
- Add tests for inactive member processing and exit_date boundary
- Document exit_date == cycle_start behavior (cycle still generated)
2025-12-12 16:21:36 +01:00

328 lines
10 KiB
Elixir

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 members with a fee type assigned.
This includes both active and inactive (left) members. Inactive members
will have cycles generated up to their exit_date if they don't have cycles
for that period yet. This allows for catch-up generation of missing cycles.
Members processed are those who:
- Have a membership_fee_type assigned
- Have a join_date set
The exit_date boundary is respected during generation (not in the query),
so inactive members will get cycles up to their exit date.
## 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 ALL members with fee type assigned (including inactive/left members)
# The exit_date boundary is applied during cycle generation, not here.
# This allows catch-up generation for members who left but are missing cycles.
query =
Member
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|> Ash.Query.filter(not is_nil(join_date))
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 exit date as boundary
# Note: If exit_date == cycle_start, the cycle IS still generated.
# This means the member is considered a member on the first day of that cycle.
# Example: exit_date = 2025-01-01, yearly interval
# -> The 2025 cycle (starting 2025-01-01) WILL be generated
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