All checks were successful
continuous-integration/drone/push Build is passing
- Use return_notifications?: true when creating cycles within transaction - Collect notifications and send them after transaction commits - Prevents 'Missed notifications' warnings in test output - Notifications are now properly sent via Ash.Notifier.notify/1
389 lines
13 KiB
Elixir
389 lines
13 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 the generation start point:
|
|
- If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
|
|
- If cycles exist: Start from the cycle AFTER the last existing one
|
|
3. Generate all cycle starts from the determined start point to today (or `exit_date`)
|
|
4. Create new cycles with the current amount from `membership_fee_type`
|
|
|
|
## Important: Gap Handling
|
|
|
|
**Gaps are NOT filled.** If a cycle was explicitly deleted (e.g., 2023 was deleted
|
|
but 2022 and 2024 exist), the generator will NOT recreate the deleted cycle.
|
|
It always continues from the LAST existing cycle, regardless of any gaps.
|
|
|
|
This behavior ensures that manually deleted cycles remain deleted and prevents
|
|
unwanted automatic recreation of intentionally removed cycles.
|
|
|
|
## 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
|
|
# Notifications are handled inside with_advisory_lock after transaction commits
|
|
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
|
|
|
|
{:exit, reason} ->
|
|
# Task crashed - log and return error tuple
|
|
Logger.error("Task crashed during cycle generation: #{inspect(reason)}")
|
|
{nil, {:error, {:task_exit, reason}}}
|
|
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)
|
|
|
|
result =
|
|
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, notifications} when is_list(notifications) ->
|
|
# Return result and notifications separately
|
|
{result, notifications}
|
|
|
|
{:ok, result} ->
|
|
# Handle case where no notifications were returned (backward compatibility)
|
|
{result, []}
|
|
|
|
{:error, reason} ->
|
|
Repo.rollback(reason)
|
|
end
|
|
end)
|
|
|
|
# Extract result and notifications, send notifications after transaction
|
|
case result do
|
|
{:ok, {cycles, notifications}} ->
|
|
if Enum.any?(notifications) do
|
|
Ash.Notifier.notify(notifications)
|
|
end
|
|
|
|
{:ok, cycles}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
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 point based on existing cycles
|
|
# Note: We do NOT fill gaps - only generate from the last existing cycle onwards
|
|
start_date = determine_generation_start(member, existing_cycles, interval)
|
|
|
|
# Determine end date (today or exit_date, whichever is earlier)
|
|
end_date = determine_end_date(member, today)
|
|
|
|
# Only generate if start_date <= end_date
|
|
if start_date && Date.compare(start_date, end_date) != :gt do
|
|
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
|
create_cycles(cycle_starts, member.id, fee_type.id, amount)
|
|
else
|
|
{:ok, [], []}
|
|
end
|
|
end
|
|
|
|
# No existing cycles: start from membership_fee_start_date
|
|
defp determine_generation_start(member, [], interval) do
|
|
determine_start_date(member, interval)
|
|
end
|
|
|
|
# Has existing cycles: start from the cycle AFTER the last one
|
|
# This ensures gaps (deleted cycles) are NOT filled
|
|
defp determine_generation_start(_member, existing_cycles, interval) do
|
|
last_cycle_start =
|
|
existing_cycles
|
|
|> Enum.map(& &1.cycle_start)
|
|
|> Enum.max(Date)
|
|
|
|
CalendarCycles.next_cycle_start(last_cycle_start, interval)
|
|
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
|
|
results =
|
|
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
|
|
}
|
|
|
|
# Return notifications to avoid warnings when creating within a transaction
|
|
case Ash.create(MembershipFeeCycle, attrs, return_notifications?: true) do
|
|
{:ok, cycle, notifications} -> {:ok, cycle, notifications}
|
|
{:error, reason} -> {:error, {cycle_start, reason}}
|
|
end
|
|
end)
|
|
|
|
{successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1))
|
|
successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end)
|
|
|
|
all_notifications =
|
|
Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end)
|
|
|
|
if Enum.empty?(errors) do
|
|
# Return cycles and notifications to be sent after transaction commits
|
|
{:ok, successful_cycles, all_notifications}
|
|
else
|
|
Logger.warning("Some cycles failed to create: #{inspect(errors)}")
|
|
# Return partial failure with both successful and failed cycles
|
|
# This allows callers to decide how to handle partial failures
|
|
{:error, {:partial_failure, successful_cycles, errors}}
|
|
end
|
|
end
|
|
end
|