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..7c8da24 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
@@ -278,8 +282,14 @@ lib/
**Implementation Pattern:**
- Use Ash change module to validate
-- Use after_action hook to trigger regeneration
-- Use transaction to ensure atomicity
+- Use after_action hook to trigger regeneration synchronously
+- Regeneration runs in the same transaction as the member update to ensure atomicity
+- CycleGenerator uses advisory locks and transactions internally to prevent race conditions
+
+**Validation Behavior:**
+
+- Fail-closed: If membership fee types cannot be loaded during validation, the change is rejected with a validation error
+- Nil assignment prevention: Attempts to set membership_fee_type_id to nil are rejected when a current type exists
---
@@ -381,7 +391,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 +401,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
@@ -413,7 +423,7 @@ lib/
**AC-TC-3:** On allowed change: future unpaid cycles regenerated
**AC-TC-4:** On allowed change: paid/suspended cycles unchanged
**AC-TC-5:** On allowed change: amount updated to new type's amount
-**AC-TC-6:** Change is atomic (transaction)
+**AC-TC-6:** Change is atomic (transaction) - ✅ Implemented: Regeneration runs synchronously in the same transaction as the member update
### Settings
@@ -472,8 +482,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..787b1d1 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,64 @@ defmodule Mv.Membership.Member do
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
+
+ # Auto-calculate membership_fee_start_date if not manually set
+ # Requires both join_date and membership_fee_type_id to be present
+ change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
+
+ # Trigger cycle generation after member creation
+ # Only runs if membership_fee_type_id is set
+ # Note: Cycle generation runs asynchronously to not block the action,
+ # but in test environment it runs synchronously for DB sandbox compatibility
+ change after_action(fn _changeset, member, _context ->
+ if member.membership_fee_type_id && member.join_date do
+ if Application.get_env(:mv, :sql_sandbox, false) do
+ # Run synchronously in test environment for DB sandbox compatibility
+ # Use skip_lock?: true to avoid nested transactions (after_action runs within action transaction)
+ # Return notifications to Ash so they are sent after commit
+ case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
+ member.id,
+ today: Date.utc_today(),
+ skip_lock?: true
+ ) do
+ {:ok, _cycles, notifications} ->
+ {:ok, member, notifications}
+
+ {:error, reason} ->
+ require Logger
+
+ Logger.warning(
+ "Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
+ )
+
+ {:ok, member}
+ end
+ else
+ # Run asynchronously in other environments
+ # Send notifications explicitly since they cannot be returned via after_action
+ Task.start(fn ->
+ case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
+ {:ok, _cycles, notifications} ->
+ # Send notifications manually for async case
+ if Enum.any?(notifications) do
+ Ash.Notifier.notify(notifications)
+ end
+
+ {:error, reason} ->
+ require Logger
+
+ Logger.warning(
+ "Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
+ )
+ end
+ end)
+
+ {:ok, member}
+ end
+ else
+ {:ok, member}
+ end
+ end)
end
update :update_member do
@@ -114,7 +172,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 +199,46 @@ defmodule Mv.Membership.Member do
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
+
+ # Validate that membership fee type changes only allow same-interval types
+ change Mv.MembershipFees.Changes.ValidateSameInterval do
+ where [changing(:membership_fee_type_id)]
+ 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 regeneration when membership_fee_type_id changes
+ # This deletes future unpaid cycles and regenerates them with the new type/amount
+ # Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
+ # CycleGenerator uses advisory locks and transactions internally to prevent race conditions
+ # Notifications are returned to Ash and sent automatically after commit
+ 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
+ case regenerate_cycles_on_type_change(member) do
+ {:ok, notifications} ->
+ # Return notifications to Ash - they will be sent automatically after commit
+ {:ok, member, notifications}
+
+ {:error, reason} ->
+ require Logger
+
+ Logger.warning(
+ "Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
+ )
+
+ {:ok, member}
+ end
+ else
+ {:ok, member}
+ end
+ end)
end
# Action to handle fuzzy search on specific fields
@@ -425,6 +523,50 @@ defmodule Mv.Membership.Member do
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
end
+ calculations do
+ calculate :current_cycle_status, :atom do
+ description "Status of the current cycle (the one that is active today)"
+ # Automatically load cycles with all attributes and membership_fee_type
+ load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
+
+ calculation fn [member], _context ->
+ case get_current_cycle(member) do
+ nil -> [nil]
+ cycle -> [cycle.status]
+ end
+ end
+
+ constraints one_of: [:unpaid, :paid, :suspended]
+ end
+
+ calculate :last_cycle_status, :atom do
+ description "Status of the last completed cycle (the most recent cycle that has ended)"
+ # Automatically load cycles with all attributes and membership_fee_type
+ load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
+
+ calculation fn [member], _context ->
+ case get_last_completed_cycle(member) do
+ nil -> [nil]
+ cycle -> [cycle.status]
+ end
+ end
+
+ constraints one_of: [:unpaid, :paid, :suspended]
+ end
+
+ calculate :overdue_count, :integer do
+ description "Count of unpaid cycles that have already ended (cycle_end < today)"
+ # Automatically load cycles with all attributes and membership_fee_type
+ load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
+
+ calculation fn [member], _context ->
+ overdue = get_overdue_cycles(member)
+ count = if is_list(overdue), do: length(overdue), else: 0
+ [count]
+ end
+ end
+ end
+
# Define identities for upsert operations
identities do
identity :unique_email, [:email]
@@ -471,6 +613,261 @@ defmodule Mv.Membership.Member do
def show_in_overview?(_), do: true
+ # Helper functions for cycle status calculations
+ #
+ # These functions expect membership_fee_cycles to be loaded with membership_fee_type
+ # preloaded. The calculations explicitly load this relationship, but if called
+ # directly, ensure membership_fee_type is loaded or the functions will return
+ # nil/[] when membership_fee_type is missing.
+
+ @doc false
+ @spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
+ def get_current_cycle(member) do
+ today = Date.utc_today()
+
+ # Check if cycles are already loaded
+ cycles = Map.get(member, :membership_fee_cycles)
+
+ if is_list(cycles) and cycles != [] do
+ Enum.find(cycles, ¤t_cycle?(&1, today))
+ else
+ nil
+ end
+ end
+
+ # Checks if a cycle is the current cycle (active today)
+ defp current_cycle?(cycle, today) do
+ case Map.get(cycle, :membership_fee_type) do
+ %{interval: interval} ->
+ cycle_end =
+ Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
+
+ Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
+ Date.compare(today, cycle_end) in [:lt, :eq]
+
+ _ ->
+ false
+ end
+ end
+
+ @doc false
+ @spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
+ def get_last_completed_cycle(member) do
+ today = Date.utc_today()
+
+ # Check if cycles are already loaded
+ cycles = Map.get(member, :membership_fee_cycles)
+
+ if is_list(cycles) and cycles != [] do
+ cycles
+ |> filter_completed_cycles(today)
+ |> sort_cycles_by_end_date()
+ |> List.first()
+ else
+ nil
+ end
+ end
+
+ # Filters cycles that have ended (cycle_end < today)
+ defp filter_completed_cycles(cycles, today) do
+ Enum.filter(cycles, fn cycle ->
+ case Map.get(cycle, :membership_fee_type) do
+ %{interval: interval} ->
+ cycle_end =
+ Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
+
+ Date.compare(today, cycle_end) == :gt
+
+ _ ->
+ false
+ end
+ end)
+ end
+
+ # Sorts cycles by end date in descending order
+ defp sort_cycles_by_end_date(cycles) do
+ Enum.sort_by(
+ cycles,
+ fn cycle ->
+ interval = Map.get(cycle, :membership_fee_type).interval
+ Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
+ end,
+ {:desc, Date}
+ )
+ end
+
+ @doc false
+ @spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
+ def get_overdue_cycles(member) do
+ today = Date.utc_today()
+
+ # Check if cycles are already loaded
+ cycles = Map.get(member, :membership_fee_cycles)
+
+ if is_list(cycles) and cycles != [] do
+ filter_overdue_cycles(cycles, today)
+ else
+ []
+ end
+ end
+
+ # Filters cycles that are unpaid and have ended (cycle_end < today)
+ defp filter_overdue_cycles(cycles, today) do
+ Enum.filter(cycles, fn cycle ->
+ case Map.get(cycle, :membership_fee_type) do
+ %{interval: interval} ->
+ cycle_end =
+ Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
+
+ cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
+
+ _ ->
+ false
+ end
+ end)
+ end
+
+ # Regenerates cycles when membership fee type changes
+ # Deletes future unpaid cycles and regenerates them with the new type/amount
+ # Uses advisory lock to prevent concurrent modifications
+ # Returns {:ok, notifications} or {:error, reason} where notifications are collected
+ # to be sent after transaction commits
+ @doc false
+ def regenerate_cycles_on_type_change(member) do
+ today = Date.utc_today()
+ lock_key = :erlang.phash2(member.id)
+
+ # Use advisory lock to prevent concurrent deletion and regeneration
+ # This ensures atomicity when multiple updates happen simultaneously
+ if Mv.Repo.in_transaction?() do
+ regenerate_cycles_in_transaction(member, today, lock_key)
+ else
+ regenerate_cycles_new_transaction(member, today, lock_key)
+ end
+ end
+
+ # Already in transaction: use advisory lock directly
+ # Returns {:ok, notifications} - notifications should be returned to after_action hook
+ defp regenerate_cycles_in_transaction(member, today, lock_key) do
+ Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
+ do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
+ end
+
+ # Not in transaction: start new transaction with advisory lock
+ # Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
+ defp regenerate_cycles_new_transaction(member, today, lock_key) do
+ Mv.Repo.transaction(fn ->
+ Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
+
+ case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
+ {:ok, notifications} ->
+ # Return notifications - they will be sent by the caller
+ notifications
+
+ {:error, reason} ->
+ Mv.Repo.rollback(reason)
+ end
+ end)
+ |> case do
+ {:ok, notifications} -> {:ok, notifications}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ # Performs the actual cycle deletion and regeneration
+ # Returns {:ok, notifications} or {:error, reason}
+ # notifications are collected to be sent after transaction commits
+ defp do_regenerate_cycles_on_type_change(member, today, opts) do
+ require Ash.Query
+
+ skip_lock? = Keyword.get(opts, :skip_lock?, false)
+
+ # Find all unpaid cycles for this member
+ # We need to check cycle_end for each cycle using its own interval
+ all_unpaid_cycles_query =
+ Mv.MembershipFees.MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.Query.filter(status == :unpaid)
+ |> Ash.Query.load([:membership_fee_type])
+
+ case Ash.read(all_unpaid_cycles_query) do
+ {:ok, all_unpaid_cycles} ->
+ cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
+ delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?)
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ # Filters cycles that haven't ended yet (cycle_end >= today)
+ # These are the "future" cycles that should be regenerated
+ defp filter_future_cycles(all_unpaid_cycles, today) do
+ Enum.filter(all_unpaid_cycles, fn cycle ->
+ case cycle.membership_fee_type do
+ %{interval: interval} ->
+ cycle_end =
+ Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
+
+ Date.compare(today, cycle_end) in [:lt, :eq]
+
+ _ ->
+ false
+ end
+ end)
+ end
+
+ # Deletes future cycles and regenerates them with the new type/amount
+ # Passes today to ensure consistent date across deletion and regeneration
+ # Returns {:ok, notifications} or {:error, reason}
+ defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
+ skip_lock? = Keyword.get(opts, :skip_lock?, false)
+
+ if Enum.empty?(cycles_to_delete) do
+ # No cycles to delete, just regenerate
+ regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
+ else
+ case delete_cycles(cycles_to_delete) do
+ :ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
+ {:error, reason} -> {:error, reason}
+ end
+ end
+ end
+
+ # Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
+ defp delete_cycles(cycles_to_delete) do
+ delete_results =
+ Enum.map(cycles_to_delete, fn cycle ->
+ Ash.destroy(cycle)
+ end)
+
+ if Enum.any?(delete_results, &match?({:error, _}, &1)) do
+ {:error, :deletion_failed}
+ else
+ :ok
+ end
+ end
+
+ # Regenerates cycles with new type/amount
+ # Passes today to ensure consistent date across deletion and regeneration
+ # skip_lock?: true means advisory lock is already set by caller
+ # Returns {:ok, notifications} - notifications should be returned to after_action hook
+ defp regenerate_cycles(member_id, today, opts) do
+ skip_lock? = Keyword.get(opts, :skip_lock?, false)
+
+ case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
+ member_id,
+ today: today,
+ skip_lock?: skip_lock?
+ ) do
+ {:ok, _cycles, notifications} when is_list(notifications) ->
+ {:ok, notifications}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
# Normalizes visibility config map keys from strings to atoms.
# JSONB in PostgreSQL converts atom keys to string keys when storing.
defp normalize_visibility_config(config) when is_map(config) do
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/changes/validate_same_interval.ex b/lib/membership_fees/changes/validate_same_interval.ex
new file mode 100644
index 0000000..8c1efb4
--- /dev/null
+++ b/lib/membership_fees/changes/validate_same_interval.ex
@@ -0,0 +1,148 @@
+defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
+ @moduledoc """
+ Validates that membership fee type changes only allow same-interval types.
+
+ Prevents changing from yearly to monthly, etc. (MVP constraint).
+
+ ## Usage
+
+ In a Member action:
+
+ update :update_member do
+ # ...
+ change Mv.MembershipFees.Changes.ValidateSameInterval
+ end
+
+ The change module only executes when `membership_fee_type_id` is being changed.
+ If the new type has a different interval than the current type, a validation error is returned.
+ """
+ use Ash.Resource.Change
+
+ @impl true
+ def change(changeset, _opts, _context) do
+ if changing_membership_fee_type?(changeset) do
+ validate_interval_match(changeset)
+ else
+ changeset
+ end
+ end
+
+ # Check if membership_fee_type_id is being changed
+ defp changing_membership_fee_type?(changeset) do
+ Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
+ end
+
+ # Validate that the new type has the same interval as the current type
+ defp validate_interval_match(changeset) do
+ current_type_id = get_current_type_id(changeset)
+ new_type_id = get_new_type_id(changeset)
+
+ cond do
+ # If no current type, allow any change (first assignment)
+ is_nil(current_type_id) ->
+ changeset
+
+ # If new type is nil, reject the change (membership_fee_type_id is required)
+ is_nil(new_type_id) ->
+ add_nil_type_error(changeset)
+
+ # Both types exist - validate intervals match
+ true ->
+ validate_intervals_match(changeset, current_type_id, new_type_id)
+ end
+ end
+
+ # Validates that intervals match when both types exist
+ defp validate_intervals_match(changeset, current_type_id, new_type_id) do
+ case get_intervals(current_type_id, new_type_id) do
+ {:ok, current_interval, new_interval} ->
+ if current_interval == new_interval do
+ changeset
+ else
+ add_interval_mismatch_error(changeset, current_interval, new_interval)
+ end
+
+ {:error, reason} ->
+ # Fail closed: If we can't load the types, reject the change
+ # This prevents inconsistent data states
+ add_type_validation_error(changeset, reason)
+ end
+ end
+
+ # Get current type ID from changeset data
+ defp get_current_type_id(changeset) do
+ case changeset.data do
+ %{membership_fee_type_id: type_id} -> type_id
+ _ -> nil
+ end
+ end
+
+ # Get new type ID from changeset
+ defp get_new_type_id(changeset) do
+ case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do
+ {:ok, type_id} -> type_id
+ :error -> nil
+ end
+ end
+
+ # Get intervals for both types
+ defp get_intervals(current_type_id, new_type_id) do
+ alias Mv.MembershipFees.MembershipFeeType
+
+ case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} do
+ {{:ok, current_type}, {:ok, new_type}} ->
+ {:ok, current_type.interval, new_type.interval}
+
+ _ ->
+ {:error, :type_not_found}
+ end
+ end
+
+ # Add validation error for interval mismatch
+ defp add_interval_mismatch_error(changeset, current_interval, new_interval) do
+ current_interval_name = format_interval(current_interval)
+ new_interval_name = format_interval(new_interval)
+
+ message =
+ "Cannot change membership fee type: current type uses #{current_interval_name} interval, " <>
+ "new type uses #{new_interval_name} interval. Only same-interval changes are allowed."
+
+ Ash.Changeset.add_error(
+ changeset,
+ field: :membership_fee_type_id,
+ message: message
+ )
+ end
+
+ # Add validation error when types cannot be loaded
+ defp add_type_validation_error(changeset, _reason) do
+ message =
+ "Could not validate membership fee type intervals. " <>
+ "The current or new membership fee type no longer exists. " <>
+ "This may indicate a data consistency issue."
+
+ Ash.Changeset.add_error(
+ changeset,
+ field: :membership_fee_type_id,
+ message: message
+ )
+ end
+
+ # Add validation error when trying to set membership_fee_type_id to nil
+ defp add_nil_type_error(changeset) do
+ message = "Cannot remove membership fee type. A membership fee type is required."
+
+ Ash.Changeset.add_error(
+ changeset,
+ field: :membership_fee_type_id,
+ message: message
+ )
+ end
+
+ # Format interval atom to human-readable string
+ defp format_interval(:monthly), do: "monthly"
+ defp format_interval(:quarterly), do: "quarterly"
+ defp format_interval(:half_yearly), do: "half-yearly"
+ defp format_interval(:yearly), do: "yearly"
+ defp format_interval(interval), do: to_string(interval)
+end
diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex
index 4c47623..b437ead 100644
--- a/lib/membership_fees/membership_fee_cycle.ex
+++ b/lib/membership_fees/membership_fee_cycle.ex
@@ -51,6 +51,36 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
primary? true
accept [:status, :notes]
end
+
+ update :mark_as_paid do
+ description "Mark cycle as paid"
+ require_atomic? false
+ accept [:notes]
+
+ change fn changeset, _context ->
+ Ash.Changeset.force_change_attribute(changeset, :status, :paid)
+ end
+ end
+
+ update :mark_as_suspended do
+ description "Mark cycle as suspended"
+ require_atomic? false
+ accept [:notes]
+
+ change fn changeset, _context ->
+ Ash.Changeset.force_change_attribute(changeset, :status, :suspended)
+ end
+ end
+
+ update :mark_as_unpaid do
+ description "Mark cycle as unpaid (for error correction)"
+ require_atomic? false
+ accept [:notes]
+
+ change fn changeset, _context ->
+ Ash.Changeset.force_change_attribute(changeset, :status, :unpaid)
+ end
+ end
end
attributes do
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/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..feb7b53
--- /dev/null
+++ b/lib/mv/membership_fees/cycle_generator.ex
@@ -0,0 +1,410 @@
+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.Membership.Member
+ alias Mv.MembershipFees.CalendarCycles
+ alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
+ alias Mv.MembershipFees.MembershipFeeCycle
+ alias Mv.Repo
+
+ require Ash.Query
+ require Logger
+
+ @type generate_result ::
+ {:ok, [MembershipFeeCycle.t()], [Ash.Notifier.Notification.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, notifications}` - List of newly created cycles and notifications
+ - `{:error, reason}` - Error with reason
+
+ ## Examples
+
+ {:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member)
+ {:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member_id)
+ {:ok, cycles, notifications} = 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())
+ skip_lock? = Keyword.get(opts, :skip_lock?, false)
+
+ do_generate_cycles_with_lock(member, today, skip_lock?)
+ end
+
+ # Generate cycles with lock handling
+ # Returns {:ok, cycles, notifications} - notifications are never sent here,
+ # they should be returned to the caller (e.g., via after_action hook)
+ defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do
+ # Lock already set by caller (e.g., regenerate_cycles_on_type_change)
+ # Just generate cycles without additional locking
+ do_generate_cycles(member, today)
+ end
+
+ defp do_generate_cycles_with_lock(member, today, false) do
+ lock_key = :erlang.phash2(member.id)
+
+ Repo.transaction(fn ->
+ Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
+
+ case do_generate_cycles(member, today) do
+ {:ok, cycles, notifications} ->
+ # Return cycles and notifications - do NOT send notifications here
+ # They will be sent by the caller (e.g., via after_action hook)
+ {cycles, notifications}
+
+ {:error, reason} ->
+ Repo.rollback(reason)
+ end
+ end)
+ |> case do
+ {:ok, {cycles, notifications}} -> {:ok, cycles, notifications}
+ {:error, reason} -> {:error, reason}
+ 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 ->
+ process_member_cycle_generation(member, 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
+
+ # Process cycle generation for a single member in batch job
+ # Returns {member_id, result} tuple where result is {:ok, cycles, notifications} or {:error, reason}
+ defp process_member_cycle_generation(member, today) do
+ case generate_cycles_for_member(member, today: today) do
+ {:ok, _cycles, notifications} = ok ->
+ send_notifications_for_batch_job(notifications)
+ {member.id, ok}
+
+ {:error, _reason} = err ->
+ {member.id, err}
+ end
+ end
+
+ # Send notifications for batch job
+ # This is a top-level job, so we need to send notifications explicitly
+ defp send_notifications_for_batch_job(notifications) do
+ if Enum.any?(notifications) do
+ Ash.Notifier.notify(notifications)
+ 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 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
+ # Always use return_notifications?: true to collect notifications
+ # Notifications will be returned to the caller, who is responsible for
+ # sending them (e.g., via after_action hook returning {:ok, result, notifications})
+ 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
+ }
+
+ case Ash.create(MembershipFeeCycle, attrs, return_notifications?: true) do
+ {:ok, cycle, notifications} when is_list(notifications) ->
+ {:ok, cycle, notifications}
+
+ {:ok, cycle} ->
+ {:ok, cycle, []}
+
+ {: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)
+ {: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 1ff589b..c2e28d6 100644
--- a/lib/mv_web/components/layouts/navbar.ex
+++ b/lib/mv_web/components/layouts/navbar.ex
@@ -34,7 +34,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."
- )}
-