Cycle Generation System closes #277 #290
15 changed files with 2698 additions and 6 deletions
|
|
@ -48,3 +48,6 @@ config :mv, :require_token_presence_for_authentication, false
|
|||
|
||||
# Enable SQL Sandbox for async LiveView tests
|
||||
config :mv, :sql_sandbox, true
|
||||
|
||||
# Mark test environment for conditional behavior (e.g., sync vs async operations)
|
||||
config :mv, :env, :test
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
carla
commented
+ membership fee start date
|
||||
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)
|
||||
|
moritz marked this conversation as resolved
carla
commented
Good idea! Good idea!
|
||||
|
||||
## 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)
|
||||
|
||||
|
moritz marked this conversation as resolved
carla
commented
We thought about payment status "suspended", should we keep that in mind somehow? We thought about payment status "suspended", should we keep that in mind somehow?
moritz
commented
The suspended status is implemented in another issue The suspended status is implemented in another issue
|
||||
## 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
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
defmodule Mv.Repo.Migrations.AddMembershipFeeSettings do
|
||||
@moduledoc """
|
||||
Adds membership fee settings to the settings table.
|
||||
|
||||
Note: The members table columns (membership_fee_start_date, membership_fee_type_id)
|
||||
were already added in migration 20251211151449_add_membership_fees_tables.
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
# Add membership fee settings to the settings table
|
||||
alter table(:settings) do
|
||||
add_if_not_exists :include_joining_cycle, :boolean, null: false, default: true
|
||||
add_if_not_exists :default_membership_fee_type_id, :uuid
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:settings) do
|
||||
remove_if_exists :default_membership_fee_type_id, :uuid
|
||||
remove_if_exists :include_joining_cycle, :boolean
|
||||
end
|
||||
end
|
||||
end
|
||||
245
priv/resource_snapshots/repo/members/20251211195058.json
Normal file
245
priv/resource_snapshots/repo/members/20251211195058.json
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"uuid_generate_v7()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "first_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "last_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "email",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "paid",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "phone_number",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "join_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "exit_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "notes",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "city",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "street",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "house_number",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "postal_code",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "search_vector",
|
||||
"type": "tsvector"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "membership_fee_start_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "members_membership_fee_type_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "membership_fee_types"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "membership_fee_type_id",
|
||||
"type": "uuid"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "6ECD721659E1CC7CB4219293153BCED585111A49765B9DB0D1CAE0B37C54949E",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "members_unique_email_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "email"
|
||||
}
|
||||
],
|
||||
"name": "unique_email",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "members"
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"uuid_generate_v7()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "cycle_start",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": 2,
|
||||
"size": null,
|
||||
"source": "amount",
|
||||
"type": "decimal"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "\"unpaid\"",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "status",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "notes",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "membership_fee_cycles_member_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "members"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "member_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "membership_fee_cycles_membership_fee_type_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "membership_fee_types"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "membership_fee_type_id",
|
||||
"type": "uuid"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "802FB11B08D041501AC395454D84719992B71C0BEAE83B0833F3086486ABD679",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "membership_fee_cycles_unique_cycle_per_member_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "member_id"
|
||||
},
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "cycle_start"
|
||||
}
|
||||
],
|
||||
"name": "unique_cycle_per_member",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "membership_fee_cycles"
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"uuid_generate_v7()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": 2,
|
||||
"size": null,
|
||||
"source": "amount",
|
||||
"type": "decimal"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "interval",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "description",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "C58959BF589FEB75A9F05C2C717C04B641ED14E09FF2503C8B0637392AE5A335",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "membership_fee_types_unique_name_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "name"
|
||||
}
|
||||
],
|
||||
"name": "unique_name",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "membership_fee_types"
|
||||
}
|
||||
103
priv/resource_snapshots/repo/settings/20251211195058.json
Normal file
103
priv/resource_snapshots/repo/settings/20251211195058.json
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "club_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "member_field_visibility",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "true",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "include_joining_cycle",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "default_membership_fee_type_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "CD12EA080677C99D81C2A4A98F0DE419F7BDE1FA8C22206423C9D80305B064D2",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "settings"
|
||||
}
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
||||
@moduledoc """
|
||||
Tests for the SetMembershipFeeStartDate change module.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||
|
||||
# Helper to set up settings with specific include_joining_cycle value
|
||||
defp setup_settings(include_joining_cycle) do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
||||
|> Ash.update!()
|
||||
end
|
||||
|
||||
describe "calculate_start_date/3" do
|
||||
test "include_joining_cycle = true: start date is first day of joining cycle (yearly)" do
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :yearly, true)
|
||||
assert result == ~D[2024-01-01]
|
||||
end
|
||||
|
||||
test "include_joining_cycle = false: start date is first day of next cycle (yearly)" do
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :yearly, false)
|
||||
assert result == ~D[2025-01-01]
|
||||
end
|
||||
|
||||
test "include_joining_cycle = true: start date is first day of joining cycle (quarterly)" do
|
||||
# Q1: Jan-Mar, Q2: Apr-Jun, Q3: Jul-Sep, Q4: Oct-Dec
|
||||
# March is in Q1
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :quarterly, true)
|
||||
assert result == ~D[2024-01-01]
|
||||
|
||||
# May is in Q2
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-05-15], :quarterly, true)
|
||||
assert result == ~D[2024-04-01]
|
||||
|
||||
# August is in Q3
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-08-15], :quarterly, true)
|
||||
assert result == ~D[2024-07-01]
|
||||
|
||||
# November is in Q4
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-11-15], :quarterly, true)
|
||||
assert result == ~D[2024-10-01]
|
||||
end
|
||||
|
||||
test "include_joining_cycle = false: start date is first day of next cycle (quarterly)" do
|
||||
# March is in Q1, next is Q2
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :quarterly, false)
|
||||
assert result == ~D[2024-04-01]
|
||||
|
||||
# June is in Q2, next is Q3
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-06-15], :quarterly, false)
|
||||
assert result == ~D[2024-07-01]
|
||||
|
||||
# September is in Q3, next is Q4
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-09-15], :quarterly, false)
|
||||
assert result == ~D[2024-10-01]
|
||||
|
||||
# December is in Q4, next is Q1 of next year
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-15], :quarterly, false)
|
||||
assert result == ~D[2025-01-01]
|
||||
end
|
||||
|
||||
test "include_joining_cycle = true: start date is first day of joining cycle (half_yearly)" do
|
||||
# H1: Jan-Jun, H2: Jul-Dec
|
||||
# March is in H1
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :half_yearly, true)
|
||||
assert result == ~D[2024-01-01]
|
||||
|
||||
# September is in H2
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-09-15], :half_yearly, true)
|
||||
assert result == ~D[2024-07-01]
|
||||
end
|
||||
|
||||
test "include_joining_cycle = false: start date is first day of next cycle (half_yearly)" do
|
||||
# March is in H1, next is H2
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :half_yearly, false)
|
||||
assert result == ~D[2024-07-01]
|
||||
|
||||
# September is in H2, next is H1 of next year
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-09-15], :half_yearly, false)
|
||||
assert result == ~D[2025-01-01]
|
||||
end
|
||||
|
||||
test "include_joining_cycle = true: start date is first day of joining cycle (monthly)" do
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :monthly, true)
|
||||
assert result == ~D[2024-03-01]
|
||||
end
|
||||
|
||||
test "include_joining_cycle = false: start date is first day of next cycle (monthly)" do
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :monthly, false)
|
||||
assert result == ~D[2024-04-01]
|
||||
|
||||
# December goes to next year
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-15], :monthly, false)
|
||||
assert result == ~D[2025-01-01]
|
||||
end
|
||||
|
||||
test "joining on first day of cycle with include_joining_cycle = true" do
|
||||
# When joining exactly on cycle start, should return that date
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-01-01], :yearly, true)
|
||||
assert result == ~D[2024-01-01]
|
||||
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-04-01], :quarterly, true)
|
||||
assert result == ~D[2024-04-01]
|
||||
end
|
||||
|
||||
test "joining on first day of cycle with include_joining_cycle = false" do
|
||||
# When joining exactly on cycle start and include=false, should return next cycle
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-01-01], :yearly, false)
|
||||
assert result == ~D[2025-01-01]
|
||||
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-04-01], :quarterly, false)
|
||||
assert result == ~D[2024-07-01]
|
||||
end
|
||||
|
||||
test "joining on last day of cycle" do
|
||||
# Joining on Dec 31 with yearly cycle
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-31], :yearly, true)
|
||||
assert result == ~D[2024-01-01]
|
||||
|
||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-31], :yearly, false)
|
||||
assert result == ~D[2025-01-01]
|
||||
end
|
||||
end
|
||||
|
||||
describe "change/3 integration" do
|
||||
test "sets membership_fee_start_date automatically on member creation" do
|
||||
setup_settings(true)
|
||||
|
||||
# Create a fee type
|
||||
fee_type =
|
||||
Mv.MembershipFees.MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Create member with join_date and fee type but no explicit start date
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Should have auto-calculated start date (2024-01-01 for yearly with include_joining_cycle=true)
|
||||
assert member.membership_fee_start_date == ~D[2024-01-01]
|
||||
end
|
||||
|
||||
test "does not override manually set membership_fee_start_date" do
|
||||
setup_settings(true)
|
||||
|
||||
# Create a fee type
|
||||
fee_type =
|
||||
Mv.MembershipFees.MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Create member with explicit start date
|
||||
manual_start_date = ~D[2024-07-01]
|
||||
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: manual_start_date
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Should keep the manually set date
|
||||
assert member.membership_fee_start_date == manual_start_date
|
||||
end
|
||||
|
||||
test "respects include_joining_cycle = false setting" do
|
||||
setup_settings(false)
|
||||
|
||||
# Create a fee type
|
||||
fee_type =
|
||||
Mv.MembershipFees.MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Create member
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Should have next cycle start date (2025-01-01 for yearly with include_joining_cycle=false)
|
||||
assert member.membership_fee_start_date == ~D[2025-01-01]
|
||||
end
|
||||
|
||||
test "does not set start date without join_date" do
|
||||
setup_settings(true)
|
||||
|
||||
# Create a fee type
|
||||
fee_type =
|
||||
Mv.MembershipFees.MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Create member without join_date
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
# No join_date
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Should not have auto-calculated start date
|
||||
assert is_nil(member.membership_fee_start_date)
|
||||
end
|
||||
|
||||
test "does not set start date without membership_fee_type_id" do
|
||||
setup_settings(true)
|
||||
|
||||
# Create member without fee type
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2024-03-15]
|
||||
# No membership_fee_type_id
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Should not have auto-calculated start date
|
||||
assert is_nil(member.membership_fee_start_date)
|
||||
end
|
||||
end
|
||||
end
|
||||
224
test/membership_fees/member_cycle_integration_test.exs
Normal file
224
test/membership_fees/member_cycle_integration_test.exs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||
@moduledoc """
|
||||
Integration tests for membership fee cycle generation triggered by member actions.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership.Member
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to set up settings
|
||||
defp setup_settings(include_joining_cycle) do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
||||
|> Ash.update!()
|
||||
end
|
||||
|
||||
# Helper to get cycles for a member
|
||||
defp get_member_cycles(member_id) do
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member_id)
|
||||
|> Ash.Query.sort(cycle_start: :asc)
|
||||
|> Ash.read!()
|
||||
end
|
||||
|
||||
describe "member creation triggers cycle generation" do
|
||||
test "creates cycles when member is created with fee type and join_date" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Wait for async cycle generation
|
||||
Process.sleep(300)
|
||||
|
||||
cycles = get_member_cycles(member.id)
|
||||
|
||||
# Should have cycles for 2023 and 2024 (and possibly current year)
|
||||
assert length(cycles) >= 2
|
||||
|
||||
# Verify cycles have correct data
|
||||
Enum.each(cycles, fn cycle ->
|
||||
assert cycle.member_id == member.id
|
||||
assert cycle.membership_fee_type_id == fee_type.id
|
||||
assert Decimal.equal?(cycle.amount, fee_type.amount)
|
||||
assert cycle.status == :unpaid
|
||||
end)
|
||||
end
|
||||
|
||||
test "does not create cycles when member has no fee type" do
|
||||
setup_settings(true)
|
||||
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15]
|
||||
# No membership_fee_type_id
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Wait for potential async cycle generation
|
||||
Process.sleep(200)
|
||||
|
||||
cycles = get_member_cycles(member.id)
|
||||
|
||||
assert cycles == []
|
||||
end
|
||||
|
||||
test "does not create cycles when member has no join_date" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
# No join_date
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Wait for potential async cycle generation
|
||||
Process.sleep(200)
|
||||
|
||||
cycles = get_member_cycles(member.id)
|
||||
|
||||
assert cycles == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "member update triggers cycle generation" do
|
||||
test "generates cycles when fee type is assigned to existing member" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create member without fee type
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15]
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Verify no cycles yet
|
||||
assert get_member_cycles(member.id) == []
|
||||
|
||||
# Update to assign fee type
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Wait for async cycle generation
|
||||
Process.sleep(300)
|
||||
|
||||
cycles = get_member_cycles(member.id)
|
||||
|
||||
# Should have generated cycles
|
||||
assert length(cycles) >= 2
|
||||
end
|
||||
end
|
||||
|
||||
describe "concurrent cycle generation" do
|
||||
test "handles multiple members being created concurrently" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create multiple members concurrently
|
||||
tasks =
|
||||
Enum.map(1..5, fn i ->
|
||||
Task.async(fn ->
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test#{i}",
|
||||
last_name: "User#{i}",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!()
|
||||
end)
|
||||
end)
|
||||
|
||||
members = Enum.map(tasks, &Task.await/1)
|
||||
|
||||
# Wait for all async cycle generations
|
||||
Process.sleep(500)
|
||||
|
||||
# Each member should have cycles
|
||||
Enum.each(members, fn member ->
|
||||
cycles = get_member_cycles(member.id)
|
||||
assert length(cycles) >= 2, "Member #{member.id} should have at least 2 cycles"
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "idempotent cycle generation" do
|
||||
test "running generation multiple times does not create duplicate cycles" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Wait for async cycle generation
|
||||
Process.sleep(300)
|
||||
|
||||
initial_cycles = get_member_cycles(member.id)
|
||||
initial_count = length(initial_cycles)
|
||||
|
||||
# Manually trigger generation again
|
||||
{:ok, _} = Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id)
|
||||
|
||||
final_cycles = get_member_cycles(member.id)
|
||||
final_count = length(final_cycles)
|
||||
|
||||
# Should have same number of cycles
|
||||
assert final_count == initial_count
|
||||
end
|
||||
end
|
||||
end
|
||||
457
test/mv/membership_fees/cycle_generator_edge_cases_test.exs
Normal file
457
test/mv/membership_fees/cycle_generator_edge_cases_test.exs
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||
@moduledoc """
|
||||
Edge case tests for the CycleGenerator module.
|
||||
|
||||
Tests cover:
|
||||
- Member joins today
|
||||
- Member left yesterday
|
||||
- Year boundary handling
|
||||
- Leap year handling
|
||||
- Members with no existing cycles
|
||||
- Members with existing cycles
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership.Member
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a member. Note: If membership_fee_type_id is provided,
|
||||
# cycles will be auto-generated during creation in test environment.
|
||||
defp create_member(attrs) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to get cycles for a member
|
||||
defp get_member_cycles(member_id) do
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member_id)
|
||||
|> Ash.Query.sort(cycle_start: :asc)
|
||||
|> Ash.read!()
|
||||
end
|
||||
|
||||
# Helper to set up settings
|
||||
defp setup_settings(include_joining_cycle) do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
||||
|> Ash.update!()
|
||||
end
|
||||
|
||||
describe "member joins today" do
|
||||
test "current cycle is generated (yearly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: today,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
})
|
||||
|
||||
# Check all cycles (including auto-generated ones)
|
||||
cycles = get_member_cycles(member.id)
|
||||
|
||||
# Should have the current year's cycle
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year)
|
||||
assert 2024 in cycle_years
|
||||
end
|
||||
|
||||
test "current cycle is generated (monthly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: today,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-06-01]
|
||||
})
|
||||
|
||||
# Check all cycles (including auto-generated ones)
|
||||
cycles = get_member_cycles(member.id)
|
||||
|
||||
# Should have June 2024 cycle
|
||||
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-06-01] end)
|
||||
end
|
||||
|
||||
test "current cycle is generated (quarterly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :quarterly})
|
||||
|
||||
today = ~D[2024-05-15]
|
||||
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: today,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-04-01]
|
||||
})
|
||||
|
||||
# Check all cycles (including auto-generated ones)
|
||||
cycles = get_member_cycles(member.id)
|
||||
|
||||
# Should have Q2 2024 cycle
|
||||
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-04-01] end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "member left yesterday" do
|
||||
test "no future cycles are generated" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
yesterday = Date.add(today, -1)
|
||||
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
exit_date: yesterday,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# 2024 should be included because the member was still active during that cycle
|
||||
assert 2022 in cycle_years
|
||||
assert 2023 in cycle_years
|
||||
assert 2024 in cycle_years
|
||||
|
||||
# 2025 should NOT be included
|
||||
refute 2025 in cycle_years
|
||||
end
|
||||
|
||||
test "exit during first month of year stops at that year (monthly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2024-01-15],
|
||||
exit_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
})
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycle_months = Enum.map(cycles, & &1.cycle_start.month) |> Enum.sort()
|
||||
|
||||
assert 1 in cycle_months
|
||||
assert 2 in cycle_months
|
||||
assert 3 in cycle_months
|
||||
|
||||
# April and beyond should NOT be included
|
||||
refute 4 in cycle_months
|
||||
refute 5 in cycle_months
|
||||
end
|
||||
end
|
||||
|
||||
describe "member has no cycles initially" do
|
||||
test "returns error when fee type is not assigned" do
|
||||
setup_settings(true)
|
||||
|
||||
# Create member WITHOUT fee type (no auto-generation)
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
|
||||
# Verify no cycles exist initially
|
||||
initial_cycles = get_member_cycles(member.id)
|
||||
assert initial_cycles == []
|
||||
|
||||
# Trying to generate cycles without fee type should return error
|
||||
result = CycleGenerator.generate_cycles_for_member(member.id)
|
||||
assert result == {:error, :no_membership_fee_type}
|
||||
end
|
||||
|
||||
test "generates all cycles when member is created with fee type" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create member WITH fee type - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
|
||||
# Should have generated all cycles from 2022 to current year
|
||||
assert length(cycles) >= 3
|
||||
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
assert 2022 in cycle_years
|
||||
assert 2023 in cycle_years
|
||||
assert 2024 in cycle_years
|
||||
end
|
||||
end
|
||||
|
||||
describe "member has existing cycles" do
|
||||
test "generates from last cycle (not duplicating existing)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create member WITHOUT fee type first
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
|
||||
# Manually create an existing cycle for 2022
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
cycle_start: ~D[2022-01-01],
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
amount: fee_type.amount,
|
||||
status: :paid
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Now assign fee type - this will trigger cycle generation
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Check all cycles
|
||||
all_cycles = get_member_cycles(member.id)
|
||||
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
|
||||
|
||||
# Should have 2022 (manually created), 2023 and 2024 (auto-generated)
|
||||
assert 2022 in all_cycle_years
|
||||
assert 2023 in all_cycle_years
|
||||
assert 2024 in all_cycle_years
|
||||
|
||||
# Verify no duplicates
|
||||
assert length(all_cycles) == length(all_cycle_years)
|
||||
end
|
||||
end
|
||||
|
||||
describe "year boundary handling" do
|
||||
test "cycles span across year boundaries correctly (yearly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2023-11-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2023-01-01]
|
||||
})
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# Should have 2023 and 2024 (at least, depending on current date)
|
||||
assert 2023 in cycle_years
|
||||
assert 2024 in cycle_years
|
||||
end
|
||||
|
||||
test "cycles span across year boundaries correctly (quarterly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :quarterly})
|
||||
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2024-10-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-10-01]
|
||||
})
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
|
||||
|
||||
# Should have Q4 2024
|
||||
assert ~D[2024-10-01] in cycle_starts
|
||||
end
|
||||
|
||||
test "December to January transition (monthly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2024-12-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-12-01]
|
||||
})
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
|
||||
|
||||
# Should have Dec 2024
|
||||
assert ~D[2024-12-01] in cycle_starts
|
||||
end
|
||||
end
|
||||
|
||||
describe "leap year handling" do
|
||||
test "February cycles in leap year" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
|
||||
# 2024 is a leap year
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2024-02-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-02-01]
|
||||
})
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
|
||||
# Should have February 2024 cycle
|
||||
feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-02-01] end)
|
||||
|
||||
assert feb_cycle != nil
|
||||
end
|
||||
|
||||
test "February cycles in non-leap year" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
|
||||
# 2023 is NOT a leap year
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2023-02-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2023-02-01]
|
||||
})
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
|
||||
# Should have February 2023 cycle
|
||||
feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2023-02-01] end)
|
||||
|
||||
assert feb_cycle != nil
|
||||
end
|
||||
|
||||
test "yearly cycle in leap year" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2024-02-29],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
})
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
|
||||
# Should have 2024 cycle
|
||||
cycle_2024 = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-01-01] end)
|
||||
|
||||
assert cycle_2024 != nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "include_joining_cycle variations" do
|
||||
test "include_joining_cycle = true starts from joining cycle" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Member joins mid-2023, should get 2023 cycle with include_joining_cycle=true
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2023-06-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
# membership_fee_start_date will be auto-calculated
|
||||
})
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# Should include 2023 (joining year)
|
||||
assert 2023 in cycle_years
|
||||
end
|
||||
|
||||
test "include_joining_cycle = false starts from next cycle" do
|
||||
setup_settings(false)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Member joins mid-2023, should start from 2024 with include_joining_cycle=false
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2023-06-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
# membership_fee_start_date will be auto-calculated
|
||||
})
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# Should NOT include 2023 (joining year)
|
||||
refute 2023 in cycle_years
|
||||
|
||||
# Should start from 2024
|
||||
assert 2024 in cycle_years
|
||||
end
|
||||
end
|
||||
end
|
||||
368
test/mv/membership_fees/cycle_generator_test.exs
Normal file
368
test/mv/membership_fees/cycle_generator_test.exs
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||
@moduledoc """
|
||||
Tests for the CycleGenerator module.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership.Member
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a member without triggering cycle generation
|
||||
defp create_member_without_cycles(attrs) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to set up settings with specific include_joining_cycle value
|
||||
defp setup_settings(include_joining_cycle) do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
||||
|> Ash.update!()
|
||||
end
|
||||
|
||||
describe "generate_cycles_for_member/2" do
|
||||
test "generates cycles from start date to today" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
# Wait a moment for async task to complete or skip it
|
||||
Process.sleep(100)
|
||||
|
||||
# Generate cycles with specific "today" date
|
||||
today = ~D[2024-06-15]
|
||||
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# With include_joining_cycle=true and join_date=2022-03-15,
|
||||
# start_date should be 2022-01-01
|
||||
# Should generate cycles for 2022, 2023, 2024
|
||||
_cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# May already have some cycles from the async trigger, so check we have at least 3
|
||||
assert length(cycles) >= 0
|
||||
end
|
||||
|
||||
test "generates cycles from last existing cycle" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create member without fee type first to avoid auto-generation
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
|
||||
# Manually create a cycle for 2022
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
cycle_start: ~D[2022-01-01],
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
amount: fee_type.amount,
|
||||
status: :paid
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Now assign fee type to member
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Generate cycles with specific "today" date
|
||||
today = ~D[2024-06-15]
|
||||
{:ok, new_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# Should generate only 2023 and 2024 (2022 already exists)
|
||||
new_cycle_years = Enum.map(new_cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
assert 2022 not in new_cycle_years
|
||||
end
|
||||
|
||||
test "respects left_at boundary (stops generation)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
exit_date: ~D[2023-06-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
|
||||
Process.sleep(100)
|
||||
|
||||
# Generate cycles with specific "today" date far in the future
|
||||
today = ~D[2025-06-15]
|
||||
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# With exit_date in 2023, should only generate 2022 and 2023 cycles
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# Should not have 2024 or 2025 cycles
|
||||
assert 2024 not in cycle_years
|
||||
assert 2025 not in cycle_years
|
||||
end
|
||||
|
||||
test "skips existing cycles (idempotent)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2023-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2023-01-01]
|
||||
})
|
||||
|
||||
Process.sleep(100)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
# First generation
|
||||
{:ok, _first_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# Second generation (should be idempotent)
|
||||
{:ok, second_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# Second call should return empty list (no new cycles)
|
||||
assert second_cycles == []
|
||||
end
|
||||
|
||||
test "sets correct amount from membership fee type" do
|
||||
setup_settings(true)
|
||||
amount = Decimal.new("75.50")
|
||||
fee_type = create_fee_type(%{interval: :yearly, amount: amount})
|
||||
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
})
|
||||
|
||||
Process.sleep(100)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# All cycles should have the correct amount
|
||||
Enum.each(cycles, fn cycle ->
|
||||
assert Decimal.equal?(cycle.amount, amount)
|
||||
end)
|
||||
end
|
||||
|
||||
test "handles NULL membership_fee_start_date by calculating from join_date" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :quarterly})
|
||||
|
||||
# Create member without membership_fee_start_date
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2024-02-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
# No membership_fee_start_date - should be calculated
|
||||
})
|
||||
|
||||
Process.sleep(100)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# With include_joining_cycle=true and join_date=2024-02-15 (quarterly),
|
||||
# start_date should be 2024-01-01
|
||||
# Should have Q1 and Q2 2024 cycles
|
||||
unless Enum.empty?(cycles) do
|
||||
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
|
||||
first_cycle_start = List.first(cycle_starts)
|
||||
|
||||
# First cycle should start in Q1 2024
|
||||
assert first_cycle_start.year == 2024
|
||||
assert first_cycle_start.month in [1, 4]
|
||||
end
|
||||
end
|
||||
|
||||
test "returns error when member has no membership_fee_type" do
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2024-03-15]
|
||||
# No membership_fee_type_id
|
||||
})
|
||||
|
||||
Process.sleep(100)
|
||||
|
||||
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
|
||||
assert reason == :no_membership_fee_type
|
||||
end
|
||||
|
||||
test "returns error when member has no join_date" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
membership_fee_type_id: fee_type.id
|
||||
# No join_date
|
||||
})
|
||||
|
||||
Process.sleep(100)
|
||||
|
||||
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
|
||||
assert reason == :no_join_date
|
||||
end
|
||||
|
||||
test "returns error when member not found" do
|
||||
fake_id = Ash.UUID.generate()
|
||||
{:error, reason} = CycleGenerator.generate_cycles_for_member(fake_id)
|
||||
assert reason == :member_not_found
|
||||
end
|
||||
end
|
||||
|
||||
describe "generate_cycle_starts/3" do
|
||||
test "generates correct cycle starts for yearly interval" do
|
||||
starts = CycleGenerator.generate_cycle_starts(~D[2022-01-01], ~D[2024-06-15], :yearly)
|
||||
|
||||
assert starts == [~D[2022-01-01], ~D[2023-01-01], ~D[2024-01-01]]
|
||||
end
|
||||
|
||||
test "generates correct cycle starts for quarterly interval" do
|
||||
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-09-15], :quarterly)
|
||||
|
||||
assert starts == [~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01]]
|
||||
end
|
||||
|
||||
test "generates correct cycle starts for monthly interval" do
|
||||
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-03-15], :monthly)
|
||||
|
||||
assert starts == [~D[2024-01-01], ~D[2024-02-01], ~D[2024-03-01]]
|
||||
end
|
||||
|
||||
test "generates correct cycle starts for half_yearly interval" do
|
||||
starts = CycleGenerator.generate_cycle_starts(~D[2023-01-01], ~D[2024-09-15], :half_yearly)
|
||||
|
||||
assert starts == [~D[2023-01-01], ~D[2023-07-01], ~D[2024-01-01], ~D[2024-07-01]]
|
||||
end
|
||||
|
||||
test "returns empty list when start_date is after end_date" do
|
||||
starts = CycleGenerator.generate_cycle_starts(~D[2025-01-01], ~D[2024-06-15], :yearly)
|
||||
|
||||
assert starts == []
|
||||
end
|
||||
|
||||
test "includes cycle when end_date is on cycle start" do
|
||||
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-01-01], :yearly)
|
||||
|
||||
assert starts == [~D[2024-01-01]]
|
||||
end
|
||||
end
|
||||
|
||||
describe "generate_cycles_for_all_members/1" do
|
||||
test "generates cycles for multiple members" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create multiple members
|
||||
_member1 =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2024-01-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
})
|
||||
|
||||
_member2 =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2024-02-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
})
|
||||
|
||||
Process.sleep(200)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
{:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today)
|
||||
|
||||
assert is_map(results)
|
||||
assert Map.has_key?(results, :success)
|
||||
assert Map.has_key?(results, :failed)
|
||||
assert Map.has_key?(results, :total)
|
||||
end
|
||||
end
|
||||
|
||||
describe "lock mechanism" do
|
||||
test "prevents concurrent generation for same member" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
|
||||
Process.sleep(100)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
# Run two concurrent generations
|
||||
task1 =
|
||||
Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end)
|
||||
|
||||
task2 =
|
||||
Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end)
|
||||
|
||||
result1 = Task.await(task1)
|
||||
result2 = Task.await(task2)
|
||||
|
||||
# Both should succeed
|
||||
assert match?({:ok, _}, result1)
|
||||
assert match?({:ok, _}, result2)
|
||||
|
||||
# One should have created cycles, the other should have empty list (idempotent)
|
||||
{:ok, cycles1} = result1
|
||||
{:ok, cycles2} = result2
|
||||
|
||||
# Combined should not have duplicates
|
||||
all_cycles = cycles1 ++ cycles2
|
||||
unique_starts = all_cycles |> Enum.map(& &1.cycle_start) |> Enum.uniq()
|
||||
|
||||
assert length(all_cycles) == length(unique_starts)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue