diff --git a/config/test.exs b/config/test.exs
index 326694e..2c4d2ba 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -47,5 +47,4 @@ 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 7c8da24..c601b79 100644
--- a/docs/membership-fee-architecture.md
+++ b/docs/membership-fee-architecture.md
@@ -153,8 +153,8 @@ lib/
**Existing Fields Used:**
-- `join_date` - For calculating membership fee start
-- `exit_date` - For limiting cycle generation
+- `joined_at` - For calculating membership fee start
+- `left_at` - For limiting cycle generation
- These fields must remain member fields and should not be replaced by custom fields in the future
### Settings Integration
@@ -186,9 +186,8 @@ lib/
- Calculate which cycles should exist for a member
- Generate missing cycles
-- Respect membership_fee_start_date and exit_date boundaries
+- Respect membership_fee_start_date and left_at boundaries
- Skip existing cycles (idempotent)
-- Use PostgreSQL advisory locks per member to prevent race conditions
**Triggers:**
@@ -200,20 +199,17 @@ lib/
**Algorithm Steps:**
1. Retrieve member with membership fee type and dates
-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
+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)
**Edge Case Handling:**
-- 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_start_date is NULL: Calculate from joined_at + global setting
+- If left_at is set: Stop generation at left_at
- 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
@@ -282,14 +278,8 @@ lib/
**Implementation Pattern:**
- Use Ash change module to validate
-- 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
+- Use after_action hook to trigger regeneration
+- Use transaction to ensure atomicity
---
@@ -391,7 +381,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 join_date and global setting
+**AC-M-4:** membership_fee_start_date auto-set based on joined_at 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)
@@ -401,7 +391,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 exit_date if member exited
+**AC-CG-5:** Generation stops at left_at 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
@@ -423,7 +413,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) - ✅ Implemented: Regeneration runs synchronously in the same transaction as the member update
+**AC-TC-6:** Change is atomic (transaction)
### Settings
@@ -482,9 +472,8 @@ lib/
- Correct cycle_start calculation for all interval types
- Correct cycle count from start to end date
- Respects membership_fee_start_date boundary
-- Respects exit_date boundary
+- Respects left_at 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 bd47faa..229b73b 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
-- exit_date (Date, nullable) - Exit date (existing)
+- left_at (Date, nullable) - Exit date (existing)
```
**Logic for membership_fee_start_date:**
@@ -167,17 +167,16 @@ value: UUID (Required) - Default membership fee type for new members
**Algorithm:**
-Use PostgreSQL advisory locks per member to prevent race conditions
+Lock the whole cycle table for the duration of the algorithm
1. Get `member.membership_fee_start_date` and member's membership fee type
-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
+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
**Example (Yearly):**
@@ -247,7 +246,7 @@ suspended → unpaid
**Logic:**
-- Cycles only generated until `member.exit_date`
+- Cycles only generated until `member.left_at`
- 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 787b1d1..5816d19 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, :membership_fee_start_date]
+ accept @member_fields ++ [:membership_fee_type_id]
change manage_relationship(:custom_field_values, type: :create)
@@ -101,64 +101,6 @@ 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
@@ -172,7 +114,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, :membership_fee_start_date]
+ accept @member_fields ++ [:membership_fee_type_id]
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
@@ -199,46 +141,6 @@ 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
@@ -523,50 +425,6 @@ 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]
@@ -613,261 +471,6 @@ 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 eedc47c..52c0328 100644
--- a/lib/membership/setting.ex
+++ b/lib/membership/setting.ex
@@ -4,15 +4,13 @@ defmodule Mv.Membership.Setting do
## Overview
Settings is a singleton resource that stores global configuration for the association,
- such as the club name, branding information, and membership fee settings. There should
- only ever be one settings record in the database.
+ such as the club name and branding information. 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.
@@ -24,12 +22,6 @@ 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
@@ -41,9 +33,6 @@ 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,
@@ -65,24 +54,13 @@ 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,
- :include_joining_cycle,
- :default_membership_fee_type_id
- ]
+ accept [:club_name, :member_field_visibility]
end
update :update do
primary? true
require_atomic? false
-
- accept [
- :club_name,
- :member_field_visibility,
- :include_joining_cycle,
- :default_membership_fee_type_id
- ]
+ accept [:club_name, :member_field_visibility]
end
update :update_member_field_visibility do
@@ -90,14 +68,6 @@ 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
@@ -143,41 +113,6 @@ 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
@@ -198,26 +133,6 @@ 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
deleted file mode 100644
index fdbe1c8..0000000
--- a/lib/membership/setting/changes/normalize_default_fee_type_id.ex
+++ /dev/null
@@ -1,19 +0,0 @@
-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
deleted file mode 100644
index a2e1ad0..0000000
--- a/lib/membership_fees/changes/set_membership_fee_start_date.ex
+++ /dev/null
@@ -1,174 +0,0 @@
-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
deleted file mode 100644
index 8c1efb4..0000000
--- a/lib/membership_fees/changes/validate_same_interval.ex
+++ /dev/null
@@ -1,148 +0,0 @@
-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 b437ead..4c47623 100644
--- a/lib/membership_fees/membership_fee_cycle.ex
+++ b/lib/membership_fees/membership_fee_cycle.ex
@@ -51,36 +51,6 @@ 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 01ae625..877a385 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]
+ defaults [:read, :destroy]
create :create do
primary? true
@@ -45,108 +45,10 @@ 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
deleted file mode 100644
index 71a3158..0000000
--- a/lib/mv/membership_fees/cycle_generation_job.ex
+++ /dev/null
@@ -1,174 +0,0 @@
-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
deleted file mode 100644
index feb7b53..0000000
--- a/lib/mv/membership_fees/cycle_generator.ex
+++ /dev/null
@@ -1,410 +0,0 @@
-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 c2e28d6..1ff589b 100644
--- a/lib/mv_web/components/layouts/navbar.ex
+++ b/lib/mv_web/components/layouts/navbar.ex
@@ -34,9 +34,7 @@ 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."
+ )}
+