feat: implement automatic cycle generation for members
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
- Add CycleGenerator module with advisory lock mechanism - Add SetMembershipFeeStartDate change for auto-calculation - Extend Settings with include_joining_cycle and default_membership_fee_type_id - Add scheduled job skeleton for future Oban integration
This commit is contained in:
parent
ecddf55331
commit
162d06da21
15 changed files with 2698 additions and 6 deletions
|
|
@ -80,7 +80,7 @@ defmodule Mv.Membership.Member do
|
|||
argument :user, :map, allow_nil?: true
|
||||
|
||||
# Accept member fields plus membership_fee_type_id (belongs_to FK)
|
||||
accept @member_fields ++ [:membership_fee_type_id]
|
||||
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
|
||||
|
||||
change manage_relationship(:custom_field_values, type: :create)
|
||||
|
||||
|
|
@ -101,6 +101,30 @@ defmodule Mv.Membership.Member do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:user)]
|
||||
end
|
||||
|
||||
# Auto-calculate membership_fee_start_date if not manually set
|
||||
# Requires both join_date and membership_fee_type_id to be present
|
||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||
|
||||
# Trigger cycle generation after member creation
|
||||
# Only runs if membership_fee_type_id is set
|
||||
# Note: Cycle generation runs asynchronously to not block the action,
|
||||
# but in test environment it runs synchronously for DB sandbox compatibility
|
||||
change after_action(fn _changeset, member, _context ->
|
||||
if member.membership_fee_type_id && member.join_date do
|
||||
if Application.get_env(:mv, :env) == :test do
|
||||
# Run synchronously in test environment for DB sandbox compatibility
|
||||
Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id)
|
||||
else
|
||||
# Run asynchronously in other environments
|
||||
Task.start(fn ->
|
||||
Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, member}
|
||||
end)
|
||||
end
|
||||
|
||||
update :update_member do
|
||||
|
|
@ -114,7 +138,7 @@ defmodule Mv.Membership.Member do
|
|||
argument :user, :map, allow_nil?: true
|
||||
|
||||
# Accept member fields plus membership_fee_type_id (belongs_to FK)
|
||||
accept @member_fields ++ [:membership_fee_type_id]
|
||||
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
|
||||
|
||||
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
||||
|
||||
|
|
@ -141,6 +165,34 @@ defmodule Mv.Membership.Member do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:user)]
|
||||
end
|
||||
|
||||
# Auto-calculate membership_fee_start_date when membership_fee_type_id is set
|
||||
# and membership_fee_start_date is not already set
|
||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
||||
where [changing(:membership_fee_type_id)]
|
||||
end
|
||||
|
||||
# Trigger cycle generation when membership_fee_type_id changes
|
||||
# Note: Cycle generation runs asynchronously to not block the action,
|
||||
# but in test environment it runs synchronously for DB sandbox compatibility
|
||||
change after_action(fn changeset, member, _context ->
|
||||
fee_type_changed =
|
||||
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
||||
|
||||
if fee_type_changed && member.membership_fee_type_id && member.join_date do
|
||||
if Application.get_env(:mv, :env) == :test do
|
||||
# Run synchronously in test environment for DB sandbox compatibility
|
||||
Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id)
|
||||
else
|
||||
# Run asynchronously in other environments
|
||||
Task.start(fn ->
|
||||
Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, member}
|
||||
end)
|
||||
end
|
||||
|
||||
# Action to handle fuzzy search on specific fields
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@ defmodule Mv.Membership.Setting do
|
|||
|
||||
## Overview
|
||||
Settings is a singleton resource that stores global configuration for the association,
|
||||
such as the club name and branding information. There should only ever be one settings
|
||||
record in the database.
|
||||
such as the club name, branding information, and membership fee settings. There should
|
||||
only ever be one settings record in the database.
|
||||
|
||||
## Attributes
|
||||
- `club_name` - The name of the association/club (required, cannot be empty)
|
||||
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
|
||||
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
|
||||
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
|
||||
- `default_membership_fee_type_id` - Default membership fee type for new members (optional)
|
||||
|
||||
## Singleton Pattern
|
||||
This resource uses a singleton pattern - there should only be one settings record.
|
||||
|
|
@ -22,6 +24,12 @@ defmodule Mv.Membership.Setting do
|
|||
If set, the environment variable value is used as a fallback when no database
|
||||
value exists. Database values always take precedence over environment variables.
|
||||
|
||||
## Membership Fee Settings
|
||||
- `include_joining_cycle`: When true, members pay from their joining cycle. When false,
|
||||
they pay from the next full cycle after joining.
|
||||
- `default_membership_fee_type_id`: The membership fee type automatically assigned to
|
||||
new members. Can be nil if no default is set.
|
||||
|
||||
## Examples
|
||||
|
||||
# Get current settings
|
||||
|
|
@ -33,6 +41,9 @@ defmodule Mv.Membership.Setting do
|
|||
|
||||
# Update member field visibility
|
||||
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||
|
||||
# Update membership fee settings
|
||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
|
|
@ -54,13 +65,24 @@ defmodule Mv.Membership.Setting do
|
|||
# Used only as fallback in get_settings/0 if settings don't exist
|
||||
# Settings should normally be created via seed script
|
||||
create :create do
|
||||
accept [:club_name, :member_field_visibility]
|
||||
accept [
|
||||
:club_name,
|
||||
:member_field_visibility,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id
|
||||
]
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
require_atomic? false
|
||||
accept [:club_name, :member_field_visibility]
|
||||
|
||||
accept [
|
||||
:club_name,
|
||||
:member_field_visibility,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id
|
||||
]
|
||||
end
|
||||
|
||||
update :update_member_field_visibility do
|
||||
|
|
@ -68,6 +90,12 @@ defmodule Mv.Membership.Setting do
|
|||
require_atomic? false
|
||||
accept [:member_field_visibility]
|
||||
end
|
||||
|
||||
update :update_membership_fee_settings do
|
||||
description "Updates the membership fee configuration"
|
||||
require_atomic? false
|
||||
accept [:include_joining_cycle, :default_membership_fee_type_id]
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
|
|
@ -133,6 +161,26 @@ defmodule Mv.Membership.Setting do
|
|||
description:
|
||||
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
||||
|
||||
# Membership fee settings
|
||||
attribute :include_joining_cycle, :boolean do
|
||||
allow_nil? false
|
||||
default true
|
||||
public? true
|
||||
description "Whether to include the joining cycle in membership fee generation"
|
||||
end
|
||||
|
||||
attribute :default_membership_fee_type_id, :uuid do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Default membership fee type ID for new members"
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
# Optional relationship to the default membership fee type
|
||||
# Note: We use manual FK (default_membership_fee_type_id attribute) instead of belongs_to
|
||||
# to avoid circular dependency between Membership and MembershipFees domains
|
||||
end
|
||||
end
|
||||
|
|
|
|||
154
lib/membership_fees/changes/set_membership_fee_start_date.ex
Normal file
154
lib/membership_fees/changes/set_membership_fee_start_date.ex
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
||||
@moduledoc """
|
||||
Ash change module that automatically calculates and sets the membership_fee_start_date.
|
||||
|
||||
## Logic
|
||||
|
||||
1. Only executes if `membership_fee_start_date` is not manually set
|
||||
2. Requires both `join_date` and `membership_fee_type_id` to be present
|
||||
3. Reads `include_joining_cycle` setting from global Settings
|
||||
4. Reads `interval` from the assigned `membership_fee_type`
|
||||
5. Calculates the start date:
|
||||
- If `include_joining_cycle = true`: First day of the joining cycle
|
||||
- If `include_joining_cycle = false`: First day of the next cycle after joining
|
||||
|
||||
## Usage
|
||||
|
||||
In a Member action:
|
||||
|
||||
create :create_member do
|
||||
# ...
|
||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
||||
where [present(:membership_fee_type_id), present(:join_date)]
|
||||
end
|
||||
end
|
||||
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
# Only calculate if membership_fee_start_date is not already set
|
||||
if has_start_date?(changeset) do
|
||||
changeset
|
||||
else
|
||||
calculate_and_set_start_date(changeset)
|
||||
end
|
||||
end
|
||||
|
||||
# Check if membership_fee_start_date is already set (either in changeset or data)
|
||||
defp has_start_date?(changeset) do
|
||||
# Check if it's being set in this changeset
|
||||
case Ash.Changeset.fetch_change(changeset, :membership_fee_start_date) do
|
||||
{:ok, date} when not is_nil(date) ->
|
||||
true
|
||||
|
||||
_ ->
|
||||
# Check if it already exists in the data (for updates)
|
||||
case changeset.data do
|
||||
%{membership_fee_start_date: date} when not is_nil(date) -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_and_set_start_date(changeset) do
|
||||
with {:ok, join_date} <- get_join_date(changeset),
|
||||
{:ok, membership_fee_type_id} <- get_membership_fee_type_id(changeset),
|
||||
{:ok, interval} <- get_interval(membership_fee_type_id),
|
||||
{:ok, include_joining_cycle} <- get_include_joining_cycle() do
|
||||
start_date = calculate_start_date(join_date, interval, include_joining_cycle)
|
||||
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
|
||||
else
|
||||
{:error, _reason} ->
|
||||
# If we can't calculate the start date (missing required fields), just return unchanged
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp get_join_date(changeset) do
|
||||
# First check the changeset for changes
|
||||
case Ash.Changeset.fetch_change(changeset, :join_date) do
|
||||
{:ok, date} when not is_nil(date) ->
|
||||
{:ok, date}
|
||||
|
||||
_ ->
|
||||
# Then check existing data
|
||||
case changeset.data do
|
||||
%{join_date: date} when not is_nil(date) -> {:ok, date}
|
||||
_ -> {:error, :join_date_not_set}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_membership_fee_type_id(changeset) do
|
||||
# First check the changeset for changes
|
||||
case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do
|
||||
{:ok, id} when not is_nil(id) ->
|
||||
{:ok, id}
|
||||
|
||||
_ ->
|
||||
# Then check existing data
|
||||
case changeset.data do
|
||||
%{membership_fee_type_id: id} when not is_nil(id) -> {:ok, id}
|
||||
_ -> {:error, :membership_fee_type_not_set}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_interval(membership_fee_type_id) do
|
||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id) do
|
||||
{:ok, %{interval: interval}} -> {:ok, interval}
|
||||
{:error, _} -> {:error, :membership_fee_type_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_include_joining_cycle do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, %{include_joining_cycle: include}} -> {:ok, include}
|
||||
{:error, _} -> {:ok, true}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the membership fee start date based on join date, interval, and settings.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `join_date` - The date the member joined
|
||||
- `interval` - The billing interval (:monthly, :quarterly, :half_yearly, :yearly)
|
||||
- `include_joining_cycle` - Whether to include the joining cycle
|
||||
|
||||
## Returns
|
||||
|
||||
The calculated start date (first day of the appropriate cycle).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> calculate_start_date(~D[2024-03-15], :yearly, true)
|
||||
~D[2024-01-01]
|
||||
|
||||
iex> calculate_start_date(~D[2024-03-15], :yearly, false)
|
||||
~D[2025-01-01]
|
||||
|
||||
iex> calculate_start_date(~D[2024-03-15], :quarterly, true)
|
||||
~D[2024-01-01]
|
||||
|
||||
iex> calculate_start_date(~D[2024-03-15], :quarterly, false)
|
||||
~D[2024-04-01]
|
||||
|
||||
"""
|
||||
@spec calculate_start_date(Date.t(), atom(), boolean()) :: Date.t()
|
||||
def calculate_start_date(join_date, interval, include_joining_cycle) do
|
||||
if include_joining_cycle do
|
||||
# Start date is the first day of the joining cycle
|
||||
CalendarCycles.calculate_cycle_start(join_date, interval)
|
||||
else
|
||||
# Start date is the first day of the next cycle after joining
|
||||
join_cycle_start = CalendarCycles.calculate_cycle_start(join_date, interval)
|
||||
CalendarCycles.next_cycle_start(join_cycle_start, interval)
|
||||
end
|
||||
end
|
||||
end
|
||||
174
lib/mv/membership_fees/cycle_generation_job.ex
Normal file
174
lib/mv/membership_fees/cycle_generation_job.ex
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
defmodule Mv.MembershipFees.CycleGenerationJob do
|
||||
@moduledoc """
|
||||
Scheduled job for generating membership fee cycles.
|
||||
|
||||
This module provides a skeleton for scheduled cycle generation.
|
||||
In the future, this can be integrated with Oban or similar job processing libraries.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
Currently provides manual execution functions that can be called:
|
||||
- From IEx console for administrative tasks
|
||||
- From a cron job via a Mix task
|
||||
- From the admin UI (future)
|
||||
|
||||
## Future Oban Integration
|
||||
|
||||
When Oban is added to the project, this module can be converted to an Oban worker:
|
||||
|
||||
defmodule Mv.MembershipFees.CycleGenerationJob do
|
||||
use Oban.Worker,
|
||||
queue: :membership_fees,
|
||||
max_attempts: 3
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
Mv.MembershipFees.CycleGenerator.generate_cycles_for_all_members()
|
||||
end
|
||||
end
|
||||
|
||||
## Usage
|
||||
|
||||
# Manual execution from IEx
|
||||
Mv.MembershipFees.CycleGenerationJob.run()
|
||||
|
||||
# Check if cycles need to be generated
|
||||
Mv.MembershipFees.CycleGenerationJob.pending_members_count()
|
||||
|
||||
"""
|
||||
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Runs the cycle generation job for all active members.
|
||||
|
||||
This is the main entry point for scheduled execution.
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, results}` - Map with success/failed counts
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CycleGenerationJob.run()
|
||||
{:ok, %{success: 45, failed: 0, total: 45}}
|
||||
|
||||
"""
|
||||
@spec run() :: {:ok, map()} | {:error, term()}
|
||||
def run do
|
||||
Logger.info("Starting membership fee cycle generation job")
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
result = CycleGenerator.generate_cycles_for_all_members()
|
||||
|
||||
elapsed = System.monotonic_time(:millisecond) - start_time
|
||||
|
||||
case result do
|
||||
{:ok, stats} ->
|
||||
Logger.info(
|
||||
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
|
||||
)
|
||||
|
||||
result
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Cycle generation failed: #{inspect(reason)}")
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Runs cycle generation with custom options.
|
||||
|
||||
## Options
|
||||
|
||||
- `:today` - Override today's date (useful for testing or catch-up)
|
||||
- `:batch_size` - Number of members to process in parallel
|
||||
|
||||
## Examples
|
||||
|
||||
# Generate cycles as if today was a specific date
|
||||
Mv.MembershipFees.CycleGenerationJob.run(today: ~D[2024-12-31])
|
||||
|
||||
# Process with smaller batch size
|
||||
Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5)
|
||||
|
||||
"""
|
||||
@spec run(keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def run(opts) when is_list(opts) do
|
||||
Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}")
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
result = CycleGenerator.generate_cycles_for_all_members(opts)
|
||||
|
||||
elapsed = System.monotonic_time(:millisecond) - start_time
|
||||
|
||||
case result do
|
||||
{:ok, stats} ->
|
||||
Logger.info(
|
||||
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
|
||||
)
|
||||
|
||||
result
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Cycle generation failed: #{inspect(reason)}")
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of members that need cycle generation.
|
||||
|
||||
A member needs cycle generation if:
|
||||
- Has a membership_fee_type assigned
|
||||
- Has a join_date set
|
||||
- Is active (no exit_date or exit_date >= today)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, count}` - Number of members needing generation
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
"""
|
||||
@spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, term()}
|
||||
def pending_members_count do
|
||||
today = Date.utc_today()
|
||||
|
||||
query =
|
||||
Mv.Membership.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.count(query) do
|
||||
{:ok, count} -> {:ok, count}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates cycles for a specific member by ID.
|
||||
|
||||
Useful for administrative tasks or manual corrections.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `member_id` - The UUID of the member
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, cycles}` - List of newly created cycles
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
"""
|
||||
@spec run_for_member(String.t()) :: {:ok, [map()]} | {:error, term()}
|
||||
def run_for_member(member_id) when is_binary(member_id) do
|
||||
Logger.info("Generating cycles for member #{member_id}")
|
||||
CycleGenerator.generate_cycles_for_member(member_id)
|
||||
end
|
||||
end
|
||||
317
lib/mv/membership_fees/cycle_generator.ex
Normal file
317
lib/mv/membership_fees/cycle_generator.ex
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue