Cycle Generation System closes #277 #290
32 changed files with 5062 additions and 34 deletions
|
|
@ -49,7 +49,7 @@ config :spark,
|
|||
config :mv,
|
||||
ecto_repos: [Mv.Repo],
|
||||
generators: [timestamp_type: :utc_datetime],
|
||||
ash_domains: [Mv.Membership, Mv.Accounts]
|
||||
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees]
|
||||
|
||||
# Configures the endpoint
|
||||
config :mv, MvWeb.Endpoint,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
// - https://dbdocs.io
|
||||
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
|
||||
//
|
||||
// Version: 1.2
|
||||
// Last Updated: 2025-11-13
|
||||
// Version: 1.3
|
||||
// Last Updated: 2025-12-11
|
||||
|
||||
Project mila_membership_management {
|
||||
database_type: 'PostgreSQL'
|
||||
|
|
@ -27,6 +27,7 @@ Project mila_membership_management {
|
|||
## Domains:
|
||||
- **Accounts**: User authentication and session management
|
||||
- **Membership**: Club member data and custom fields
|
||||
- **MembershipFees**: Membership fee types and billing cycles
|
||||
|
||||
## Required PostgreSQL Extensions:
|
||||
- uuid-ossp (UUID generation)
|
||||
|
|
@ -132,6 +133,8 @@ Table members {
|
|||
house_number text [null, note: 'House number']
|
||||
postal_code text [null, note: '5-digit German postal code']
|
||||
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
|
||||
membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type']
|
||||
membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated']
|
||||
|
||||
indexes {
|
||||
email [unique, name: 'members_unique_email_index']
|
||||
|
|
@ -146,6 +149,7 @@ Table members {
|
|||
last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting']
|
||||
join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters']
|
||||
(paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL']
|
||||
membership_fee_type_id [name: 'members_membership_fee_type_id_index', note: 'B-tree index for fee type lookups']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
|
|
@ -178,6 +182,8 @@ Table members {
|
|||
**Relationships:**
|
||||
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
|
||||
- 1:N with custom_field_values (custom dynamic fields)
|
||||
- Optional N:1 with membership_fee_types - assigned fee type
|
||||
- 1:N with membership_fee_cycles - billing history
|
||||
|
||||
**Validation Rules:**
|
||||
- first_name, last_name: min 1 character
|
||||
|
|
@ -281,6 +287,98 @@ Table custom_fields {
|
|||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MEMBERSHIP_FEES DOMAIN
|
||||
// ============================================
|
||||
|
||||
Table membership_fee_types {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
|
||||
name text [not null, unique, note: 'Unique name for the fee type (e.g., "Standard", "Reduced")']
|
||||
amount numeric(10,2) [not null, note: 'Fee amount in default currency (CHECK: >= 0)']
|
||||
interval text [not null, note: 'Billing interval (CHECK: IN monthly, quarterly, half_yearly, yearly) - immutable']
|
||||
description text [null, note: 'Optional description for the fee type']
|
||||
|
||||
indexes {
|
||||
name [unique, name: 'membership_fee_types_unique_name_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Membership Fee Type Definitions**
|
||||
|
||||
Defines the different types of membership fees with fixed billing intervals.
|
||||
|
||||
**Attributes:**
|
||||
- `name`: Unique identifier for the fee type
|
||||
- `amount`: Default fee amount (stored per cycle for audit trail)
|
||||
- `interval`: Billing cycle - immutable after creation
|
||||
- `description`: Optional documentation
|
||||
|
||||
**Interval Values:**
|
||||
- `monthly`: 1st to last day of month
|
||||
- `quarterly`: 1st of Jan/Apr/Jul/Oct to last day of quarter
|
||||
- `half_yearly`: 1st of Jan/Jul to last day of half
|
||||
- `yearly`: Jan 1 to Dec 31
|
||||
|
||||
**Immutability:**
|
||||
The `interval` field cannot be changed after creation to prevent
|
||||
complex migration scenarios. Create a new fee type to change intervals.
|
||||
|
||||
**Relationships:**
|
||||
- 1:N with members - members assigned to this fee type
|
||||
- 1:N with membership_fee_cycles - all cycles using this fee type
|
||||
|
||||
**Deletion Behavior:**
|
||||
- ON DELETE RESTRICT: Cannot delete if members or cycles reference it
|
||||
'''
|
||||
}
|
||||
|
||||
Table membership_fee_cycles {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
|
||||
cycle_start date [not null, note: 'Start date of the billing cycle']
|
||||
amount numeric(10,2) [not null, note: 'Fee amount for this cycle (CHECK: >= 0)']
|
||||
status text [not null, default: 'unpaid', note: 'Payment status (CHECK: IN unpaid, paid, suspended)']
|
||||
notes text [null, note: 'Optional notes for this cycle']
|
||||
member_id uuid [not null, note: 'FK to members - the member this cycle belongs to']
|
||||
membership_fee_type_id uuid [not null, note: 'FK to membership_fee_types - fee type for this cycle']
|
||||
|
||||
indexes {
|
||||
member_id [name: 'membership_fee_cycles_member_id_index']
|
||||
membership_fee_type_id [name: 'membership_fee_cycles_membership_fee_type_id_index']
|
||||
status [name: 'membership_fee_cycles_status_index']
|
||||
cycle_start [name: 'membership_fee_cycles_cycle_start_index']
|
||||
(member_id, cycle_start) [unique, name: 'membership_fee_cycles_unique_cycle_per_member_index', note: 'One cycle per member per cycle_start']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Individual Membership Fee Cycles**
|
||||
|
||||
Represents a single billing cycle for a member with payment tracking.
|
||||
|
||||
**Design Decisions:**
|
||||
- `cycle_end` is NOT stored - calculated from cycle_start + interval
|
||||
- `amount` is stored per cycle to preserve historical values when fee type amount changes
|
||||
- Cycles are aligned to calendar boundaries
|
||||
|
||||
**Status Values:**
|
||||
- `unpaid`: Payment pending (default)
|
||||
- `paid`: Payment received
|
||||
- `suspended`: Payment suspended (e.g., hardship case)
|
||||
|
||||
**Constraints:**
|
||||
- Unique: One cycle per member per cycle_start date
|
||||
- member_id: Required (belongs_to)
|
||||
- membership_fee_type_id: Required (belongs_to)
|
||||
|
||||
**Relationships:**
|
||||
- N:1 with members - the member this cycle belongs to
|
||||
- N:1 with membership_fee_types - the fee type for this cycle
|
||||
|
||||
**Deletion Behavior:**
|
||||
- ON DELETE CASCADE (member_id): Cycles deleted when member deleted
|
||||
- ON DELETE RESTRICT (membership_fee_type_id): Cannot delete fee type if cycles exist
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RELATIONSHIPS
|
||||
// ============================================
|
||||
|
|
@ -306,6 +404,22 @@ Ref: custom_field_values.member_id > members.id [delete: cascade]
|
|||
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
|
||||
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict]
|
||||
|
||||
// Member → MembershipFeeType (N:1)
|
||||
// - Many members can be assigned to one fee type
|
||||
// - Optional relationship (member can have no fee type)
|
||||
// - ON DELETE RESTRICT: Cannot delete fee type if members are assigned
|
||||
Ref: members.membership_fee_type_id > membership_fee_types.id [delete: restrict]
|
||||
|
||||
// MembershipFeeCycle → Member (N:1)
|
||||
// - Many cycles belong to one member
|
||||
// - ON DELETE CASCADE: Cycles deleted when member deleted
|
||||
Ref: membership_fee_cycles.member_id > members.id [delete: cascade]
|
||||
|
||||
// MembershipFeeCycle → MembershipFeeType (N:1)
|
||||
// - Many cycles reference one fee type
|
||||
// - ON DELETE RESTRICT: Cannot delete fee type if cycles reference it
|
||||
Ref: membership_fee_cycles.membership_fee_type_id > membership_fee_types.id [delete: restrict]
|
||||
|
||||
// ============================================
|
||||
// ENUMS
|
||||
// ============================================
|
||||
|
|
@ -328,6 +442,21 @@ Enum token_purpose {
|
|||
email_confirmation [note: 'Email verification tokens']
|
||||
}
|
||||
|
||||
// Billing interval for membership fee types
|
||||
Enum membership_fee_interval {
|
||||
monthly [note: '1st to last day of month']
|
||||
quarterly [note: '1st of Jan/Apr/Jul/Oct to last day of quarter']
|
||||
half_yearly [note: '1st of Jan/Jul to last day of half']
|
||||
yearly [note: 'Jan 1 to Dec 31']
|
||||
}
|
||||
|
||||
// Payment status for membership fee cycles
|
||||
Enum membership_fee_status {
|
||||
unpaid [note: 'Payment pending (default)']
|
||||
paid [note: 'Payment received']
|
||||
suspended [note: 'Payment suspended']
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TABLE GROUPS
|
||||
// ============================================
|
||||
|
|
@ -357,3 +486,17 @@ TableGroup membership_domain {
|
|||
'''
|
||||
}
|
||||
|
||||
TableGroup membership_fees_domain {
|
||||
membership_fee_types
|
||||
membership_fee_cycles
|
||||
|
||||
Note: '''
|
||||
**Membership Fees Domain**
|
||||
|
||||
Handles membership fee management including:
|
||||
- Fee type definitions with intervals
|
||||
- Individual billing cycles per member
|
||||
- Payment status tracking
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -79,7 +79,8 @@ defmodule Mv.Membership.Member do
|
|||
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
||||
argument :user, :map, allow_nil?: true
|
||||
|
||||
accept @member_fields
|
||||
# Accept member fields plus membership_fee_type_id (belongs_to FK)
|
||||
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
|
||||
|
||||
change manage_relationship(:custom_field_values, type: :create)
|
||||
|
||||
|
|
@ -100,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
|
||||
|
|
@ -112,7 +149,8 @@ defmodule Mv.Membership.Member do
|
|||
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
||||
argument :user, :map, allow_nil?: true
|
||||
|
||||
accept @member_fields
|
||||
# Accept member fields plus membership_fee_type_id (belongs_to FK)
|
||||
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
|
||||
|
||||
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
||||
|
||||
|
|
@ -139,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
|
||||
|
|
@ -394,6 +472,15 @@ defmodule Mv.Membership.Member do
|
|||
writable?: false,
|
||||
public?: false,
|
||||
select_by_default?: false
|
||||
|
||||
# Membership fee fields
|
||||
# membership_fee_start_date: Date from which membership fees should be calculated
|
||||
# If nil, calculated from join_date + global setting
|
||||
attribute :membership_fee_start_date, :date do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Date from which membership fees should be calculated"
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
|
|
@ -402,6 +489,16 @@ defmodule Mv.Membership.Member do
|
|||
# This references the User's member_id attribute
|
||||
# The relationship is optional (allow_nil? true by default)
|
||||
has_one :user, Mv.Accounts.User
|
||||
|
||||
# Membership fee relationships
|
||||
# belongs_to: The fee type assigned to this member
|
||||
# Optional for MVP - can be nil if no fee type assigned yet
|
||||
belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
# has_many: All fee cycles for this member
|
||||
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
|
||||
end
|
||||
|
||||
# Define identities for upsert operations
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@ defmodule Mv.Membership.Setting do
|
|||
|
||||
## Overview
|
||||
Settings is a singleton resource that stores global configuration for the association,
|
||||
such as the club name and branding information. There should only ever be one settings
|
||||
record in the database.
|
||||
such as the club name, branding information, and membership fee settings. There should
|
||||
only ever be one settings record in the database.
|
||||
|
||||
## Attributes
|
||||
- `club_name` - The name of the association/club (required, cannot be empty)
|
||||
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
|
||||
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
|
||||
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
|
||||
- `default_membership_fee_type_id` - Default membership fee type for new members (optional)
|
||||
|
||||
## Singleton Pattern
|
||||
This resource uses a singleton pattern - there should only be one settings record.
|
||||
|
|
@ -22,6 +24,12 @@ defmodule Mv.Membership.Setting do
|
|||
If set, the environment variable value is used as a fallback when no database
|
||||
value exists. Database values always take precedence over environment variables.
|
||||
|
||||
## Membership Fee Settings
|
||||
- `include_joining_cycle`: When true, members pay from their joining cycle. When false,
|
||||
they pay from the next full cycle after joining.
|
||||
- `default_membership_fee_type_id`: The membership fee type automatically assigned to
|
||||
new members. Can be nil if no default is set.
|
||||
|
||||
## Examples
|
||||
|
||||
# Get current settings
|
||||
|
|
@ -33,6 +41,9 @@ defmodule Mv.Membership.Setting do
|
|||
|
||||
# Update member field visibility
|
||||
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||
|
||||
# Update membership fee settings
|
||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
|
|
@ -54,13 +65,24 @@ defmodule Mv.Membership.Setting do
|
|||
# Used only as fallback in get_settings/0 if settings don't exist
|
||||
# Settings should normally be created via seed script
|
||||
create :create do
|
||||
accept [:club_name, :member_field_visibility]
|
||||
accept [
|
||||
:club_name,
|
||||
:member_field_visibility,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id
|
||||
]
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
require_atomic? false
|
||||
accept [:club_name, :member_field_visibility]
|
||||
|
||||
accept [
|
||||
:club_name,
|
||||
:member_field_visibility,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id
|
||||
]
|
||||
end
|
||||
|
||||
update :update_member_field_visibility do
|
||||
|
|
@ -68,6 +90,12 @@ defmodule Mv.Membership.Setting do
|
|||
require_atomic? false
|
||||
accept [:member_field_visibility]
|
||||
end
|
||||
|
||||
update :update_membership_fee_settings do
|
||||
description "Updates the membership fee configuration"
|
||||
require_atomic? false
|
||||
accept [:include_joining_cycle, :default_membership_fee_type_id]
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
|
|
@ -133,6 +161,26 @@ defmodule Mv.Membership.Setting do
|
|||
description:
|
||||
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
||||
|
||||
# Membership fee settings
|
||||
attribute :include_joining_cycle, :boolean do
|
||||
allow_nil? false
|
||||
default true
|
||||
public? true
|
||||
description "Whether to include the joining cycle in membership fee generation"
|
||||
end
|
||||
|
||||
attribute :default_membership_fee_type_id, :uuid do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Default membership fee type ID for new members"
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
# Optional relationship to the default membership fee type
|
||||
# Note: We use manual FK (default_membership_fee_type_id attribute) instead of belongs_to
|
||||
# to avoid circular dependency between Membership and MembershipFees domains
|
||||
end
|
||||
end
|
||||
|
|
|
|||
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
|
||||
102
lib/membership_fees/membership_fee_cycle.ex
Normal file
102
lib/membership_fees/membership_fee_cycle.ex
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||
@moduledoc """
|
||||
Ash resource representing an individual membership fee cycle for a member.
|
||||
|
||||
## Overview
|
||||
MembershipFeeCycle represents a single billing cycle for a member. Each cycle
|
||||
tracks the payment status and amount for a specific time period.
|
||||
|
||||
## Attributes
|
||||
- `cycle_start` - Start date of the billing cycle (aligned to calendar boundaries)
|
||||
- `amount` - The fee amount for this cycle (stored for audit trail)
|
||||
- `status` - Payment status: unpaid, paid, or suspended
|
||||
- `notes` - Optional notes for this cycle
|
||||
|
||||
## Design Decisions
|
||||
- **No cycle_end field**: Calculated from cycle_start + interval (from fee type)
|
||||
- **Amount stored per cycle**: Preserves historical amounts when fee type changes
|
||||
- **Calendar-aligned cycles**: All cycles start on calendar boundaries
|
||||
|
||||
## Relationships
|
||||
- `belongs_to :member` - The member this cycle belongs to
|
||||
- `belongs_to :membership_fee_type` - The fee type for this cycle
|
||||
|
||||
## Constraints
|
||||
- Unique constraint on (member_id, cycle_start) - one cycle per period per member
|
||||
- CASCADE delete when member is deleted
|
||||
- RESTRICT delete on membership_fee_type if cycles exist
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.MembershipFees,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "membership_fee_cycles"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
resource do
|
||||
description "Individual membership fee cycle for a member"
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :create do
|
||||
primary? true
|
||||
accept [:cycle_start, :amount, :status, :notes, :member_id, :membership_fee_type_id]
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
accept [:status, :notes]
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_v7_primary_key :id
|
||||
|
||||
attribute :cycle_start, :date do
|
||||
allow_nil? false
|
||||
public? true
|
||||
description "Start date of the billing cycle"
|
||||
end
|
||||
|
||||
attribute :amount, :decimal do
|
||||
allow_nil? false
|
||||
public? true
|
||||
|
||||
description "Fee amount for this cycle (stored for audit trail, non-negative, max 2 decimal places)"
|
||||
|
||||
constraints min: 0, scale: 2
|
||||
end
|
||||
|
||||
attribute :status, :atom do
|
||||
allow_nil? false
|
||||
public? true
|
||||
default :unpaid
|
||||
description "Payment status of this cycle"
|
||||
constraints one_of: [:unpaid, :paid, :suspended]
|
||||
end
|
||||
|
||||
attribute :notes, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Optional notes for this cycle"
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :member, Mv.Membership.Member do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do
|
||||
allow_nil? false
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_cycle_per_member, [:member_id, :cycle_start]
|
||||
end
|
||||
end
|
||||
92
lib/membership_fees/membership_fee_type.ex
Normal file
92
lib/membership_fees/membership_fee_type.ex
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
defmodule Mv.MembershipFees.MembershipFeeType do
|
||||
@moduledoc """
|
||||
Ash resource representing a membership fee type definition.
|
||||
|
||||
## Overview
|
||||
MembershipFeeType defines the different types of membership fees that can be
|
||||
assigned to members. Each type has a fixed interval (billing cycle) and a
|
||||
default amount.
|
||||
|
||||
## Attributes
|
||||
- `name` - Unique name for the fee type (e.g., "Standard", "Reduced", "Family")
|
||||
- `amount` - The fee amount in the default currency (decimal)
|
||||
- `interval` - Billing interval: monthly, quarterly, half_yearly, or yearly
|
||||
- `description` - Optional description for the fee type
|
||||
|
||||
## Immutability
|
||||
The `interval` field is immutable after creation. This prevents complex
|
||||
migration scenarios when changing billing cycles. To change intervals,
|
||||
create a new fee type and migrate members.
|
||||
|
||||
## Relationships
|
||||
- `has_many :members` - Members assigned to this fee type
|
||||
- `has_many :membership_fee_cycles` - All cycles using this fee type
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.MembershipFees,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "membership_fee_types"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
resource do
|
||||
description "Membership fee type definition with interval and amount"
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :create do
|
||||
primary? true
|
||||
accept [:name, :amount, :interval, :description]
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
# Note: interval is NOT in accept list - it's immutable after creation
|
||||
# Immutability validation will be added in a future issue
|
||||
accept [:name, :amount, :description]
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_v7_primary_key :id
|
||||
|
||||
attribute :name, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
description "Unique name for the membership fee type"
|
||||
end
|
||||
|
||||
attribute :amount, :decimal do
|
||||
allow_nil? false
|
||||
public? true
|
||||
description "Fee amount in default currency (non-negative, max 2 decimal places)"
|
||||
constraints min: 0, scale: 2
|
||||
end
|
||||
|
||||
attribute :interval, :atom do
|
||||
allow_nil? false
|
||||
public? true
|
||||
description "Billing interval (immutable after creation)"
|
||||
constraints one_of: [:monthly, :quarterly, :half_yearly, :yearly]
|
||||
end
|
||||
|
||||
attribute :description, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Optional description for the fee type"
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
|
||||
has_many :members, Mv.Membership.Member
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_name, [:name]
|
||||
end
|
||||
end
|
||||
42
lib/membership_fees/membership_fees.ex
Normal file
42
lib/membership_fees/membership_fees.ex
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
defmodule Mv.MembershipFees do
|
||||
@moduledoc """
|
||||
Ash Domain for membership fee management.
|
||||
|
||||
## Resources
|
||||
- `MembershipFeeType` - Defines membership fee types with intervals and amounts
|
||||
- `MembershipFeeCycle` - Individual membership fee cycles per member
|
||||
|
||||
## Overview
|
||||
This domain handles the complete membership fee lifecycle including:
|
||||
- Fee type definitions (monthly, quarterly, half-yearly, yearly)
|
||||
- Individual fee cycles for each member
|
||||
- Payment status tracking (unpaid, paid, suspended)
|
||||
|
||||
## Architecture Decisions
|
||||
- `interval` field on MembershipFeeType is immutable after creation
|
||||
- `cycle_end` is calculated, not stored (from cycle_start + interval)
|
||||
- `amount` is stored per cycle for audit trail when prices change
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
||||
admin do
|
||||
show? true
|
||||
end
|
||||
|
||||
resources do
|
||||
resource Mv.MembershipFees.MembershipFeeType do
|
||||
define :create_membership_fee_type, action: :create
|
||||
define :list_membership_fee_types, action: :read
|
||||
define :update_membership_fee_type, action: :update
|
||||
define :destroy_membership_fee_type, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.MembershipFees.MembershipFeeCycle do
|
||||
define :create_membership_fee_cycle, action: :create
|
||||
define :list_membership_fee_cycles, action: :read
|
||||
define :update_membership_fee_cycle, action: :update
|
||||
define :destroy_membership_fee_cycle, action: :destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -15,7 +15,8 @@ defmodule Mv.Constants do
|
|||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code
|
||||
:postal_code,
|
||||
:membership_fee_start_date
|
||||
]
|
||||
|
||||
@custom_field_prefix "custom_field_"
|
||||
|
|
|
|||
329
lib/mv/membership_fees/calendar_cycles.ex
Normal file
329
lib/mv/membership_fees/calendar_cycles.ex
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
defmodule Mv.MembershipFees.CalendarCycles do
|
||||
@moduledoc """
|
||||
Calendar-based cycle calculation functions for membership fees.
|
||||
|
||||
This module provides functions for calculating cycle boundaries
|
||||
based on interval types (monthly, quarterly, half-yearly, yearly).
|
||||
|
||||
The calculation functions (`calculate_cycle_start/3`, `calculate_cycle_end/2`,
|
||||
`next_cycle_start/2`) are pure functions with no side effects.
|
||||
|
||||
The time-dependent functions (`current_cycle?/3`, `last_completed_cycle?/3`)
|
||||
depend on a date parameter for testability. Their 2-argument variants
|
||||
(`current_cycle?/2`, `last_completed_cycle?/2`) use `Date.utc_today()` and
|
||||
are not referentially transparent.
|
||||
|
||||
## Interval Types
|
||||
|
||||
- `:monthly` - Cycles from 1st to last day of each month
|
||||
- `:quarterly` - Cycles from 1st of Jan/Apr/Jul/Oct to last day of quarter
|
||||
- `:half_yearly` - Cycles from 1st of Jan/Jul to last day of half-year
|
||||
- `:yearly` - Cycles from Jan 1st to Dec 31st
|
||||
|
||||
## Examples
|
||||
|
||||
iex> date = ~D[2024-03-15]
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(date, :monthly)
|
||||
~D[2024-03-01]
|
||||
|
||||
iex> cycle_start = ~D[2024-01-01]
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle_start, :yearly)
|
||||
~D[2024-12-31]
|
||||
|
||||
iex> cycle_start = ~D[2024-01-01]
|
||||
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(cycle_start, :yearly)
|
||||
~D[2025-01-01]
|
||||
"""
|
||||
|
||||
@typedoc """
|
||||
Interval type for membership fee cycles.
|
||||
|
||||
- `:monthly` - Monthly cycles (1st to last day of month)
|
||||
- `:quarterly` - Quarterly cycles (1st of quarter to last day of quarter)
|
||||
- `:half_yearly` - Half-yearly cycles (1st of half-year to last day of half-year)
|
||||
- `:yearly` - Yearly cycles (Jan 1st to Dec 31st)
|
||||
"""
|
||||
@type interval :: :monthly | :quarterly | :half_yearly | :yearly
|
||||
|
||||
@doc """
|
||||
Calculates the start date of the cycle that contains the reference date.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `date` - Ignored in this 3-argument version (kept for API consistency)
|
||||
- `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`)
|
||||
- `reference_date` - The date used to determine which cycle to calculate
|
||||
|
||||
## Returns
|
||||
|
||||
The start date of the cycle containing the reference date.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly, ~D[2024-05-20])
|
||||
~D[2024-05-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :quarterly, ~D[2024-05-20])
|
||||
~D[2024-04-01]
|
||||
"""
|
||||
@spec calculate_cycle_start(Date.t(), interval(), Date.t()) :: Date.t()
|
||||
def calculate_cycle_start(_date, interval, reference_date) do
|
||||
case interval do
|
||||
:monthly -> monthly_cycle_start(reference_date)
|
||||
:quarterly -> quarterly_cycle_start(reference_date)
|
||||
:half_yearly -> half_yearly_cycle_start(reference_date)
|
||||
:yearly -> yearly_cycle_start(reference_date)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the start date of the cycle that contains the given date.
|
||||
|
||||
This is a convenience function that calls `calculate_cycle_start/3` with `date` as both
|
||||
the input and reference date.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `date` - The date used to determine which cycle to calculate
|
||||
- `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`)
|
||||
|
||||
## Returns
|
||||
|
||||
The start date of the cycle containing the given date.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly)
|
||||
~D[2024-03-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly)
|
||||
~D[2024-04-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly)
|
||||
~D[2024-07-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-12-15], :yearly)
|
||||
~D[2024-01-01]
|
||||
"""
|
||||
@spec calculate_cycle_start(Date.t(), interval()) :: Date.t()
|
||||
def calculate_cycle_start(date, interval) do
|
||||
calculate_cycle_start(date, interval, date)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the end date of a cycle based on its start date and interval.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `cycle_start` - The start date of the cycle
|
||||
- `interval` - The interval type
|
||||
|
||||
## Returns
|
||||
|
||||
The end date of the cycle.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly)
|
||||
~D[2024-03-31]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly)
|
||||
~D[2024-02-29]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly)
|
||||
~D[2024-03-31]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly)
|
||||
~D[2024-06-30]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly)
|
||||
~D[2024-12-31]
|
||||
"""
|
||||
@spec calculate_cycle_end(Date.t(), interval()) :: Date.t()
|
||||
def calculate_cycle_end(cycle_start, interval) do
|
||||
case interval do
|
||||
:monthly -> monthly_cycle_end(cycle_start)
|
||||
:quarterly -> quarterly_cycle_end(cycle_start)
|
||||
:half_yearly -> half_yearly_cycle_end(cycle_start)
|
||||
:yearly -> yearly_cycle_end(cycle_start)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the start date of the next cycle.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `cycle_start` - The start date of the current cycle
|
||||
- `interval` - The interval type
|
||||
|
||||
## Returns
|
||||
|
||||
The start date of the next cycle.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly)
|
||||
~D[2024-02-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly)
|
||||
~D[2024-04-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly)
|
||||
~D[2024-07-01]
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly)
|
||||
~D[2025-01-01]
|
||||
"""
|
||||
@spec next_cycle_start(Date.t(), interval()) :: Date.t()
|
||||
def next_cycle_start(cycle_start, interval) do
|
||||
cycle_end = calculate_cycle_end(cycle_start, interval)
|
||||
next_date = Date.add(cycle_end, 1)
|
||||
calculate_cycle_start(next_date, interval)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the cycle contains the given date.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `cycle_start` - The start date of the cycle
|
||||
- `interval` - The interval type
|
||||
- `today` - The date to check (defaults to today's date)
|
||||
|
||||
## Returns
|
||||
|
||||
`true` if the given date is within the cycle, `false` otherwise.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15])
|
||||
true
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-02-01], :monthly, ~D[2024-03-15])
|
||||
false
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-01])
|
||||
true
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-31])
|
||||
true
|
||||
"""
|
||||
@spec current_cycle?(Date.t(), interval(), Date.t()) :: boolean()
|
||||
def current_cycle?(cycle_start, interval, today) do
|
||||
cycle_end = calculate_cycle_end(cycle_start, interval)
|
||||
|
||||
Date.compare(cycle_start, today) in [:lt, :eq] and
|
||||
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||
end
|
||||
|
||||
@spec current_cycle?(Date.t(), interval()) :: boolean()
|
||||
def current_cycle?(cycle_start, interval) do
|
||||
current_cycle?(cycle_start, interval, Date.utc_today())
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the cycle is the last completed cycle.
|
||||
|
||||
A cycle is considered the last completed cycle if:
|
||||
- The cycle has ended (cycle_end < today)
|
||||
- The next cycle has not ended yet (today <= next_end)
|
||||
|
||||
In other words: `cycle_end < today <= next_end`
|
||||
|
||||
## Parameters
|
||||
|
||||
- `cycle_start` - The start date of the cycle
|
||||
- `interval` - The interval type
|
||||
- `today` - The date to check against (defaults to today's date)
|
||||
|
||||
## Returns
|
||||
|
||||
`true` if the cycle is the last completed cycle, `false` otherwise.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-04-01])
|
||||
true
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15])
|
||||
false
|
||||
|
||||
iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-02-01], :monthly, ~D[2024-04-15])
|
||||
false
|
||||
"""
|
||||
@spec last_completed_cycle?(Date.t(), interval(), Date.t()) :: boolean()
|
||||
def last_completed_cycle?(cycle_start, interval, today) do
|
||||
cycle_end = calculate_cycle_end(cycle_start, interval)
|
||||
|
||||
# Cycle must have ended (cycle_end < today)
|
||||
case Date.compare(today, cycle_end) do
|
||||
:gt ->
|
||||
# Check if this is the most recent completed cycle
|
||||
# by verifying that the next cycle hasn't ended yet (today <= next_end)
|
||||
next_start = next_cycle_start(cycle_start, interval)
|
||||
next_end = calculate_cycle_end(next_start, interval)
|
||||
|
||||
Date.compare(today, next_end) in [:lt, :eq]
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@spec last_completed_cycle?(Date.t(), interval()) :: boolean()
|
||||
def last_completed_cycle?(cycle_start, interval) do
|
||||
last_completed_cycle?(cycle_start, interval, Date.utc_today())
|
||||
end
|
||||
|
||||
# Private helper functions
|
||||
|
||||
defp monthly_cycle_start(date) do
|
||||
Date.new!(date.year, date.month, 1)
|
||||
end
|
||||
|
||||
defp monthly_cycle_end(cycle_start) do
|
||||
Date.end_of_month(cycle_start)
|
||||
end
|
||||
|
||||
defp quarterly_cycle_start(date) do
|
||||
quarter_start_month =
|
||||
case date.month do
|
||||
m when m in [1, 2, 3] -> 1
|
||||
m when m in [4, 5, 6] -> 4
|
||||
m when m in [7, 8, 9] -> 7
|
||||
m when m in [10, 11, 12] -> 10
|
||||
end
|
||||
|
||||
Date.new!(date.year, quarter_start_month, 1)
|
||||
end
|
||||
|
||||
defp quarterly_cycle_end(cycle_start) do
|
||||
case cycle_start.month do
|
||||
1 -> Date.new!(cycle_start.year, 3, 31)
|
||||
4 -> Date.new!(cycle_start.year, 6, 30)
|
||||
7 -> Date.new!(cycle_start.year, 9, 30)
|
||||
10 -> Date.new!(cycle_start.year, 12, 31)
|
||||
end
|
||||
end
|
||||
|
||||
defp half_yearly_cycle_start(date) do
|
||||
half_start_month = if date.month in 1..6, do: 1, else: 7
|
||||
Date.new!(date.year, half_start_month, 1)
|
||||
end
|
||||
|
||||
defp half_yearly_cycle_end(cycle_start) do
|
||||
case cycle_start.month do
|
||||
1 -> Date.new!(cycle_start.year, 6, 30)
|
||||
7 -> Date.new!(cycle_start.year, 12, 31)
|
||||
end
|
||||
end
|
||||
|
||||
defp yearly_cycle_start(date) do
|
||||
Date.new!(date.year, 1, 1)
|
||||
end
|
||||
|
||||
defp yearly_cycle_end(cycle_start) do
|
||||
Date.new!(cycle_start.year, 12, 31)
|
||||
end
|
||||
end
|
||||
174
lib/mv/membership_fees/cycle_generation_job.ex
Normal file
174
lib/mv/membership_fees/cycle_generation_job.ex
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
defmodule Mv.MembershipFees.CycleGenerationJob do
|
||||
@moduledoc """
|
||||
Scheduled job for generating membership fee cycles.
|
||||
|
||||
This module provides a skeleton for scheduled cycle generation.
|
||||
In the future, this can be integrated with Oban or similar job processing libraries.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
Currently provides manual execution functions that can be called:
|
||||
- From IEx console for administrative tasks
|
||||
- From a cron job via a Mix task
|
||||
- From the admin UI (future)
|
||||
|
||||
## Future Oban Integration
|
||||
|
||||
When Oban is added to the project, this module can be converted to an Oban worker:
|
||||
|
||||
defmodule Mv.MembershipFees.CycleGenerationJob do
|
||||
use Oban.Worker,
|
||||
queue: :membership_fees,
|
||||
max_attempts: 3
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
Mv.MembershipFees.CycleGenerator.generate_cycles_for_all_members()
|
||||
end
|
||||
end
|
||||
|
||||
## Usage
|
||||
|
||||
# Manual execution from IEx
|
||||
Mv.MembershipFees.CycleGenerationJob.run()
|
||||
|
||||
# Check if cycles need to be generated
|
||||
Mv.MembershipFees.CycleGenerationJob.pending_members_count()
|
||||
|
||||
"""
|
||||
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Runs the cycle generation job for all active members.
|
||||
|
||||
This is the main entry point for scheduled execution.
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, results}` - Map with success/failed counts
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.MembershipFees.CycleGenerationJob.run()
|
||||
{:ok, %{success: 45, failed: 0, total: 45}}
|
||||
|
||||
"""
|
||||
@spec run() :: {:ok, map()} | {:error, term()}
|
||||
def run do
|
||||
Logger.info("Starting membership fee cycle generation job")
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
result = CycleGenerator.generate_cycles_for_all_members()
|
||||
|
||||
elapsed = System.monotonic_time(:millisecond) - start_time
|
||||
|
||||
case result do
|
||||
{:ok, stats} ->
|
||||
Logger.info(
|
||||
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
|
||||
)
|
||||
|
||||
result
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Cycle generation failed: #{inspect(reason)}")
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Runs cycle generation with custom options.
|
||||
|
||||
## Options
|
||||
|
||||
- `:today` - Override today's date (useful for testing or catch-up)
|
||||
- `:batch_size` - Number of members to process in parallel
|
||||
|
||||
## Examples
|
||||
|
||||
# Generate cycles as if today was a specific date
|
||||
Mv.MembershipFees.CycleGenerationJob.run(today: ~D[2024-12-31])
|
||||
|
||||
# Process with smaller batch size
|
||||
Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5)
|
||||
|
||||
"""
|
||||
@spec run(keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def run(opts) when is_list(opts) do
|
||||
Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}")
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
result = CycleGenerator.generate_cycles_for_all_members(opts)
|
||||
|
||||
elapsed = System.monotonic_time(:millisecond) - start_time
|
||||
|
||||
case result do
|
||||
{:ok, stats} ->
|
||||
Logger.info(
|
||||
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
|
||||
)
|
||||
|
||||
result
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Cycle generation failed: #{inspect(reason)}")
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of members that need cycle generation.
|
||||
|
||||
A member needs cycle generation if:
|
||||
- Has a membership_fee_type assigned
|
||||
- Has a join_date set
|
||||
- Is active (no exit_date or exit_date >= today)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, count}` - Number of members needing generation
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
"""
|
||||
@spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, term()}
|
||||
def pending_members_count do
|
||||
today = Date.utc_today()
|
||||
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|
||||
|> Ash.Query.filter(not is_nil(join_date))
|
||||
|> Ash.Query.filter(is_nil(exit_date) or exit_date >= ^today)
|
||||
|
||||
case Ash.count(query) do
|
||||
{:ok, count} -> {:ok, count}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates cycles for a specific member by ID.
|
||||
|
||||
Useful for administrative tasks or manual corrections.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `member_id` - The UUID of the member
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, cycles}` - List of newly created cycles
|
||||
- `{:error, reason}` - Error with reason
|
||||
|
||||
"""
|
||||
@spec run_for_member(String.t()) :: {:ok, [map()]} | {:error, term()}
|
||||
def run_for_member(member_id) when is_binary(member_id) do
|
||||
Logger.info("Generating cycles for member #{member_id}")
|
||||
CycleGenerator.generate_cycles_for_member(member_id)
|
||||
end
|
||||
end
|
||||
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
|
||||
2
mix.lock
2
mix.lock
|
|
@ -30,7 +30,7 @@
|
|||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
|
||||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
||||
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
defmodule Mv.Repo.Migrations.AddMembershipFeesTables do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
create table(:membership_fee_types, primary_key: false) do
|
||||
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
|
||||
add :name, :text, null: false
|
||||
# Precision: 10 digits total, 2 decimal places (max 99,999,999.99)
|
||||
add :amount, :numeric, null: false, precision: 10, scale: 2
|
||||
add :interval, :text, null: false
|
||||
add :description, :text
|
||||
end
|
||||
|
||||
create unique_index(:membership_fee_types, [:name],
|
||||
name: "membership_fee_types_unique_name_index"
|
||||
)
|
||||
|
||||
# CHECK constraint for interval values (enforced at DB level)
|
||||
create constraint(:membership_fee_types, :membership_fee_types_interval_check,
|
||||
check: "interval IN ('monthly', 'quarterly', 'half_yearly', 'yearly')"
|
||||
)
|
||||
|
||||
# CHECK constraint for non-negative amount
|
||||
create constraint(:membership_fee_types, :membership_fee_types_amount_check,
|
||||
check: "amount >= 0"
|
||||
)
|
||||
|
||||
create table(:membership_fee_cycles, primary_key: false) do
|
||||
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
|
||||
add :cycle_start, :date, null: false
|
||||
# Precision: 10 digits total, 2 decimal places (max 99,999,999.99)
|
||||
add :amount, :numeric, null: false, precision: 10, scale: 2
|
||||
add :status, :text, null: false, default: "unpaid"
|
||||
add :notes, :text
|
||||
|
||||
# CASCADE: Delete cycles when member is deleted
|
||||
add :member_id,
|
||||
references(:members,
|
||||
column: :id,
|
||||
name: "membership_fee_cycles_member_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public",
|
||||
on_delete: :delete_all
|
||||
),
|
||||
null: false
|
||||
|
||||
# RESTRICT: Cannot delete fee type if cycles reference it
|
||||
add :membership_fee_type_id,
|
||||
references(:membership_fee_types,
|
||||
column: :id,
|
||||
name: "membership_fee_cycles_membership_fee_type_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public",
|
||||
on_delete: :restrict
|
||||
),
|
||||
null: false
|
||||
end
|
||||
|
||||
# CHECK constraint for status values (enforced at DB level)
|
||||
create constraint(:membership_fee_cycles, :membership_fee_cycles_status_check,
|
||||
check: "status IN ('unpaid', 'paid', 'suspended')"
|
||||
)
|
||||
|
||||
# CHECK constraint for non-negative amount
|
||||
create constraint(:membership_fee_cycles, :membership_fee_cycles_amount_check,
|
||||
check: "amount >= 0"
|
||||
)
|
||||
|
||||
# Indexes as specified in architecture document
|
||||
create index(:membership_fee_cycles, [:member_id])
|
||||
create index(:membership_fee_cycles, [:membership_fee_type_id])
|
||||
create index(:membership_fee_cycles, [:status])
|
||||
create index(:membership_fee_cycles, [:cycle_start])
|
||||
|
||||
# Composite unique index: one cycle per member per cycle_start
|
||||
create unique_index(:membership_fee_cycles, [:member_id, :cycle_start],
|
||||
name: "membership_fee_cycles_unique_cycle_per_member_index"
|
||||
)
|
||||
|
||||
# Extend members table with membership fee fields
|
||||
alter table(:members) do
|
||||
add :membership_fee_start_date, :date
|
||||
|
||||
# RESTRICT: Cannot delete fee type if members are assigned to it
|
||||
add :membership_fee_type_id,
|
||||
references(:membership_fee_types,
|
||||
column: :id,
|
||||
name: "members_membership_fee_type_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public",
|
||||
on_delete: :restrict
|
||||
)
|
||||
end
|
||||
|
||||
# Index for efficient lookup of members by fee type
|
||||
create index(:members, [:membership_fee_type_id])
|
||||
end
|
||||
|
||||
def down do
|
||||
# First: Remove members extension (depends on membership_fee_types)
|
||||
drop_if_exists index(:members, [:membership_fee_type_id])
|
||||
drop constraint(:members, "members_membership_fee_type_id_fkey")
|
||||
|
||||
alter table(:members) do
|
||||
remove :membership_fee_type_id
|
||||
remove :membership_fee_start_date
|
||||
end
|
||||
|
||||
# Second: Drop cycles table (depends on membership_fee_types)
|
||||
drop_if_exists unique_index(:membership_fee_cycles, [:member_id, :cycle_start],
|
||||
name: "membership_fee_cycles_unique_cycle_per_member_index"
|
||||
)
|
||||
|
||||
drop_if_exists index(:membership_fee_cycles, [:cycle_start])
|
||||
drop_if_exists index(:membership_fee_cycles, [:status])
|
||||
drop_if_exists index(:membership_fee_cycles, [:membership_fee_type_id])
|
||||
drop_if_exists index(:membership_fee_cycles, [:member_id])
|
||||
|
||||
drop constraint(:membership_fee_cycles, "membership_fee_cycles_member_id_fkey")
|
||||
drop constraint(:membership_fee_cycles, "membership_fee_cycles_membership_fee_type_id_fkey")
|
||||
drop_if_exists constraint(:membership_fee_cycles, :membership_fee_cycles_status_check)
|
||||
drop_if_exists constraint(:membership_fee_cycles, :membership_fee_cycles_amount_check)
|
||||
|
||||
drop table(:membership_fee_cycles)
|
||||
|
||||
# Third: Drop fee types table
|
||||
drop_if_exists unique_index(:membership_fee_types, [:name],
|
||||
name: "membership_fee_types_unique_name_index"
|
||||
)
|
||||
|
||||
drop_if_exists constraint(:membership_fee_types, :membership_fee_types_interval_check)
|
||||
drop_if_exists constraint(:membership_fee_types, :membership_fee_types_amount_check)
|
||||
|
||||
drop table(:membership_fee_types)
|
||||
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": null,
|
||||
"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": "43EA9EA365C09D423249AC4B6757A9AC07788C6C1E4BC7C50F8EF2CE01DE5684",
|
||||
"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,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": null,
|
||||
"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": "E93A7A1EE90E5CEAC98CEA57C99C6330465716248642D5E2949EF578DE514E99",
|
||||
"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"
|
||||
}
|
||||
|
|
@ -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
|
||||
220
test/membership_fees/foreign_key_test.exs
Normal file
220
test/membership_fees/foreign_key_test.exs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
defmodule Mv.MembershipFees.ForeignKeyTest do
|
||||
@moduledoc """
|
||||
Tests for foreign key behaviors (CASCADE and RESTRICT).
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership.Member
|
||||
|
||||
describe "CASCADE behavior" do
|
||||
test "deleting member deletes associated membership_fee_cycles" do
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Ash.create(Member, %{
|
||||
first_name: "Cascade",
|
||||
last_name: "Test",
|
||||
email: "cascade.test.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|
||||
# Create fee type
|
||||
{:ok, fee_type} =
|
||||
Ash.create(MembershipFeeType, %{
|
||||
name: "Cascade Test Fee #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :monthly
|
||||
})
|
||||
|
||||
# Create multiple cycles for this member
|
||||
{:ok, cycle1} =
|
||||
Ash.create(MembershipFeeCycle, %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
{:ok, cycle2} =
|
||||
Ash.create(MembershipFeeCycle, %{
|
||||
cycle_start: ~D[2025-02-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
# Verify cycles exist
|
||||
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle1.id)
|
||||
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle2.id)
|
||||
|
||||
# Delete member
|
||||
assert :ok = Ash.destroy(member)
|
||||
|
||||
# Verify cycles are also deleted (CASCADE)
|
||||
# NotFound is wrapped in Ash.Error.Invalid
|
||||
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle1.id)
|
||||
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle2.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "RESTRICT behavior" do
|
||||
test "cannot delete membership_fee_type if cycles reference it" do
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Ash.create(Member, %{
|
||||
first_name: "Restrict",
|
||||
last_name: "Test",
|
||||
email: "restrict.test.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|
||||
# Create fee type
|
||||
{:ok, fee_type} =
|
||||
Ash.create(MembershipFeeType, %{
|
||||
name: "Restrict Test Fee #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :monthly
|
||||
})
|
||||
|
||||
# Create a cycle referencing this fee type
|
||||
{:ok, _cycle} =
|
||||
Ash.create(MembershipFeeCycle, %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
# Try to delete fee type - should fail due to RESTRICT
|
||||
assert {:error, error} = Ash.destroy(fee_type)
|
||||
|
||||
# Check that it's a foreign key violation error
|
||||
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
|
||||
end
|
||||
|
||||
test "can delete membership_fee_type if no cycles reference it" do
|
||||
# Create fee type without any cycles
|
||||
{:ok, fee_type} =
|
||||
Ash.create(MembershipFeeType, %{
|
||||
name: "Deletable Fee #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :monthly
|
||||
})
|
||||
|
||||
# Should be able to delete
|
||||
assert :ok = Ash.destroy(fee_type)
|
||||
|
||||
# Verify it's gone (NotFound is wrapped in Ash.Error.Invalid)
|
||||
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeType, fee_type.id)
|
||||
end
|
||||
|
||||
test "cannot delete membership_fee_type if members reference it" do
|
||||
# Create fee type
|
||||
{:ok, fee_type} =
|
||||
Ash.create(MembershipFeeType, %{
|
||||
name: "Member Ref Fee #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :monthly
|
||||
})
|
||||
|
||||
# Create member with this fee type
|
||||
{:ok, _member} =
|
||||
Ash.create(Member, %{
|
||||
first_name: "FeeType",
|
||||
last_name: "Reference",
|
||||
email: "feetype.ref.#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
# Try to delete fee type - should fail due to RESTRICT
|
||||
assert {:error, error} = Ash.destroy(fee_type)
|
||||
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
|
||||
end
|
||||
end
|
||||
|
||||
describe "member extensions" do
|
||||
test "member can be created with membership_fee_type_id" do
|
||||
# Create fee type first
|
||||
{:ok, fee_type} =
|
||||
Ash.create(MembershipFeeType, %{
|
||||
name: "Create Test Fee #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :yearly
|
||||
})
|
||||
|
||||
# Create member with fee type
|
||||
{:ok, member} =
|
||||
Ash.create(Member, %{
|
||||
first_name: "With",
|
||||
last_name: "FeeType",
|
||||
email: "with.feetype.#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
assert member.membership_fee_type_id == fee_type.id
|
||||
end
|
||||
|
||||
test "member can be created with membership_fee_start_date" do
|
||||
{:ok, member} =
|
||||
Ash.create(Member, %{
|
||||
first_name: "With",
|
||||
last_name: "StartDate",
|
||||
email: "with.startdate.#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_start_date: ~D[2025-01-01]
|
||||
})
|
||||
|
||||
assert member.membership_fee_start_date == ~D[2025-01-01]
|
||||
end
|
||||
|
||||
test "member can be created without membership fee fields" do
|
||||
{:ok, member} =
|
||||
Ash.create(Member, %{
|
||||
first_name: "No",
|
||||
last_name: "FeeFields",
|
||||
email: "no.feefields.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|
||||
assert member.membership_fee_type_id == nil
|
||||
assert member.membership_fee_start_date == nil
|
||||
end
|
||||
|
||||
test "member can be updated with membership_fee_type_id" do
|
||||
# Create fee type
|
||||
{:ok, fee_type} =
|
||||
Ash.create(MembershipFeeType, %{
|
||||
name: "Update Test Fee #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :yearly
|
||||
})
|
||||
|
||||
# Create member without fee type
|
||||
{:ok, member} =
|
||||
Ash.create(Member, %{
|
||||
first_name: "Update",
|
||||
last_name: "Test",
|
||||
email: "update.test.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|
||||
assert member.membership_fee_type_id == nil
|
||||
|
||||
# Update member with fee type
|
||||
{:ok, updated_member} = Ash.update(member, %{membership_fee_type_id: fee_type.id})
|
||||
|
||||
assert updated_member.membership_fee_type_id == fee_type.id
|
||||
end
|
||||
|
||||
test "member can be updated with membership_fee_start_date" do
|
||||
{:ok, member} =
|
||||
Ash.create(Member, %{
|
||||
first_name: "Start",
|
||||
last_name: "Date",
|
||||
email: "start.date.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|
||||
assert member.membership_fee_start_date == nil
|
||||
|
||||
{:ok, updated_member} = Ash.update(member, %{membership_fee_start_date: ~D[2025-06-01]})
|
||||
|
||||
assert updated_member.membership_fee_start_date == ~D[2025-06-01]
|
||||
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
|
||||
282
test/membership_fees/membership_fee_cycle_test.exs
Normal file
282
test/membership_fees/membership_fee_cycle_test.exs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||
@moduledoc """
|
||||
Tests for MembershipFeeCycle resource.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership.Member
|
||||
|
||||
setup do
|
||||
# Create a member for testing
|
||||
{:ok, member} =
|
||||
Ash.create(Member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|
||||
# Create a fee type for testing
|
||||
{:ok, fee_type} =
|
||||
Ash.create(MembershipFeeType, %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :monthly
|
||||
})
|
||||
|
||||
%{member: member, fee_type: fee_type}
|
||||
end
|
||||
|
||||
describe "create MembershipFeeCycle" do
|
||||
test "can create cycle with valid attributes", %{member: member, fee_type: fee_type} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
}
|
||||
|
||||
assert {:ok, %MembershipFeeCycle{} = cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert cycle.cycle_start == ~D[2025-01-01]
|
||||
assert Decimal.equal?(cycle.amount, Decimal.new("100.00"))
|
||||
assert cycle.member_id == member.id
|
||||
assert cycle.membership_fee_type_id == fee_type.id
|
||||
end
|
||||
|
||||
test "can create cycle with notes", %{member: member, fee_type: fee_type} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
notes: "First payment cycle"
|
||||
}
|
||||
|
||||
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert cycle.notes == "First payment cycle"
|
||||
end
|
||||
|
||||
test "requires cycle_start", %{member: member, fee_type: fee_type} do
|
||||
attrs = %{
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
}
|
||||
|
||||
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert error_on_field?(error, :cycle_start)
|
||||
end
|
||||
|
||||
test "requires amount", %{member: member, fee_type: fee_type} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
}
|
||||
|
||||
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert error_on_field?(error, :amount)
|
||||
end
|
||||
|
||||
test "requires member_id", %{fee_type: fee_type} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
membership_fee_type_id: fee_type.id
|
||||
}
|
||||
|
||||
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert error_on_field?(error, :member_id) or error_on_field?(error, :member)
|
||||
end
|
||||
|
||||
test "requires membership_fee_type_id", %{member: member} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id
|
||||
}
|
||||
|
||||
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||
|
||||
assert error_on_field?(error, :membership_fee_type_id) or
|
||||
error_on_field?(error, :membership_fee_type)
|
||||
end
|
||||
|
||||
test "status defaults to :unpaid", %{member: member, fee_type: fee_type} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
}
|
||||
|
||||
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert cycle.status == :unpaid
|
||||
end
|
||||
|
||||
test "validates status enum values - unpaid", %{member: member, fee_type: fee_type} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :unpaid
|
||||
}
|
||||
|
||||
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert cycle.status == :unpaid
|
||||
end
|
||||
|
||||
test "validates status enum values - paid", %{member: member, fee_type: fee_type} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-02-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :paid
|
||||
}
|
||||
|
||||
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert cycle.status == :paid
|
||||
end
|
||||
|
||||
test "validates status enum values - suspended", %{member: member, fee_type: fee_type} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-03-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :suspended
|
||||
}
|
||||
|
||||
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert cycle.status == :suspended
|
||||
end
|
||||
|
||||
test "rejects invalid status values", %{member: member, fee_type: fee_type} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :cancelled
|
||||
}
|
||||
|
||||
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert error_on_field?(error, :status)
|
||||
end
|
||||
|
||||
test "rejects negative amount", %{member: member, fee_type: fee_type} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-04-01],
|
||||
amount: Decimal.new("-50.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
}
|
||||
|
||||
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert error_on_field?(error, :amount)
|
||||
end
|
||||
|
||||
test "accepts zero amount", %{member: member, fee_type: fee_type} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-05-01],
|
||||
amount: Decimal.new("0.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
}
|
||||
|
||||
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert Decimal.equal?(cycle.amount, Decimal.new("0.00"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "uniqueness constraint" do
|
||||
test "cannot create duplicate cycle for same member and cycle_start", %{
|
||||
member: member,
|
||||
fee_type: fee_type
|
||||
} do
|
||||
attrs = %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
}
|
||||
|
||||
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs)
|
||||
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||
|
||||
# Should fail due to uniqueness constraint
|
||||
assert is_struct(error, Ash.Error.Invalid)
|
||||
end
|
||||
|
||||
test "can create cycles for same member with different cycle_start", %{
|
||||
member: member,
|
||||
fee_type: fee_type
|
||||
} do
|
||||
attrs1 = %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
}
|
||||
|
||||
attrs2 = %{
|
||||
cycle_start: ~D[2025-02-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
}
|
||||
|
||||
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs1)
|
||||
assert {:ok, _cycle2} = Ash.create(MembershipFeeCycle, attrs2)
|
||||
end
|
||||
|
||||
test "can create cycles for different members with same cycle_start", %{fee_type: fee_type} do
|
||||
{:ok, member1} =
|
||||
Ash.create(Member, %{
|
||||
first_name: "Member",
|
||||
last_name: "One",
|
||||
email: "member.one.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|
||||
{:ok, member2} =
|
||||
Ash.create(Member, %{
|
||||
first_name: "Member",
|
||||
last_name: "Two",
|
||||
email: "member.two.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|
||||
attrs1 = %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member1.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
}
|
||||
|
||||
attrs2 = %{
|
||||
cycle_start: ~D[2025-01-01],
|
||||
amount: Decimal.new("100.00"),
|
||||
member_id: member2.id,
|
||||
membership_fee_type_id: fee_type.id
|
||||
}
|
||||
|
||||
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs1)
|
||||
assert {:ok, _cycle2} = Ash.create(MembershipFeeCycle, attrs2)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to check if an error occurred on a specific field
|
||||
defp error_on_field?(%Ash.Error.Invalid{} = error, field) do
|
||||
Enum.any?(error.errors, fn e ->
|
||||
case e do
|
||||
%{field: ^field} -> true
|
||||
%{fields: fields} when is_list(fields) -> field in fields
|
||||
_ -> false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp error_on_field?(_, _), do: false
|
||||
end
|
||||
172
test/membership_fees/membership_fee_type_test.exs
Normal file
172
test/membership_fees/membership_fee_type_test.exs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
||||
@moduledoc """
|
||||
Tests for MembershipFeeType resource.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
describe "create MembershipFeeType" do
|
||||
test "can create membership fee type with valid attributes" do
|
||||
attrs = %{
|
||||
name: "Standard Membership",
|
||||
amount: Decimal.new("120.00"),
|
||||
interval: :yearly,
|
||||
description: "Standard yearly membership fee"
|
||||
}
|
||||
|
||||
assert {:ok, %MembershipFeeType{} = fee_type} =
|
||||
Ash.create(MembershipFeeType, attrs)
|
||||
|
||||
assert fee_type.name == "Standard Membership"
|
||||
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
|
||||
assert fee_type.interval == :yearly
|
||||
assert fee_type.description == "Standard yearly membership fee"
|
||||
end
|
||||
|
||||
test "can create membership fee type without description" do
|
||||
attrs = %{
|
||||
name: "Basic",
|
||||
amount: Decimal.new("60.00"),
|
||||
interval: :monthly
|
||||
}
|
||||
|
||||
assert {:ok, %MembershipFeeType{}} = Ash.create(MembershipFeeType, attrs)
|
||||
end
|
||||
|
||||
test "requires name" do
|
||||
attrs = %{
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
||||
assert error_on_field?(error, :name)
|
||||
end
|
||||
|
||||
test "requires amount" do
|
||||
attrs = %{
|
||||
name: "Test Fee",
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
||||
assert error_on_field?(error, :amount)
|
||||
end
|
||||
|
||||
test "requires interval" do
|
||||
attrs = %{
|
||||
name: "Test Fee",
|
||||
amount: Decimal.new("100.00")
|
||||
}
|
||||
|
||||
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
||||
assert error_on_field?(error, :interval)
|
||||
end
|
||||
|
||||
test "validates interval enum values - monthly" do
|
||||
attrs = %{name: "Monthly", amount: Decimal.new("10.00"), interval: :monthly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
|
||||
assert fee_type.interval == :monthly
|
||||
end
|
||||
|
||||
test "validates interval enum values - quarterly" do
|
||||
attrs = %{name: "Quarterly", amount: Decimal.new("30.00"), interval: :quarterly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
|
||||
assert fee_type.interval == :quarterly
|
||||
end
|
||||
|
||||
test "validates interval enum values - half_yearly" do
|
||||
attrs = %{name: "Half Yearly", amount: Decimal.new("60.00"), interval: :half_yearly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
|
||||
assert fee_type.interval == :half_yearly
|
||||
end
|
||||
|
||||
test "validates interval enum values - yearly" do
|
||||
attrs = %{name: "Yearly", amount: Decimal.new("120.00"), interval: :yearly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
|
||||
assert fee_type.interval == :yearly
|
||||
end
|
||||
|
||||
test "rejects invalid interval values" do
|
||||
attrs = %{name: "Invalid", amount: Decimal.new("100.00"), interval: :weekly}
|
||||
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
||||
assert error_on_field?(error, :interval)
|
||||
end
|
||||
|
||||
test "name must be unique" do
|
||||
attrs = %{name: "Unique Name", amount: Decimal.new("100.00"), interval: :yearly}
|
||||
|
||||
assert {:ok, _} = Ash.create(MembershipFeeType, attrs)
|
||||
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
||||
|
||||
# Check for uniqueness error
|
||||
assert error_on_field?(error, :name)
|
||||
end
|
||||
|
||||
test "rejects negative amount" do
|
||||
attrs = %{name: "Negative Test", amount: Decimal.new("-10.00"), interval: :yearly}
|
||||
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
||||
assert error_on_field?(error, :amount)
|
||||
end
|
||||
|
||||
test "accepts zero amount" do
|
||||
attrs = %{name: "Zero Amount", amount: Decimal.new("0.00"), interval: :yearly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
|
||||
assert Decimal.equal?(fee_type.amount, Decimal.new("0.00"))
|
||||
end
|
||||
|
||||
test "amount respects scale of 2 decimal places" do
|
||||
attrs = %{name: "Scale Test", amount: Decimal.new("100.50"), interval: :yearly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
|
||||
assert Decimal.equal?(fee_type.amount, Decimal.new("100.50"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "update MembershipFeeType" do
|
||||
setup do
|
||||
{:ok, fee_type} =
|
||||
Ash.create(MembershipFeeType, %{
|
||||
name: "Original Name",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :yearly,
|
||||
description: "Original description"
|
||||
})
|
||||
|
||||
%{fee_type: fee_type}
|
||||
end
|
||||
|
||||
test "can update name", %{fee_type: fee_type} do
|
||||
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"})
|
||||
assert updated.name == "Updated Name"
|
||||
end
|
||||
|
||||
test "can update amount", %{fee_type: fee_type} do
|
||||
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")})
|
||||
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
|
||||
end
|
||||
|
||||
test "can update description", %{fee_type: fee_type} do
|
||||
assert {:ok, updated} = Ash.update(fee_type, %{description: "Updated description"})
|
||||
assert updated.description == "Updated description"
|
||||
end
|
||||
|
||||
test "can clear description", %{fee_type: fee_type} do
|
||||
assert {:ok, updated} = Ash.update(fee_type, %{description: nil})
|
||||
assert updated.description == nil
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to check if an error occurred on a specific field
|
||||
defp error_on_field?(%Ash.Error.Invalid{} = error, field) do
|
||||
Enum.any?(error.errors, fn e ->
|
||||
case e do
|
||||
%{field: ^field} -> true
|
||||
%{fields: fields} when is_list(fields) -> field in fields
|
||||
_ -> false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp error_on_field?(_, _), do: false
|
||||
end
|
||||
181
test/mv/membership_fees/calendar_cycles_test.exs
Normal file
181
test/mv/membership_fees/calendar_cycles_test.exs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
defmodule Mv.MembershipFees.CalendarCyclesTest do
|
||||
@moduledoc """
|
||||
Tests for CalendarCycles module.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
|
||||
doctest Mv.MembershipFees.CalendarCycles
|
||||
|
||||
describe "calculate_cycle_start/3" do
|
||||
test "uses reference_date when provided" do
|
||||
date = ~D[2024-03-15]
|
||||
reference = ~D[2024-05-20]
|
||||
|
||||
assert CalendarCycles.calculate_cycle_start(date, :monthly, reference) == ~D[2024-05-01]
|
||||
assert CalendarCycles.calculate_cycle_start(date, :quarterly, reference) == ~D[2024-04-01]
|
||||
end
|
||||
end
|
||||
|
||||
describe "current_cycle?/3" do
|
||||
# Basic examples are covered by doctests
|
||||
|
||||
test "works for all interval types" do
|
||||
today = ~D[2024-03-15]
|
||||
|
||||
for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do
|
||||
cycle_start = CalendarCycles.calculate_cycle_start(today, interval)
|
||||
result = CalendarCycles.current_cycle?(cycle_start, interval, today)
|
||||
|
||||
assert result == true, "Expected current cycle for #{interval} with start #{cycle_start}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "current_cycle?/2 wrapper" do
|
||||
test "calls current_cycle?/3 with Date.utc_today()" do
|
||||
today = Date.utc_today()
|
||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
||||
|
||||
# This test verifies the wrapper works, but uses actual today
|
||||
# The real testing happens in current_cycle?/3 tests above
|
||||
result = CalendarCycles.current_cycle?(cycle_start, :monthly)
|
||||
|
||||
assert result == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "last_completed_cycle?/3" do
|
||||
# Basic examples are covered by doctests
|
||||
|
||||
test "returns false when next cycle has also ended" do
|
||||
# Two cycles ago: cycle ended, but next cycle also ended
|
||||
today = ~D[2024-05-15]
|
||||
cycle_start = ~D[2024-03-01]
|
||||
# Cycle ended 2024-03-31, next cycle ended 2024-04-30, today is 2024-05-15
|
||||
|
||||
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false
|
||||
end
|
||||
|
||||
test "works correctly for quarterly intervals" do
|
||||
# Q1 2024 ended on 2024-03-31
|
||||
# Q2 2024 ends on 2024-06-30
|
||||
# Today is 2024-04-15 (after Q1 ended, before Q2 ended)
|
||||
today = ~D[2024-04-15]
|
||||
past_quarter_start = ~D[2024-01-01]
|
||||
|
||||
assert CalendarCycles.last_completed_cycle?(past_quarter_start, :quarterly, today) == true
|
||||
end
|
||||
|
||||
test "returns false when cycle ended on the given date" do
|
||||
# Cycle ends on today, so it's still current, not completed
|
||||
today = ~D[2024-03-31]
|
||||
cycle_start = ~D[2024-03-01]
|
||||
|
||||
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false
|
||||
end
|
||||
end
|
||||
|
||||
describe "last_completed_cycle?/2 wrapper" do
|
||||
test "calls last_completed_cycle?/3 with Date.utc_today()" do
|
||||
today = Date.utc_today()
|
||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
||||
|
||||
# This test verifies the wrapper works, but uses actual today
|
||||
# The real testing happens in last_completed_cycle?/3 tests above
|
||||
result = CalendarCycles.last_completed_cycle?(cycle_start, :monthly)
|
||||
|
||||
# Result depends on actual today, so we just verify it's a boolean
|
||||
assert is_boolean(result)
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "leap year: February has 29 days" do
|
||||
# 2024 is a leap year
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) == ~D[2024-02-29]
|
||||
|
||||
# 2023 is not a leap year
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2023-02-01], :monthly) == ~D[2023-02-28]
|
||||
end
|
||||
|
||||
test "year boundary: December 31 to January 1" do
|
||||
# Yearly cycle
|
||||
assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01]
|
||||
|
||||
# Monthly cycle across year boundary
|
||||
assert CalendarCycles.next_cycle_start(~D[2024-12-01], :monthly) == ~D[2025-01-01]
|
||||
|
||||
# Half-yearly cycle across year boundary
|
||||
assert CalendarCycles.next_cycle_start(~D[2024-07-01], :half_yearly) == ~D[2025-01-01]
|
||||
|
||||
# Quarterly cycle across year boundary
|
||||
assert CalendarCycles.next_cycle_start(~D[2024-10-01], :quarterly) == ~D[2025-01-01]
|
||||
end
|
||||
|
||||
test "month boundary: different month lengths" do
|
||||
# 31-day months
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :monthly) == ~D[2024-01-31]
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) == ~D[2024-03-31]
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-05-01], :monthly) == ~D[2024-05-31]
|
||||
|
||||
# 30-day months
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :monthly) == ~D[2024-04-30]
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-06-01], :monthly) == ~D[2024-06-30]
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-09-01], :monthly) == ~D[2024-09-30]
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-11-01], :monthly) == ~D[2024-11-30]
|
||||
end
|
||||
|
||||
test "date in middle of cycle: all functions work correctly" do
|
||||
middle_date = ~D[2024-03-15]
|
||||
|
||||
# calculate_cycle_start
|
||||
assert CalendarCycles.calculate_cycle_start(middle_date, :monthly) == ~D[2024-03-01]
|
||||
assert CalendarCycles.calculate_cycle_start(middle_date, :quarterly) == ~D[2024-01-01]
|
||||
assert CalendarCycles.calculate_cycle_start(middle_date, :half_yearly) == ~D[2024-01-01]
|
||||
assert CalendarCycles.calculate_cycle_start(middle_date, :yearly) == ~D[2024-01-01]
|
||||
|
||||
# calculate_cycle_end
|
||||
monthly_start = CalendarCycles.calculate_cycle_start(middle_date, :monthly)
|
||||
assert CalendarCycles.calculate_cycle_end(monthly_start, :monthly) == ~D[2024-03-31]
|
||||
|
||||
# next_cycle_start
|
||||
assert CalendarCycles.next_cycle_start(monthly_start, :monthly) == ~D[2024-04-01]
|
||||
end
|
||||
|
||||
test "quarterly: all quarter boundaries correct" do
|
||||
# Q1 boundaries
|
||||
assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :quarterly) == ~D[2024-01-01]
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) == ~D[2024-03-31]
|
||||
|
||||
# Q2 boundaries
|
||||
assert CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) == ~D[2024-04-01]
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) == ~D[2024-06-30]
|
||||
|
||||
# Q3 boundaries
|
||||
assert CalendarCycles.calculate_cycle_start(~D[2024-08-15], :quarterly) == ~D[2024-07-01]
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :quarterly) == ~D[2024-09-30]
|
||||
|
||||
# Q4 boundaries
|
||||
assert CalendarCycles.calculate_cycle_start(~D[2024-11-15], :quarterly) == ~D[2024-10-01]
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-10-01], :quarterly) == ~D[2024-12-31]
|
||||
end
|
||||
|
||||
test "half_yearly: both half boundaries correct" do
|
||||
# First half boundaries
|
||||
assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :half_yearly) == ~D[2024-01-01]
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) == ~D[2024-06-30]
|
||||
|
||||
# Second half boundaries
|
||||
assert CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) == ~D[2024-07-01]
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :half_yearly) == ~D[2024-12-31]
|
||||
end
|
||||
|
||||
test "yearly: full year boundaries" do
|
||||
assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :yearly) == ~D[2024-01-01]
|
||||
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) == ~D[2024-12-31]
|
||||
assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01]
|
||||
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