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 d6b5ee2..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 @@ -385,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) @@ -395,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 @@ -476,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 ae32abd..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,42 +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 - generate_fn = fn -> - case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do - {:ok, _cycles} -> - :ok - - {:error, reason} -> - require Logger - - Logger.warning( - "Failed to generate cycles for member #{member.id}: #{inspect(reason)}" - ) - end - end - - if Application.get_env(:mv, :sql_sandbox, false) do - # Run synchronously in test environment for DB sandbox compatibility - generate_fn.() - else - # Run asynchronously in other environments - Task.start(generate_fn) - end - end - - {:ok, member} - end) end update :update_member do @@ -150,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) @@ -177,46 +141,6 @@ defmodule Mv.Membership.Member do change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:user)] end - - # Auto-calculate membership_fee_start_date when membership_fee_type_id is set - # and membership_fee_start_date is not already set - change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do - where [changing(:membership_fee_type_id)] - end - - # Trigger cycle generation when membership_fee_type_id changes - # Note: Cycle generation runs asynchronously to not block the action, - # but in test environment it runs synchronously for DB sandbox compatibility - change after_action(fn changeset, member, _context -> - fee_type_changed = - Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id) - - if fee_type_changed && member.membership_fee_type_id && member.join_date do - generate_fn = fn -> - case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do - {:ok, _cycles} -> - :ok - - {:error, reason} -> - require Logger - - Logger.warning( - "Failed to generate cycles for member #{member.id}: #{inspect(reason)}" - ) - end - end - - if Application.get_env(:mv, :sql_sandbox, false) do - # Run synchronously in test environment for DB sandbox compatibility - generate_fn.() - else - # Run asynchronously in other environments - Task.start(generate_fn) - end - end - - {:ok, member} - end) end # Action to handle fuzzy search on specific fields diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 93f5a59..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,12 +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] - end end validations do @@ -161,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_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/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 0727a62..0000000 --- a/lib/mv/membership_fees/cycle_generator.ex +++ /dev/null @@ -1,390 +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.MembershipFees.CalendarCycles - alias Mv.MembershipFees.MembershipFeeCycle - alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate - alias Mv.Membership.Member - alias Mv.Repo - - require Ash.Query - require Logger - - @type generate_result :: {:ok, [MembershipFeeCycle.t()]} | {:error, term()} - - @doc """ - Generates membership fee cycles for a single member. - - Uses an advisory lock to prevent concurrent generation for the same member. - - ## Parameters - - - `member` - The member struct or member ID - - `opts` - Options: - - `:today` - Override today's date (useful for testing) - - ## Returns - - - `{:ok, cycles}` - List of newly created cycles - - `{:error, reason}` - Error with reason - - ## Examples - - {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member) - {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member_id) - {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member, today: ~D[2024-12-31]) - - """ - @spec generate_cycles_for_member(Member.t() | String.t(), keyword()) :: generate_result() - def generate_cycles_for_member(member_or_id, opts \\ []) - - def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do - case load_member(member_id) do - {:ok, member} -> generate_cycles_for_member(member, opts) - {:error, reason} -> {:error, reason} - end - end - - def generate_cycles_for_member(%Member{} = member, opts) do - today = Keyword.get(opts, :today, Date.utc_today()) - - # Use advisory lock to prevent concurrent generation - # Notifications are handled inside with_advisory_lock after transaction commits - with_advisory_lock(member.id, fn -> - do_generate_cycles(member, today) - end) - end - - @doc """ - Generates membership fee cycles for all members with a fee type assigned. - - This includes both active and inactive (left) members. Inactive members - will have cycles generated up to their exit_date if they don't have cycles - for that period yet. This allows for catch-up generation of missing cycles. - - Members processed are those who: - - Have a membership_fee_type assigned - - Have a join_date set - - The exit_date boundary is respected during generation (not in the query), - so inactive members will get cycles up to their exit date. - - ## Parameters - - - `opts` - Options: - - `:today` - Override today's date (useful for testing) - - `:batch_size` - Number of members to process in parallel (default: 10) - - ## Returns - - - `{:ok, results}` - Map with :success and :failed counts - - `{:error, reason}` - Error with reason - - """ - @spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()} - def generate_cycles_for_all_members(opts \\ []) do - today = Keyword.get(opts, :today, Date.utc_today()) - batch_size = Keyword.get(opts, :batch_size, 10) - - # Query ALL members with fee type assigned (including inactive/left members) - # The exit_date boundary is applied during cycle generation, not here. - # This allows catch-up generation for members who left but are missing cycles. - query = - Member - |> Ash.Query.filter(not is_nil(membership_fee_type_id)) - |> Ash.Query.filter(not is_nil(join_date)) - - case Ash.read(query) do - {:ok, members} -> - results = process_members_in_batches(members, batch_size, today) - {:ok, build_results_summary(results)} - - {:error, reason} -> - {:error, reason} - end - end - - defp process_members_in_batches(members, batch_size, today) do - members - |> Enum.chunk_every(batch_size) - |> Enum.flat_map(&process_batch(&1, today)) - end - - defp process_batch(batch, today) do - batch - |> Task.async_stream(fn member -> - {member.id, generate_cycles_for_member(member, today: today)} - end) - |> Enum.map(fn - {:ok, result} -> - result - - {:exit, reason} -> - # Task crashed - log and return error tuple - Logger.error("Task crashed during cycle generation: #{inspect(reason)}") - {nil, {:error, {:task_exit, reason}}} - end) - end - - defp build_results_summary(results) do - success_count = Enum.count(results, fn {_id, result} -> match?({:ok, _}, result) end) - failed_count = Enum.count(results, fn {_id, result} -> match?({:error, _}, result) end) - - %{success: success_count, failed: failed_count, total: length(results)} - end - - # Private functions - - defp load_member(member_id) do - Member - |> Ash.Query.filter(id == ^member_id) - |> Ash.Query.load([:membership_fee_type, :membership_fee_cycles]) - |> Ash.read_one() - |> case do - {:ok, nil} -> {:error, :member_not_found} - {:ok, member} -> {:ok, member} - {:error, reason} -> {:error, reason} - end - end - - defp with_advisory_lock(member_id, fun) do - # Convert UUID to integer for advisory lock (use hash) - lock_key = :erlang.phash2(member_id) - - result = - Repo.transaction(fn -> - # Acquire advisory lock for this transaction - Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) - - case fun.() do - {:ok, result, notifications} when is_list(notifications) -> - # Return result and notifications separately - {result, notifications} - - {:ok, result} -> - # Handle case where no notifications were returned (backward compatibility) - {result, []} - - {:error, reason} -> - Repo.rollback(reason) - end - end) - - # Extract result and notifications, send notifications after transaction - case result do - {:ok, {cycles, notifications}} -> - if Enum.any?(notifications) do - Ash.Notifier.notify(notifications) - end - - {:ok, cycles} - - {:error, reason} -> - {:error, reason} - end - end - - defp do_generate_cycles(member, today) do - # Reload member with relationships to ensure fresh data - case load_member(member.id) do - {:ok, member} -> - cond do - is_nil(member.membership_fee_type_id) -> - {:error, :no_membership_fee_type} - - is_nil(member.join_date) -> - {:error, :no_join_date} - - true -> - generate_missing_cycles(member, today) - end - - {:error, reason} -> - {:error, reason} - end - end - - defp generate_missing_cycles(member, today) do - fee_type = member.membership_fee_type - interval = fee_type.interval - amount = fee_type.amount - existing_cycles = member.membership_fee_cycles || [] - - # Determine start point based on existing cycles - # Note: We do NOT fill gaps - only generate from the last existing cycle onwards - start_date = determine_generation_start(member, existing_cycles, interval) - - # Determine end date (today or exit_date, whichever is earlier) - end_date = determine_end_date(member, today) - - # Only generate if start_date <= end_date - if start_date && Date.compare(start_date, end_date) != :gt do - cycle_starts = generate_cycle_starts(start_date, end_date, interval) - create_cycles(cycle_starts, member.id, fee_type.id, amount) - else - {:ok, [], []} - end - end - - # No existing cycles: start from membership_fee_start_date - defp determine_generation_start(member, [], interval) do - determine_start_date(member, interval) - end - - # Has existing cycles: start from the cycle AFTER the last one - # This ensures gaps (deleted cycles) are NOT filled - defp determine_generation_start(_member, existing_cycles, interval) do - last_cycle_start = - existing_cycles - |> Enum.map(& &1.cycle_start) - |> Enum.max(Date) - - CalendarCycles.next_cycle_start(last_cycle_start, interval) - end - - defp determine_start_date(member, interval) do - if member.membership_fee_start_date do - member.membership_fee_start_date - else - # Calculate from join_date using global settings - include_joining_cycle = get_include_joining_cycle() - - SetMembershipFeeStartDate.calculate_start_date( - member.join_date, - interval, - include_joining_cycle - ) - end - end - - defp determine_end_date(member, today) do - if member.exit_date && Date.compare(member.exit_date, today) == :lt do - # Member has left - use the exit date as boundary - # Note: If exit_date == cycle_start, the cycle IS still generated. - # This means the member is considered a member on the first day of that cycle. - # Example: exit_date = 2025-01-01, yearly interval - # -> The 2025 cycle (starting 2025-01-01) WILL be generated - member.exit_date - else - today - end - end - - defp get_include_joining_cycle do - case Mv.Membership.get_settings() do - {:ok, %{include_joining_cycle: include}} -> include - {:error, _} -> true - end - end - - @doc """ - Generates all cycle start dates from a start date to an end date. - - ## Parameters - - - `start_date` - The first cycle start date - - `end_date` - The date up to which cycles should be generated - - `interval` - The billing interval - - ## Returns - - List of cycle start dates. - - ## Examples - - iex> generate_cycle_starts(~D[2024-01-01], ~D[2024-12-31], :quarterly) - [~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01], ~D[2024-10-01]] - - """ - @spec generate_cycle_starts(Date.t(), Date.t(), atom()) :: [Date.t()] - def generate_cycle_starts(start_date, end_date, interval) do - # Ensure start_date is aligned to cycle boundary - aligned_start = CalendarCycles.calculate_cycle_start(start_date, interval) - - generate_cycle_starts_acc(aligned_start, end_date, interval, []) - end - - defp generate_cycle_starts_acc(current_start, end_date, interval, acc) do - if Date.compare(current_start, end_date) == :gt do - # Current cycle start is after end date - stop - Enum.reverse(acc) - else - # Include this cycle and continue to next - next_start = CalendarCycles.next_cycle_start(current_start, interval) - generate_cycle_starts_acc(next_start, end_date, interval, [current_start | acc]) - end - end - - defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do - results = - Enum.map(cycle_starts, fn cycle_start -> - attrs = %{ - cycle_start: cycle_start, - member_id: member_id, - membership_fee_type_id: fee_type_id, - amount: amount, - status: :unpaid - } - - # Return notifications to avoid warnings when creating within a transaction - case Ash.create(MembershipFeeCycle, attrs, return_notifications?: true) do - {:ok, cycle, notifications} -> {:ok, cycle, notifications} - {:error, reason} -> {:error, {cycle_start, reason}} - end - end) - - {successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1)) - - all_notifications = - Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end) - - if Enum.empty?(errors) do - successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end) - # Return cycles and notifications to be sent after transaction commits - {:ok, successful_cycles, all_notifications} - else - Logger.warning("Some cycles failed to create: #{inspect(errors)}") - # Return partial failure with errors - # Note: When this error occurs, the transaction will be rolled back, - # so no cycles were actually persisted in the database - {:error, {:partial_failure, errors}} - end - end -end diff --git a/priv/repo/migrations/20251211195058_add_membership_fee_settings.exs b/priv/repo/migrations/20251211195058_add_membership_fee_settings.exs deleted file mode 100644 index a77ff5f..0000000 --- a/priv/repo/migrations/20251211195058_add_membership_fee_settings.exs +++ /dev/null @@ -1,25 +0,0 @@ -defmodule Mv.Repo.Migrations.AddMembershipFeeSettings do - @moduledoc """ - Adds membership fee settings to the settings table. - - Note: The members table columns (membership_fee_start_date, membership_fee_type_id) - were already added in migration 20251211151449_add_membership_fees_tables. - """ - - use Ecto.Migration - - def up do - # Add membership fee settings to the settings table - alter table(:settings) do - add_if_not_exists :include_joining_cycle, :boolean, null: false, default: true - add_if_not_exists :default_membership_fee_type_id, :uuid - end - end - - def down do - alter table(:settings) do - remove_if_exists :default_membership_fee_type_id, :uuid - remove_if_exists :include_joining_cycle, :boolean - end - end -end diff --git a/priv/resource_snapshots/repo/members/20251211195058.json b/priv/resource_snapshots/repo/members/20251211195058.json deleted file mode 100644 index a72bf8d..0000000 --- a/priv/resource_snapshots/repo/members/20251211195058.json +++ /dev/null @@ -1,245 +0,0 @@ -{ - "attributes": [ - { - "allow_nil?": false, - "default": "fragment(\"uuid_generate_v7()\")", - "generated?": false, - "precision": null, - "primary_key?": true, - "references": null, - "scale": null, - "size": null, - "source": "id", - "type": "uuid" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "first_name", - "type": "text" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "last_name", - "type": "text" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "email", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "paid", - "type": "boolean" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "phone_number", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "join_date", - "type": "date" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "exit_date", - "type": "date" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "notes", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "city", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "street", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "house_number", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "postal_code", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "search_vector", - "type": "tsvector" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "membership_fee_start_date", - "type": "date" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": { - "deferrable": false, - "destination_attribute": "id", - "destination_attribute_default": null, - "destination_attribute_generated": null, - "index?": false, - "match_type": null, - "match_with": null, - "multitenancy": { - "attribute": null, - "global": null, - "strategy": null - }, - "name": "members_membership_fee_type_id_fkey", - "on_delete": null, - "on_update": null, - "primary_key?": true, - "schema": "public", - "table": "membership_fee_types" - }, - "scale": null, - "size": null, - "source": "membership_fee_type_id", - "type": "uuid" - } - ], - "base_filter": null, - "check_constraints": [], - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true, - "hash": "6ECD721659E1CC7CB4219293153BCED585111A49765B9DB0D1CAE0B37C54949E", - "identities": [ - { - "all_tenants?": false, - "base_filter": null, - "index_name": "members_unique_email_index", - "keys": [ - { - "type": "atom", - "value": "email" - } - ], - "name": "unique_email", - "nils_distinct?": true, - "where": null - } - ], - "multitenancy": { - "attribute": null, - "global": null, - "strategy": null - }, - "repo": "Elixir.Mv.Repo", - "schema": null, - "table": "members" -} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/membership_fee_cycles/20251211195058.json b/priv/resource_snapshots/repo/membership_fee_cycles/20251211195058.json deleted file mode 100644 index 3644d11..0000000 --- a/priv/resource_snapshots/repo/membership_fee_cycles/20251211195058.json +++ /dev/null @@ -1,160 +0,0 @@ -{ - "attributes": [ - { - "allow_nil?": false, - "default": "fragment(\"uuid_generate_v7()\")", - "generated?": false, - "precision": null, - "primary_key?": true, - "references": null, - "scale": null, - "size": null, - "source": "id", - "type": "uuid" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "cycle_start", - "type": "date" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": 2, - "size": null, - "source": "amount", - "type": "decimal" - }, - { - "allow_nil?": false, - "default": "\"unpaid\"", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "status", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "notes", - "type": "text" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": { - "deferrable": false, - "destination_attribute": "id", - "destination_attribute_default": null, - "destination_attribute_generated": null, - "index?": false, - "match_type": null, - "match_with": null, - "multitenancy": { - "attribute": null, - "global": null, - "strategy": null - }, - "name": "membership_fee_cycles_member_id_fkey", - "on_delete": null, - "on_update": null, - "primary_key?": true, - "schema": "public", - "table": "members" - }, - "scale": null, - "size": null, - "source": "member_id", - "type": "uuid" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": { - "deferrable": false, - "destination_attribute": "id", - "destination_attribute_default": null, - "destination_attribute_generated": null, - "index?": false, - "match_type": null, - "match_with": null, - "multitenancy": { - "attribute": null, - "global": null, - "strategy": null - }, - "name": "membership_fee_cycles_membership_fee_type_id_fkey", - "on_delete": null, - "on_update": null, - "primary_key?": true, - "schema": "public", - "table": "membership_fee_types" - }, - "scale": null, - "size": null, - "source": "membership_fee_type_id", - "type": "uuid" - } - ], - "base_filter": null, - "check_constraints": [], - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true, - "hash": "802FB11B08D041501AC395454D84719992B71C0BEAE83B0833F3086486ABD679", - "identities": [ - { - "all_tenants?": false, - "base_filter": null, - "index_name": "membership_fee_cycles_unique_cycle_per_member_index", - "keys": [ - { - "type": "atom", - "value": "member_id" - }, - { - "type": "atom", - "value": "cycle_start" - } - ], - "name": "unique_cycle_per_member", - "nils_distinct?": true, - "where": null - } - ], - "multitenancy": { - "attribute": null, - "global": null, - "strategy": null - }, - "repo": "Elixir.Mv.Repo", - "schema": null, - "table": "membership_fee_cycles" -} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/membership_fee_types/20251211195058.json b/priv/resource_snapshots/repo/membership_fee_types/20251211195058.json deleted file mode 100644 index c5de933..0000000 --- a/priv/resource_snapshots/repo/membership_fee_types/20251211195058.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "attributes": [ - { - "allow_nil?": false, - "default": "fragment(\"uuid_generate_v7()\")", - "generated?": false, - "precision": null, - "primary_key?": true, - "references": null, - "scale": null, - "size": null, - "source": "id", - "type": "uuid" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "name", - "type": "text" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": 2, - "size": null, - "source": "amount", - "type": "decimal" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "interval", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "description", - "type": "text" - } - ], - "base_filter": null, - "check_constraints": [], - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true, - "hash": "C58959BF589FEB75A9F05C2C717C04B641ED14E09FF2503C8B0637392AE5A335", - "identities": [ - { - "all_tenants?": false, - "base_filter": null, - "index_name": "membership_fee_types_unique_name_index", - "keys": [ - { - "type": "atom", - "value": "name" - } - ], - "name": "unique_name", - "nils_distinct?": true, - "where": null - } - ], - "multitenancy": { - "attribute": null, - "global": null, - "strategy": null - }, - "repo": "Elixir.Mv.Repo", - "schema": null, - "table": "membership_fee_types" -} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/settings/20251211195058.json b/priv/resource_snapshots/repo/settings/20251211195058.json deleted file mode 100644 index 4b437b8..0000000 --- a/priv/resource_snapshots/repo/settings/20251211195058.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "attributes": [ - { - "allow_nil?": false, - "default": "fragment(\"gen_random_uuid()\")", - "generated?": false, - "precision": null, - "primary_key?": true, - "references": null, - "scale": null, - "size": null, - "source": "id", - "type": "uuid" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "club_name", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "member_field_visibility", - "type": "map" - }, - { - "allow_nil?": false, - "default": "true", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "include_joining_cycle", - "type": "boolean" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "default_membership_fee_type_id", - "type": "uuid" - }, - { - "allow_nil?": false, - "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "inserted_at", - "type": "utc_datetime_usec" - }, - { - "allow_nil?": false, - "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "updated_at", - "type": "utc_datetime_usec" - } - ], - "base_filter": null, - "check_constraints": [], - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true, - "hash": "CD12EA080677C99D81C2A4A98F0DE419F7BDE1FA8C22206423C9D80305B064D2", - "identities": [], - "multitenancy": { - "attribute": null, - "global": null, - "strategy": null - }, - "repo": "Elixir.Mv.Repo", - "schema": null, - "table": "settings" -} \ No newline at end of file diff --git a/test/membership_fees/changes/set_membership_fee_start_date_test.exs b/test/membership_fees/changes/set_membership_fee_start_date_test.exs deleted file mode 100644 index 4af59db..0000000 --- a/test/membership_fees/changes/set_membership_fee_start_date_test.exs +++ /dev/null @@ -1,268 +0,0 @@ -defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do - @moduledoc """ - Tests for the SetMembershipFeeStartDate change module. - """ - use Mv.DataCase, async: false - - alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate - - # Helper to set up settings with specific include_joining_cycle value - defp setup_settings(include_joining_cycle) do - {:ok, settings} = Mv.Membership.get_settings() - - settings - |> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle}) - |> Ash.update!() - end - - describe "calculate_start_date/3" do - test "include_joining_cycle = true: start date is first day of joining cycle (yearly)" do - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :yearly, true) - assert result == ~D[2024-01-01] - end - - test "include_joining_cycle = false: start date is first day of next cycle (yearly)" do - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :yearly, false) - assert result == ~D[2025-01-01] - end - - test "include_joining_cycle = true: start date is first day of joining cycle (quarterly)" do - # Q1: Jan-Mar, Q2: Apr-Jun, Q3: Jul-Sep, Q4: Oct-Dec - # March is in Q1 - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :quarterly, true) - assert result == ~D[2024-01-01] - - # May is in Q2 - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-05-15], :quarterly, true) - assert result == ~D[2024-04-01] - - # August is in Q3 - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-08-15], :quarterly, true) - assert result == ~D[2024-07-01] - - # November is in Q4 - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-11-15], :quarterly, true) - assert result == ~D[2024-10-01] - end - - test "include_joining_cycle = false: start date is first day of next cycle (quarterly)" do - # March is in Q1, next is Q2 - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :quarterly, false) - assert result == ~D[2024-04-01] - - # June is in Q2, next is Q3 - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-06-15], :quarterly, false) - assert result == ~D[2024-07-01] - - # September is in Q3, next is Q4 - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-09-15], :quarterly, false) - assert result == ~D[2024-10-01] - - # December is in Q4, next is Q1 of next year - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-15], :quarterly, false) - assert result == ~D[2025-01-01] - end - - test "include_joining_cycle = true: start date is first day of joining cycle (half_yearly)" do - # H1: Jan-Jun, H2: Jul-Dec - # March is in H1 - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :half_yearly, true) - assert result == ~D[2024-01-01] - - # September is in H2 - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-09-15], :half_yearly, true) - assert result == ~D[2024-07-01] - end - - test "include_joining_cycle = false: start date is first day of next cycle (half_yearly)" do - # March is in H1, next is H2 - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :half_yearly, false) - assert result == ~D[2024-07-01] - - # September is in H2, next is H1 of next year - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-09-15], :half_yearly, false) - assert result == ~D[2025-01-01] - end - - test "include_joining_cycle = true: start date is first day of joining cycle (monthly)" do - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :monthly, true) - assert result == ~D[2024-03-01] - end - - test "include_joining_cycle = false: start date is first day of next cycle (monthly)" do - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :monthly, false) - assert result == ~D[2024-04-01] - - # December goes to next year - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-15], :monthly, false) - assert result == ~D[2025-01-01] - end - - test "joining on first day of cycle with include_joining_cycle = true" do - # When joining exactly on cycle start, should return that date - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-01-01], :yearly, true) - assert result == ~D[2024-01-01] - - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-04-01], :quarterly, true) - assert result == ~D[2024-04-01] - end - - test "joining on first day of cycle with include_joining_cycle = false" do - # When joining exactly on cycle start and include=false, should return next cycle - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-01-01], :yearly, false) - assert result == ~D[2025-01-01] - - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-04-01], :quarterly, false) - assert result == ~D[2024-07-01] - end - - test "joining on last day of cycle" do - # Joining on Dec 31 with yearly cycle - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-31], :yearly, true) - assert result == ~D[2024-01-01] - - result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-31], :yearly, false) - assert result == ~D[2025-01-01] - end - end - - describe "change/3 integration" do - test "sets membership_fee_start_date automatically on member creation" do - setup_settings(true) - - # Create a fee type - fee_type = - Mv.MembershipFees.MembershipFeeType - |> Ash.Changeset.for_create(:create, %{ - name: "Test Type #{System.unique_integer([:positive])}", - amount: Decimal.new("50.00"), - interval: :yearly - }) - |> Ash.create!() - - # Create member with join_date and fee type but no explicit start date - member = - Mv.Membership.Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer([:positive])}@example.com", - join_date: ~D[2024-03-15], - membership_fee_type_id: fee_type.id - }) - |> Ash.create!() - - # Should have auto-calculated start date (2024-01-01 for yearly with include_joining_cycle=true) - assert member.membership_fee_start_date == ~D[2024-01-01] - end - - test "does not override manually set membership_fee_start_date" do - setup_settings(true) - - # Create a fee type - fee_type = - Mv.MembershipFees.MembershipFeeType - |> Ash.Changeset.for_create(:create, %{ - name: "Test Type #{System.unique_integer([:positive])}", - amount: Decimal.new("50.00"), - interval: :yearly - }) - |> Ash.create!() - - # Create member with explicit start date - manual_start_date = ~D[2024-07-01] - - member = - Mv.Membership.Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer([:positive])}@example.com", - join_date: ~D[2024-03-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: manual_start_date - }) - |> Ash.create!() - - # Should keep the manually set date - assert member.membership_fee_start_date == manual_start_date - end - - test "respects include_joining_cycle = false setting" do - setup_settings(false) - - # Create a fee type - fee_type = - Mv.MembershipFees.MembershipFeeType - |> Ash.Changeset.for_create(:create, %{ - name: "Test Type #{System.unique_integer([:positive])}", - amount: Decimal.new("50.00"), - interval: :yearly - }) - |> Ash.create!() - - # Create member - member = - Mv.Membership.Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer([:positive])}@example.com", - join_date: ~D[2024-03-15], - membership_fee_type_id: fee_type.id - }) - |> Ash.create!() - - # Should have next cycle start date (2025-01-01 for yearly with include_joining_cycle=false) - assert member.membership_fee_start_date == ~D[2025-01-01] - end - - test "does not set start date without join_date" do - setup_settings(true) - - # Create a fee type - fee_type = - Mv.MembershipFees.MembershipFeeType - |> Ash.Changeset.for_create(:create, %{ - name: "Test Type #{System.unique_integer([:positive])}", - amount: Decimal.new("50.00"), - interval: :yearly - }) - |> Ash.create!() - - # Create member without join_date - member = - Mv.Membership.Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer([:positive])}@example.com", - membership_fee_type_id: fee_type.id - # No join_date - }) - |> Ash.create!() - - # Should not have auto-calculated start date - assert is_nil(member.membership_fee_start_date) - end - - test "does not set start date without membership_fee_type_id" do - setup_settings(true) - - # Create member without fee type - member = - Mv.Membership.Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer([:positive])}@example.com", - join_date: ~D[2024-03-15] - # No membership_fee_type_id - }) - |> Ash.create!() - - # Should not have auto-calculated start date - assert is_nil(member.membership_fee_start_date) - end - end -end diff --git a/test/membership_fees/member_cycle_integration_test.exs b/test/membership_fees/member_cycle_integration_test.exs deleted file mode 100644 index 7cbfbff..0000000 --- a/test/membership_fees/member_cycle_integration_test.exs +++ /dev/null @@ -1,211 +0,0 @@ -defmodule Mv.MembershipFees.MemberCycleIntegrationTest do - @moduledoc """ - Integration tests for membership fee cycle generation triggered by member actions. - """ - use Mv.DataCase, async: false - - alias Mv.MembershipFees.MembershipFeeCycle - alias Mv.MembershipFees.MembershipFeeType - alias Mv.Membership.Member - - require Ash.Query - - # Helper to create a membership fee type - defp create_fee_type(attrs) do - default_attrs = %{ - name: "Test Fee Type #{System.unique_integer([:positive])}", - amount: Decimal.new("50.00"), - interval: :yearly - } - - attrs = Map.merge(default_attrs, attrs) - - MembershipFeeType - |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!() - end - - # Helper to set up settings - defp setup_settings(include_joining_cycle) do - {:ok, settings} = Mv.Membership.get_settings() - - settings - |> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle}) - |> Ash.update!() - end - - # Helper to get cycles for a member - defp get_member_cycles(member_id) do - MembershipFeeCycle - |> Ash.Query.filter(member_id == ^member_id) - |> Ash.Query.sort(cycle_start: :asc) - |> Ash.read!() - end - - describe "member creation triggers cycle generation" do - test "creates cycles when member is created with fee type and join_date" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - member = - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer([:positive])}@example.com", - join_date: ~D[2023-03-15], - membership_fee_type_id: fee_type.id - }) - |> Ash.create!() - - cycles = get_member_cycles(member.id) - - # Should have cycles for 2023 and 2024 (and possibly current year) - assert length(cycles) >= 2 - - # Verify cycles have correct data - Enum.each(cycles, fn cycle -> - assert cycle.member_id == member.id - assert cycle.membership_fee_type_id == fee_type.id - assert Decimal.equal?(cycle.amount, fee_type.amount) - assert cycle.status == :unpaid - end) - end - - test "does not create cycles when member has no fee type" do - setup_settings(true) - - member = - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer([:positive])}@example.com", - join_date: ~D[2023-03-15] - # No membership_fee_type_id - }) - |> Ash.create!() - - cycles = get_member_cycles(member.id) - - assert cycles == [] - end - - test "does not create cycles when member has no join_date" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - member = - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer([:positive])}@example.com", - membership_fee_type_id: fee_type.id - # No join_date - }) - |> Ash.create!() - - cycles = get_member_cycles(member.id) - - assert cycles == [] - end - end - - describe "member update triggers cycle generation" do - test "generates cycles when fee type is assigned to existing member" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - # Create member without fee type - member = - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer([:positive])}@example.com", - join_date: ~D[2023-03-15] - }) - |> Ash.create!() - - # Verify no cycles yet - assert get_member_cycles(member.id) == [] - - # Update to assign fee type - member - |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) - |> Ash.update!() - - cycles = get_member_cycles(member.id) - - # Should have generated cycles - assert length(cycles) >= 2 - end - end - - describe "concurrent cycle generation" do - test "handles multiple members being created concurrently" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - # Create multiple members concurrently - tasks = - Enum.map(1..5, fn i -> - Task.async(fn -> - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Test#{i}", - last_name: "User#{i}", - email: "test#{System.unique_integer([:positive])}@example.com", - join_date: ~D[2023-03-15], - membership_fee_type_id: fee_type.id - }) - |> Ash.create!() - end) - end) - - members = Enum.map(tasks, &Task.await/1) - - # Each member should have cycles - Enum.each(members, fn member -> - cycles = get_member_cycles(member.id) - assert length(cycles) >= 2, "Member #{member.id} should have at least 2 cycles" - end) - end - end - - describe "idempotent cycle generation" do - test "running generation multiple times does not create duplicate cycles" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - member = - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer([:positive])}@example.com", - join_date: ~D[2023-03-15], - membership_fee_type_id: fee_type.id - }) - |> Ash.create!() - - initial_cycles = get_member_cycles(member.id) - initial_count = length(initial_cycles) - - # Use a fixed "today" date to avoid date dependency - # Use a date far enough in the future to ensure all cycles are generated - today = ~D[2025-12-31] - - # Manually trigger generation again with fixed "today" date - {:ok, _} = - Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, today: today) - - final_cycles = get_member_cycles(member.id) - final_count = length(final_cycles) - - # Should have same number of cycles (idempotent) - assert final_count == initial_count - end - end -end diff --git a/test/mv/membership_fees/cycle_generator_edge_cases_test.exs b/test/mv/membership_fees/cycle_generator_edge_cases_test.exs deleted file mode 100644 index adca77a..0000000 --- a/test/mv/membership_fees/cycle_generator_edge_cases_test.exs +++ /dev/null @@ -1,644 +0,0 @@ -defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do - @moduledoc """ - Edge case tests for the CycleGenerator module. - - Tests cover: - - Member joins today - - Member left yesterday - - Year boundary handling - - Leap year handling - - Members with no existing cycles - - Members with existing cycles - """ - use Mv.DataCase, async: false - - alias Mv.MembershipFees.CycleGenerator - alias Mv.MembershipFees.MembershipFeeCycle - alias Mv.MembershipFees.MembershipFeeType - alias Mv.Membership.Member - - require Ash.Query - - # Helper to create a membership fee type - defp create_fee_type(attrs) do - default_attrs = %{ - name: "Test Fee Type #{System.unique_integer([:positive])}", - amount: Decimal.new("50.00"), - interval: :yearly - } - - attrs = Map.merge(default_attrs, attrs) - - MembershipFeeType - |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!() - end - - # Helper to create a member. Note: If membership_fee_type_id is provided, - # cycles will be auto-generated during creation in test environment. - defp create_member(attrs) do - default_attrs = %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer([:positive])}@example.com" - } - - attrs = Map.merge(default_attrs, attrs) - - Member - |> Ash.Changeset.for_create(:create_member, attrs) - |> Ash.create!() - end - - # Helper to create a member and explicitly generate cycles with a fixed "today" date. - # This avoids date dependency issues in tests. - # - # Note: We first create the member without fee_type_id, then assign it via update, - # which triggers the after_action hook. However, we then explicitly regenerate - # cycles with the fixed "today" date to ensure consistency. - defp create_member_with_cycles(attrs, today) do - # Extract membership_fee_type_id if present - fee_type_id = Map.get(attrs, :membership_fee_type_id) - - # Create member WITHOUT fee type first to avoid auto-generation with real today - attrs_without_fee_type = Map.delete(attrs, :membership_fee_type_id) - - member = - create_member(attrs_without_fee_type) - - # Assign fee type if provided (this will trigger auto-generation with real today) - member = - if fee_type_id do - member - |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type_id}) - |> Ash.update!() - else - member - end - - # Explicitly regenerate cycles with fixed "today" date to override any auto-generated cycles - # This ensures the test uses the fixed date, not the real current date - if fee_type_id && member.join_date do - # Delete any existing cycles first to ensure clean state - existing_cycles = get_member_cycles(member.id) - Enum.each(existing_cycles, &Ash.destroy!(&1)) - - # Generate cycles with fixed "today" date - {:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) - end - - member - end - - # Helper to get cycles for a member - defp get_member_cycles(member_id) do - MembershipFeeCycle - |> Ash.Query.filter(member_id == ^member_id) - |> Ash.Query.sort(cycle_start: :asc) - |> Ash.read!() - end - - # Helper to set up settings - defp setup_settings(include_joining_cycle) do - {:ok, settings} = Mv.Membership.get_settings() - - settings - |> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle}) - |> Ash.update!() - end - - describe "member joins today" do - test "current cycle is generated (yearly)" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - today = ~D[2024-06-15] - - # Create member WITHOUT fee type first to avoid auto-generation with real today - member = - create_member(%{ - join_date: today, - membership_fee_start_date: ~D[2024-01-01] - }) - - # Assign fee type - member = - member - |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) - |> Ash.update!() - - # Explicitly generate cycles with fixed "today" date - {:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) - - # Check all cycles - cycles = get_member_cycles(member.id) - - # Should have the current year's cycle - cycle_years = Enum.map(cycles, & &1.cycle_start.year) - assert 2024 in cycle_years - end - - test "current cycle is generated (monthly)" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :monthly}) - - today = ~D[2024-06-15] - - # Create member WITHOUT fee type first to avoid auto-generation with real today - member = - create_member(%{ - join_date: today, - membership_fee_start_date: ~D[2024-06-01] - }) - - # Assign fee type - member = - member - |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) - |> Ash.update!() - - # Explicitly generate cycles with fixed "today" date - {:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) - - # Check all cycles - cycles = get_member_cycles(member.id) - - # Should have June 2024 cycle - assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-06-01] end) - end - - test "current cycle is generated (quarterly)" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :quarterly}) - - today = ~D[2024-05-15] - - # Create member and generate cycles with fixed "today" date - member = - create_member_with_cycles( - %{ - join_date: today, - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2024-04-01] - }, - today - ) - - # Check all cycles - cycles = get_member_cycles(member.id) - - # Should have Q2 2024 cycle - assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-04-01] end) - end - end - - describe "member left yesterday" do - test "no future cycles are generated" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - today = ~D[2024-06-15] - yesterday = Date.add(today, -1) - - # Create member and generate cycles with fixed "today" date - member = - create_member_with_cycles( - %{ - join_date: ~D[2022-03-15], - exit_date: yesterday, - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2022-01-01] - }, - today - ) - - # Check all cycles - cycles = get_member_cycles(member.id) - cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() - - # 2024 should be included because the member was still active during that cycle - assert 2022 in cycle_years - assert 2023 in cycle_years - assert 2024 in cycle_years - - # 2025 should NOT be included - refute 2025 in cycle_years - end - - test "exit during first month of year stops at that year (monthly)" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :monthly}) - - # Create member - cycles will be auto-generated - member = - create_member(%{ - join_date: ~D[2024-01-15], - exit_date: ~D[2024-03-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2024-01-01] - }) - - # Check all cycles - cycles = get_member_cycles(member.id) - cycle_months = Enum.map(cycles, & &1.cycle_start.month) |> Enum.sort() - - assert 1 in cycle_months - assert 2 in cycle_months - assert 3 in cycle_months - - # April and beyond should NOT be included - refute 4 in cycle_months - refute 5 in cycle_months - end - end - - describe "member has no cycles initially" do - test "returns error when fee type is not assigned" do - setup_settings(true) - - # Create member WITHOUT fee type (no auto-generation) - member = - create_member(%{ - join_date: ~D[2022-03-15], - membership_fee_start_date: ~D[2022-01-01] - }) - - # Verify no cycles exist initially - initial_cycles = get_member_cycles(member.id) - assert initial_cycles == [] - - # Trying to generate cycles without fee type should return error - result = CycleGenerator.generate_cycles_for_member(member.id) - assert result == {:error, :no_membership_fee_type} - end - - test "generates all cycles when member is created with fee type" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - today = ~D[2024-06-15] - - # Create member and generate cycles with fixed "today" date - member = - create_member_with_cycles( - %{ - join_date: ~D[2022-03-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2022-01-01] - }, - today - ) - - # Check all cycles - cycles = get_member_cycles(member.id) - - # Should have generated all cycles from 2022 to 2024 (3 cycles) - cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() - assert 2022 in cycle_years - assert 2023 in cycle_years - assert 2024 in cycle_years - # Should NOT have 2025 (today is 2024-06-15) - refute 2025 in cycle_years - end - end - - describe "member has existing cycles" do - test "generates from last cycle (not duplicating existing)" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - # Create member WITHOUT fee type first - member = - create_member(%{ - join_date: ~D[2022-03-15], - membership_fee_start_date: ~D[2022-01-01] - }) - - # Manually create an existing cycle for 2022 - MembershipFeeCycle - |> Ash.Changeset.for_create(:create, %{ - cycle_start: ~D[2022-01-01], - member_id: member.id, - membership_fee_type_id: fee_type.id, - amount: fee_type.amount, - status: :paid - }) - |> Ash.create!() - - # Now assign fee type - member = - member - |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) - |> Ash.update!() - - # Explicitly generate cycles with fixed "today" date - today = ~D[2024-06-15] - {:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) - - # Check all cycles - all_cycles = get_member_cycles(member.id) - all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq() - - # Should have 2022 (manually created), 2023 and 2024 (auto-generated) - assert 2022 in all_cycle_years - assert 2023 in all_cycle_years - assert 2024 in all_cycle_years - - # Verify no duplicates - assert length(all_cycles) == length(all_cycle_years) - end - end - - describe "year boundary handling" do - test "cycles span across year boundaries correctly (yearly)" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - today = ~D[2024-06-15] - - # Create member and generate cycles with fixed "today" date - member = - create_member_with_cycles( - %{ - join_date: ~D[2023-11-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2023-01-01] - }, - today - ) - - # Check all cycles - cycles = get_member_cycles(member.id) - cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() - - # Should have 2023 and 2024 - assert 2023 in cycle_years - assert 2024 in cycle_years - end - - test "cycles span across year boundaries correctly (quarterly)" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :quarterly}) - - today = ~D[2024-12-15] - - # Create member and generate cycles with fixed "today" date - member = - create_member_with_cycles( - %{ - join_date: ~D[2024-10-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2024-10-01] - }, - today - ) - - # Check all cycles - cycles = get_member_cycles(member.id) - cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date) - - # Should have Q4 2024 - assert ~D[2024-10-01] in cycle_starts - end - - test "December to January transition (monthly)" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :monthly}) - - today = ~D[2024-12-31] - - # Create member and generate cycles with fixed "today" date - member = - create_member_with_cycles( - %{ - join_date: ~D[2024-12-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2024-12-01] - }, - today - ) - - # Check all cycles - cycles = get_member_cycles(member.id) - cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date) - - # Should have Dec 2024 - assert ~D[2024-12-01] in cycle_starts - end - end - - describe "leap year handling" do - test "February cycles in leap year" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :monthly}) - - today = ~D[2024-03-15] - - # 2024 is a leap year - # Create member and generate cycles with fixed "today" date - member = - create_member_with_cycles( - %{ - join_date: ~D[2024-02-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2024-02-01] - }, - today - ) - - # Check all cycles - cycles = get_member_cycles(member.id) - - # Should have February 2024 cycle - feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-02-01] end) - - assert feb_cycle != nil - end - - test "February cycles in non-leap year" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :monthly}) - - today = ~D[2023-03-15] - - # 2023 is NOT a leap year - # Create member and generate cycles with fixed "today" date - member = - create_member_with_cycles( - %{ - join_date: ~D[2023-02-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2023-02-01] - }, - today - ) - - # Check all cycles - cycles = get_member_cycles(member.id) - - # Should have February 2023 cycle - feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2023-02-01] end) - - assert feb_cycle != nil - end - - test "yearly cycle in leap year" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - today = ~D[2024-12-31] - - # Create member and generate cycles with fixed "today" date - member = - create_member_with_cycles( - %{ - join_date: ~D[2024-02-29], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2024-01-01] - }, - today - ) - - # Check all cycles - cycles = get_member_cycles(member.id) - - # Should have 2024 cycle - cycle_2024 = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-01-01] end) - - assert cycle_2024 != nil - end - end - - describe "include_joining_cycle variations" do - test "include_joining_cycle = true starts from joining cycle" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - today = ~D[2024-06-15] - - # Member joins mid-2023, should get 2023 cycle with include_joining_cycle=true - # Create member and generate cycles with fixed "today" date - member = - create_member_with_cycles( - %{ - join_date: ~D[2023-06-15], - membership_fee_type_id: fee_type.id - # membership_fee_start_date will be auto-calculated - }, - today - ) - - # Check all cycles - cycles = get_member_cycles(member.id) - cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() - - # Should include 2023 (joining year) - assert 2023 in cycle_years - end - - test "include_joining_cycle = false starts from next cycle" do - setup_settings(false) - fee_type = create_fee_type(%{interval: :yearly}) - - today = ~D[2024-06-15] - - # Member joins mid-2023, should start from 2024 with include_joining_cycle=false - # Create member and generate cycles with fixed "today" date - member = - create_member_with_cycles( - %{ - join_date: ~D[2023-06-15], - membership_fee_type_id: fee_type.id - # membership_fee_start_date will be auto-calculated - }, - today - ) - - # Check all cycles - cycles = get_member_cycles(member.id) - cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() - - # Should NOT include 2023 (joining year) - refute 2023 in cycle_years - - # Should start from 2024 - assert 2024 in cycle_years - end - end - - describe "inactive member processing" do - test "inactive members receive cycles up to exit_date via generate_cycles_for_all_members" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - # Create an inactive member (left in 2023) WITHOUT fee type initially - # This simulates a member that was created before the fee system existed - member = - create_member(%{ - join_date: ~D[2021-03-15], - exit_date: ~D[2023-06-15] - }) - - # Now assign fee type (simulating a retroactive assignment) - member = - member - |> Ash.Changeset.for_update(:update_member, %{ - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2021-01-01] - }) - |> Ash.update!() - - # Run batch generation with a "today" date after the member left - today = ~D[2024-06-15] - {:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today) - - # The inactive member should have been processed - assert results.total >= 1 - - # Check the member's cycles - cycles = get_member_cycles(member.id) - cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq() - - # Should have 2021, 2022, 2023 (exit year included) - assert 2021 in cycle_years - assert 2022 in cycle_years - assert 2023 in cycle_years - - # Should NOT have 2024 (after exit) - refute 2024 in cycle_years - end - - test "exit_date on cycle_start still generates that cycle" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - today = ~D[2024-12-31] - - # Member exits exactly on cycle start (2024-01-01) - # Create member and generate cycles with fixed "today" date - member = - create_member_with_cycles( - %{ - join_date: ~D[2022-03-15], - exit_date: ~D[2024-01-01], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2022-01-01] - }, - today - ) - - # Check cycles - cycles = get_member_cycles(member.id) - cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() - - # 2024 should be included because exit_date == cycle_start means - # the member was still a member on that day - assert 2022 in cycle_years - assert 2023 in cycle_years - assert 2024 in cycle_years - - # 2025 should NOT be included - refute 2025 in cycle_years - end - end -end diff --git a/test/mv/membership_fees/cycle_generator_test.exs b/test/mv/membership_fees/cycle_generator_test.exs deleted file mode 100644 index 06dd59e..0000000 --- a/test/mv/membership_fees/cycle_generator_test.exs +++ /dev/null @@ -1,428 +0,0 @@ -defmodule Mv.MembershipFees.CycleGeneratorTest do - @moduledoc """ - Tests for the CycleGenerator module. - """ - use Mv.DataCase, async: false - - alias Mv.MembershipFees.CycleGenerator - alias Mv.MembershipFees.MembershipFeeCycle - alias Mv.MembershipFees.MembershipFeeType - alias Mv.Membership.Member - - require Ash.Query - - # Helper to create a membership fee type - defp create_fee_type(attrs) do - default_attrs = %{ - name: "Test Fee Type #{System.unique_integer([:positive])}", - amount: Decimal.new("50.00"), - interval: :yearly - } - - attrs = Map.merge(default_attrs, attrs) - - MembershipFeeType - |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!() - end - - # Helper to create a member without triggering cycle generation - defp create_member_without_cycles(attrs) do - default_attrs = %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer([:positive])}@example.com" - } - - attrs = Map.merge(default_attrs, attrs) - - Member - |> Ash.Changeset.for_create(:create_member, attrs) - |> Ash.create!() - end - - # Helper to set up settings with specific include_joining_cycle value - defp setup_settings(include_joining_cycle) do - {:ok, settings} = Mv.Membership.get_settings() - - settings - |> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle}) - |> Ash.update!() - end - - # Helper to get cycles for a member - defp get_member_cycles(member_id) do - MembershipFeeCycle - |> Ash.Query.filter(member_id == ^member_id) - |> Ash.Query.sort(cycle_start: :asc) - |> Ash.read!() - end - - describe "generate_cycles_for_member/2" do - test "generates cycles from start date to today" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - # Create member WITHOUT fee type first to avoid auto-generation - member = - create_member_without_cycles(%{ - join_date: ~D[2022-03-15], - membership_fee_start_date: ~D[2022-01-01] - }) - - # Assign fee type - member = - member - |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) - |> Ash.update!() - - # Explicitly generate cycles with fixed "today" date to avoid date dependency - today = ~D[2024-06-15] - {:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) - - # Verify cycles were generated - all_cycles = get_member_cycles(member.id) - cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq() - - # With include_joining_cycle=true and join_date=2022-03-15, - # start_date should be 2022-01-01 - # Should have cycles for 2022, 2023, 2024 - assert 2022 in cycle_years - assert 2023 in cycle_years - assert 2024 in cycle_years - end - - test "generates cycles from last existing cycle" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - # Create member without fee type first to avoid auto-generation - member = - create_member_without_cycles(%{ - join_date: ~D[2022-03-15], - membership_fee_start_date: ~D[2022-01-01] - }) - - # Manually create a cycle for 2022 - MembershipFeeCycle - |> Ash.Changeset.for_create(:create, %{ - cycle_start: ~D[2022-01-01], - member_id: member.id, - membership_fee_type_id: fee_type.id, - amount: fee_type.amount, - status: :paid - }) - |> Ash.create!() - - # Now assign fee type to member - member = - member - |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) - |> Ash.update!() - - # Generate cycles with specific "today" date - today = ~D[2024-06-15] - {:ok, new_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today) - - # Should generate only 2023 and 2024 (2022 already exists) - new_cycle_years = Enum.map(new_cycles, & &1.cycle_start.year) |> Enum.sort() - - assert 2022 not in new_cycle_years - end - - test "respects left_at boundary (stops generation)" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - member = - create_member_without_cycles(%{ - join_date: ~D[2022-03-15], - exit_date: ~D[2023-06-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2022-01-01] - }) - - # Generate cycles with specific "today" date far in the future - today = ~D[2025-06-15] - {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today) - - # With exit_date in 2023, should only generate 2022 and 2023 cycles - cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() - - # Should not have 2024 or 2025 cycles - assert 2024 not in cycle_years - assert 2025 not in cycle_years - end - - test "skips existing cycles (idempotent)" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - member = - create_member_without_cycles(%{ - join_date: ~D[2023-03-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2023-01-01] - }) - - today = ~D[2024-06-15] - - # First generation - {:ok, _first_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today) - - # Second generation (should be idempotent) - {:ok, second_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today) - - # Second call should return empty list (no new cycles) - assert second_cycles == [] - end - - test "does not fill gaps when cycles were deleted" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - # Create member without fee type first to control which cycles exist - member = - create_member_without_cycles(%{ - join_date: ~D[2020-03-15], - membership_fee_start_date: ~D[2020-01-01] - }) - - # Manually create cycles for 2020, 2021, 2022, 2023 - for year <- [2020, 2021, 2022, 2023] do - MembershipFeeCycle - |> Ash.Changeset.for_create(:create, %{ - cycle_start: Date.new!(year, 1, 1), - member_id: member.id, - membership_fee_type_id: fee_type.id, - amount: fee_type.amount, - status: :unpaid - }) - |> Ash.create!() - end - - # Delete the 2021 cycle (create a gap) - cycle_2021 = - MembershipFeeCycle - |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^~D[2021-01-01]) - |> Ash.read_one!() - - Ash.destroy!(cycle_2021) - - # Now assign fee type to member (this triggers generation) - # Since cycles already exist (2020, 2022, 2023), the generator will - # start from the last existing cycle (2023) and go forward - member = - member - |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) - |> Ash.update!() - - # Verify gap was NOT filled and new cycles were generated from last existing - all_cycles = get_member_cycles(member.id) - all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() - - # 2021 should NOT exist (gap was not filled) - refute 2021 in all_cycle_years, "Gap at 2021 should NOT be filled" - - # 2020, 2022, 2023 should exist (original cycles) - assert 2020 in all_cycle_years - assert 2022 in all_cycle_years - assert 2023 in all_cycle_years - - # 2024 and 2025 should exist (generated after last existing cycle 2023) - assert 2024 in all_cycle_years - assert 2025 in all_cycle_years - end - - test "sets correct amount from membership fee type" do - setup_settings(true) - amount = Decimal.new("75.50") - fee_type = create_fee_type(%{interval: :yearly, amount: amount}) - - member = - create_member_without_cycles(%{ - join_date: ~D[2024-03-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2024-01-01] - }) - - # Verify cycles were generated with correct amount - all_cycles = get_member_cycles(member.id) - refute Enum.empty?(all_cycles), "Expected cycles to be generated" - - # All cycles should have the correct amount - Enum.each(all_cycles, fn cycle -> - assert Decimal.equal?(cycle.amount, amount) - end) - end - - test "handles NULL membership_fee_start_date by calculating from join_date" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :quarterly}) - - # Create member without membership_fee_start_date - it will be auto-calculated - # and cycles will be auto-generated - member = - create_member_without_cycles(%{ - join_date: ~D[2024-02-15], - membership_fee_type_id: fee_type.id - # No membership_fee_start_date - should be calculated - }) - - # Verify cycles were auto-generated - all_cycles = get_member_cycles(member.id) - - # With include_joining_cycle=true and join_date=2024-02-15 (quarterly), - # start_date should be 2024-01-01 (Q1 start) - # Should have Q1, Q2, Q3, Q4 2024 cycles (based on current date) - refute Enum.empty?(all_cycles), "Expected cycles to be generated" - - cycle_starts = Enum.map(all_cycles, & &1.cycle_start) |> Enum.sort(Date) - first_cycle_start = List.first(cycle_starts) - - # First cycle should start in Q1 2024 (2024-01-01) - assert first_cycle_start == ~D[2024-01-01] - end - - test "returns error when member has no membership_fee_type" do - # Create member without fee type - no auto-generation will occur - member = - create_member_without_cycles(%{ - join_date: ~D[2024-03-15] - # No membership_fee_type_id - }) - - {:error, reason} = CycleGenerator.generate_cycles_for_member(member.id) - assert reason == :no_membership_fee_type - end - - test "returns error when member has no join_date" do - fee_type = create_fee_type(%{interval: :yearly}) - - # Create member without join_date - no auto-generation will occur - # (after_action hook checks for join_date) - member = - create_member_without_cycles(%{ - membership_fee_type_id: fee_type.id - # No join_date - }) - - {:error, reason} = CycleGenerator.generate_cycles_for_member(member.id) - assert reason == :no_join_date - end - - test "returns error when member not found" do - fake_id = Ash.UUID.generate() - {:error, reason} = CycleGenerator.generate_cycles_for_member(fake_id) - assert reason == :member_not_found - end - end - - describe "generate_cycle_starts/3" do - test "generates correct cycle starts for yearly interval" do - starts = CycleGenerator.generate_cycle_starts(~D[2022-01-01], ~D[2024-06-15], :yearly) - - assert starts == [~D[2022-01-01], ~D[2023-01-01], ~D[2024-01-01]] - end - - test "generates correct cycle starts for quarterly interval" do - starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-09-15], :quarterly) - - assert starts == [~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01]] - end - - test "generates correct cycle starts for monthly interval" do - starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-03-15], :monthly) - - assert starts == [~D[2024-01-01], ~D[2024-02-01], ~D[2024-03-01]] - end - - test "generates correct cycle starts for half_yearly interval" do - starts = CycleGenerator.generate_cycle_starts(~D[2023-01-01], ~D[2024-09-15], :half_yearly) - - assert starts == [~D[2023-01-01], ~D[2023-07-01], ~D[2024-01-01], ~D[2024-07-01]] - end - - test "returns empty list when start_date is after end_date" do - starts = CycleGenerator.generate_cycle_starts(~D[2025-01-01], ~D[2024-06-15], :yearly) - - assert starts == [] - end - - test "includes cycle when end_date is on cycle start" do - starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-01-01], :yearly) - - assert starts == [~D[2024-01-01]] - end - end - - describe "generate_cycles_for_all_members/1" do - test "generates cycles for multiple members" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - # Create multiple members - _member1 = - create_member_without_cycles(%{ - join_date: ~D[2024-01-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2024-01-01] - }) - - _member2 = - create_member_without_cycles(%{ - join_date: ~D[2024-02-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2024-01-01] - }) - - today = ~D[2024-06-15] - {:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today) - - assert is_map(results) - assert Map.has_key?(results, :success) - assert Map.has_key?(results, :failed) - assert Map.has_key?(results, :total) - end - end - - describe "lock mechanism" do - test "prevents concurrent generation for same member" do - setup_settings(true) - fee_type = create_fee_type(%{interval: :yearly}) - - member = - create_member_without_cycles(%{ - join_date: ~D[2022-03-15], - membership_fee_type_id: fee_type.id, - membership_fee_start_date: ~D[2022-01-01] - }) - - today = ~D[2024-06-15] - - # Run two concurrent generations - task1 = - Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end) - - task2 = - Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end) - - result1 = Task.await(task1) - result2 = Task.await(task2) - - # Both should succeed - assert match?({:ok, _}, result1) - assert match?({:ok, _}, result2) - - # One should have created cycles, the other should have empty list (idempotent) - {:ok, cycles1} = result1 - {:ok, cycles2} = result2 - - # Combined should not have duplicates - all_cycles = cycles1 ++ cycles2 - unique_starts = all_cycles |> Enum.map(& &1.cycle_start) |> Enum.uniq() - - assert length(all_cycles) == length(unique_starts) - end - end -end