Compare commits

..

11 commits

Author SHA1 Message Date
ed083830b9 refactor: improve cycle generation code quality and documentation
All checks were successful
continuous-integration/drone/push Build is passing
- Remove Process.sleep calls from integration tests (tests run synchronously in SQL sandbox)
- Improve error handling: membership_fee_type_not_found now returns changeset error instead of just logging
- Clarify partial_failure documentation: successful_cycles are not persisted on rollback
- Update documentation: joined_at → join_date, left_at → exit_date
- Document PostgreSQL advisory locks per member (not whole table lock)
- Document gap handling: explicitly deleted cycles are not recreated
2025-12-16 16:40:11 +01:00
62a2bd41ea fix: handle Ash notifications in CycleGenerator transactions
- Use return_notifications?: true when creating cycles within transaction
- Collect notifications and send them after transaction commits
- Prevents 'Missed notifications' warnings in test output
- Notifications are now properly sent via Ash.Notifier.notify/1
2025-12-16 16:40:11 +01:00
d9ca6b1763 test: fix date dependencies in cycle generator tests
- Add create_member_with_cycles helper that uses fixed 'today' date
- Update tests to use explicit 'today:' option instead of Date.utc_today()
- Prevents test failures when current date changes (e.g., in 2026+)
- Tests now explicitly delete and regenerate cycles with fixed dates
- Ensures consistent test behavior regardless of execution date
2025-12-16 16:40:11 +01:00
de7a94ab07 fix: CycleGenerator generates from last cycle, not filling gaps
- Change algorithm to start from last existing cycle instead of start_date
- Deleted cycles (gaps) are no longer automatically filled
- Add test to verify gaps remain unfilled
- Update documentation to clarify gap handling behavior
2025-12-16 16:40:11 +01:00
539084fdf1 test: make CycleGenerator tests more robust
- Replace weak assertions (>= 0, if length > 0) with concrete expectations
- Remove unnecessary Process.sleep calls (tests run synchronously)
- Add get_member_cycles helper for direct cycle verification
- Tests now validate actual generated cycles instead of relying on async behavior
2025-12-16 16:40:11 +01:00
13790dda43 feat: improve error handling in CycleGenerator
- Handle Task crashes in async_stream with {:exit, reason}
- Return {:error, {:partial_failure, successes, errors}} when some cycles fail
- Previously returned {:ok, successful} even on partial failures
- Improves debuggability and allows callers to handle partial failures
2025-12-16 16:40:11 +01:00
d01033c720 feat: include inactive members in batch cycle generation
- Remove exit_date filter from generate_cycles_for_all_members query
- Inactive members now get cycles generated up to their exit_date
- Add tests for inactive member processing and exit_date boundary
- Document exit_date == cycle_start behavior (cycle still generated)
2025-12-16 16:40:11 +01:00
78747d7da0 refactor: improve SetMembershipFeeStartDate change module
- Add warning logging for unexpected errors (not missing prerequisites)
- Use CalendarCycles.interval() type instead of generic atom()
- Update moduledoc to reflect actual usage (no where clause needed)
2025-12-16 16:40:11 +01:00
e899004b3c feat: add error logging in after_action cycle generation hooks
- Log warnings when cycle generation fails in Member create/update
- Extract generate_fn to reduce code duplication
- Improves debuggability of silent failures
2025-12-16 16:40:11 +01:00
434bcd269f refactor: use sql_sandbox config instead of env for sync/async
- Replace Application.get_env(:mv, :env) with :sql_sandbox config
- Remove redundant :env config from test.exs
- More explicit and less error-prone for test environment detection
2025-12-16 16:40:11 +01:00
25cc41b02e feat: implement automatic cycle generation for members
- Add CycleGenerator module with advisory lock mechanism
- Add SetMembershipFeeStartDate change for auto-calculation
- Extend Settings with include_joining_cycle and default_membership_fee_type_id
- Add scheduled job skeleton for future Oban integration
2025-12-16 16:40:11 +01:00
17 changed files with 3076 additions and 29 deletions

View file

@ -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

View file

@ -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:**

View file

@ -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"

View file

@ -80,7 +80,7 @@ defmodule Mv.Membership.Member do
argument :user, :map, allow_nil?: true
# Accept member fields plus membership_fee_type_id (belongs_to FK)
accept @member_fields ++ [:membership_fee_type_id]
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
change manage_relationship(:custom_field_values, type: :create)
@ -101,6 +101,42 @@ defmodule Mv.Membership.Member do
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
# Auto-calculate membership_fee_start_date if not manually set
# Requires both join_date and membership_fee_type_id to be present
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
# Trigger cycle generation after member creation
# Only runs if membership_fee_type_id is set
# Note: Cycle generation runs asynchronously to not block the action,
# but in test environment it runs synchronously for DB sandbox compatibility
change after_action(fn _changeset, member, _context ->
if member.membership_fee_type_id && member.join_date do
generate_fn = fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _cycles} ->
:ok
{:error, reason} ->
require Logger
Logger.warning(
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
)
end
end
if Application.get_env(:mv, :sql_sandbox, false) do
# Run synchronously in test environment for DB sandbox compatibility
generate_fn.()
else
# Run asynchronously in other environments
Task.start(generate_fn)
end
end
{:ok, member}
end)
end
update :update_member do
@ -114,7 +150,7 @@ defmodule Mv.Membership.Member do
argument :user, :map, allow_nil?: true
# Accept member fields plus membership_fee_type_id (belongs_to FK)
accept @member_fields ++ [:membership_fee_type_id]
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
@ -141,6 +177,46 @@ defmodule Mv.Membership.Member do
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
# Auto-calculate membership_fee_start_date when membership_fee_type_id is set
# and membership_fee_start_date is not already set
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
where [changing(:membership_fee_type_id)]
end
# Trigger cycle generation when membership_fee_type_id changes
# Note: Cycle generation runs asynchronously to not block the action,
# but in test environment it runs synchronously for DB sandbox compatibility
change after_action(fn changeset, member, _context ->
fee_type_changed =
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
if fee_type_changed && member.membership_fee_type_id && member.join_date do
generate_fn = fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _cycles} ->
:ok
{:error, reason} ->
require Logger
Logger.warning(
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
)
end
end
if Application.get_env(:mv, :sql_sandbox, false) do
# Run synchronously in test environment for DB sandbox compatibility
generate_fn.()
else
# Run asynchronously in other environments
Task.start(generate_fn)
end
end
{:ok, member}
end)
end
# Action to handle fuzzy search on specific fields

View file

@ -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

View 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

View 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

View 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

View file

@ -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

View 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"
}

View file

@ -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"
}

View file

@ -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"
}

View 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"
}

View file

@ -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

View 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

View 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

View 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