diff --git a/config/test.exs b/config/test.exs
index 2c4d2ba..326694e 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -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
diff --git a/docs/membership-fee-architecture.md b/docs/membership-fee-architecture.md
index c601b79..d6b5ee2 100644
--- a/docs/membership-fee-architecture.md
+++ b/docs/membership-fee-architecture.md
@@ -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:**
diff --git a/docs/membership-fee-overview.md b/docs/membership-fee-overview.md
index 229b73b..bd47faa 100644
--- a/docs/membership-fee-overview.md
+++ b/docs/membership-fee-overview.md
@@ -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"
diff --git a/lib/membership/member.ex b/lib/membership/member.ex
index 5816d19..ae32abd 100644
--- a/lib/membership/member.ex
+++ b/lib/membership/member.ex
@@ -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
diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex
index 52c0328..eedc47c 100644
--- a/lib/membership/setting.ex
+++ b/lib/membership/setting.ex
@@ -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,14 @@ 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]
+
+ change Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId
+ end
end
validations do
@@ -113,6 +143,41 @@ defmodule Mv.Membership.Setting do
end
end,
on: [:create, :update]
+
+ # Validate default_membership_fee_type_id exists if set
+ validate fn changeset, _context ->
+ fee_type_id =
+ Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
+
+ if fee_type_id do
+ case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id) do
+ {:ok, _} ->
+ :ok
+
+ {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
+ {:error,
+ field: :default_membership_fee_type_id,
+ message: "Membership fee type not found"}
+
+ {:error, err} ->
+ # Log unexpected errors (DB timeout, connection errors, etc.)
+ require Logger
+
+ Logger.warning(
+ "Unexpected error when validating default_membership_fee_type_id: #{inspect(err)}"
+ )
+
+ # Return generic error to user
+ {:error,
+ field: :default_membership_fee_type_id,
+ message: "Could not validate membership fee type"}
+ end
+ else
+ # Optional, can be nil
+ :ok
+ end
+ end,
+ on: [:create, :update]
end
attributes do
@@ -133,6 +198,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
diff --git a/lib/membership/setting/changes/normalize_default_fee_type_id.ex b/lib/membership/setting/changes/normalize_default_fee_type_id.ex
new file mode 100644
index 0000000..fdbe1c8
--- /dev/null
+++ b/lib/membership/setting/changes/normalize_default_fee_type_id.ex
@@ -0,0 +1,19 @@
+defmodule Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId do
+ @moduledoc """
+ Ash change that normalizes empty strings to nil for default_membership_fee_type_id.
+
+ HTML forms submit empty select values as empty strings (""), but the database
+ expects nil for optional UUID fields. This change converts "" to nil.
+ """
+ use Ash.Resource.Change
+
+ def change(changeset, _opts, _context) do
+ default_fee_type_id = Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
+
+ if default_fee_type_id == "" do
+ Ash.Changeset.force_change_attribute(changeset, :default_membership_fee_type_id, nil)
+ else
+ changeset
+ end
+ end
+end
diff --git a/lib/membership_fees/changes/set_membership_fee_start_date.ex b/lib/membership_fees/changes/set_membership_fee_start_date.ex
new file mode 100644
index 0000000..a2e1ad0
--- /dev/null
+++ b/lib/membership_fees/changes/set_membership_fee_start_date.ex
@@ -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
diff --git a/lib/membership_fees/membership_fee_type.ex b/lib/membership_fees/membership_fee_type.ex
index 877a385..01ae625 100644
--- a/lib/membership_fees/membership_fee_type.ex
+++ b/lib/membership_fees/membership_fee_type.ex
@@ -36,7 +36,7 @@ defmodule Mv.MembershipFees.MembershipFeeType do
end
actions do
- defaults [:read, :destroy]
+ defaults [:read]
create :create do
primary? true
@@ -45,10 +45,108 @@ defmodule Mv.MembershipFees.MembershipFeeType do
update :update do
primary? true
+ # require_atomic? false because validation queries (member/cycle counts) are not atomic
+ # DB constraints serve as the final safeguard if data changes between validation and update
+ require_atomic? false
# 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
+
+ destroy :destroy do
+ primary? true
+
+ # require_atomic? false because validation queries (member/cycle/settings counts) are not atomic
+ # DB constraints serve as the final safeguard if data changes between validation and delete
+ require_atomic? false
+ end
+ end
+
+ validations do
+ # Prevent interval changes after creation
+ validate fn changeset, _context ->
+ if Ash.Changeset.changing_attribute?(changeset, :interval) do
+ case changeset.data do
+ # Creating new resource, interval can be set
+ nil ->
+ :ok
+
+ _existing ->
+ {:error,
+ field: :interval, message: "Interval cannot be changed after creation"}
+ end
+ else
+ :ok
+ end
+ end,
+ on: [:update]
+
+ # Prevent deletion if assigned to members
+ validate fn changeset, _context ->
+ if changeset.action_type == :destroy do
+ require Ash.Query
+
+ member_count =
+ Mv.Membership.Member
+ |> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
+ |> Ash.count!()
+
+ if member_count > 0 do
+ {:error,
+ message:
+ "Cannot delete membership fee type: #{member_count} member(s) are assigned to it"}
+ else
+ :ok
+ end
+ else
+ :ok
+ end
+ end,
+ on: [:destroy]
+
+ # Prevent deletion if cycles exist
+ validate fn changeset, _context ->
+ if changeset.action_type == :destroy do
+ require Ash.Query
+
+ cycle_count =
+ Mv.MembershipFees.MembershipFeeCycle
+ |> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
+ |> Ash.count!()
+
+ if cycle_count > 0 do
+ {:error,
+ message:
+ "Cannot delete membership fee type: #{cycle_count} cycle(s) reference it"}
+ else
+ :ok
+ end
+ else
+ :ok
+ end
+ end,
+ on: [:destroy]
+
+ # Prevent deletion if used as default in settings
+ validate fn changeset, _context ->
+ if changeset.action_type == :destroy do
+ require Ash.Query
+
+ setting_count =
+ Mv.Membership.Setting
+ |> Ash.Query.filter(default_membership_fee_type_id == ^changeset.data.id)
+ |> Ash.count!()
+
+ if setting_count > 0 do
+ {:error,
+ message: "Cannot delete membership fee type: it's used as default in settings"}
+ else
+ :ok
+ end
+ else
+ :ok
+ end
+ end,
+ on: [:destroy]
end
attributes do
diff --git a/lib/mv/membership_fees/calendar_cycles.ex b/lib/mv/membership_fees/calendar_cycles.ex
new file mode 100644
index 0000000..8a4ef24
--- /dev/null
+++ b/lib/mv/membership_fees/calendar_cycles.ex
@@ -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
diff --git a/lib/mv/membership_fees/cycle_generation_job.ex b/lib/mv/membership_fees/cycle_generation_job.ex
new file mode 100644
index 0000000..71a3158
--- /dev/null
+++ b/lib/mv/membership_fees/cycle_generation_job.ex
@@ -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
diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex
new file mode 100644
index 0000000..0727a62
--- /dev/null
+++ b/lib/mv/membership_fees/cycle_generator.ex
@@ -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
diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex
index 4246c99..6aee397 100644
--- a/lib/mv_web/components/layouts/navbar.ex
+++ b/lib/mv_web/components/layouts/navbar.ex
@@ -31,7 +31,9 @@ defmodule MvWeb.Layouts.Navbar do
- {gettext(
- "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
- )}
-