diff --git a/config/config.exs b/config/config.exs
index 17891e0..053fc19 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -49,7 +49,7 @@ config :spark,
config :mv,
ecto_repos: [Mv.Repo],
generators: [timestamp_type: :utc_datetime],
- ash_domains: [Mv.Membership, Mv.Accounts]
+ ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees]
# Configures the endpoint
config :mv, MvWeb.Endpoint,
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/database_schema.dbml b/docs/database_schema.dbml
index b620830..f97463e 100644
--- a/docs/database_schema.dbml
+++ b/docs/database_schema.dbml
@@ -6,8 +6,8 @@
// - https://dbdocs.io
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
//
-// Version: 1.2
-// Last Updated: 2025-11-13
+// Version: 1.3
+// Last Updated: 2025-12-11
Project mila_membership_management {
database_type: 'PostgreSQL'
@@ -27,6 +27,7 @@ Project mila_membership_management {
## Domains:
- **Accounts**: User authentication and session management
- **Membership**: Club member data and custom fields
+ - **MembershipFees**: Membership fee types and billing cycles
## Required PostgreSQL Extensions:
- uuid-ossp (UUID generation)
@@ -132,6 +133,8 @@ Table members {
house_number text [null, note: 'House number']
postal_code text [null, note: '5-digit German postal code']
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
+ membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type']
+ membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated']
indexes {
email [unique, name: 'members_unique_email_index']
@@ -146,6 +149,7 @@ Table members {
last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting']
join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters']
(paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL']
+ membership_fee_type_id [name: 'members_membership_fee_type_id_index', note: 'B-tree index for fee type lookups']
}
Note: '''
@@ -178,6 +182,8 @@ Table members {
**Relationships:**
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
- 1:N with custom_field_values (custom dynamic fields)
+ - Optional N:1 with membership_fee_types - assigned fee type
+ - 1:N with membership_fee_cycles - billing history
**Validation Rules:**
- first_name, last_name: min 1 character
@@ -281,6 +287,98 @@ Table custom_fields {
'''
}
+// ============================================
+// MEMBERSHIP_FEES DOMAIN
+// ============================================
+
+Table membership_fee_types {
+ id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
+ name text [not null, unique, note: 'Unique name for the fee type (e.g., "Standard", "Reduced")']
+ amount numeric(10,2) [not null, note: 'Fee amount in default currency (CHECK: >= 0)']
+ interval text [not null, note: 'Billing interval (CHECK: IN monthly, quarterly, half_yearly, yearly) - immutable']
+ description text [null, note: 'Optional description for the fee type']
+
+ indexes {
+ name [unique, name: 'membership_fee_types_unique_name_index']
+ }
+
+ Note: '''
+ **Membership Fee Type Definitions**
+
+ Defines the different types of membership fees with fixed billing intervals.
+
+ **Attributes:**
+ - `name`: Unique identifier for the fee type
+ - `amount`: Default fee amount (stored per cycle for audit trail)
+ - `interval`: Billing cycle - immutable after creation
+ - `description`: Optional documentation
+
+ **Interval Values:**
+ - `monthly`: 1st to last day of month
+ - `quarterly`: 1st of Jan/Apr/Jul/Oct to last day of quarter
+ - `half_yearly`: 1st of Jan/Jul to last day of half
+ - `yearly`: Jan 1 to Dec 31
+
+ **Immutability:**
+ The `interval` field cannot be changed after creation to prevent
+ complex migration scenarios. Create a new fee type to change intervals.
+
+ **Relationships:**
+ - 1:N with members - members assigned to this fee type
+ - 1:N with membership_fee_cycles - all cycles using this fee type
+
+ **Deletion Behavior:**
+ - ON DELETE RESTRICT: Cannot delete if members or cycles reference it
+ '''
+}
+
+Table membership_fee_cycles {
+ id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
+ cycle_start date [not null, note: 'Start date of the billing cycle']
+ amount numeric(10,2) [not null, note: 'Fee amount for this cycle (CHECK: >= 0)']
+ status text [not null, default: 'unpaid', note: 'Payment status (CHECK: IN unpaid, paid, suspended)']
+ notes text [null, note: 'Optional notes for this cycle']
+ member_id uuid [not null, note: 'FK to members - the member this cycle belongs to']
+ membership_fee_type_id uuid [not null, note: 'FK to membership_fee_types - fee type for this cycle']
+
+ indexes {
+ member_id [name: 'membership_fee_cycles_member_id_index']
+ membership_fee_type_id [name: 'membership_fee_cycles_membership_fee_type_id_index']
+ status [name: 'membership_fee_cycles_status_index']
+ cycle_start [name: 'membership_fee_cycles_cycle_start_index']
+ (member_id, cycle_start) [unique, name: 'membership_fee_cycles_unique_cycle_per_member_index', note: 'One cycle per member per cycle_start']
+ }
+
+ Note: '''
+ **Individual Membership Fee Cycles**
+
+ Represents a single billing cycle for a member with payment tracking.
+
+ **Design Decisions:**
+ - `cycle_end` is NOT stored - calculated from cycle_start + interval
+ - `amount` is stored per cycle to preserve historical values when fee type amount changes
+ - Cycles are aligned to calendar boundaries
+
+ **Status Values:**
+ - `unpaid`: Payment pending (default)
+ - `paid`: Payment received
+ - `suspended`: Payment suspended (e.g., hardship case)
+
+ **Constraints:**
+ - Unique: One cycle per member per cycle_start date
+ - member_id: Required (belongs_to)
+ - membership_fee_type_id: Required (belongs_to)
+
+ **Relationships:**
+ - N:1 with members - the member this cycle belongs to
+ - N:1 with membership_fee_types - the fee type for this cycle
+
+ **Deletion Behavior:**
+ - ON DELETE CASCADE (member_id): Cycles deleted when member deleted
+ - ON DELETE RESTRICT (membership_fee_type_id): Cannot delete fee type if cycles exist
+ '''
+}
+
// ============================================
// RELATIONSHIPS
// ============================================
@@ -306,6 +404,22 @@ Ref: custom_field_values.member_id > members.id [delete: cascade]
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict]
+// Member → MembershipFeeType (N:1)
+// - Many members can be assigned to one fee type
+// - Optional relationship (member can have no fee type)
+// - ON DELETE RESTRICT: Cannot delete fee type if members are assigned
+Ref: members.membership_fee_type_id > membership_fee_types.id [delete: restrict]
+
+// MembershipFeeCycle → Member (N:1)
+// - Many cycles belong to one member
+// - ON DELETE CASCADE: Cycles deleted when member deleted
+Ref: membership_fee_cycles.member_id > members.id [delete: cascade]
+
+// MembershipFeeCycle → MembershipFeeType (N:1)
+// - Many cycles reference one fee type
+// - ON DELETE RESTRICT: Cannot delete fee type if cycles reference it
+Ref: membership_fee_cycles.membership_fee_type_id > membership_fee_types.id [delete: restrict]
+
// ============================================
// ENUMS
// ============================================
@@ -328,6 +442,21 @@ Enum token_purpose {
email_confirmation [note: 'Email verification tokens']
}
+// Billing interval for membership fee types
+Enum membership_fee_interval {
+ monthly [note: '1st to last day of month']
+ quarterly [note: '1st of Jan/Apr/Jul/Oct to last day of quarter']
+ half_yearly [note: '1st of Jan/Jul to last day of half']
+ yearly [note: 'Jan 1 to Dec 31']
+}
+
+// Payment status for membership fee cycles
+Enum membership_fee_status {
+ unpaid [note: 'Payment pending (default)']
+ paid [note: 'Payment received']
+ suspended [note: 'Payment suspended']
+}
+
// ============================================
// TABLE GROUPS
// ============================================
@@ -357,3 +486,17 @@ TableGroup membership_domain {
'''
}
+TableGroup membership_fees_domain {
+ membership_fee_types
+ membership_fee_cycles
+
+ Note: '''
+ **Membership Fees Domain**
+
+ Handles membership fee management including:
+ - Fee type definitions with intervals
+ - Individual billing cycles per member
+ - Payment status tracking
+ '''
+}
+
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 d29a759..787b1d1 100644
--- a/lib/membership/member.ex
+++ b/lib/membership/member.ex
@@ -79,7 +79,8 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
- accept @member_fields
+ # Accept member fields plus membership_fee_type_id (belongs_to FK)
+ accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
change manage_relationship(:custom_field_values, type: :create)
@@ -100,6 +101,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
@@ -112,7 +171,8 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
- accept @member_fields
+ # Accept member fields plus membership_fee_type_id (belongs_to FK)
+ accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
@@ -139,6 +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
@@ -394,6 +494,15 @@ defmodule Mv.Membership.Member do
writable?: false,
public?: false,
select_by_default?: false
+
+ # Membership fee fields
+ # membership_fee_start_date: Date from which membership fees should be calculated
+ # If nil, calculated from join_date + global setting
+ attribute :membership_fee_start_date, :date do
+ allow_nil? true
+ public? true
+ description "Date from which membership fees should be calculated"
+ end
end
relationships do
@@ -402,6 +511,60 @@ defmodule Mv.Membership.Member do
# This references the User's member_id attribute
# The relationship is optional (allow_nil? true by default)
has_one :user, Mv.Accounts.User
+
+ # Membership fee relationships
+ # belongs_to: The fee type assigned to this member
+ # Optional for MVP - can be nil if no fee type assigned yet
+ belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do
+ allow_nil? true
+ end
+
+ # has_many: All fee cycles for this member
+ has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
+ end
+
+ 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
@@ -450,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
new file mode 100644
index 0000000..b437ead
--- /dev/null
+++ b/lib/membership_fees/membership_fee_cycle.ex
@@ -0,0 +1,132 @@
+defmodule Mv.MembershipFees.MembershipFeeCycle do
+ @moduledoc """
+ Ash resource representing an individual membership fee cycle for a member.
+
+ ## Overview
+ MembershipFeeCycle represents a single billing cycle for a member. Each cycle
+ tracks the payment status and amount for a specific time period.
+
+ ## Attributes
+ - `cycle_start` - Start date of the billing cycle (aligned to calendar boundaries)
+ - `amount` - The fee amount for this cycle (stored for audit trail)
+ - `status` - Payment status: unpaid, paid, or suspended
+ - `notes` - Optional notes for this cycle
+
+ ## Design Decisions
+ - **No cycle_end field**: Calculated from cycle_start + interval (from fee type)
+ - **Amount stored per cycle**: Preserves historical amounts when fee type changes
+ - **Calendar-aligned cycles**: All cycles start on calendar boundaries
+
+ ## Relationships
+ - `belongs_to :member` - The member this cycle belongs to
+ - `belongs_to :membership_fee_type` - The fee type for this cycle
+
+ ## Constraints
+ - Unique constraint on (member_id, cycle_start) - one cycle per period per member
+ - CASCADE delete when member is deleted
+ - RESTRICT delete on membership_fee_type if cycles exist
+ """
+ use Ash.Resource,
+ domain: Mv.MembershipFees,
+ data_layer: AshPostgres.DataLayer
+
+ postgres do
+ table "membership_fee_cycles"
+ repo Mv.Repo
+ end
+
+ resource do
+ description "Individual membership fee cycle for a member"
+ end
+
+ actions do
+ defaults [:read, :destroy]
+
+ create :create do
+ primary? true
+ accept [:cycle_start, :amount, :status, :notes, :member_id, :membership_fee_type_id]
+ end
+
+ update :update do
+ primary? true
+ accept [:status, :notes]
+ end
+
+ 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
+ uuid_v7_primary_key :id
+
+ attribute :cycle_start, :date do
+ allow_nil? false
+ public? true
+ description "Start date of the billing cycle"
+ end
+
+ attribute :amount, :decimal do
+ allow_nil? false
+ public? true
+
+ description "Fee amount for this cycle (stored for audit trail, non-negative, max 2 decimal places)"
+
+ constraints min: 0, scale: 2
+ end
+
+ attribute :status, :atom do
+ allow_nil? false
+ public? true
+ default :unpaid
+ description "Payment status of this cycle"
+ constraints one_of: [:unpaid, :paid, :suspended]
+ end
+
+ attribute :notes, :string do
+ allow_nil? true
+ public? true
+ description "Optional notes for this cycle"
+ end
+ end
+
+ relationships do
+ belongs_to :member, Mv.Membership.Member do
+ allow_nil? false
+ end
+
+ belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do
+ allow_nil? false
+ end
+ end
+
+ identities do
+ identity :unique_cycle_per_member, [:member_id, :cycle_start]
+ end
+end
diff --git a/lib/membership_fees/membership_fee_type.ex b/lib/membership_fees/membership_fee_type.ex
new file mode 100644
index 0000000..01ae625
--- /dev/null
+++ b/lib/membership_fees/membership_fee_type.ex
@@ -0,0 +1,190 @@
+defmodule Mv.MembershipFees.MembershipFeeType do
+ @moduledoc """
+ Ash resource representing a membership fee type definition.
+
+ ## Overview
+ MembershipFeeType defines the different types of membership fees that can be
+ assigned to members. Each type has a fixed interval (billing cycle) and a
+ default amount.
+
+ ## Attributes
+ - `name` - Unique name for the fee type (e.g., "Standard", "Reduced", "Family")
+ - `amount` - The fee amount in the default currency (decimal)
+ - `interval` - Billing interval: monthly, quarterly, half_yearly, or yearly
+ - `description` - Optional description for the fee type
+
+ ## Immutability
+ The `interval` field is immutable after creation. This prevents complex
+ migration scenarios when changing billing cycles. To change intervals,
+ create a new fee type and migrate members.
+
+ ## Relationships
+ - `has_many :members` - Members assigned to this fee type
+ - `has_many :membership_fee_cycles` - All cycles using this fee type
+ """
+ use Ash.Resource,
+ domain: Mv.MembershipFees,
+ data_layer: AshPostgres.DataLayer
+
+ postgres do
+ table "membership_fee_types"
+ repo Mv.Repo
+ end
+
+ resource do
+ description "Membership fee type definition with interval and amount"
+ end
+
+ actions do
+ defaults [:read]
+
+ create :create do
+ primary? true
+ accept [:name, :amount, :interval, :description]
+ end
+
+ 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
+ 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
+ uuid_v7_primary_key :id
+
+ attribute :name, :string do
+ allow_nil? false
+ public? true
+ description "Unique name for the membership fee type"
+ end
+
+ attribute :amount, :decimal do
+ allow_nil? false
+ public? true
+ description "Fee amount in default currency (non-negative, max 2 decimal places)"
+ constraints min: 0, scale: 2
+ end
+
+ attribute :interval, :atom do
+ allow_nil? false
+ public? true
+ description "Billing interval (immutable after creation)"
+ constraints one_of: [:monthly, :quarterly, :half_yearly, :yearly]
+ end
+
+ attribute :description, :string do
+ allow_nil? true
+ public? true
+ description "Optional description for the fee type"
+ end
+ end
+
+ relationships do
+ has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
+ has_many :members, Mv.Membership.Member
+ end
+
+ identities do
+ identity :unique_name, [:name]
+ end
+end
diff --git a/lib/membership_fees/membership_fees.ex b/lib/membership_fees/membership_fees.ex
new file mode 100644
index 0000000..7a2833a
--- /dev/null
+++ b/lib/membership_fees/membership_fees.ex
@@ -0,0 +1,42 @@
+defmodule Mv.MembershipFees do
+ @moduledoc """
+ Ash Domain for membership fee management.
+
+ ## Resources
+ - `MembershipFeeType` - Defines membership fee types with intervals and amounts
+ - `MembershipFeeCycle` - Individual membership fee cycles per member
+
+ ## Overview
+ This domain handles the complete membership fee lifecycle including:
+ - Fee type definitions (monthly, quarterly, half-yearly, yearly)
+ - Individual fee cycles for each member
+ - Payment status tracking (unpaid, paid, suspended)
+
+ ## Architecture Decisions
+ - `interval` field on MembershipFeeType is immutable after creation
+ - `cycle_end` is calculated, not stored (from cycle_start + interval)
+ - `amount` is stored per cycle for audit trail when prices change
+ """
+ use Ash.Domain,
+ extensions: [AshAdmin.Domain, AshPhoenix]
+
+ admin do
+ show? true
+ end
+
+ resources do
+ resource Mv.MembershipFees.MembershipFeeType do
+ define :create_membership_fee_type, action: :create
+ define :list_membership_fee_types, action: :read
+ define :update_membership_fee_type, action: :update
+ define :destroy_membership_fee_type, action: :destroy
+ end
+
+ resource Mv.MembershipFees.MembershipFeeCycle do
+ define :create_membership_fee_cycle, action: :create
+ define :list_membership_fee_cycles, action: :read
+ define :update_membership_fee_cycle, action: :update
+ define :destroy_membership_fee_cycle, action: :destroy
+ end
+ end
+end
diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex
index 7bfb07b..843ad2b 100644
--- a/lib/mv/constants.ex
+++ b/lib/mv/constants.ex
@@ -15,7 +15,8 @@ defmodule Mv.Constants do
:city,
:street,
:house_number,
- :postal_code
+ :postal_code,
+ :membership_fee_start_date
]
@custom_field_prefix "custom_field_"
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..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 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."
- )}
-