Cycle Generation System closes #277 #290
17 changed files with 3076 additions and 29 deletions
|
|
@ -47,4 +47,5 @@ config :mv, :session_identifier, :unsafe
|
|||
config :mv, :require_token_presence_for_authentication, false
|
||||
|
||||
# Enable SQL Sandbox for async LiveView tests
|
||||
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
|
||||
config :mv, :sql_sandbox, true
|
||||
|
|
|
|||
|
|
@ -153,8 +153,8 @@ lib/
|
|||
|
||||
**Existing Fields Used:**
|
||||
|
||||
- `joined_at` - For calculating membership fee start
|
||||
- `left_at` - For limiting cycle generation
|
||||
- `join_date` - For calculating membership fee start
|
||||
- `exit_date` - For limiting cycle generation
|
||||
- These fields must remain member fields and should not be replaced by custom fields in the future
|
||||
|
||||
### Settings Integration
|
||||
|
|
@ -186,8 +186,9 @@ lib/
|
|||
|
||||
- Calculate which cycles should exist for a member
|
||||
- Generate missing cycles
|
||||
- Respect membership_fee_start_date and left_at boundaries
|
||||
- Respect membership_fee_start_date and exit_date boundaries
|
||||
- Skip existing cycles (idempotent)
|
||||
- Use PostgreSQL advisory locks per member to prevent race conditions
|
||||
|
||||
**Triggers:**
|
||||
|
||||
|
|
@ -199,17 +200,20 @@ lib/
|
|||
**Algorithm Steps:**
|
||||
|
||||
1. Retrieve member with membership fee type and dates
|
||||
2. Determine first cycle start (based on membership_fee_start_date)
|
||||
3. Calculate all cycle starts from first to today (or left_at)
|
||||
4. Query existing cycles for member
|
||||
5. Generate missing cycles with current membership fee type's amount
|
||||
6. Insert new cycles (batch operation)
|
||||
2. Determine generation start point:
|
||||
- If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
|
||||
- If cycles exist: Start from the cycle AFTER the last existing one
|
||||
3. Generate all cycle starts from the determined start point to today (or `exit_date`)
|
||||
4. Create new cycles with current membership fee type's amount
|
||||
5. Use PostgreSQL advisory locks per member to prevent race conditions
|
||||
|
||||
**Edge Case Handling:**
|
||||
|
||||
- If membership_fee_start_date is NULL: Calculate from joined_at + global setting
|
||||
- If left_at is set: Stop generation at left_at
|
||||
- If membership_fee_start_date is NULL: Calculate from join_date + global setting
|
||||
- If exit_date is set: Stop generation at exit_date
|
||||
- If membership fee type changes: Handled separately by regeneration logic
|
||||
- **Gap Handling:** If cycles were explicitly deleted (gaps exist), they are NOT recreated.
|
||||
The generator always continues from the cycle AFTER the last existing cycle, regardless of gaps.
|
||||
|
||||
### Calendar Cycle Calculations
|
||||
|
||||
|
|
@ -381,7 +385,7 @@ lib/
|
|||
**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default)
|
||||
**AC-M-2:** Member has membership_fee_start_date field (nullable)
|
||||
**AC-M-3:** New members get default membership fee type from global setting
|
||||
**AC-M-4:** membership_fee_start_date auto-set based on joined_at and global setting
|
||||
**AC-M-4:** membership_fee_start_date auto-set based on join_date and global setting
|
||||
**AC-M-5:** Admin can manually override membership_fee_start_date
|
||||
**AC-M-6:** Cannot change to membership fee type with different interval (MVP)
|
||||
|
||||
|
|
@ -391,7 +395,7 @@ lib/
|
|||
**AC-CG-2:** Cycles generated when member created (via change hook)
|
||||
**AC-CG-3:** Scheduled job generates missing cycles daily
|
||||
**AC-CG-4:** Generation respects membership_fee_start_date
|
||||
**AC-CG-5:** Generation stops at left_at if member exited
|
||||
**AC-CG-5:** Generation stops at exit_date if member exited
|
||||
**AC-CG-6:** Generation is idempotent (skips existing cycles)
|
||||
**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year)
|
||||
**AC-CG-8:** Amount comes from membership_fee_type at generation time
|
||||
|
|
@ -472,8 +476,9 @@ lib/
|
|||
- Correct cycle_start calculation for all interval types
|
||||
- Correct cycle count from start to end date
|
||||
- Respects membership_fee_start_date boundary
|
||||
- Respects left_at boundary
|
||||
- Respects exit_date boundary
|
||||
- Skips existing cycles (idempotent)
|
||||
- Does not fill gaps when cycles were deleted
|
||||
- Handles edge dates (year boundaries, leap years)
|
||||
|
||||
**Calendar Cycles Tests:**
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ This document provides a comprehensive overview of the Membership Fees system. I
|
|||
```
|
||||
- membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings)
|
||||
- membership_fee_start_date (Date, nullable) - When to start generating membership fees
|
||||
- left_at (Date, nullable) - Exit date (existing)
|
||||
- exit_date (Date, nullable) - Exit date (existing)
|
||||
```
|
||||
|
||||
**Logic for membership_fee_start_date:**
|
||||
|
|
@ -167,16 +167,17 @@ value: UUID (Required) - Default membership fee type for new members
|
|||
|
||||
**Algorithm:**
|
||||
|
||||
Lock the whole cycle table for the duration of the algorithm
|
||||
Use PostgreSQL advisory locks per member to prevent race conditions
|
||||
|
||||
1. Get `member.membership_fee_start_date` and member's membership fee type
|
||||
2. Generate cycles until today (or `left_at` if present):
|
||||
- If no cycle exists:
|
||||
- Generate all cycles from `membership_fee_start_date`
|
||||
- else:
|
||||
- Generate all cycles from last existing cycle
|
||||
- use the interval to generate the cycles
|
||||
3. Set `amount` to current membership fee type's amount
|
||||
2. Determine generation start point:
|
||||
- If NO cycles exist: Start from `membership_fee_start_date`
|
||||
- If cycles exist: Start from the cycle AFTER the last existing one
|
||||
3. Generate cycles until today (or `exit_date` if present):
|
||||
- Use the interval to generate the cycles
|
||||
- **Note:** If cycles were explicitly deleted (gaps exist), they are NOT recreated.
|
||||
The generator always continues from the cycle AFTER the last existing cycle.
|
||||
4. Set `amount` to current membership fee type's amount
|
||||
|
||||
**Example (Yearly):**
|
||||
|
||||
|
|
@ -246,7 +247,7 @@ suspended → unpaid
|
|||
|
||||
**Logic:**
|
||||
|
||||
- Cycles only generated until `member.left_at`
|
||||
- Cycles only generated until `member.exit_date`
|
||||
- Existing cycles remain visible
|
||||
- Unpaid exit cycle can be marked as "suspended"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,42 @@ 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
|
||||
generate_fn = fn ->
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
{:ok, _cycles} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
require Logger
|
||||
|
||||
Logger.warning(
|
||||
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if Application.get_env(:mv, :sql_sandbox, false) do
|
||||
# Run synchronously in test environment for DB sandbox compatibility
|
||||
generate_fn.()
|
||||
else
|
||||
# Run asynchronously in other environments
|
||||
Task.start(generate_fn)
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, member}
|
||||
end)
|
||||
end
|
||||
|
||||
update :update_member do
|
||||
|
|
@ -114,7 +150,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 +177,46 @@ 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
|
||||
generate_fn = fn ->
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
{:ok, _cycles} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
require Logger
|
||||
|
||||
Logger.warning(
|
||||
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if Application.get_env(:mv, :sql_sandbox, false) do
|
||||
# Run synchronously in test environment for DB sandbox compatibility
|
||||
generate_fn.()
|
||||
else
|
||||
# Run asynchronously in other environments
|
||||
Task.start(generate_fn)
|
||||
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
|
||||
|
|
|
|||
174
lib/membership_fees/changes/set_membership_fee_start_date.ex
Normal file
174
lib/membership_fees/changes/set_membership_fee_start_date.ex
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
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
|
||||
end
|
||||
|
||||
The change module handles all prerequisite checks internally (join_date, membership_fee_type_id).
|
||||
If any required data is missing, the changeset is returned unchanged with a warning logged.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
require Logger
|
||||
|
||||
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, :join_date_not_set} ->
|
||||
# Missing join_date is expected for partial creates
|
||||
changeset
|
||||
|
||||
{:error, :membership_fee_type_not_set} ->
|
||||
# Missing membership_fee_type_id is expected for partial creates
|
||||
changeset
|
||||
|
||||
{:error, :membership_fee_type_not_found} ->
|
||||
# This is a data integrity error - membership_fee_type_id references non-existent type
|
||||
# Return changeset error to fail the action
|
||||
Ash.Changeset.add_error(
|
||||
changeset,
|
||||
field: :membership_fee_type_id,
|
||||
message: "not found"
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
# Log warning for other unexpected errors
|
||||
Logger.warning("Could not auto-set membership_fee_start_date: #{inspect(reason)}")
|
||||
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(), CalendarCycles.interval(), 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
|
||||
390
lib/mv/membership_fees/cycle_generator.ex
Normal file
390
lib/mv/membership_fees/cycle_generator.ex
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
defmodule Mv.MembershipFees.CycleGenerator do
|
||||
@moduledoc """
|
||||
Module for generating membership fee cycles for members.
|
||||
|
||||
This module provides functions to automatically generate membership fee cycles
|
||||
based on a member's fee type, start date, and exit date.
|
||||
|
||||
## Algorithm
|
||||
|
||||
1. Load member with relationships (membership_fee_type, membership_fee_cycles)
|
||||
2. Determine the generation start point:
|
||||
- If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
|
||||
- If cycles exist: Start from the cycle AFTER the last existing one
|
||||
3. Generate all cycle starts from the determined start point to today (or `exit_date`)
|
||||
4. Create new cycles with the current amount from `membership_fee_type`
|
||||
|
||||
## Important: Gap Handling
|
||||
|
||||
**Gaps are NOT filled.** If a cycle was explicitly deleted (e.g., 2023 was deleted
|
||||
but 2022 and 2024 exist), the generator will NOT recreate the deleted cycle.
|
||||
It always continues from the LAST existing cycle, regardless of any gaps.
|
||||
|
||||
This behavior ensures that manually deleted cycles remain deleted and prevents
|
||||
unwanted automatic recreation of intentionally removed cycles.
|
||||
|
||||
## Concurrency
|
||||
|
||||
Uses PostgreSQL advisory locks to prevent race conditions when generating
|
||||
cycles for the same member concurrently.
|
||||
|
||||
## Examples
|
||||
|
||||
# Generate cycles for a single member
|
||||
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member)
|
||||
|
||||
# Generate cycles for all active members
|
||||
{:ok, results} = CycleGenerator.generate_cycles_for_all_members()
|
||||
|
||||
"""
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Repo
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@type generate_result :: {:ok, [MembershipFeeCycle.t()]} | {:error, term()}
|
||||
|
||||
@doc """
|
||||
Generates membership fee cycles for a single member.
|
||||
|
||||
Uses an advisory lock to prevent concurrent generation for the same member.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `member` - The member struct or member ID
|
||||
- `opts` - Options:
|
||||
- `:today` - Override today's date (useful for testing)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, cycles}` - List of newly created cycles
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
## Examples
|
||||
|
||||
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member)
|
||||
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member_id)
|
||||
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member, today: ~D[2024-12-31])
|
||||
|
||||
"""
|
||||
@spec generate_cycles_for_member(Member.t() | String.t(), keyword()) :: generate_result()
|
||||
def generate_cycles_for_member(member_or_id, opts \\ [])
|
||||
|
||||
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
|
||||
case load_member(member_id) do
|
||||
{:ok, member} -> generate_cycles_for_member(member, opts)
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def generate_cycles_for_member(%Member{} = member, opts) do
|
||||
today = Keyword.get(opts, :today, Date.utc_today())
|
||||
|
||||
# Use advisory lock to prevent concurrent generation
|
||||
# Notifications are handled inside with_advisory_lock after transaction commits
|
||||
with_advisory_lock(member.id, fn ->
|
||||
do_generate_cycles(member, today)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates membership fee cycles for all members with a fee type assigned.
|
||||
|
||||
This includes both active and inactive (left) members. Inactive members
|
||||
will have cycles generated up to their exit_date if they don't have cycles
|
||||
for that period yet. This allows for catch-up generation of missing cycles.
|
||||
|
||||
Members processed are those who:
|
||||
- Have a membership_fee_type assigned
|
||||
- Have a join_date set
|
||||
|
||||
The exit_date boundary is respected during generation (not in the query),
|
||||
so inactive members will get cycles up to their exit date.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `opts` - Options:
|
||||
- `:today` - Override today's date (useful for testing)
|
||||
- `:batch_size` - Number of members to process in parallel (default: 10)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, results}` - Map with :success and :failed counts
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
"""
|
||||
@spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def generate_cycles_for_all_members(opts \\ []) do
|
||||
today = Keyword.get(opts, :today, Date.utc_today())
|
||||
batch_size = Keyword.get(opts, :batch_size, 10)
|
||||
|
||||
# Query ALL members with fee type assigned (including inactive/left members)
|
||||
# The exit_date boundary is applied during cycle generation, not here.
|
||||
# This allows catch-up generation for members who left but are missing cycles.
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|
||||
|> Ash.Query.filter(not is_nil(join_date))
|
||||
|
||||
case Ash.read(query) do
|
||||
{:ok, members} ->
|
||||
results = process_members_in_batches(members, batch_size, today)
|
||||
{:ok, build_results_summary(results)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp process_members_in_batches(members, batch_size, today) do
|
||||
members
|
||||
|> Enum.chunk_every(batch_size)
|
||||
|> Enum.flat_map(&process_batch(&1, today))
|
||||
end
|
||||
|
||||
defp process_batch(batch, today) do
|
||||
batch
|
||||
|> Task.async_stream(fn member ->
|
||||
{member.id, generate_cycles_for_member(member, today: today)}
|
||||
end)
|
||||
|> Enum.map(fn
|
||||
{:ok, result} ->
|
||||
result
|
||||
|
||||
{:exit, reason} ->
|
||||
# Task crashed - log and return error tuple
|
||||
Logger.error("Task crashed during cycle generation: #{inspect(reason)}")
|
||||
{nil, {:error, {:task_exit, reason}}}
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_results_summary(results) do
|
||||
success_count = Enum.count(results, fn {_id, result} -> match?({:ok, _}, result) end)
|
||||
failed_count = Enum.count(results, fn {_id, result} -> match?({:error, _}, result) end)
|
||||
|
||||
%{success: success_count, failed: failed_count, total: length(results)}
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp load_member(member_id) do
|
||||
Member
|
||||
|> Ash.Query.filter(id == ^member_id)
|
||||
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|
||||
|> Ash.read_one()
|
||||
|> case do
|
||||
{:ok, nil} -> {:error, :member_not_found}
|
||||
{:ok, member} -> {:ok, member}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp with_advisory_lock(member_id, fun) do
|
||||
# Convert UUID to integer for advisory lock (use hash)
|
||||
lock_key = :erlang.phash2(member_id)
|
||||
|
||||
result =
|
||||
Repo.transaction(fn ->
|
||||
# Acquire advisory lock for this transaction
|
||||
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||
|
||||
case fun.() do
|
||||
{:ok, result, notifications} when is_list(notifications) ->
|
||||
# Return result and notifications separately
|
||||
{result, notifications}
|
||||
|
||||
{:ok, result} ->
|
||||
# Handle case where no notifications were returned (backward compatibility)
|
||||
{result, []}
|
||||
|
||||
{:error, reason} ->
|
||||
Repo.rollback(reason)
|
||||
end
|
||||
end)
|
||||
|
||||
# Extract result and notifications, send notifications after transaction
|
||||
case result do
|
||||
{:ok, {cycles, notifications}} ->
|
||||
if Enum.any?(notifications) do
|
||||
Ash.Notifier.notify(notifications)
|
||||
end
|
||||
|
||||
{:ok, cycles}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_generate_cycles(member, today) do
|
||||
# Reload member with relationships to ensure fresh data
|
||||
case load_member(member.id) do
|
||||
{:ok, member} ->
|
||||
cond do
|
||||
is_nil(member.membership_fee_type_id) ->
|
||||
{:error, :no_membership_fee_type}
|
||||
|
||||
is_nil(member.join_date) ->
|
||||
{:error, :no_join_date}
|
||||
|
||||
true ->
|
||||
generate_missing_cycles(member, today)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_missing_cycles(member, today) do
|
||||
fee_type = member.membership_fee_type
|
||||
interval = fee_type.interval
|
||||
amount = fee_type.amount
|
||||
existing_cycles = member.membership_fee_cycles || []
|
||||
|
||||
# Determine start point based on existing cycles
|
||||
# Note: We do NOT fill gaps - only generate from the last existing cycle onwards
|
||||
start_date = determine_generation_start(member, existing_cycles, interval)
|
||||
|
||||
# Determine end date (today or exit_date, whichever is earlier)
|
||||
end_date = determine_end_date(member, today)
|
||||
|
||||
# Only generate if start_date <= end_date
|
||||
if start_date && Date.compare(start_date, end_date) != :gt do
|
||||
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
||||
create_cycles(cycle_starts, member.id, fee_type.id, amount)
|
||||
else
|
||||
{:ok, [], []}
|
||||
end
|
||||
end
|
||||
|
||||
# No existing cycles: start from membership_fee_start_date
|
||||
defp determine_generation_start(member, [], interval) do
|
||||
determine_start_date(member, interval)
|
||||
end
|
||||
|
||||
# Has existing cycles: start from the cycle AFTER the last one
|
||||
# This ensures gaps (deleted cycles) are NOT filled
|
||||
defp determine_generation_start(_member, existing_cycles, interval) do
|
||||
last_cycle_start =
|
||||
existing_cycles
|
||||
|> Enum.map(& &1.cycle_start)
|
||||
|> Enum.max(Date)
|
||||
|
||||
CalendarCycles.next_cycle_start(last_cycle_start, interval)
|
||||
end
|
||||
|
||||
defp determine_start_date(member, interval) do
|
||||
if member.membership_fee_start_date do
|
||||
member.membership_fee_start_date
|
||||
else
|
||||
# Calculate from join_date using global settings
|
||||
include_joining_cycle = get_include_joining_cycle()
|
||||
|
||||
SetMembershipFeeStartDate.calculate_start_date(
|
||||
member.join_date,
|
||||
interval,
|
||||
include_joining_cycle
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp determine_end_date(member, today) do
|
||||
if member.exit_date && Date.compare(member.exit_date, today) == :lt do
|
||||
# Member has left - use the exit date as boundary
|
||||
# Note: If exit_date == cycle_start, the cycle IS still generated.
|
||||
# This means the member is considered a member on the first day of that cycle.
|
||||
# Example: exit_date = 2025-01-01, yearly interval
|
||||
# -> The 2025 cycle (starting 2025-01-01) WILL be generated
|
||||
member.exit_date
|
||||
else
|
||||
today
|
||||
end
|
||||
end
|
||||
|
||||
defp get_include_joining_cycle do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, %{include_joining_cycle: include}} -> include
|
||||
{:error, _} -> true
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates all cycle start dates from a start date to an end date.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `start_date` - The first cycle start date
|
||||
- `end_date` - The date up to which cycles should be generated
|
||||
- `interval` - The billing interval
|
||||
|
||||
## Returns
|
||||
|
||||
List of cycle start dates.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> generate_cycle_starts(~D[2024-01-01], ~D[2024-12-31], :quarterly)
|
||||
[~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01], ~D[2024-10-01]]
|
||||
|
||||
"""
|
||||
@spec generate_cycle_starts(Date.t(), Date.t(), atom()) :: [Date.t()]
|
||||
def generate_cycle_starts(start_date, end_date, interval) do
|
||||
# Ensure start_date is aligned to cycle boundary
|
||||
aligned_start = CalendarCycles.calculate_cycle_start(start_date, interval)
|
||||
|
||||
generate_cycle_starts_acc(aligned_start, end_date, interval, [])
|
||||
end
|
||||
|
||||
defp generate_cycle_starts_acc(current_start, end_date, interval, acc) do
|
||||
if Date.compare(current_start, end_date) == :gt do
|
||||
# Current cycle start is after end date - stop
|
||||
Enum.reverse(acc)
|
||||
else
|
||||
# Include this cycle and continue to next
|
||||
next_start = CalendarCycles.next_cycle_start(current_start, interval)
|
||||
generate_cycle_starts_acc(next_start, end_date, interval, [current_start | acc])
|
||||
end
|
||||
end
|
||||
|
||||
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
|
||||
results =
|
||||
Enum.map(cycle_starts, fn cycle_start ->
|
||||
attrs = %{
|
||||
cycle_start: cycle_start,
|
||||
member_id: member_id,
|
||||
membership_fee_type_id: fee_type_id,
|
||||
amount: amount,
|
||||
status: :unpaid
|
||||
}
|
||||
|
||||
# Return notifications to avoid warnings when creating within a transaction
|
||||
case Ash.create(MembershipFeeCycle, attrs, return_notifications?: true) do
|
||||
{:ok, cycle, notifications} -> {:ok, cycle, notifications}
|
||||
{:error, reason} -> {:error, {cycle_start, reason}}
|
||||
end
|
||||
end)
|
||||
|
||||
{successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1))
|
||||
|
||||
all_notifications =
|
||||
Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end)
|
||||
|
||||
if Enum.empty?(errors) do
|
||||
successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end)
|
||||
# Return cycles and notifications to be sent after transaction commits
|
||||
{:ok, successful_cycles, all_notifications}
|
||||
else
|
||||
Logger.warning("Some cycles failed to create: #{inspect(errors)}")
|
||||
# Return partial failure with errors
|
||||
# Note: When this error occurs, the transaction will be rolled back,
|
||||
# so no cycles were actually persisted in the database
|
||||
{:error, {:partial_failure, errors}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
211
test/membership_fees/member_cycle_integration_test.exs
Normal file
211
test/membership_fees/member_cycle_integration_test.exs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
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!()
|
||||
|
||||
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!()
|
||||
|
||||
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!()
|
||||
|
||||
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!()
|
||||
|
||||
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)
|
||||
|
||||
# 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!()
|
||||
|
||||
initial_cycles = get_member_cycles(member.id)
|
||||
initial_count = length(initial_cycles)
|
||||
|
||||
# Use a fixed "today" date to avoid date dependency
|
||||
# Use a date far enough in the future to ensure all cycles are generated
|
||||
today = ~D[2025-12-31]
|
||||
|
||||
# Manually trigger generation again with fixed "today" date
|
||||
{:ok, _} =
|
||||
Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
final_cycles = get_member_cycles(member.id)
|
||||
final_count = length(final_cycles)
|
||||
|
||||
# Should have same number of cycles (idempotent)
|
||||
assert final_count == initial_count
|
||||
end
|
||||
end
|
||||
end
|
||||
644
test/mv/membership_fees/cycle_generator_edge_cases_test.exs
Normal file
644
test/mv/membership_fees/cycle_generator_edge_cases_test.exs
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
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 create a member and explicitly generate cycles with a fixed "today" date.
|
||||
# This avoids date dependency issues in tests.
|
||||
#
|
||||
# Note: We first create the member without fee_type_id, then assign it via update,
|
||||
# which triggers the after_action hook. However, we then explicitly regenerate
|
||||
# cycles with the fixed "today" date to ensure consistency.
|
||||
defp create_member_with_cycles(attrs, today) do
|
||||
# Extract membership_fee_type_id if present
|
||||
fee_type_id = Map.get(attrs, :membership_fee_type_id)
|
||||
|
||||
# Create member WITHOUT fee type first to avoid auto-generation with real today
|
||||
attrs_without_fee_type = Map.delete(attrs, :membership_fee_type_id)
|
||||
|
||||
member =
|
||||
create_member(attrs_without_fee_type)
|
||||
|
||||
# Assign fee type if provided (this will trigger auto-generation with real today)
|
||||
member =
|
||||
if fee_type_id do
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type_id})
|
||||
|> Ash.update!()
|
||||
else
|
||||
member
|
||||
end
|
||||
|
||||
# Explicitly regenerate cycles with fixed "today" date to override any auto-generated cycles
|
||||
# This ensures the test uses the fixed date, not the real current date
|
||||
if fee_type_id && member.join_date do
|
||||
# Delete any existing cycles first to ensure clean state
|
||||
existing_cycles = get_member_cycles(member.id)
|
||||
Enum.each(existing_cycles, &Ash.destroy!(&1))
|
||||
|
||||
# Generate cycles with fixed "today" date
|
||||
{:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
end
|
||||
|
||||
member
|
||||
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 WITHOUT fee type first to avoid auto-generation with real today
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: today,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
})
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Explicitly generate cycles with fixed "today" date
|
||||
{:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# Check all cycles
|
||||
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 WITHOUT fee type first to avoid auto-generation with real today
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: today,
|
||||
membership_fee_start_date: ~D[2024-06-01]
|
||||
})
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Explicitly generate cycles with fixed "today" date
|
||||
{:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# Check all cycles
|
||||
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 and generate cycles with fixed "today" date
|
||||
member =
|
||||
create_member_with_cycles(
|
||||
%{
|
||||
join_date: today,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-04-01]
|
||||
},
|
||||
today
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
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 and generate cycles with fixed "today" date
|
||||
member =
|
||||
create_member_with_cycles(
|
||||
%{
|
||||
join_date: ~D[2022-03-15],
|
||||
exit_date: yesterday,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
},
|
||||
today
|
||||
)
|
||||
|
||||
# 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})
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
# Create member and generate cycles with fixed "today" date
|
||||
member =
|
||||
create_member_with_cycles(
|
||||
%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
},
|
||||
today
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
|
||||
# Should have generated all cycles from 2022 to 2024 (3 cycles)
|
||||
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
|
||||
# Should NOT have 2025 (today is 2024-06-15)
|
||||
refute 2025 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
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Explicitly generate cycles with fixed "today" date
|
||||
today = ~D[2024-06-15]
|
||||
{:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# 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})
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
# Create member and generate cycles with fixed "today" date
|
||||
member =
|
||||
create_member_with_cycles(
|
||||
%{
|
||||
join_date: ~D[2023-11-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2023-01-01]
|
||||
},
|
||||
today
|
||||
)
|
||||
|
||||
# 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
|
||||
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})
|
||||
|
||||
today = ~D[2024-12-15]
|
||||
|
||||
# Create member and generate cycles with fixed "today" date
|
||||
member =
|
||||
create_member_with_cycles(
|
||||
%{
|
||||
join_date: ~D[2024-10-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-10-01]
|
||||
},
|
||||
today
|
||||
)
|
||||
|
||||
# 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})
|
||||
|
||||
today = ~D[2024-12-31]
|
||||
|
||||
# Create member and generate cycles with fixed "today" date
|
||||
member =
|
||||
create_member_with_cycles(
|
||||
%{
|
||||
join_date: ~D[2024-12-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-12-01]
|
||||
},
|
||||
today
|
||||
)
|
||||
|
||||
# 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})
|
||||
|
||||
today = ~D[2024-03-15]
|
||||
|
||||
# 2024 is a leap year
|
||||
# Create member and generate cycles with fixed "today" date
|
||||
member =
|
||||
create_member_with_cycles(
|
||||
%{
|
||||
join_date: ~D[2024-02-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-02-01]
|
||||
},
|
||||
today
|
||||
)
|
||||
|
||||
# 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})
|
||||
|
||||
today = ~D[2023-03-15]
|
||||
|
||||
# 2023 is NOT a leap year
|
||||
# Create member and generate cycles with fixed "today" date
|
||||
member =
|
||||
create_member_with_cycles(
|
||||
%{
|
||||
join_date: ~D[2023-02-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2023-02-01]
|
||||
},
|
||||
today
|
||||
)
|
||||
|
||||
# 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})
|
||||
|
||||
today = ~D[2024-12-31]
|
||||
|
||||
# Create member and generate cycles with fixed "today" date
|
||||
member =
|
||||
create_member_with_cycles(
|
||||
%{
|
||||
join_date: ~D[2024-02-29],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
},
|
||||
today
|
||||
)
|
||||
|
||||
# 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})
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
# Member joins mid-2023, should get 2023 cycle with include_joining_cycle=true
|
||||
# Create member and generate cycles with fixed "today" date
|
||||
member =
|
||||
create_member_with_cycles(
|
||||
%{
|
||||
join_date: ~D[2023-06-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
# membership_fee_start_date will be auto-calculated
|
||||
},
|
||||
today
|
||||
)
|
||||
|
||||
# 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})
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
# Member joins mid-2023, should start from 2024 with include_joining_cycle=false
|
||||
# Create member and generate cycles with fixed "today" date
|
||||
member =
|
||||
create_member_with_cycles(
|
||||
%{
|
||||
join_date: ~D[2023-06-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
# membership_fee_start_date will be auto-calculated
|
||||
},
|
||||
today
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
describe "inactive member processing" do
|
||||
test "inactive members receive cycles up to exit_date via generate_cycles_for_all_members" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create an inactive member (left in 2023) WITHOUT fee type initially
|
||||
# This simulates a member that was created before the fee system existed
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2021-03-15],
|
||||
exit_date: ~D[2023-06-15]
|
||||
})
|
||||
|
||||
# Now assign fee type (simulating a retroactive assignment)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2021-01-01]
|
||||
})
|
||||
|> Ash.update!()
|
||||
|
||||
# Run batch generation with a "today" date after the member left
|
||||
today = ~D[2024-06-15]
|
||||
{:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today)
|
||||
|
||||
# The inactive member should have been processed
|
||||
assert results.total >= 1
|
||||
|
||||
# Check the member's cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
|
||||
|
||||
# Should have 2021, 2022, 2023 (exit year included)
|
||||
assert 2021 in cycle_years
|
||||
assert 2022 in cycle_years
|
||||
assert 2023 in cycle_years
|
||||
|
||||
# Should NOT have 2024 (after exit)
|
||||
refute 2024 in cycle_years
|
||||
end
|
||||
|
||||
test "exit_date on cycle_start still generates that cycle" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
today = ~D[2024-12-31]
|
||||
|
||||
# Member exits exactly on cycle start (2024-01-01)
|
||||
# Create member and generate cycles with fixed "today" date
|
||||
member =
|
||||
create_member_with_cycles(
|
||||
%{
|
||||
join_date: ~D[2022-03-15],
|
||||
exit_date: ~D[2024-01-01],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
},
|
||||
today
|
||||
)
|
||||
|
||||
# Check cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# 2024 should be included because exit_date == cycle_start means
|
||||
# the member was still a member on that day
|
||||
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
|
||||
end
|
||||
end
|
||||
428
test/mv/membership_fees/cycle_generator_test.exs
Normal file
428
test/mv/membership_fees/cycle_generator_test.exs
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
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
|
||||
|
||||
# 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 "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})
|
||||
|
||||
# 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]
|
||||
})
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Explicitly generate cycles with fixed "today" date to avoid date dependency
|
||||
today = ~D[2024-06-15]
|
||||
{:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# Verify cycles were generated
|
||||
all_cycles = get_member_cycles(member.id)
|
||||
cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
|
||||
|
||||
# With include_joining_cycle=true and join_date=2022-03-15,
|
||||
# start_date should be 2022-01-01
|
||||
# Should have cycles for 2022, 2023, 2024
|
||||
assert 2022 in cycle_years
|
||||
assert 2023 in cycle_years
|
||||
assert 2024 in cycle_years
|
||||
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]
|
||||
})
|
||||
|
||||
# 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]
|
||||
})
|
||||
|
||||
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 "does not fill gaps when cycles were deleted" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create member without fee type first to control which cycles exist
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2020-03-15],
|
||||
membership_fee_start_date: ~D[2020-01-01]
|
||||
})
|
||||
|
||||
# Manually create cycles for 2020, 2021, 2022, 2023
|
||||
for year <- [2020, 2021, 2022, 2023] do
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
cycle_start: Date.new!(year, 1, 1),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
amount: fee_type.amount,
|
||||
status: :unpaid
|
||||
})
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Delete the 2021 cycle (create a gap)
|
||||
cycle_2021 =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^~D[2021-01-01])
|
||||
|> Ash.read_one!()
|
||||
|
||||
Ash.destroy!(cycle_2021)
|
||||
|
||||
# Now assign fee type to member (this triggers generation)
|
||||
# Since cycles already exist (2020, 2022, 2023), the generator will
|
||||
# start from the last existing cycle (2023) and go forward
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Verify gap was NOT filled and new cycles were generated from last existing
|
||||
all_cycles = get_member_cycles(member.id)
|
||||
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# 2021 should NOT exist (gap was not filled)
|
||||
refute 2021 in all_cycle_years, "Gap at 2021 should NOT be filled"
|
||||
|
||||
# 2020, 2022, 2023 should exist (original cycles)
|
||||
assert 2020 in all_cycle_years
|
||||
assert 2022 in all_cycle_years
|
||||
assert 2023 in all_cycle_years
|
||||
|
||||
# 2024 and 2025 should exist (generated after last existing cycle 2023)
|
||||
assert 2024 in all_cycle_years
|
||||
assert 2025 in all_cycle_years
|
||||
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]
|
||||
})
|
||||
|
||||
# Verify cycles were generated with correct amount
|
||||
all_cycles = get_member_cycles(member.id)
|
||||
refute Enum.empty?(all_cycles), "Expected cycles to be generated"
|
||||
|
||||
# All cycles should have the correct amount
|
||||
Enum.each(all_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 - it will be auto-calculated
|
||||
# and cycles will be auto-generated
|
||||
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
|
||||
})
|
||||
|
||||
# Verify cycles were auto-generated
|
||||
all_cycles = get_member_cycles(member.id)
|
||||
|
||||
# With include_joining_cycle=true and join_date=2024-02-15 (quarterly),
|
||||
# start_date should be 2024-01-01 (Q1 start)
|
||||
# Should have Q1, Q2, Q3, Q4 2024 cycles (based on current date)
|
||||
refute Enum.empty?(all_cycles), "Expected cycles to be generated"
|
||||
|
||||
cycle_starts = Enum.map(all_cycles, & &1.cycle_start) |> Enum.sort(Date)
|
||||
first_cycle_start = List.first(cycle_starts)
|
||||
|
||||
# First cycle should start in Q1 2024 (2024-01-01)
|
||||
assert first_cycle_start == ~D[2024-01-01]
|
||||
end
|
||||
|
||||
test "returns error when member has no membership_fee_type" do
|
||||
# Create member without fee type - no auto-generation will occur
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2024-03-15]
|
||||
# No membership_fee_type_id
|
||||
})
|
||||
|
||||
{: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})
|
||||
|
||||
# Create member without join_date - no auto-generation will occur
|
||||
# (after_action hook checks for join_date)
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
membership_fee_type_id: fee_type.id
|
||||
# No join_date
|
||||
})
|
||||
|
||||
{: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]
|
||||
})
|
||||
|
||||
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]
|
||||
})
|
||||
|
||||
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