- 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)
328 lines
10 KiB
Elixir
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
|