1. Fix critical notifications bug 2. Fix today inconsistency 3. Add advisory lock around deletion 4. Improve helper function documentation 5. Improve error message UX
440 lines
15 KiB
Elixir
440 lines
15 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)
|
|
|
|
# Check if we're already in a transaction (e.g., called from Ash action)
|
|
if Repo.in_transaction?() do
|
|
with_advisory_lock_in_transaction(lock_key, fun)
|
|
else
|
|
with_advisory_lock_new_transaction(lock_key, fun)
|
|
end
|
|
end
|
|
|
|
# Already in transaction: use advisory lock directly without starting new transaction
|
|
# This prevents nested transactions which can cause deadlocks
|
|
defp with_advisory_lock_in_transaction(lock_key, fun) do
|
|
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
|
normalize_fun_result(fun.())
|
|
end
|
|
|
|
# Not in transaction: start new transaction with advisory lock
|
|
defp with_advisory_lock_new_transaction(lock_key, fun) do
|
|
result =
|
|
Repo.transaction(fn ->
|
|
# Acquire advisory lock for this transaction
|
|
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
|
execute_within_transaction(fun)
|
|
end)
|
|
|
|
handle_transaction_result(result)
|
|
end
|
|
|
|
# Execute function within transaction and return normalized result
|
|
# When not in transaction, create_cycles returns {:ok, cycles, notifications}
|
|
# When in transaction, create_cycles returns {:ok, cycles} (notifications handled by Ash)
|
|
defp execute_within_transaction(fun) do
|
|
case fun.() do
|
|
{:ok, result, notifications} when is_list(notifications) ->
|
|
# Return result and notifications separately (not in transaction case)
|
|
{result, notifications}
|
|
|
|
{:ok, result} ->
|
|
# In transaction case: notifications handled by Ash automatically
|
|
{result, []}
|
|
|
|
{:error, reason} ->
|
|
Repo.rollback(reason)
|
|
end
|
|
end
|
|
|
|
# Normalize function result to consistent format
|
|
# When in transaction, create_cycles returns {:ok, cycles} (notifications handled by Ash)
|
|
# When not in transaction, create_cycles returns {:ok, cycles, notifications}
|
|
defp normalize_fun_result({:ok, result, _notifications}) do
|
|
# This case should not occur when in transaction (create_cycles handles it)
|
|
# But handle it for safety
|
|
{:ok, result}
|
|
end
|
|
|
|
defp normalize_fun_result({:ok, result}), do: {:ok, result}
|
|
defp normalize_fun_result({:error, reason}), do: {:error, reason}
|
|
|
|
# Handle transaction result and send notifications if needed
|
|
defp handle_transaction_result({:ok, {cycles, notifications}}) do
|
|
if Enum.any?(notifications) do
|
|
Ash.Notifier.notify(notifications)
|
|
end
|
|
|
|
{:ok, cycles}
|
|
end
|
|
|
|
defp handle_transaction_result({:error, reason}), do: {:error, reason}
|
|
|
|
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
|
|
# If already in a transaction, let Ash handle notifications automatically
|
|
# Otherwise, return notifications to send them after transaction commits
|
|
return_notifications? = not Repo.in_transaction?()
|
|
|
|
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
|
|
}
|
|
|
|
case Ash.create(MembershipFeeCycle, attrs, return_notifications?: return_notifications?) do
|
|
{:ok, cycle, notifications} when is_list(notifications) ->
|
|
{:ok, cycle, notifications}
|
|
|
|
{:ok, cycle} ->
|
|
{:ok, cycle, []}
|
|
|
|
{:error, reason} ->
|
|
{:error, {cycle_start, reason}}
|
|
end
|
|
end)
|
|
|
|
{successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1))
|
|
|
|
all_notifications =
|
|
Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end)
|
|
|
|
if Enum.empty?(errors) do
|
|
successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end)
|
|
|
|
if return_notifications? do
|
|
# Return cycles and notifications to be sent after transaction commits
|
|
{:ok, successful_cycles, all_notifications}
|
|
else
|
|
# Notifications are handled automatically by Ash when in transaction
|
|
{:ok, successful_cycles}
|
|
end
|
|
else
|
|
Logger.warning("Some cycles failed to create: #{inspect(errors)}")
|
|
# Return partial failure with errors
|
|
# Note: When this error occurs, the transaction will be rolled back,
|
|
# so no cycles were actually persisted in the database
|
|
{:error, {:partial_failure, errors}}
|
|
end
|
|
end
|
|
end
|