diff --git a/config/test.exs b/config/test.exs index 2c4d2ba..326694e 100644 --- a/config/test.exs +++ b/config/test.exs @@ -47,4 +47,5 @@ config :mv, :session_identifier, :unsafe config :mv, :require_token_presence_for_authentication, false # Enable SQL Sandbox for async LiveView tests +# This flag controls sync vs async behavior in CycleGenerator after_action hooks config :mv, :sql_sandbox, true diff --git a/docs/membership-fee-architecture.md b/docs/membership-fee-architecture.md index c601b79..d6b5ee2 100644 --- a/docs/membership-fee-architecture.md +++ b/docs/membership-fee-architecture.md @@ -153,8 +153,8 @@ lib/ **Existing Fields Used:** -- `joined_at` - For calculating membership fee start -- `left_at` - For limiting cycle generation +- `join_date` - For calculating membership fee start +- `exit_date` - For limiting cycle generation - These fields must remain member fields and should not be replaced by custom fields in the future ### Settings Integration @@ -186,8 +186,9 @@ lib/ - Calculate which cycles should exist for a member - Generate missing cycles -- Respect membership_fee_start_date and left_at boundaries +- Respect membership_fee_start_date and exit_date boundaries - Skip existing cycles (idempotent) +- Use PostgreSQL advisory locks per member to prevent race conditions **Triggers:** @@ -199,17 +200,20 @@ lib/ **Algorithm Steps:** 1. Retrieve member with membership fee type and dates -2. Determine first cycle start (based on membership_fee_start_date) -3. Calculate all cycle starts from first to today (or left_at) -4. Query existing cycles for member -5. Generate missing cycles with current membership fee type's amount -6. Insert new cycles (batch operation) +2. Determine generation start point: + - If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`) + - If cycles exist: Start from the cycle AFTER the last existing one +3. Generate all cycle starts from the determined start point to today (or `exit_date`) +4. Create new cycles with current membership fee type's amount +5. Use PostgreSQL advisory locks per member to prevent race conditions **Edge Case Handling:** -- If membership_fee_start_date is NULL: Calculate from joined_at + global setting -- If left_at is set: Stop generation at left_at +- If membership_fee_start_date is NULL: Calculate from join_date + global setting +- If exit_date is set: Stop generation at exit_date - If membership fee type changes: Handled separately by regeneration logic +- **Gap Handling:** If cycles were explicitly deleted (gaps exist), they are NOT recreated. + The generator always continues from the cycle AFTER the last existing cycle, regardless of gaps. ### Calendar Cycle Calculations @@ -381,7 +385,7 @@ lib/ **AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default) **AC-M-2:** Member has membership_fee_start_date field (nullable) **AC-M-3:** New members get default membership fee type from global setting -**AC-M-4:** membership_fee_start_date auto-set based on joined_at and global setting +**AC-M-4:** membership_fee_start_date auto-set based on join_date and global setting **AC-M-5:** Admin can manually override membership_fee_start_date **AC-M-6:** Cannot change to membership fee type with different interval (MVP) @@ -391,7 +395,7 @@ lib/ **AC-CG-2:** Cycles generated when member created (via change hook) **AC-CG-3:** Scheduled job generates missing cycles daily **AC-CG-4:** Generation respects membership_fee_start_date -**AC-CG-5:** Generation stops at left_at if member exited +**AC-CG-5:** Generation stops at exit_date if member exited **AC-CG-6:** Generation is idempotent (skips existing cycles) **AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year) **AC-CG-8:** Amount comes from membership_fee_type at generation time @@ -472,8 +476,9 @@ lib/ - Correct cycle_start calculation for all interval types - Correct cycle count from start to end date - Respects membership_fee_start_date boundary -- Respects left_at boundary +- Respects exit_date boundary - Skips existing cycles (idempotent) +- Does not fill gaps when cycles were deleted - Handles edge dates (year boundaries, leap years) **Calendar Cycles Tests:** diff --git a/docs/membership-fee-overview.md b/docs/membership-fee-overview.md index 229b73b..bd47faa 100644 --- a/docs/membership-fee-overview.md +++ b/docs/membership-fee-overview.md @@ -120,7 +120,7 @@ This document provides a comprehensive overview of the Membership Fees system. I ``` - membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings) - membership_fee_start_date (Date, nullable) - When to start generating membership fees -- left_at (Date, nullable) - Exit date (existing) +- exit_date (Date, nullable) - Exit date (existing) ``` **Logic for membership_fee_start_date:** @@ -167,16 +167,17 @@ value: UUID (Required) - Default membership fee type for new members **Algorithm:** -Lock the whole cycle table for the duration of the algorithm +Use PostgreSQL advisory locks per member to prevent race conditions 1. Get `member.membership_fee_start_date` and member's membership fee type -2. Generate cycles until today (or `left_at` if present): - - If no cycle exists: - - Generate all cycles from `membership_fee_start_date` - - else: - - Generate all cycles from last existing cycle - - use the interval to generate the cycles -3. Set `amount` to current membership fee type's amount +2. Determine generation start point: + - If NO cycles exist: Start from `membership_fee_start_date` + - If cycles exist: Start from the cycle AFTER the last existing one +3. Generate cycles until today (or `exit_date` if present): + - Use the interval to generate the cycles + - **Note:** If cycles were explicitly deleted (gaps exist), they are NOT recreated. + The generator always continues from the cycle AFTER the last existing cycle. +4. Set `amount` to current membership fee type's amount **Example (Yearly):** @@ -246,7 +247,7 @@ suspended → unpaid **Logic:** -- Cycles only generated until `member.left_at` +- Cycles only generated until `member.exit_date` - Existing cycles remain visible - Unpaid exit cycle can be marked as "suspended" diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index 5b7514c..18b8154 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -12,7 +12,6 @@ defmodule Mv.Membership.CustomField do - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) - `description` - Optional human-readable description - - `immutable` - If true, custom field values cannot be changed after creation - `required` - If true, all members must have this custom field (future feature) - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted @@ -60,10 +59,10 @@ defmodule Mv.Membership.CustomField do actions do defaults [:read, :update] - default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] + default_accept [:name, :value_type, :description, :required, :show_in_overview] create :create do - accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] + accept [:name, :value_type, :description, :required, :show_in_overview] change Mv.Membership.CustomField.Changes.GenerateSlug validate string_length(:slug, min: 1) end @@ -113,10 +112,6 @@ defmodule Mv.Membership.CustomField do trim?: true ] - attribute :immutable, :boolean, - default: false, - allow_nil?: false - attribute :required, :boolean, default: false, allow_nil?: false diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 5816d19..ae32abd 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -80,7 +80,7 @@ defmodule Mv.Membership.Member do argument :user, :map, allow_nil?: true # Accept member fields plus membership_fee_type_id (belongs_to FK) - accept @member_fields ++ [:membership_fee_type_id] + accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date] change manage_relationship(:custom_field_values, type: :create) @@ -101,6 +101,42 @@ defmodule Mv.Membership.Member do change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:user)] end + + # Auto-calculate membership_fee_start_date if not manually set + # Requires both join_date and membership_fee_type_id to be present + change Mv.MembershipFees.Changes.SetMembershipFeeStartDate + + # Trigger cycle generation after member creation + # Only runs if membership_fee_type_id is set + # Note: Cycle generation runs asynchronously to not block the action, + # but in test environment it runs synchronously for DB sandbox compatibility + change after_action(fn _changeset, member, _context -> + if member.membership_fee_type_id && member.join_date do + generate_fn = fn -> + case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do + {:ok, _cycles} -> + :ok + + {:error, reason} -> + require Logger + + Logger.warning( + "Failed to generate cycles for member #{member.id}: #{inspect(reason)}" + ) + end + end + + if Application.get_env(:mv, :sql_sandbox, false) do + # Run synchronously in test environment for DB sandbox compatibility + generate_fn.() + else + # Run asynchronously in other environments + Task.start(generate_fn) + end + end + + {:ok, member} + end) end update :update_member do @@ -114,7 +150,7 @@ defmodule Mv.Membership.Member do argument :user, :map, allow_nil?: true # Accept member fields plus membership_fee_type_id (belongs_to FK) - accept @member_fields ++ [:membership_fee_type_id] + accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date] change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) @@ -141,6 +177,46 @@ defmodule Mv.Membership.Member do change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:user)] end + + # Auto-calculate membership_fee_start_date when membership_fee_type_id is set + # and membership_fee_start_date is not already set + change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do + where [changing(:membership_fee_type_id)] + end + + # Trigger cycle generation when membership_fee_type_id changes + # Note: Cycle generation runs asynchronously to not block the action, + # but in test environment it runs synchronously for DB sandbox compatibility + change after_action(fn changeset, member, _context -> + fee_type_changed = + Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id) + + if fee_type_changed && member.membership_fee_type_id && member.join_date do + generate_fn = fn -> + case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do + {:ok, _cycles} -> + :ok + + {:error, reason} -> + require Logger + + Logger.warning( + "Failed to generate cycles for member #{member.id}: #{inspect(reason)}" + ) + end + end + + if Application.get_env(:mv, :sql_sandbox, false) do + # Run synchronously in test environment for DB sandbox compatibility + generate_fn.() + else + # Run asynchronously in other environments + Task.start(generate_fn) + end + end + + {:ok, member} + end) end # Action to handle fuzzy search on specific fields diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 52c0328..eedc47c 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -4,13 +4,15 @@ defmodule Mv.Membership.Setting do ## Overview Settings is a singleton resource that stores global configuration for the association, - such as the club name and branding information. There should only ever be one settings - record in the database. + such as the club name, branding information, and membership fee settings. There should + only ever be one settings record in the database. ## Attributes - `club_name` - The name of the association/club (required, cannot be empty) - `member_field_visibility` - JSONB map storing visibility configuration for member fields (e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`. + - `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true) + - `default_membership_fee_type_id` - Default membership fee type for new members (optional) ## Singleton Pattern This resource uses a singleton pattern - there should only be one settings record. @@ -22,6 +24,12 @@ defmodule Mv.Membership.Setting do If set, the environment variable value is used as a fallback when no database value exists. Database values always take precedence over environment variables. + ## Membership Fee Settings + - `include_joining_cycle`: When true, members pay from their joining cycle. When false, + they pay from the next full cycle after joining. + - `default_membership_fee_type_id`: The membership fee type automatically assigned to + new members. Can be nil if no default is set. + ## Examples # Get current settings @@ -33,6 +41,9 @@ defmodule Mv.Membership.Setting do # Update member field visibility {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false}) + + # Update membership fee settings + {:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false}) """ use Ash.Resource, domain: Mv.Membership, @@ -54,13 +65,24 @@ defmodule Mv.Membership.Setting do # Used only as fallback in get_settings/0 if settings don't exist # Settings should normally be created via seed script create :create do - accept [:club_name, :member_field_visibility] + accept [ + :club_name, + :member_field_visibility, + :include_joining_cycle, + :default_membership_fee_type_id + ] end update :update do primary? true require_atomic? false - accept [:club_name, :member_field_visibility] + + accept [ + :club_name, + :member_field_visibility, + :include_joining_cycle, + :default_membership_fee_type_id + ] end update :update_member_field_visibility do @@ -68,6 +90,14 @@ defmodule Mv.Membership.Setting do require_atomic? false accept [:member_field_visibility] end + + update :update_membership_fee_settings do + description "Updates the membership fee configuration" + require_atomic? false + accept [:include_joining_cycle, :default_membership_fee_type_id] + + change Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId + end end validations do @@ -113,6 +143,41 @@ defmodule Mv.Membership.Setting do end end, on: [:create, :update] + + # Validate default_membership_fee_type_id exists if set + validate fn changeset, _context -> + fee_type_id = + Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id) + + if fee_type_id do + case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id) do + {:ok, _} -> + :ok + + {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} -> + {:error, + field: :default_membership_fee_type_id, + message: "Membership fee type not found"} + + {:error, err} -> + # Log unexpected errors (DB timeout, connection errors, etc.) + require Logger + + Logger.warning( + "Unexpected error when validating default_membership_fee_type_id: #{inspect(err)}" + ) + + # Return generic error to user + {:error, + field: :default_membership_fee_type_id, + message: "Could not validate membership fee type"} + end + else + # Optional, can be nil + :ok + end + end, + on: [:create, :update] end attributes do @@ -133,6 +198,26 @@ defmodule Mv.Membership.Setting do description: "Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans." + # Membership fee settings + attribute :include_joining_cycle, :boolean do + allow_nil? false + default true + public? true + description "Whether to include the joining cycle in membership fee generation" + end + + attribute :default_membership_fee_type_id, :uuid do + allow_nil? true + public? true + description "Default membership fee type ID for new members" + end + timestamps() end + + relationships do + # Optional relationship to the default membership fee type + # Note: We use manual FK (default_membership_fee_type_id attribute) instead of belongs_to + # to avoid circular dependency between Membership and MembershipFees domains + end end diff --git a/lib/membership/setting/changes/normalize_default_fee_type_id.ex b/lib/membership/setting/changes/normalize_default_fee_type_id.ex new file mode 100644 index 0000000..fdbe1c8 --- /dev/null +++ b/lib/membership/setting/changes/normalize_default_fee_type_id.ex @@ -0,0 +1,19 @@ +defmodule Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId do + @moduledoc """ + Ash change that normalizes empty strings to nil for default_membership_fee_type_id. + + HTML forms submit empty select values as empty strings (""), but the database + expects nil for optional UUID fields. This change converts "" to nil. + """ + use Ash.Resource.Change + + def change(changeset, _opts, _context) do + default_fee_type_id = Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id) + + if default_fee_type_id == "" do + Ash.Changeset.force_change_attribute(changeset, :default_membership_fee_type_id, nil) + else + changeset + end + end +end diff --git a/lib/membership_fees/changes/set_membership_fee_start_date.ex b/lib/membership_fees/changes/set_membership_fee_start_date.ex new file mode 100644 index 0000000..a2e1ad0 --- /dev/null +++ b/lib/membership_fees/changes/set_membership_fee_start_date.ex @@ -0,0 +1,174 @@ +defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do + @moduledoc """ + Ash change module that automatically calculates and sets the membership_fee_start_date. + + ## Logic + + 1. Only executes if `membership_fee_start_date` is not manually set + 2. Requires both `join_date` and `membership_fee_type_id` to be present + 3. Reads `include_joining_cycle` setting from global Settings + 4. Reads `interval` from the assigned `membership_fee_type` + 5. Calculates the start date: + - If `include_joining_cycle = true`: First day of the joining cycle + - If `include_joining_cycle = false`: First day of the next cycle after joining + + ## Usage + + In a Member action: + + create :create_member do + # ... + change Mv.MembershipFees.Changes.SetMembershipFeeStartDate + end + + The change module handles all prerequisite checks internally (join_date, membership_fee_type_id). + If any required data is missing, the changeset is returned unchanged with a warning logged. + """ + use Ash.Resource.Change + + require Logger + + alias Mv.MembershipFees.CalendarCycles + + @impl true + def change(changeset, _opts, _context) do + # Only calculate if membership_fee_start_date is not already set + if has_start_date?(changeset) do + changeset + else + calculate_and_set_start_date(changeset) + end + end + + # Check if membership_fee_start_date is already set (either in changeset or data) + defp has_start_date?(changeset) do + # Check if it's being set in this changeset + case Ash.Changeset.fetch_change(changeset, :membership_fee_start_date) do + {:ok, date} when not is_nil(date) -> + true + + _ -> + # Check if it already exists in the data (for updates) + case changeset.data do + %{membership_fee_start_date: date} when not is_nil(date) -> true + _ -> false + end + end + end + + defp calculate_and_set_start_date(changeset) do + with {:ok, join_date} <- get_join_date(changeset), + {:ok, membership_fee_type_id} <- get_membership_fee_type_id(changeset), + {:ok, interval} <- get_interval(membership_fee_type_id), + {:ok, include_joining_cycle} <- get_include_joining_cycle() do + start_date = calculate_start_date(join_date, interval, include_joining_cycle) + Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date) + else + {:error, :join_date_not_set} -> + # Missing join_date is expected for partial creates + changeset + + {:error, :membership_fee_type_not_set} -> + # Missing membership_fee_type_id is expected for partial creates + changeset + + {:error, :membership_fee_type_not_found} -> + # This is a data integrity error - membership_fee_type_id references non-existent type + # Return changeset error to fail the action + Ash.Changeset.add_error( + changeset, + field: :membership_fee_type_id, + message: "not found" + ) + + {:error, reason} -> + # Log warning for other unexpected errors + Logger.warning("Could not auto-set membership_fee_start_date: #{inspect(reason)}") + changeset + end + end + + defp get_join_date(changeset) do + # First check the changeset for changes + case Ash.Changeset.fetch_change(changeset, :join_date) do + {:ok, date} when not is_nil(date) -> + {:ok, date} + + _ -> + # Then check existing data + case changeset.data do + %{join_date: date} when not is_nil(date) -> {:ok, date} + _ -> {:error, :join_date_not_set} + end + end + end + + defp get_membership_fee_type_id(changeset) do + # First check the changeset for changes + case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do + {:ok, id} when not is_nil(id) -> + {:ok, id} + + _ -> + # Then check existing data + case changeset.data do + %{membership_fee_type_id: id} when not is_nil(id) -> {:ok, id} + _ -> {:error, :membership_fee_type_not_set} + end + end + end + + defp get_interval(membership_fee_type_id) do + case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id) do + {:ok, %{interval: interval}} -> {:ok, interval} + {:error, _} -> {:error, :membership_fee_type_not_found} + end + end + + defp get_include_joining_cycle do + case Mv.Membership.get_settings() do + {:ok, %{include_joining_cycle: include}} -> {:ok, include} + {:error, _} -> {:ok, true} + end + end + + @doc """ + Calculates the membership fee start date based on join date, interval, and settings. + + ## Parameters + + - `join_date` - The date the member joined + - `interval` - The billing interval (:monthly, :quarterly, :half_yearly, :yearly) + - `include_joining_cycle` - Whether to include the joining cycle + + ## Returns + + The calculated start date (first day of the appropriate cycle). + + ## Examples + + iex> calculate_start_date(~D[2024-03-15], :yearly, true) + ~D[2024-01-01] + + iex> calculate_start_date(~D[2024-03-15], :yearly, false) + ~D[2025-01-01] + + iex> calculate_start_date(~D[2024-03-15], :quarterly, true) + ~D[2024-01-01] + + iex> calculate_start_date(~D[2024-03-15], :quarterly, false) + ~D[2024-04-01] + + """ + @spec calculate_start_date(Date.t(), CalendarCycles.interval(), boolean()) :: Date.t() + def calculate_start_date(join_date, interval, include_joining_cycle) do + if include_joining_cycle do + # Start date is the first day of the joining cycle + CalendarCycles.calculate_cycle_start(join_date, interval) + else + # Start date is the first day of the next cycle after joining + join_cycle_start = CalendarCycles.calculate_cycle_start(join_date, interval) + CalendarCycles.next_cycle_start(join_cycle_start, interval) + end + end +end diff --git a/lib/membership_fees/membership_fee_type.ex b/lib/membership_fees/membership_fee_type.ex index 877a385..01ae625 100644 --- a/lib/membership_fees/membership_fee_type.ex +++ b/lib/membership_fees/membership_fee_type.ex @@ -36,7 +36,7 @@ defmodule Mv.MembershipFees.MembershipFeeType do end actions do - defaults [:read, :destroy] + defaults [:read] create :create do primary? true @@ -45,10 +45,108 @@ defmodule Mv.MembershipFees.MembershipFeeType do update :update do primary? true + # require_atomic? false because validation queries (member/cycle counts) are not atomic + # DB constraints serve as the final safeguard if data changes between validation and update + require_atomic? false # Note: interval is NOT in accept list - it's immutable after creation - # Immutability validation will be added in a future issue accept [:name, :amount, :description] end + + destroy :destroy do + primary? true + + # require_atomic? false because validation queries (member/cycle/settings counts) are not atomic + # DB constraints serve as the final safeguard if data changes between validation and delete + require_atomic? false + end + end + + validations do + # Prevent interval changes after creation + validate fn changeset, _context -> + if Ash.Changeset.changing_attribute?(changeset, :interval) do + case changeset.data do + # Creating new resource, interval can be set + nil -> + :ok + + _existing -> + {:error, + field: :interval, message: "Interval cannot be changed after creation"} + end + else + :ok + end + end, + on: [:update] + + # Prevent deletion if assigned to members + validate fn changeset, _context -> + if changeset.action_type == :destroy do + require Ash.Query + + member_count = + Mv.Membership.Member + |> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id) + |> Ash.count!() + + if member_count > 0 do + {:error, + message: + "Cannot delete membership fee type: #{member_count} member(s) are assigned to it"} + else + :ok + end + else + :ok + end + end, + on: [:destroy] + + # Prevent deletion if cycles exist + validate fn changeset, _context -> + if changeset.action_type == :destroy do + require Ash.Query + + cycle_count = + Mv.MembershipFees.MembershipFeeCycle + |> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id) + |> Ash.count!() + + if cycle_count > 0 do + {:error, + message: + "Cannot delete membership fee type: #{cycle_count} cycle(s) reference it"} + else + :ok + end + else + :ok + end + end, + on: [:destroy] + + # Prevent deletion if used as default in settings + validate fn changeset, _context -> + if changeset.action_type == :destroy do + require Ash.Query + + setting_count = + Mv.Membership.Setting + |> Ash.Query.filter(default_membership_fee_type_id == ^changeset.data.id) + |> Ash.count!() + + if setting_count > 0 do + {:error, + message: "Cannot delete membership fee type: it's used as default in settings"} + else + :ok + end + else + :ok + end + end, + on: [:destroy] end attributes do diff --git a/lib/mv/membership_fees/calendar_cycles.ex b/lib/mv/membership_fees/calendar_cycles.ex new file mode 100644 index 0000000..8a4ef24 --- /dev/null +++ b/lib/mv/membership_fees/calendar_cycles.ex @@ -0,0 +1,329 @@ +defmodule Mv.MembershipFees.CalendarCycles do + @moduledoc """ + Calendar-based cycle calculation functions for membership fees. + + This module provides functions for calculating cycle boundaries + based on interval types (monthly, quarterly, half-yearly, yearly). + + The calculation functions (`calculate_cycle_start/3`, `calculate_cycle_end/2`, + `next_cycle_start/2`) are pure functions with no side effects. + + The time-dependent functions (`current_cycle?/3`, `last_completed_cycle?/3`) + depend on a date parameter for testability. Their 2-argument variants + (`current_cycle?/2`, `last_completed_cycle?/2`) use `Date.utc_today()` and + are not referentially transparent. + + ## Interval Types + + - `:monthly` - Cycles from 1st to last day of each month + - `:quarterly` - Cycles from 1st of Jan/Apr/Jul/Oct to last day of quarter + - `:half_yearly` - Cycles from 1st of Jan/Jul to last day of half-year + - `:yearly` - Cycles from Jan 1st to Dec 31st + + ## Examples + + iex> date = ~D[2024-03-15] + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(date, :monthly) + ~D[2024-03-01] + + iex> cycle_start = ~D[2024-01-01] + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle_start, :yearly) + ~D[2024-12-31] + + iex> cycle_start = ~D[2024-01-01] + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(cycle_start, :yearly) + ~D[2025-01-01] + """ + + @typedoc """ + Interval type for membership fee cycles. + + - `:monthly` - Monthly cycles (1st to last day of month) + - `:quarterly` - Quarterly cycles (1st of quarter to last day of quarter) + - `:half_yearly` - Half-yearly cycles (1st of half-year to last day of half-year) + - `:yearly` - Yearly cycles (Jan 1st to Dec 31st) + """ + @type interval :: :monthly | :quarterly | :half_yearly | :yearly + + @doc """ + Calculates the start date of the cycle that contains the reference date. + + ## Parameters + + - `date` - Ignored in this 3-argument version (kept for API consistency) + - `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`) + - `reference_date` - The date used to determine which cycle to calculate + + ## Returns + + The start date of the cycle containing the reference date. + + ## Examples + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly, ~D[2024-05-20]) + ~D[2024-05-01] + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :quarterly, ~D[2024-05-20]) + ~D[2024-04-01] + """ + @spec calculate_cycle_start(Date.t(), interval(), Date.t()) :: Date.t() + def calculate_cycle_start(_date, interval, reference_date) do + case interval do + :monthly -> monthly_cycle_start(reference_date) + :quarterly -> quarterly_cycle_start(reference_date) + :half_yearly -> half_yearly_cycle_start(reference_date) + :yearly -> yearly_cycle_start(reference_date) + end + end + + @doc """ + Calculates the start date of the cycle that contains the given date. + + This is a convenience function that calls `calculate_cycle_start/3` with `date` as both + the input and reference date. + + ## Parameters + + - `date` - The date used to determine which cycle to calculate + - `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`) + + ## Returns + + The start date of the cycle containing the given date. + + ## Examples + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly) + ~D[2024-03-01] + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) + ~D[2024-04-01] + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) + ~D[2024-07-01] + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-12-15], :yearly) + ~D[2024-01-01] + """ + @spec calculate_cycle_start(Date.t(), interval()) :: Date.t() + def calculate_cycle_start(date, interval) do + calculate_cycle_start(date, interval, date) + end + + @doc """ + Calculates the end date of a cycle based on its start date and interval. + + ## Parameters + + - `cycle_start` - The start date of the cycle + - `interval` - The interval type + + ## Returns + + The end date of the cycle. + + ## Examples + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) + ~D[2024-03-31] + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) + ~D[2024-02-29] + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) + ~D[2024-03-31] + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) + ~D[2024-06-30] + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) + ~D[2024-12-31] + """ + @spec calculate_cycle_end(Date.t(), interval()) :: Date.t() + def calculate_cycle_end(cycle_start, interval) do + case interval do + :monthly -> monthly_cycle_end(cycle_start) + :quarterly -> quarterly_cycle_end(cycle_start) + :half_yearly -> half_yearly_cycle_end(cycle_start) + :yearly -> yearly_cycle_end(cycle_start) + end + end + + @doc """ + Calculates the start date of the next cycle. + + ## Parameters + + - `cycle_start` - The start date of the current cycle + - `interval` - The interval type + + ## Returns + + The start date of the next cycle. + + ## Examples + + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly) + ~D[2024-02-01] + + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly) + ~D[2024-04-01] + + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly) + ~D[2024-07-01] + + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) + ~D[2025-01-01] + """ + @spec next_cycle_start(Date.t(), interval()) :: Date.t() + def next_cycle_start(cycle_start, interval) do + cycle_end = calculate_cycle_end(cycle_start, interval) + next_date = Date.add(cycle_end, 1) + calculate_cycle_start(next_date, interval) + end + + @doc """ + Checks if the cycle contains the given date. + + ## Parameters + + - `cycle_start` - The start date of the cycle + - `interval` - The interval type + - `today` - The date to check (defaults to today's date) + + ## Returns + + `true` if the given date is within the cycle, `false` otherwise. + + ## Examples + + iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15]) + true + + iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-02-01], :monthly, ~D[2024-03-15]) + false + + iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-01]) + true + + iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-31]) + true + """ + @spec current_cycle?(Date.t(), interval(), Date.t()) :: boolean() + def current_cycle?(cycle_start, interval, today) do + cycle_end = calculate_cycle_end(cycle_start, interval) + + Date.compare(cycle_start, today) in [:lt, :eq] and + Date.compare(today, cycle_end) in [:lt, :eq] + end + + @spec current_cycle?(Date.t(), interval()) :: boolean() + def current_cycle?(cycle_start, interval) do + current_cycle?(cycle_start, interval, Date.utc_today()) + end + + @doc """ + Checks if the cycle is the last completed cycle. + + A cycle is considered the last completed cycle if: + - The cycle has ended (cycle_end < today) + - The next cycle has not ended yet (today <= next_end) + + In other words: `cycle_end < today <= next_end` + + ## Parameters + + - `cycle_start` - The start date of the cycle + - `interval` - The interval type + - `today` - The date to check against (defaults to today's date) + + ## Returns + + `true` if the cycle is the last completed cycle, `false` otherwise. + + ## Examples + + iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-04-01]) + true + + iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15]) + false + + iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-02-01], :monthly, ~D[2024-04-15]) + false + """ + @spec last_completed_cycle?(Date.t(), interval(), Date.t()) :: boolean() + def last_completed_cycle?(cycle_start, interval, today) do + cycle_end = calculate_cycle_end(cycle_start, interval) + + # Cycle must have ended (cycle_end < today) + case Date.compare(today, cycle_end) do + :gt -> + # Check if this is the most recent completed cycle + # by verifying that the next cycle hasn't ended yet (today <= next_end) + next_start = next_cycle_start(cycle_start, interval) + next_end = calculate_cycle_end(next_start, interval) + + Date.compare(today, next_end) in [:lt, :eq] + + _ -> + false + end + end + + @spec last_completed_cycle?(Date.t(), interval()) :: boolean() + def last_completed_cycle?(cycle_start, interval) do + last_completed_cycle?(cycle_start, interval, Date.utc_today()) + end + + # Private helper functions + + defp monthly_cycle_start(date) do + Date.new!(date.year, date.month, 1) + end + + defp monthly_cycle_end(cycle_start) do + Date.end_of_month(cycle_start) + end + + defp quarterly_cycle_start(date) do + quarter_start_month = + case date.month do + m when m in [1, 2, 3] -> 1 + m when m in [4, 5, 6] -> 4 + m when m in [7, 8, 9] -> 7 + m when m in [10, 11, 12] -> 10 + end + + Date.new!(date.year, quarter_start_month, 1) + end + + defp quarterly_cycle_end(cycle_start) do + case cycle_start.month do + 1 -> Date.new!(cycle_start.year, 3, 31) + 4 -> Date.new!(cycle_start.year, 6, 30) + 7 -> Date.new!(cycle_start.year, 9, 30) + 10 -> Date.new!(cycle_start.year, 12, 31) + end + end + + defp half_yearly_cycle_start(date) do + half_start_month = if date.month in 1..6, do: 1, else: 7 + Date.new!(date.year, half_start_month, 1) + end + + defp half_yearly_cycle_end(cycle_start) do + case cycle_start.month do + 1 -> Date.new!(cycle_start.year, 6, 30) + 7 -> Date.new!(cycle_start.year, 12, 31) + end + end + + defp yearly_cycle_start(date) do + Date.new!(date.year, 1, 1) + end + + defp yearly_cycle_end(cycle_start) do + Date.new!(cycle_start.year, 12, 31) + end +end diff --git a/lib/mv/membership_fees/cycle_generation_job.ex b/lib/mv/membership_fees/cycle_generation_job.ex new file mode 100644 index 0000000..71a3158 --- /dev/null +++ b/lib/mv/membership_fees/cycle_generation_job.ex @@ -0,0 +1,174 @@ +defmodule Mv.MembershipFees.CycleGenerationJob do + @moduledoc """ + Scheduled job for generating membership fee cycles. + + This module provides a skeleton for scheduled cycle generation. + In the future, this can be integrated with Oban or similar job processing libraries. + + ## Current Implementation + + Currently provides manual execution functions that can be called: + - From IEx console for administrative tasks + - From a cron job via a Mix task + - From the admin UI (future) + + ## Future Oban Integration + + When Oban is added to the project, this module can be converted to an Oban worker: + + defmodule Mv.MembershipFees.CycleGenerationJob do + use Oban.Worker, + queue: :membership_fees, + max_attempts: 3 + + @impl Oban.Worker + def perform(%Oban.Job{}) do + Mv.MembershipFees.CycleGenerator.generate_cycles_for_all_members() + end + end + + ## Usage + + # Manual execution from IEx + Mv.MembershipFees.CycleGenerationJob.run() + + # Check if cycles need to be generated + Mv.MembershipFees.CycleGenerationJob.pending_members_count() + + """ + + alias Mv.MembershipFees.CycleGenerator + + require Ash.Query + require Logger + + @doc """ + Runs the cycle generation job for all active members. + + This is the main entry point for scheduled execution. + + ## Returns + + - `{:ok, results}` - Map with success/failed counts + - `{:error, reason}` - Error with reason + + ## Examples + + iex> Mv.MembershipFees.CycleGenerationJob.run() + {:ok, %{success: 45, failed: 0, total: 45}} + + """ + @spec run() :: {:ok, map()} | {:error, term()} + def run do + Logger.info("Starting membership fee cycle generation job") + start_time = System.monotonic_time(:millisecond) + + result = CycleGenerator.generate_cycles_for_all_members() + + elapsed = System.monotonic_time(:millisecond) - start_time + + case result do + {:ok, stats} -> + Logger.info( + "Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total" + ) + + result + + {:error, reason} -> + Logger.error("Cycle generation failed: #{inspect(reason)}") + result + end + end + + @doc """ + Runs cycle generation with custom options. + + ## Options + + - `:today` - Override today's date (useful for testing or catch-up) + - `:batch_size` - Number of members to process in parallel + + ## Examples + + # Generate cycles as if today was a specific date + Mv.MembershipFees.CycleGenerationJob.run(today: ~D[2024-12-31]) + + # Process with smaller batch size + Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5) + + """ + @spec run(keyword()) :: {:ok, map()} | {:error, term()} + def run(opts) when is_list(opts) do + Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}") + start_time = System.monotonic_time(:millisecond) + + result = CycleGenerator.generate_cycles_for_all_members(opts) + + elapsed = System.monotonic_time(:millisecond) - start_time + + case result do + {:ok, stats} -> + Logger.info( + "Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total" + ) + + result + + {:error, reason} -> + Logger.error("Cycle generation failed: #{inspect(reason)}") + result + end + end + + @doc """ + Returns the count of members that need cycle generation. + + A member needs cycle generation if: + - Has a membership_fee_type assigned + - Has a join_date set + - Is active (no exit_date or exit_date >= today) + + ## Returns + + - `{:ok, count}` - Number of members needing generation + - `{:error, reason}` - Error with reason + + """ + @spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, term()} + def pending_members_count do + today = Date.utc_today() + + query = + Mv.Membership.Member + |> Ash.Query.filter(not is_nil(membership_fee_type_id)) + |> Ash.Query.filter(not is_nil(join_date)) + |> Ash.Query.filter(is_nil(exit_date) or exit_date >= ^today) + + case Ash.count(query) do + {:ok, count} -> {:ok, count} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Generates cycles for a specific member by ID. + + Useful for administrative tasks or manual corrections. + + ## Parameters + + - `member_id` - The UUID of the member + + ## Returns + + - `{:ok, cycles}` - List of newly created cycles + - `{:error, reason}` - Error with reason + + """ + @spec run_for_member(String.t()) :: {:ok, [map()]} | {:error, term()} + def run_for_member(member_id) when is_binary(member_id) do + Logger.info("Generating cycles for member #{member_id}") + CycleGenerator.generate_cycles_for_member(member_id) + end +end diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex new file mode 100644 index 0000000..0727a62 --- /dev/null +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -0,0 +1,390 @@ +defmodule Mv.MembershipFees.CycleGenerator do + @moduledoc """ + Module for generating membership fee cycles for members. + + This module provides functions to automatically generate membership fee cycles + based on a member's fee type, start date, and exit date. + + ## Algorithm + + 1. Load member with relationships (membership_fee_type, membership_fee_cycles) + 2. Determine the generation start point: + - If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`) + - If cycles exist: Start from the cycle AFTER the last existing one + 3. Generate all cycle starts from the determined start point to today (or `exit_date`) + 4. Create new cycles with the current amount from `membership_fee_type` + + ## Important: Gap Handling + + **Gaps are NOT filled.** If a cycle was explicitly deleted (e.g., 2023 was deleted + but 2022 and 2024 exist), the generator will NOT recreate the deleted cycle. + It always continues from the LAST existing cycle, regardless of any gaps. + + This behavior ensures that manually deleted cycles remain deleted and prevents + unwanted automatic recreation of intentionally removed cycles. + + ## Concurrency + + Uses PostgreSQL advisory locks to prevent race conditions when generating + cycles for the same member concurrently. + + ## Examples + + # Generate cycles for a single member + {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member) + + # Generate cycles for all active members + {:ok, results} = CycleGenerator.generate_cycles_for_all_members() + + """ + + alias Mv.MembershipFees.CalendarCycles + alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate + alias Mv.Membership.Member + alias Mv.Repo + + require Ash.Query + require Logger + + @type generate_result :: {:ok, [MembershipFeeCycle.t()]} | {:error, term()} + + @doc """ + Generates membership fee cycles for a single member. + + Uses an advisory lock to prevent concurrent generation for the same member. + + ## Parameters + + - `member` - The member struct or member ID + - `opts` - Options: + - `:today` - Override today's date (useful for testing) + + ## Returns + + - `{:ok, cycles}` - List of newly created cycles + - `{:error, reason}` - Error with reason + + ## Examples + + {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member) + {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member_id) + {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member, today: ~D[2024-12-31]) + + """ + @spec generate_cycles_for_member(Member.t() | String.t(), keyword()) :: generate_result() + def generate_cycles_for_member(member_or_id, opts \\ []) + + def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do + case load_member(member_id) do + {:ok, member} -> generate_cycles_for_member(member, opts) + {:error, reason} -> {:error, reason} + end + end + + def generate_cycles_for_member(%Member{} = member, opts) do + today = Keyword.get(opts, :today, Date.utc_today()) + + # Use advisory lock to prevent concurrent generation + # Notifications are handled inside with_advisory_lock after transaction commits + with_advisory_lock(member.id, fn -> + do_generate_cycles(member, today) + end) + end + + @doc """ + Generates membership fee cycles for all members with a fee type assigned. + + This includes both active and inactive (left) members. Inactive members + will have cycles generated up to their exit_date if they don't have cycles + for that period yet. This allows for catch-up generation of missing cycles. + + Members processed are those who: + - Have a membership_fee_type assigned + - Have a join_date set + + The exit_date boundary is respected during generation (not in the query), + so inactive members will get cycles up to their exit date. + + ## Parameters + + - `opts` - Options: + - `:today` - Override today's date (useful for testing) + - `:batch_size` - Number of members to process in parallel (default: 10) + + ## Returns + + - `{:ok, results}` - Map with :success and :failed counts + - `{:error, reason}` - Error with reason + + """ + @spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()} + def generate_cycles_for_all_members(opts \\ []) do + today = Keyword.get(opts, :today, Date.utc_today()) + batch_size = Keyword.get(opts, :batch_size, 10) + + # Query ALL members with fee type assigned (including inactive/left members) + # The exit_date boundary is applied during cycle generation, not here. + # This allows catch-up generation for members who left but are missing cycles. + query = + Member + |> Ash.Query.filter(not is_nil(membership_fee_type_id)) + |> Ash.Query.filter(not is_nil(join_date)) + + case Ash.read(query) do + {:ok, members} -> + results = process_members_in_batches(members, batch_size, today) + {:ok, build_results_summary(results)} + + {:error, reason} -> + {:error, reason} + end + end + + defp process_members_in_batches(members, batch_size, today) do + members + |> Enum.chunk_every(batch_size) + |> Enum.flat_map(&process_batch(&1, today)) + end + + defp process_batch(batch, today) do + batch + |> Task.async_stream(fn member -> + {member.id, generate_cycles_for_member(member, today: today)} + end) + |> Enum.map(fn + {:ok, result} -> + result + + {:exit, reason} -> + # Task crashed - log and return error tuple + Logger.error("Task crashed during cycle generation: #{inspect(reason)}") + {nil, {:error, {:task_exit, reason}}} + end) + end + + defp build_results_summary(results) do + success_count = Enum.count(results, fn {_id, result} -> match?({:ok, _}, result) end) + failed_count = Enum.count(results, fn {_id, result} -> match?({:error, _}, result) end) + + %{success: success_count, failed: failed_count, total: length(results)} + end + + # Private functions + + defp load_member(member_id) do + Member + |> Ash.Query.filter(id == ^member_id) + |> Ash.Query.load([:membership_fee_type, :membership_fee_cycles]) + |> Ash.read_one() + |> case do + {:ok, nil} -> {:error, :member_not_found} + {:ok, member} -> {:ok, member} + {:error, reason} -> {:error, reason} + end + end + + defp with_advisory_lock(member_id, fun) do + # Convert UUID to integer for advisory lock (use hash) + lock_key = :erlang.phash2(member_id) + + result = + Repo.transaction(fn -> + # Acquire advisory lock for this transaction + Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) + + case fun.() do + {:ok, result, notifications} when is_list(notifications) -> + # Return result and notifications separately + {result, notifications} + + {:ok, result} -> + # Handle case where no notifications were returned (backward compatibility) + {result, []} + + {:error, reason} -> + Repo.rollback(reason) + end + end) + + # Extract result and notifications, send notifications after transaction + case result do + {:ok, {cycles, notifications}} -> + if Enum.any?(notifications) do + Ash.Notifier.notify(notifications) + end + + {:ok, cycles} + + {:error, reason} -> + {:error, reason} + end + end + + defp do_generate_cycles(member, today) do + # Reload member with relationships to ensure fresh data + case load_member(member.id) do + {:ok, member} -> + cond do + is_nil(member.membership_fee_type_id) -> + {:error, :no_membership_fee_type} + + is_nil(member.join_date) -> + {:error, :no_join_date} + + true -> + generate_missing_cycles(member, today) + end + + {:error, reason} -> + {:error, reason} + end + end + + defp generate_missing_cycles(member, today) do + fee_type = member.membership_fee_type + interval = fee_type.interval + amount = fee_type.amount + existing_cycles = member.membership_fee_cycles || [] + + # Determine start point based on existing cycles + # Note: We do NOT fill gaps - only generate from the last existing cycle onwards + start_date = determine_generation_start(member, existing_cycles, interval) + + # Determine end date (today or exit_date, whichever is earlier) + end_date = determine_end_date(member, today) + + # Only generate if start_date <= end_date + if start_date && Date.compare(start_date, end_date) != :gt do + cycle_starts = generate_cycle_starts(start_date, end_date, interval) + create_cycles(cycle_starts, member.id, fee_type.id, amount) + else + {:ok, [], []} + end + end + + # No existing cycles: start from membership_fee_start_date + defp determine_generation_start(member, [], interval) do + determine_start_date(member, interval) + end + + # Has existing cycles: start from the cycle AFTER the last one + # This ensures gaps (deleted cycles) are NOT filled + defp determine_generation_start(_member, existing_cycles, interval) do + last_cycle_start = + existing_cycles + |> Enum.map(& &1.cycle_start) + |> Enum.max(Date) + + CalendarCycles.next_cycle_start(last_cycle_start, interval) + end + + defp determine_start_date(member, interval) do + if member.membership_fee_start_date do + member.membership_fee_start_date + else + # Calculate from join_date using global settings + include_joining_cycle = get_include_joining_cycle() + + SetMembershipFeeStartDate.calculate_start_date( + member.join_date, + interval, + include_joining_cycle + ) + end + end + + defp determine_end_date(member, today) do + if member.exit_date && Date.compare(member.exit_date, today) == :lt do + # Member has left - use the exit date as boundary + # Note: If exit_date == cycle_start, the cycle IS still generated. + # This means the member is considered a member on the first day of that cycle. + # Example: exit_date = 2025-01-01, yearly interval + # -> The 2025 cycle (starting 2025-01-01) WILL be generated + member.exit_date + else + today + end + end + + defp get_include_joining_cycle do + case Mv.Membership.get_settings() do + {:ok, %{include_joining_cycle: include}} -> include + {:error, _} -> true + end + end + + @doc """ + Generates all cycle start dates from a start date to an end date. + + ## Parameters + + - `start_date` - The first cycle start date + - `end_date` - The date up to which cycles should be generated + - `interval` - The billing interval + + ## Returns + + List of cycle start dates. + + ## Examples + + iex> generate_cycle_starts(~D[2024-01-01], ~D[2024-12-31], :quarterly) + [~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01], ~D[2024-10-01]] + + """ + @spec generate_cycle_starts(Date.t(), Date.t(), atom()) :: [Date.t()] + def generate_cycle_starts(start_date, end_date, interval) do + # Ensure start_date is aligned to cycle boundary + aligned_start = CalendarCycles.calculate_cycle_start(start_date, interval) + + generate_cycle_starts_acc(aligned_start, end_date, interval, []) + end + + defp generate_cycle_starts_acc(current_start, end_date, interval, acc) do + if Date.compare(current_start, end_date) == :gt do + # Current cycle start is after end date - stop + Enum.reverse(acc) + else + # Include this cycle and continue to next + next_start = CalendarCycles.next_cycle_start(current_start, interval) + generate_cycle_starts_acc(next_start, end_date, interval, [current_start | acc]) + end + end + + defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do + results = + Enum.map(cycle_starts, fn cycle_start -> + attrs = %{ + cycle_start: cycle_start, + member_id: member_id, + membership_fee_type_id: fee_type_id, + amount: amount, + status: :unpaid + } + + # Return notifications to avoid warnings when creating within a transaction + case Ash.create(MembershipFeeCycle, attrs, return_notifications?: true) do + {:ok, cycle, notifications} -> {:ok, cycle, notifications} + {:error, reason} -> {:error, {cycle_start, reason}} + end + end) + + {successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1)) + + all_notifications = + Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end) + + if Enum.empty?(errors) do + successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end) + # Return cycles and notifications to be sent after transaction commits + {:ok, successful_cycles, all_notifications} + else + Logger.warning("Some cycles failed to create: #{inspect(errors)}") + # Return partial failure with errors + # Note: When this error occurs, the transaction will be rolled back, + # so no cycles were actually persisted in the database + {:error, {:partial_failure, errors}} + end + end +end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index a23381d..a1020ef 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -95,9 +95,11 @@ defmodule MvWeb.CoreComponents do <.button>Send! <.button phx-click="go" variant="primary">Send! <.button navigate={~p"/"}>Home + <.button disabled={true}>Disabled """ attr :rest, :global, include: ~w(href navigate patch method) attr :variant, :string, values: ~w(primary) + attr :disabled, :boolean, default: false, doc: "Whether the button is disabled" slot :inner_block, required: true def button(%{rest: rest} = assigns) do @@ -105,14 +107,37 @@ defmodule MvWeb.CoreComponents do assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) if rest[:href] || rest[:navigate] || rest[:patch] do + # For links, we can't use disabled attribute, so we use btn-disabled class + # DaisyUI's btn-disabled provides the same styling as :disabled on buttons + link_class = + if assigns[:disabled], + do: ["btn", assigns.class, "btn-disabled"], + else: ["btn", assigns.class] + + # Prevent interaction when disabled + # Remove navigation attributes to prevent "Open in new tab", "Copy link" etc. + link_attrs = + if assigns[:disabled] do + rest + |> Map.drop([:href, :navigate, :patch]) + |> Map.merge(%{tabindex: "-1", "aria-disabled": "true"}) + else + rest + end + + assigns = + assigns + |> assign(:link_class, link_class) + |> assign(:link_attrs, link_attrs) + ~H""" - <.link class={["btn", @class]} {@rest}> + <.link class={@link_class} {@link_attrs}> {render_slot(@inner_block)} """ else ~H""" - """ diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 487a01f..86090a8 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -36,12 +36,16 @@ defmodule MvWeb.Layouts do default: nil, doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)" + attr :club_name, :string, + default: nil, + doc: "optional club name to pass to navbar" + slot :inner_block, required: true def app(assigns) do ~H""" <%= if @current_user do %> - <.navbar current_user={@current_user} /> + <.navbar current_user={@current_user} club_name={@club_name} /> <% end %>
diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex index 4246c99..c2e28d6 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -12,15 +12,18 @@ defmodule MvWeb.Layouts.Navbar do required: true, doc: "The current user - navbar is only shown when user is present" - def navbar(assigns) do - club_name = get_club_name() + attr :club_name, :string, + default: nil, + doc: "Optional club name - if not provided, will be loaded from database" + def navbar(assigns) do + club_name = assigns[:club_name] || get_club_name() assigns = assign(assigns, :club_name, club_name) ~H"""
diff --git a/lib/mv_web/live/custom_field_value_live/form.ex b/lib/mv_web/live/custom_field_value_live/form.ex index 4a7b02d..9663927 100644 --- a/lib/mv_web/live/custom_field_value_live/form.ex +++ b/lib/mv_web/live/custom_field_value_live/form.ex @@ -72,7 +72,7 @@ defmodule MvWeb.CustomFieldValueLive.Form do <% end %> <.button phx-disable-with={gettext("Saving...")} variant="primary"> - {gettext("Save Custom field value")} + {gettext("Save Custom Field Value")} <.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")} diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 0b3ec1c..9bce04b 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -37,7 +37,7 @@ defmodule MvWeb.GlobalSettingsLive do @impl true def render(assigns) do ~H""" - + <.header> {gettext("Settings")} <:subtitle> @@ -80,10 +80,13 @@ defmodule MvWeb.GlobalSettingsLive do @impl true def handle_event("save", %{"setting" => setting_params}, socket) do case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do - {:ok, updated_settings} -> + {:ok, _updated_settings} -> + # Reload settings from database to ensure all dependent data is updated + {:ok, fresh_settings} = Membership.get_settings() + socket = socket - |> assign(:settings, updated_settings) + |> assign(:settings, fresh_settings) |> put_flash(:info, gettext("Settings updated successfully")) |> assign_form() diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 8857298..25c23f9 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -145,7 +145,10 @@ defmodule MvWeb.MemberLive.Index do MapSet.put(socket.assigns.selected_members, id) end - {:noreply, assign(socket, :selected_members, selected)} + {:noreply, + socket + |> assign(:selected_members, selected) + |> update_selection_assigns()} end @impl true @@ -159,7 +162,10 @@ defmodule MvWeb.MemberLive.Index do all_ids end - {:noreply, assign(socket, :selected_members, selected)} + {:noreply, + socket + |> assign(:selected_members, selected) + |> update_selection_assigns()} end @impl true @@ -238,6 +244,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:query, q) |> load_members() + |> update_selection_assigns() existing_field_query = socket.assigns.sort_field existing_sort_query = socket.assigns.sort_order @@ -263,6 +270,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:paid_filter, filter) |> load_members() + |> update_selection_assigns() # Build the URL with all params including new filter query_params = @@ -309,6 +317,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members() |> prepare_dynamic_cols() + |> update_selection_assigns() |> push_field_selection_url() {:noreply, socket} @@ -338,6 +347,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members() |> prepare_dynamic_cols() + |> update_selection_assigns() |> push_field_selection_url() {:noreply, socket} @@ -389,6 +399,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members() |> prepare_dynamic_cols() + |> update_selection_assigns() {:noreply, socket} end @@ -1112,4 +1123,34 @@ defmodule MvWeb.MemberLive.Index do # Public helper function to format dates for use in templates def format_date(date), do: DateFormatter.format_date(date) + + # Updates selection-related assigns (selected_count, any_selected?, mailto_bcc) + # to avoid recalculating Enum.any? and Enum.count multiple times in templates. + # + # Note: Mailto URLs have length limits that vary by email client. + # For large selections, consider using export functionality instead. + defp update_selection_assigns(socket) do + members = socket.assigns.members + selected_members = socket.assigns.selected_members + + selected_count = + Enum.count(members, &MapSet.member?(selected_members, &1.id)) + + any_selected? = + Enum.any?(members, &MapSet.member?(selected_members, &1.id)) + + mailto_bcc = + if any_selected? do + format_selected_member_emails(members, selected_members) + |> Enum.join(", ") + |> URI.encode_www_form() + else + "" + end + + socket + |> assign(:selected_count, selected_count) + |> assign(:any_selected?, any_selected?) + |> assign(:mailto_bcc, mailto_bcc) + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index fbeb416..13c4367 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -3,23 +3,21 @@ {gettext("Members")} <:actions> <.button - :if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))} + class="secondary" id="copy-emails-btn" phx-hook="CopyToClipboard" phx-click="copy_emails" + disabled={not @any_selected?} aria-label={gettext("Copy email addresses of selected members")} > <.icon name="hero-clipboard-document" /> - {gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))}) + {gettext("Copy email addresses")} ({@selected_count}) <.button - :if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))} - href={ - "mailto:?bcc=" <> - (MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members) - |> Enum.join(", ") - |> URI.encode()) - } + class="secondary" + id="open-email-btn" + href={"mailto:?bcc=" <> @mailto_bcc} + disabled={not @any_selected?} aria-label={gettext("Open email program with BCC recipients")} > <.icon name="hero-envelope" /> diff --git a/lib/mv_web/live/membership_fee_settings_live.ex b/lib/mv_web/live/membership_fee_settings_live.ex new file mode 100644 index 0000000..5ca32e9 --- /dev/null +++ b/lib/mv_web/live/membership_fee_settings_live.ex @@ -0,0 +1,261 @@ +defmodule MvWeb.MembershipFeeSettingsLive do + @moduledoc """ + LiveView for managing membership fee settings (Admin). + + Allows administrators to configure: + - Default membership fee type for new members + - Whether to include the joining cycle in membership fee generation + """ + use MvWeb, :live_view + + alias Mv.Membership + alias Mv.MembershipFees.MembershipFeeType + + @impl true + def mount(_params, _session, socket) do + {:ok, settings} = Membership.get_settings() + + membership_fee_types = + MembershipFeeType + |> Ash.Query.sort(name: :asc) + |> Ash.read!() + + {:ok, + socket + |> assign(:page_title, gettext("Membership Fee Settings")) + |> assign(:settings, settings) + |> assign(:membership_fee_types, membership_fee_types) + |> assign_form()} + end + + @impl true + def handle_event("validate", %{"settings" => params}, socket) do + {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, params))} + end + + def handle_event("save", %{"settings" => params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: params) do + {:ok, updated_settings} -> + {:noreply, + socket + |> assign(:settings, updated_settings) + |> put_flash(:info, gettext("Settings saved successfully.")) + |> assign_form()} + + {:error, form} -> + {:noreply, assign(socket, form: form)} + end + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + {gettext("Membership Fee Settings")} + <:subtitle> + {gettext("Configure global settings for membership fees.")} + + + +
+ <%!-- Settings Form --%> +
+
+

+ <.icon name="hero-cog-6-tooth" class="size-5" /> + {gettext("Global Settings")} +

+ + <.form + for={@form} + phx-change="validate" + phx-submit="save" + class="space-y-6" + > + <%!-- Default Membership Fee Type --%> +
+ + + <%= for {msg, _opts} <- @form.errors[:default_membership_fee_type_id] || [] do %> +

{msg}

+ <% end %> +

+ {gettext( + "This membership fee type is automatically assigned to all new members. Can be changed individually per member." + )} +

+
+ + <%!-- Include Joining Cycle --%> +
+ + <%= for {msg, _opts} <- @form.errors[:include_joining_cycle] || [] do %> +

{msg}

+ <% end %> +
+

+ {gettext("When active: Members pay from the cycle of their joining.")} +

+

+ {gettext("When inactive: Members pay from the next full cycle after joining.")} +

+
+
+ +
+ + + +
+
+ + <%!-- Examples Card --%> +
+
+

+ <.icon name="hero-light-bulb" class="size-5" /> + {gettext("Examples")} +

+ + <.example_section + title={gettext("Yearly Interval - Joining Cycle Included")} + joining_date="15.03.2023" + include_joining={true} + start_date="01.01.2023" + periods={["2023", "2024", "2025"]} + note={gettext("Member pays for the year they joined")} + /> + +
+ + <.example_section + title={gettext("Yearly Interval - Joining Cycle Excluded")} + joining_date="15.03.2023" + include_joining={false} + start_date="01.01.2024" + periods={["2024", "2025"]} + note={gettext("Member pays from the next full year")} + /> + +
+ + <.example_section + title={gettext("Quarterly Interval - Joining Cycle Excluded")} + joining_date="15.05.2024" + include_joining={false} + start_date="01.07.2024" + periods={["Q3/2024", "Q4/2024", "Q1/2025"]} + note={gettext("Member pays from the next full quarter")} + /> + +
+ + <.example_section + title={gettext("Monthly Interval - Joining Cycle Included")} + joining_date="15.03.2024" + include_joining={true} + start_date="01.03.2024" + periods={["03/2024", "04/2024", "05/2024", "..."]} + note={gettext("Member pays from the joining month")} + /> +
+
+
+
+ """ + end + + # Example section component + attr :title, :string, required: true + attr :joining_date, :string, required: true + attr :include_joining, :boolean, required: true + attr :start_date, :string, required: true + attr :periods, :list, required: true + attr :note, :string, required: true + + defp example_section(assigns) do + ~H""" +
+

{@title}

+
+

+ {gettext("Joining date")}: + {@joining_date} +

+

+ {gettext("Membership fee start")}: + {@start_date} +

+

+ {gettext("Generated cycles")}: + + {Enum.join(@periods, ", ")} + +

+
+

→ {@note}

+
+ """ + end + + defp format_currency(%Decimal{} = amount) do + "#{Decimal.to_string(amount)} €" + end + + defp format_interval(:monthly), do: gettext("Monthly") + defp format_interval(:quarterly), do: gettext("Quarterly") + defp format_interval(:half_yearly), do: gettext("Half-yearly") + defp format_interval(:yearly), do: gettext("Yearly") + + defp assign_form(%{assigns: %{settings: settings}} = socket) do + form = + AshPhoenix.Form.for_update( + settings, + :update_membership_fee_settings, + api: Membership, + as: "settings", + forms: [auto?: true] + ) + + assign(socket, form: to_form(form)) + end +end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index d6f108e..887628e 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -69,9 +69,11 @@ defmodule MvWeb.Router do live "/settings", GlobalSettingsLive + # Membership Fee Settings + live "/membership_fee_settings", MembershipFeeSettingsLive + # Contribution Management (Mock-ups) live "/contribution_types", ContributionTypeLive.Index, :index - live "/contribution_settings", ContributionSettingsLive live "/contributions/member/:id", ContributionPeriodLive.Show, :show post "/set_locale", LocaleController, :set_locale diff --git a/mix.lock b/mix.lock index 44dffbf..1dd3d48 100644 --- a/mix.lock +++ b/mix.lock @@ -26,7 +26,7 @@ "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"}, - "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, + "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, @@ -39,7 +39,7 @@ "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, - "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, + "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"}, @@ -80,7 +80,7 @@ "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, - "thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"}, + "thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"}, "tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 25f685d..ec6812a 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -282,11 +282,6 @@ msgstr "Benutzer*in bearbeiten" msgid "Enabled" msgstr "Aktiviert" -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Immutable" -msgstr "Unveränderlich" - #: lib/mv_web/components/layouts/navbar.ex #, elixir-autogen, elixir-format msgid "Logout" @@ -612,16 +607,6 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}" msgid "Please select a custom field first" msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Save Custom field" -msgstr "Benutzerdefiniertes Feld speichern" - -#: lib/mv_web/live/custom_field_value_live/form.ex -#, elixir-autogen, elixir-format -msgid "Save Custom field value" -msgstr "Benutzerdefinierten Feldwert speichern" - #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -687,8 +672,8 @@ msgstr "Vereinsdaten" msgid "Manage global settings for the association." msgstr "Passe übergreifende Einstellungen für den Verein an." -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Save Settings" msgstr "Einstellungen speichern" @@ -760,11 +745,6 @@ msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert" msgid "Copy email addresses of selected members" msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Copy emails" -msgstr "E-Mails kopieren" - #: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "No email addresses found" @@ -796,7 +776,6 @@ msgid "This field cannot be empty" msgstr "Dieses Feld darf nicht leer bleiben" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/payment_filter_component.ex #, elixir-autogen, elixir-format msgid "All" msgstr "Alle" @@ -951,17 +930,6 @@ msgstr "Löschen nicht möglich – es sind Mitglieder zugewiesen" msgid "Change Contribution Type" msgstr "Beitragsart ändern" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configure global settings for membership contributions." -msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren." - -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Contribution Settings" -msgstr "Beitragseinstellungen" - #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Contribution Start" @@ -973,11 +941,6 @@ msgstr "Beitragsbeginn" msgid "Contribution Types" msgstr "Beitragsarten" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Contribution start" -msgstr "Beitragsbeginn" - #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Contribution type" @@ -1003,27 +966,16 @@ msgstr "Beiträge für %{name}" msgid "Current" msgstr "Aktuell" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Default Contribution Type" -msgstr "Standard-Beitragsart" - #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Deletion" msgstr "Löschen" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Example: Member Contribution View" -msgstr "Beispiel: Ansicht Mitgliedsbeiträge" - -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Examples" msgstr "Beispiele" -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Family" @@ -1034,19 +986,14 @@ msgstr "Familie" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln." -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Generated periods" -msgstr "Generierte Zyklen" - -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Global Settings" msgstr "Vereinsdaten" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Half-yearly" msgstr "Halbjährlich" @@ -1062,18 +1009,13 @@ msgstr "Halbjährlicher Beitrag für Fördermitglieder" msgid "Honorary" msgstr "Ehrenamtlich" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Include joining period" -msgstr "Beitrittsdatum einbeziehen" - #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Interval" msgstr "Zyklus" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Joining date" msgstr "Beitrittsdatum" @@ -1108,22 +1050,22 @@ msgstr "Als unbezahlt markieren" msgid "Member Contributions" msgstr "Mitgliedsbeiträge" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays for the year they joined" msgstr "Mitglied zahlt für das Beitrittsjahr" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the joining month" msgstr "Mitglied zahlt ab Beitrittsmonat" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the next full quarter" msgstr "Mitglied zahlt ab dem nächsten vollständigen Quartal" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the next full year" msgstr "Mitglied zahlt ab dem nächsten vollständigen Jahr" @@ -1139,17 +1081,12 @@ msgid "Members can only switch between contribution types with the same payment msgstr "Mitglieder können nur zwischen Beitragsarten mit demselben Zahlungszyklus wechseln (z. B. jährlich zu jährlich). Dadurch werden komplexe Überlappungen vermieden." #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Monthly" msgstr "Monatlich" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Monthly Interval - Joining Period Included" -msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen" - #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Monthly fee for students and trainees" @@ -1186,31 +1123,24 @@ msgid "Paid via bank transfer" msgstr "Bezahlt durch Überweisung" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Preview Mockup" msgstr "Vorschau" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Quarterly" msgstr "Vierteljährlich" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Quarterly Interval - Joining Period Excluded" -msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen" - #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Quarterly fee for family memberships" msgstr "Vierteljährlicher Beitrag für Familienmitgliedschaften" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Reduced" @@ -1222,7 +1152,6 @@ msgid "Reduced fee for unemployed, pensioners, or low income" msgstr "Ermäßigter Beitrag für Arbeitslose, Rentner*innen oder Geringverdienende" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Regular" @@ -1233,11 +1162,6 @@ msgstr "Regulär" msgid "Reopen" msgstr "Wieder öffnen" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods." -msgstr "Beispielhafte Anzeige der Beitragsperioden für ein einzelnes Mitglied. In diesem Beispiel wird Maria Weber mit mehreren Zyklen angezeigt." - #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Standard membership fee for regular members" @@ -1248,7 +1172,6 @@ msgstr "Regulärer Mitgliedsbeitrag für Vollmitglieder" msgid "Status" msgstr "Status" -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Student" @@ -1269,13 +1192,7 @@ msgstr "Pausieren" msgid "Suspended" msgstr "Pausiert" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member." -msgstr "Dieser Beitragstyp wird automatisch neuen Mitgliedern zugewiesen. Kann individuell angepasst werden." - #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "This page is not functional and only displays the planned features." @@ -1296,43 +1213,18 @@ msgstr "Gesamtbeiträge" msgid "Unpaid" msgstr "Unbezahlt" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "View Example Member" -msgstr "Beispielmitglied anzeigen" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When active: Members pay from the period of their joining." -msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts." - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When inactive: Members pay from the next full period after joining." -msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt." - #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Why are not all contribution types shown?" msgstr "Warum werden nicht alle Beitragsarten angezeigt?" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Yearly" msgstr "jährlich" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Yearly Interval - Joining Period Excluded" -msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Yearly Interval - Joining Period Included" -msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" - #: lib/mv_web/live/components/field_visibility_dropdown_component.ex #, elixir-autogen, elixir-format msgid "Columns" @@ -1389,14 +1281,10 @@ msgid "Failed to delete custom field: %{error}" msgstr "Konnte Feld nicht löschen: %{error}" #: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "New Custom Field" -msgstr "Benutzerdefiniertes Feld speichern" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy -msgid "New Custom field" -msgstr "Benutzerdefiniertes Feld speichern" +msgid "New Custom Field" +msgstr "Neues Benutzerdefiniertes Feld" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format @@ -1438,16 +1326,127 @@ msgstr "Textfeld" msgid "Yes/No-Selection" msgstr "Ja/Nein-Auswahl" +#: lib/mv_web/live/components/payment_filter_component.ex +#, elixir-autogen, elixir-format +msgid "All payment statuses" +msgstr "Jeder Zahlungs-Zustand" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Copy email addresses" +msgstr "E-Mail-Adressen kopieren" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Custom Field" +msgstr "Benutzerdefiniertes Feld speichern" + +#: lib/mv_web/live/custom_field_value_live/form.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Custom Field Value" +msgstr "Benutzerdefinierten Feldwert speichern" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Configure global settings for membership fees." +msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren." + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Default Membership Fee Type" +msgstr "Standard-Mitgliedsbeitragsart" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Generated cycles" +msgstr "Generierte Zyklen" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Include joining cycle" +msgstr "Beitrittsdatum einbeziehen" + +#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Membership Fee Settings" +msgstr "Mitgliedsbeitragseinstellungen" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Membership fee start" +msgstr "Beitragsbeginn" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Monthly Interval - Joining Cycle Included" +msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "None (no default)" +msgstr "Keine (kein Standard)" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Quarterly Interval - Joining Cycle Excluded" +msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Settings saved successfully." +msgstr "Einstellungen erfolgreich gespeichert" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member." +msgstr "Diese Mitgliedsbeitragsart wird automatisch allen neuen Mitgliedern zugewiesen. Kann individuell pro Mitglied geändert werden." + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "When active: Members pay from the cycle of their joining." +msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts." + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "When inactive: Members pay from the next full cycle after joining." +msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt." + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Yearly Interval - Joining Cycle Excluded" +msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Yearly Interval - Joining Cycle Included" +msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" + #~ #: lib/mv_web/live/custom_field_live/show.ex #~ #, elixir-autogen, elixir-format #~ msgid "Auto-generated identifier (immutable)" #~ msgstr "Automatisch generierter Bezeichner (unveränderlich)" -#~ #: lib/mv_web/live/member_live/form.ex -#~ #: lib/mv_web/live/member_live/show.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "Birth Date" -#~ msgstr "Geburtsdatum" +#~ msgid "Configure global settings for membership contributions." +#~ msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren." + +#~ #: lib/mv_web/components/layouts/navbar.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Contribution Settings" +#~ msgstr "Beitragseinstellungen" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Contribution start" +#~ msgstr "Beitragsbeginn" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Copy emails" +#~ msgstr "E-Mails kopieren" #~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/member_live/show.ex @@ -1455,21 +1454,45 @@ msgstr "Ja/Nein-Auswahl" #~ msgid "Custom Field Values" #~ msgstr "Benutzerdefinierte Feldwerte" -#~ #: lib/mv_web/live/member_live/form.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "Fields marked with an asterisk (*) cannot be empty." -#~ msgstr "Felder, die mit einem Sternchen (*) markiert sind, dürfen nicht leer bleiben." +#~ msgid "Default Contribution Type" +#~ msgstr "Standard-Beitragsart" -#~ #: lib/mv_web/live/custom_field_live/form.ex -#~ #: lib/mv_web/live/user_live/show.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "ID" -#~ msgstr "ID" +#~ msgid "Example: Member Contribution View" +#~ msgstr "Beispiel: Ansicht Mitgliedsbeiträge" -#~ #: lib/mv_web/live/member_live/show.ex +#~ #: lib/mv_web/live/membership_fee_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "Id" -#~ msgstr "ID" +#~ msgid "Failed to save settings. Please check the errors below." +#~ msgstr "Einstellungen konnten nicht gespeichert werden. Bitte prüfen Sie die Fehler unten." + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Generated periods" +#~ msgstr "Generierte Zyklen" + +#~ #: lib/mv_web/live/custom_field_live/form_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Immutable" +#~ msgstr "Unveränderlich" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Include joining period" +#~ msgstr "Beitrittsdatum einbeziehen" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Monthly Interval - Joining Period Included" +#~ msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen" + +#~ #: lib/mv_web/live/custom_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "New Custom field" +#~ msgstr "Benutzerdefiniertes Feld speichern" #~ #: lib/mv_web/live/user_live/form.ex #~ #: lib/mv_web/live/user_live/show.ex @@ -1477,23 +1500,42 @@ msgstr "Ja/Nein-Auswahl" #~ msgid "Not set" #~ msgstr "Nicht gesetzt" -#~ #: lib/mv_web/live/user_live/index.html.heex -#~ #: lib/mv_web/live/user_live/show.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "OIDC ID" -#~ msgstr "OIDC ID" +#~ msgid "Quarterly Interval - Joining Period Excluded" +#~ msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen" -#~ #: lib/mv_web/live/custom_field_live/index_component.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Show in Overview" -#~ msgstr "In der Mitglieder-Übersicht anzeigen" - -#~ #: lib/mv_web/live/member_live/show.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "This is a member record from your database." -#~ msgstr "Dies ist ein Mitglied aus deiner Datenbank." +#~ msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods." +#~ msgstr "Beispielhafte Anzeige der Beitragsperioden für ein einzelnes Mitglied. In diesem Beispiel wird Maria Weber mit mehreren Zyklen angezeigt." -#~ #: lib/mv_web/live/custom_field_live/form.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "Use this form to manage custom_field records in your database." -#~ msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." +#~ msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member." +#~ msgstr "Dieser Beitragstyp wird automatisch neuen Mitgliedern zugewiesen. Kann individuell angepasst werden." + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "View Example Member" +#~ msgstr "Beispielmitglied anzeigen" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "When active: Members pay from the period of their joining." +#~ msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts." + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "When inactive: Members pay from the next full period after joining." +#~ msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt." + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Yearly Interval - Joining Period Excluded" +#~ msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Yearly Interval - Joining Period Included" +#~ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a7ab36b..e2bbf32 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -283,11 +283,6 @@ msgstr "" msgid "Enabled" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Immutable" -msgstr "" - #: lib/mv_web/components/layouts/navbar.ex #, elixir-autogen, elixir-format msgid "Logout" @@ -613,16 +608,6 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Save Custom field" -msgstr "" - -#: lib/mv_web/live/custom_field_value_live/form.ex -#, elixir-autogen, elixir-format -msgid "Save Custom field value" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -688,8 +673,8 @@ msgstr "" msgid "Manage global settings for the association." msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Save Settings" msgstr "" @@ -761,11 +746,6 @@ msgstr[1] "" msgid "Copy email addresses of selected members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Copy emails" -msgstr "" - #: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "No email addresses found" @@ -797,7 +777,6 @@ msgid "This field cannot be empty" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/payment_filter_component.ex #, elixir-autogen, elixir-format msgid "All" msgstr "" @@ -952,17 +931,6 @@ msgstr "" msgid "Change Contribution Type" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configure global settings for membership contributions." -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Contribution Settings" -msgstr "" - #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Contribution Start" @@ -974,11 +942,6 @@ msgstr "" msgid "Contribution Types" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Contribution start" -msgstr "" - #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Contribution type" @@ -1004,27 +967,16 @@ msgstr "" msgid "Current" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Default Contribution Type" -msgstr "" - #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Deletion" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Example: Member Contribution View" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Examples" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Family" @@ -1035,19 +987,14 @@ msgstr "" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Generated periods" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Global Settings" msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Half-yearly" msgstr "" @@ -1063,18 +1010,13 @@ msgstr "" msgid "Honorary" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Include joining period" -msgstr "" - #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Interval" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Joining date" msgstr "" @@ -1109,22 +1051,22 @@ msgstr "" msgid "Member Contributions" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays for the year they joined" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the joining month" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the next full quarter" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the next full year" msgstr "" @@ -1140,17 +1082,12 @@ msgid "Members can only switch between contribution types with the same payment msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Monthly" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Monthly Interval - Joining Period Included" -msgstr "" - #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Monthly fee for students and trainees" @@ -1187,31 +1124,24 @@ msgid "Paid via bank transfer" msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Preview Mockup" msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Quarterly" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Quarterly Interval - Joining Period Excluded" -msgstr "" - #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Quarterly fee for family memberships" msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Reduced" @@ -1223,7 +1153,6 @@ msgid "Reduced fee for unemployed, pensioners, or low income" msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Regular" @@ -1234,11 +1163,6 @@ msgstr "" msgid "Reopen" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods." -msgstr "" - #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Standard membership fee for regular members" @@ -1249,7 +1173,6 @@ msgstr "" msgid "Status" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Student" @@ -1270,13 +1193,7 @@ msgstr "" msgid "Suspended" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member." -msgstr "" - #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "This page is not functional and only displays the planned features." @@ -1297,43 +1214,18 @@ msgstr "" msgid "Unpaid" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "View Example Member" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When active: Members pay from the period of their joining." -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When inactive: Members pay from the next full period after joining." -msgstr "" - #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Why are not all contribution types shown?" msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Yearly" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Yearly Interval - Joining Period Excluded" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Yearly Interval - Joining Period Included" -msgstr "" - #: lib/mv_web/live/components/field_visibility_dropdown_component.ex #, elixir-autogen, elixir-format msgid "Columns" @@ -1390,13 +1282,9 @@ msgid "Failed to delete custom field: %{error}" msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "New Custom Field" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format -msgid "New Custom field" +msgid "New Custom Field" msgstr "" #: lib/mv_web/live/global_settings_live.ex @@ -1438,3 +1326,99 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Yes/No-Selection" msgstr "" + +#: lib/mv_web/live/components/payment_filter_component.ex +#, elixir-autogen, elixir-format +msgid "All payment statuses" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Copy email addresses" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Save Custom Field" +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex +#, elixir-autogen, elixir-format +msgid "Save Custom Field Value" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configure global settings for membership fees." +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Default Membership Fee Type" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Generated cycles" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Include joining cycle" +msgstr "" + +#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Membership Fee Settings" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Membership fee start" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Monthly Interval - Joining Cycle Included" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "None (no default)" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Quarterly Interval - Joining Cycle Excluded" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Settings saved successfully." +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member." +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "When active: Members pay from the cycle of their joining." +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "When inactive: Members pay from the next full cycle after joining." +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Yearly Interval - Joining Cycle Excluded" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Yearly Interval - Joining Cycle Included" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index e2a1876..d3ee646 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -283,11 +283,6 @@ msgstr "" msgid "Enabled" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Immutable" -msgstr "" - #: lib/mv_web/components/layouts/navbar.ex #, elixir-autogen, elixir-format msgid "Logout" @@ -613,16 +608,6 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Save Custom field" -msgstr "" - -#: lib/mv_web/live/custom_field_value_live/form.ex -#, elixir-autogen, elixir-format -msgid "Save Custom field value" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -688,8 +673,8 @@ msgstr "" msgid "Manage global settings for the association." msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Save Settings" msgstr "" @@ -761,11 +746,6 @@ msgstr[1] "" msgid "Copy email addresses of selected members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Copy emails" -msgstr "" - #: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "No email addresses found" @@ -797,7 +777,6 @@ msgid "This field cannot be empty" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/payment_filter_component.ex #, elixir-autogen, elixir-format msgid "All" msgstr "" @@ -952,17 +931,6 @@ msgstr "" msgid "Change Contribution Type" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configure global settings for membership contributions." -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Contribution Settings" -msgstr "" - #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Contribution Start" @@ -974,11 +942,6 @@ msgstr "" msgid "Contribution Types" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Contribution start" -msgstr "" - #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Contribution type" @@ -1004,27 +967,16 @@ msgstr "" msgid "Current" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Default Contribution Type" -msgstr "" - #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Deletion" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Example: Member Contribution View" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Examples" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Family" @@ -1035,19 +987,14 @@ msgstr "" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Generated periods" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Global Settings" msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Half-yearly" msgstr "" @@ -1063,18 +1010,13 @@ msgstr "" msgid "Honorary" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Include joining period" -msgstr "" - #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Interval" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Joining date" msgstr "" @@ -1109,22 +1051,22 @@ msgstr "" msgid "Member Contributions" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays for the year they joined" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the joining month" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the next full quarter" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the next full year" msgstr "" @@ -1140,17 +1082,12 @@ msgid "Members can only switch between contribution types with the same payment msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Monthly" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Monthly Interval - Joining Period Included" -msgstr "" - #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Monthly fee for students and trainees" @@ -1187,31 +1124,24 @@ msgid "Paid via bank transfer" msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Preview Mockup" msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Quarterly" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Quarterly Interval - Joining Period Excluded" -msgstr "" - #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Quarterly fee for family memberships" msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Reduced" @@ -1223,7 +1153,6 @@ msgid "Reduced fee for unemployed, pensioners, or low income" msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Regular" @@ -1234,11 +1163,6 @@ msgstr "" msgid "Reopen" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods." -msgstr "" - #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Standard membership fee for regular members" @@ -1249,7 +1173,6 @@ msgstr "" msgid "Status" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Student" @@ -1270,13 +1193,7 @@ msgstr "" msgid "Suspended" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member." -msgstr "" - #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "This page is not functional and only displays the planned features." @@ -1297,43 +1214,18 @@ msgstr "" msgid "Unpaid" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "View Example Member" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When active: Members pay from the period of their joining." -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When inactive: Members pay from the next full period after joining." -msgstr "" - #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Why are not all contribution types shown?" msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex +#: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Yearly" msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Yearly Interval - Joining Period Excluded" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Yearly Interval - Joining Period Included" -msgstr "" - #: lib/mv_web/live/components/field_visibility_dropdown_component.ex #, elixir-autogen, elixir-format msgid "Columns" @@ -1390,13 +1282,9 @@ msgid "Failed to delete custom field: %{error}" msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "New Custom Field" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy -msgid "New Custom field" +msgid "New Custom Field" msgstr "" #: lib/mv_web/live/global_settings_live.ex @@ -1439,15 +1327,126 @@ msgstr "" msgid "Yes/No-Selection" msgstr "" +#: lib/mv_web/live/components/payment_filter_component.ex +#, elixir-autogen, elixir-format +msgid "All payment statuses" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Copy email addresses" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Custom Field" +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Custom Field Value" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Configure global settings for membership fees." +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Default Membership Fee Type" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Generated cycles" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Include joining cycle" +msgstr "" + +#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Membership Fee Settings" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Membership fee start" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Monthly Interval - Joining Cycle Included" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "None (no default)" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Quarterly Interval - Joining Cycle Excluded" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Settings saved successfully." +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format +msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member." +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "When active: Members pay from the cycle of their joining." +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "When inactive: Members pay from the next full cycle after joining." +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Yearly Interval - Joining Cycle Excluded" +msgstr "" + +#: lib/mv_web/live/membership_fee_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Yearly Interval - Joining Cycle Included" +msgstr "" + #~ #: lib/mv_web/live/custom_field_live/show.ex #~ #, elixir-autogen, elixir-format #~ msgid "Auto-generated identifier (immutable)" #~ msgstr "" -#~ #: lib/mv_web/live/member_live/form.ex -#~ #: lib/mv_web/live/member_live/show.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "Birth Date" +#~ msgid "Configure global settings for membership contributions." +#~ msgstr "" + +#~ #: lib/mv_web/components/layouts/navbar.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Contribution Settings" +#~ msgstr "" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Contribution start" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Copy emails" #~ msgstr "" #~ #: lib/mv_web/live/member_live/form.ex @@ -1456,19 +1455,44 @@ msgstr "" #~ msgid "Custom Field Values" #~ msgstr "" -#~ #: lib/mv_web/live/member_live/form.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "Fields marked with an asterisk (*) cannot be empty." +#~ msgid "Default Contribution Type" #~ msgstr "" -#~ #: lib/mv_web/live/user_live/show.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "ID" +#~ msgid "Example: Member Contribution View" #~ msgstr "" -#~ #: lib/mv_web/live/member_live/show.ex +#~ #: lib/mv_web/live/membership_fee_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "Id" +#~ msgid "Failed to save settings. Please check the errors below." +#~ msgstr "" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Generated periods" +#~ msgstr "" + +#~ #: lib/mv_web/live/custom_field_live/form_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Immutable" +#~ msgstr "" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Include joining period" +#~ msgstr "" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Monthly Interval - Joining Period Included" +#~ msgstr "" + +#~ #: lib/mv_web/live/custom_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "New Custom field" #~ msgstr "" #~ #: lib/mv_web/live/user_live/show.ex @@ -1476,23 +1500,42 @@ msgstr "" #~ msgid "Not set" #~ msgstr "" -#~ #: lib/mv_web/live/user_live/index.html.heex -#~ #: lib/mv_web/live/user_live/show.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "OIDC ID" +#~ msgid "Quarterly Interval - Joining Period Excluded" #~ msgstr "" -#~ #: lib/mv_web/live/custom_field_live/index_component.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Show in Overview" -#~ msgstr "" - -#~ #: lib/mv_web/live/member_live/show.ex +#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #, elixir-autogen, elixir-format -#~ msgid "This is a member record from your database." +#~ msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods." #~ msgstr "" -#~ #: lib/mv_web/live/custom_field_live/form.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Use this form to manage custom_field records in your database." +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member." +#~ msgstr "" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "View Example Member" +#~ msgstr "" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "When active: Members pay from the period of their joining." +#~ msgstr "" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "When inactive: Members pay from the next full period after joining." +#~ msgstr "" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Yearly Interval - Joining Period Excluded" +#~ msgstr "" + +#~ #: lib/mv_web/live/contribution_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Yearly Interval - Joining Period Included" #~ msgstr "" diff --git a/priv/repo/migrations/20251211172549_remove_immutable_from_custom_fields.exs b/priv/repo/migrations/20251211172549_remove_immutable_from_custom_fields.exs new file mode 100644 index 0000000..9d25d49 --- /dev/null +++ b/priv/repo/migrations/20251211172549_remove_immutable_from_custom_fields.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.RemoveImmutableFromCustomFields do + @moduledoc """ + Removes the immutable column from custom_fields table. + + The immutable field is no longer needed in the custom field definition. + """ + + use Ecto.Migration + + def up do + alter table(:custom_fields) do + remove :immutable + end + end + + def down do + alter table(:custom_fields) do + add :immutable, :boolean, null: false, default: false + 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 new file mode 100644 index 0000000..a77ff5f --- /dev/null +++ b/priv/repo/migrations/20251211195058_add_membership_fee_settings.exs @@ -0,0 +1,25 @@ +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/repo/seeds.exs b/priv/repo/seeds.exs index bec9006..2e8694d 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -5,6 +5,39 @@ alias Mv.Membership alias Mv.Accounts +alias Mv.MembershipFees.MembershipFeeType + +# Create example membership fee types +for fee_type_attrs <- [ + %{ + name: "Standard (Jährlich)", + amount: Decimal.new("120.00"), + interval: :yearly, + description: "Standard jährlicher Mitgliedsbeitrag" + }, + %{ + name: "Standard (Halbjährlich)", + amount: Decimal.new("65.00"), + interval: :half_yearly, + description: "Standard halbjährlicher Mitgliedsbeitrag" + }, + %{ + name: "Standard (Vierteljährlich)", + amount: Decimal.new("35.00"), + interval: :quarterly, + description: "Standard vierteljährlicher Mitgliedsbeitrag" + }, + %{ + name: "Standard (Monatlich)", + amount: Decimal.new("12.00"), + interval: :monthly, + description: "Standard monatlicher Mitgliedsbeitrag" + } + ] do + MembershipFeeType + |> Ash.Changeset.for_create(:create, fee_type_attrs) + |> Ash.create!(upsert?: true, upsert_identity: :unique_name) +end for attrs <- [ # Basic example fields (for testing) @@ -12,28 +45,24 @@ for attrs <- [ name: "String Field", value_type: :string, description: "Example for a field of type string", - immutable: true, required: false }, %{ name: "Date Field", value_type: :date, description: "Example for a field of type date", - immutable: true, required: false }, %{ name: "Boolean Field", value_type: :boolean, description: "Example for a field of type boolean", - immutable: true, required: false }, %{ name: "Email Field", value_type: :email, description: "Example for a field of type email", - immutable: true, required: false }, # Realistic custom fields @@ -41,56 +70,48 @@ for attrs <- [ name: "Membership Number", value_type: :string, description: "Unique membership identification number", - immutable: false, required: false }, %{ name: "Emergency Contact", value_type: :string, description: "Emergency contact person name and phone", - immutable: false, required: false }, %{ name: "T-Shirt Size", value_type: :string, description: "T-Shirt size for events (XS, S, M, L, XL, XXL)", - immutable: false, required: false }, %{ name: "Newsletter Subscription", value_type: :boolean, description: "Whether member wants to receive newsletter", - immutable: false, required: false }, %{ name: "Date of Last Medical Check", value_type: :date, description: "Date of last medical examination", - immutable: false, required: false }, %{ name: "Secondary Email", value_type: :email, description: "Alternative email address", - immutable: false, required: false }, %{ name: "Membership Type", value_type: :string, description: "Type of membership (e.g., Regular, Student, Senior)", - immutable: false, required: false }, %{ name: "Parking Permit", value_type: :boolean, description: "Whether member has parking permit", - immutable: false, required: false } ] do @@ -332,6 +353,7 @@ end IO.puts("✅ Seeds completed successfully!") IO.puts("📝 Created sample data:") IO.puts(" - Global settings: club_name = #{default_club_name}") +IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)") IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)") IO.puts(" - Admin user: admin@mv.local (password: testpassword)") IO.puts(" - Sample members: Hans, Greta, Friedrich") diff --git a/priv/resource_snapshots/repo/members/20251211195058.json b/priv/resource_snapshots/repo/members/20251211195058.json new file mode 100644 index 0000000..a72bf8d --- /dev/null +++ b/priv/resource_snapshots/repo/members/20251211195058.json @@ -0,0 +1,245 @@ +{ + "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 new file mode 100644 index 0000000..3644d11 --- /dev/null +++ b/priv/resource_snapshots/repo/membership_fee_cycles/20251211195058.json @@ -0,0 +1,160 @@ +{ + "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 new file mode 100644 index 0000000..c5de933 --- /dev/null +++ b/priv/resource_snapshots/repo/membership_fee_types/20251211195058.json @@ -0,0 +1,94 @@ +{ + "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 new file mode 100644 index 0000000..4b437b8 --- /dev/null +++ b/priv/resource_snapshots/repo/settings/20251211195058.json @@ -0,0 +1,103 @@ +{ + "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/membership_fee_settings_test.exs b/test/membership/membership_fee_settings_test.exs new file mode 100644 index 0000000..05a0d04 --- /dev/null +++ b/test/membership/membership_fee_settings_test.exs @@ -0,0 +1,98 @@ +defmodule Mv.Membership.MembershipFeeSettingsTest do + @moduledoc """ + Tests for membership fee settings in the Settings resource. + """ + use Mv.DataCase, async: true + + alias Mv.Membership.Setting + alias Mv.MembershipFees.MembershipFeeType + + describe "membership fee settings" do + test "default values are correct" do + {:ok, settings} = Mv.Membership.get_settings() + assert settings.include_joining_cycle == true + end + + test "settings can be read" do + {:ok, settings} = Mv.Membership.get_settings() + assert %Setting{} = settings + end + + test "settings can be written via update_membership_fee_settings" do + {:ok, settings} = Mv.Membership.get_settings() + + {:ok, updated} = + settings + |> Ash.Changeset.for_update(:update_membership_fee_settings, %{ + include_joining_cycle: false + }) + |> Ash.update() + + assert updated.include_joining_cycle == false + end + + test "default_membership_fee_type_id can be nil (optional)" do + {:ok, settings} = Mv.Membership.get_settings() + + {:ok, updated} = + settings + |> Ash.Changeset.for_update(:update_membership_fee_settings, %{ + default_membership_fee_type_id: nil + }) + |> Ash.update() + + assert updated.default_membership_fee_type_id == nil + end + + test "default_membership_fee_type_id validation: must exist if set" do + {:ok, settings} = Mv.Membership.get_settings() + + # Create a valid fee type + {:ok, fee_type} = + Ash.create(MembershipFeeType, %{ + name: "Test Fee Type #{System.unique_integer([:positive])}", + amount: Decimal.new("100.00"), + interval: :yearly + }) + + # Setting a valid fee type should work + {:ok, updated} = + settings + |> Ash.Changeset.for_update(:update_membership_fee_settings, %{ + default_membership_fee_type_id: fee_type.id + }) + |> Ash.update() + + assert updated.default_membership_fee_type_id == fee_type.id + end + + test "default_membership_fee_type_id validation: fails if not found" do + {:ok, settings} = Mv.Membership.get_settings() + + # Use a non-existent UUID + fake_uuid = Ecto.UUID.generate() + + assert {:error, error} = + settings + |> Ash.Changeset.for_update(:update_membership_fee_settings, %{ + default_membership_fee_type_id: fake_uuid + }) + |> Ash.update() + + assert error_on_field?(error, :default_membership_fee_type_id) + end + end + + # Helper to check if an error occurred on a specific field + defp error_on_field?(%Ash.Error.Invalid{} = error, field) do + Enum.any?(error.errors, fn e -> + case e do + %{field: ^field} -> true + %{fields: fields} when is_list(fields) -> field in fields + _ -> false + end + end) + end + + defp error_on_field?(_, _), do: false +end 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 new file mode 100644 index 0000000..4af59db --- /dev/null +++ b/test/membership_fees/changes/set_membership_fee_start_date_test.exs @@ -0,0 +1,268 @@ +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 new file mode 100644 index 0000000..7cbfbff --- /dev/null +++ b/test/membership_fees/member_cycle_integration_test.exs @@ -0,0 +1,211 @@ +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/membership_fees/membership_fee_type_integration_test.exs b/test/membership_fees/membership_fee_type_integration_test.exs new file mode 100644 index 0000000..b70f47c --- /dev/null +++ b/test/membership_fees/membership_fee_type_integration_test.exs @@ -0,0 +1,221 @@ +defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do + @moduledoc """ + Integration tests for MembershipFeeType CRUD operations. + """ + use Mv.DataCase, async: false + + alias Mv.MembershipFees.MembershipFeeType + alias Mv.MembershipFees.MembershipFeeCycle + 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 + + describe "admin can create membership fee type" do + test "creates type with all fields" do + attrs = %{ + name: "Standard Membership", + amount: Decimal.new("120.00"), + interval: :yearly, + description: "Standard yearly membership fee" + } + + assert {:ok, %MembershipFeeType{} = fee_type} = Ash.create(MembershipFeeType, attrs) + + assert fee_type.name == "Standard Membership" + assert Decimal.equal?(fee_type.amount, Decimal.new("120.00")) + assert fee_type.interval == :yearly + assert fee_type.description == "Standard yearly membership fee" + end + end + + describe "admin can update membership fee type" do + setup do + {:ok, fee_type} = + Ash.create(MembershipFeeType, %{ + name: "Original Name", + amount: Decimal.new("100.00"), + interval: :yearly, + description: "Original description" + }) + + %{fee_type: fee_type} + end + + test "can update name", %{fee_type: fee_type} do + assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"}) + assert updated.name == "Updated Name" + end + + test "can update amount", %{fee_type: fee_type} do + assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")}) + assert Decimal.equal?(updated.amount, Decimal.new("150.00")) + end + + test "can update description", %{fee_type: fee_type} do + assert {:ok, updated} = Ash.update(fee_type, %{description: "Updated description"}) + assert updated.description == "Updated description" + end + + test "cannot update interval", %{fee_type: fee_type} do + # Currently, interval is not in the accept list, so it's rejected as "NoSuchInput" + # After implementing validation, it should return a validation error + assert {:error, error} = Ash.update(fee_type, %{interval: :monthly}) + # For now, check that it's an error (either NoSuchInput or validation error) + assert %Ash.Error.Invalid{} = error + end + end + + describe "admin cannot delete membership fee type when in use" do + test "cannot delete when members are assigned" do + fee_type = create_fee_type(%{interval: :yearly}) + + # Create a member with this fee type + {:ok, _member} = + Ash.create(Member, %{ + first_name: "Test", + last_name: "Member", + email: "test.member.#{System.unique_integer([:positive])}@example.com", + membership_fee_type_id: fee_type.id + }) + + assert {:error, error} = Ash.destroy(fee_type) + error_message = extract_error_message(error) + assert error_message =~ "member(s) are assigned" + end + + test "cannot delete when cycles exist" do + fee_type = create_fee_type(%{interval: :yearly}) + + # Create a member with this fee type + {:ok, member} = + Ash.create(Member, %{ + first_name: "Test", + last_name: "Member", + email: "test.member.#{System.unique_integer([:positive])}@example.com", + membership_fee_type_id: fee_type.id + }) + + # Create a cycle for this fee type + {:ok, _cycle} = + Ash.create(MembershipFeeCycle, %{ + cycle_start: ~D[2025-01-01], + amount: Decimal.new("100.00"), + member_id: member.id, + membership_fee_type_id: fee_type.id + }) + + assert {:error, error} = Ash.destroy(fee_type) + error_message = extract_error_message(error) + assert error_message =~ "cycle(s) reference" + end + + test "cannot delete when used as default in settings" do + fee_type = create_fee_type(%{interval: :yearly}) + + # Set as default in settings + {:ok, settings} = Mv.Membership.get_settings() + + settings + |> Ash.Changeset.for_update(:update_membership_fee_settings, %{ + default_membership_fee_type_id: fee_type.id + }) + |> Ash.update!() + + # Try to delete + assert {:error, error} = Ash.destroy(fee_type) + error_message = extract_error_message(error) + assert error_message =~ "used as default in settings" + end + end + + describe "settings integration" do + test "default_membership_fee_type_id is used during member creation" do + # Create a fee type + fee_type = create_fee_type(%{interval: :yearly}) + + # Set it as default in settings + {:ok, settings} = Mv.Membership.get_settings() + + settings + |> Ash.Changeset.for_update(:update_membership_fee_settings, %{ + default_membership_fee_type_id: fee_type.id + }) + |> Ash.update!() + + # Create a member without explicitly setting membership_fee_type_id + # Note: This test assumes that the Member resource automatically assigns + # the default_membership_fee_type_id during creation. If this is not yet + # implemented, this test will fail initially (which is expected in TDD). + # For now, we skip this test as the auto-assignment feature is not yet implemented. + {:ok, member} = + Ash.create(Member, %{ + first_name: "Test", + last_name: "Member", + email: "test.member.#{System.unique_integer([:positive])}@example.com" + }) + + # TODO: When auto-assignment is implemented, uncomment this assertion + # assert member.membership_fee_type_id == fee_type.id + # For now, we just verify the member was created successfully + assert %Member{} = member + end + + test "include_joining_cycle is used during cycle generation" do + # This test verifies that the include_joining_cycle setting affects + # cycle generation. The actual cycle generation logic is tested in + # CycleGeneratorTest, but this integration test ensures the setting + # is properly used. + + fee_type = create_fee_type(%{interval: :yearly}) + + # Set include_joining_cycle to false + {:ok, settings} = Mv.Membership.get_settings() + + settings + |> Ash.Changeset.for_update(:update_membership_fee_settings, %{ + include_joining_cycle: false + }) + |> Ash.update!() + + # Create a member with join_date in the middle of a year + {:ok, member} = + Ash.create(Member, %{ + first_name: "Test", + last_name: "Member", + email: "test.member.#{System.unique_integer([:positive])}@example.com", + join_date: ~D[2023-03-15], + membership_fee_type_id: fee_type.id + }) + + # Verify that membership_fee_start_date was calculated correctly + # (should be 2024-01-01, not 2023-01-01, because include_joining_cycle = false) + assert member.membership_fee_start_date == ~D[2024-01-01] + end + end + + # Helper to extract error message from various error types + defp extract_error_message(%Ash.Error.Invalid{} = error) do + Enum.map_join(error.errors, " ", fn + %{message: message} -> message + %{detail: detail} -> detail + _ -> "" + end) + end + + defp extract_error_message(_), do: "" +end diff --git a/test/membership_fees/membership_fee_type_test.exs b/test/membership_fees/membership_fee_type_test.exs index 9ca6f0a..626e096 100644 --- a/test/membership_fees/membership_fee_type_test.exs +++ b/test/membership_fees/membership_fee_type_test.exs @@ -155,6 +155,95 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do assert {:ok, updated} = Ash.update(fee_type, %{description: nil}) assert updated.description == nil end + + test "interval immutability: update fails when interval is changed", %{fee_type: fee_type} do + # Currently, interval is not in the accept list, so it's rejected as "NoSuchInput" + # After implementing validation, it should return a validation error + assert {:error, error} = Ash.update(fee_type, %{interval: :monthly}) + # For now, check that it's an error (either NoSuchInput or validation error) + assert %Ash.Error.Invalid{} = error + end + end + + describe "delete MembershipFeeType" do + setup do + {:ok, fee_type} = + Ash.create(MembershipFeeType, %{ + name: "Test Fee Type #{System.unique_integer([:positive])}", + amount: Decimal.new("100.00"), + interval: :yearly + }) + + %{fee_type: fee_type} + end + + test "can delete when not in use", %{fee_type: fee_type} do + result = Ash.destroy(fee_type) + # Ash.destroy returns :ok or {:ok, _} depending on version + assert result == :ok or match?({:ok, _}, result) + end + + test "cannot delete when members are assigned", %{fee_type: fee_type} do + alias Mv.Membership.Member + + # Create a member with this fee type + {:ok, _member} = + Ash.create(Member, %{ + first_name: "Test", + last_name: "Member", + email: "test.member.#{System.unique_integer([:positive])}@example.com", + membership_fee_type_id: fee_type.id + }) + + assert {:error, error} = Ash.destroy(fee_type) + # Check for either validation error message or DB constraint error + error_message = extract_error_message(error) + assert error_message =~ "member" or error_message =~ "referenced" + end + + test "cannot delete when cycles exist", %{fee_type: fee_type} do + alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.Membership.Member + + # Create a member with this fee type + {:ok, member} = + Ash.create(Member, %{ + first_name: "Test", + last_name: "Member", + email: "test.member.#{System.unique_integer([:positive])}@example.com", + membership_fee_type_id: fee_type.id + }) + + # Create a cycle for this fee type + {:ok, _cycle} = + Ash.create(MembershipFeeCycle, %{ + cycle_start: ~D[2025-01-01], + amount: Decimal.new("100.00"), + member_id: member.id, + membership_fee_type_id: fee_type.id + }) + + assert {:error, error} = Ash.destroy(fee_type) + # Check for either validation error message or DB constraint error + error_message = extract_error_message(error) + assert error_message =~ "cycle" or error_message =~ "referenced" + end + + test "cannot delete when used as default in settings", %{fee_type: fee_type} do + # Set as default in settings + {:ok, settings} = Mv.Membership.get_settings() + + settings + |> Ash.Changeset.for_update(:update_membership_fee_settings, %{ + default_membership_fee_type_id: fee_type.id + }) + |> Ash.update!() + + # Try to delete + assert {:error, error} = Ash.destroy(fee_type) + error_message = extract_error_message(error) + assert error_message =~ "used as default in settings" + end end # Helper to check if an error occurred on a specific field @@ -169,4 +258,15 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do end defp error_on_field?(_, _), do: false + + # Helper to extract error message from various error types + defp extract_error_message(%Ash.Error.Invalid{} = error) do + Enum.map_join(error.errors, " ", fn + %{message: message} -> message + %{detail: detail} -> detail + _ -> "" + end) + end + + defp extract_error_message(_), do: "" end diff --git a/test/mv/membership_fees/calendar_cycles_test.exs b/test/mv/membership_fees/calendar_cycles_test.exs new file mode 100644 index 0000000..29fec48 --- /dev/null +++ b/test/mv/membership_fees/calendar_cycles_test.exs @@ -0,0 +1,181 @@ +defmodule Mv.MembershipFees.CalendarCyclesTest do + @moduledoc """ + Tests for CalendarCycles module. + """ + use ExUnit.Case, async: true + + alias Mv.MembershipFees.CalendarCycles + + doctest Mv.MembershipFees.CalendarCycles + + describe "calculate_cycle_start/3" do + test "uses reference_date when provided" do + date = ~D[2024-03-15] + reference = ~D[2024-05-20] + + assert CalendarCycles.calculate_cycle_start(date, :monthly, reference) == ~D[2024-05-01] + assert CalendarCycles.calculate_cycle_start(date, :quarterly, reference) == ~D[2024-04-01] + end + end + + describe "current_cycle?/3" do + # Basic examples are covered by doctests + + test "works for all interval types" do + today = ~D[2024-03-15] + + for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do + cycle_start = CalendarCycles.calculate_cycle_start(today, interval) + result = CalendarCycles.current_cycle?(cycle_start, interval, today) + + assert result == true, "Expected current cycle for #{interval} with start #{cycle_start}" + end + end + end + + describe "current_cycle?/2 wrapper" do + test "calls current_cycle?/3 with Date.utc_today()" do + today = Date.utc_today() + cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly) + + # This test verifies the wrapper works, but uses actual today + # The real testing happens in current_cycle?/3 tests above + result = CalendarCycles.current_cycle?(cycle_start, :monthly) + + assert result == true + end + end + + describe "last_completed_cycle?/3" do + # Basic examples are covered by doctests + + test "returns false when next cycle has also ended" do + # Two cycles ago: cycle ended, but next cycle also ended + today = ~D[2024-05-15] + cycle_start = ~D[2024-03-01] + # Cycle ended 2024-03-31, next cycle ended 2024-04-30, today is 2024-05-15 + + assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false + end + + test "works correctly for quarterly intervals" do + # Q1 2024 ended on 2024-03-31 + # Q2 2024 ends on 2024-06-30 + # Today is 2024-04-15 (after Q1 ended, before Q2 ended) + today = ~D[2024-04-15] + past_quarter_start = ~D[2024-01-01] + + assert CalendarCycles.last_completed_cycle?(past_quarter_start, :quarterly, today) == true + end + + test "returns false when cycle ended on the given date" do + # Cycle ends on today, so it's still current, not completed + today = ~D[2024-03-31] + cycle_start = ~D[2024-03-01] + + assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false + end + end + + describe "last_completed_cycle?/2 wrapper" do + test "calls last_completed_cycle?/3 with Date.utc_today()" do + today = Date.utc_today() + cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly) + + # This test verifies the wrapper works, but uses actual today + # The real testing happens in last_completed_cycle?/3 tests above + result = CalendarCycles.last_completed_cycle?(cycle_start, :monthly) + + # Result depends on actual today, so we just verify it's a boolean + assert is_boolean(result) + end + end + + describe "edge cases" do + test "leap year: February has 29 days" do + # 2024 is a leap year + assert CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) == ~D[2024-02-29] + + # 2023 is not a leap year + assert CalendarCycles.calculate_cycle_end(~D[2023-02-01], :monthly) == ~D[2023-02-28] + end + + test "year boundary: December 31 to January 1" do + # Yearly cycle + assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01] + + # Monthly cycle across year boundary + assert CalendarCycles.next_cycle_start(~D[2024-12-01], :monthly) == ~D[2025-01-01] + + # Half-yearly cycle across year boundary + assert CalendarCycles.next_cycle_start(~D[2024-07-01], :half_yearly) == ~D[2025-01-01] + + # Quarterly cycle across year boundary + assert CalendarCycles.next_cycle_start(~D[2024-10-01], :quarterly) == ~D[2025-01-01] + end + + test "month boundary: different month lengths" do + # 31-day months + assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :monthly) == ~D[2024-01-31] + assert CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) == ~D[2024-03-31] + assert CalendarCycles.calculate_cycle_end(~D[2024-05-01], :monthly) == ~D[2024-05-31] + + # 30-day months + assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :monthly) == ~D[2024-04-30] + assert CalendarCycles.calculate_cycle_end(~D[2024-06-01], :monthly) == ~D[2024-06-30] + assert CalendarCycles.calculate_cycle_end(~D[2024-09-01], :monthly) == ~D[2024-09-30] + assert CalendarCycles.calculate_cycle_end(~D[2024-11-01], :monthly) == ~D[2024-11-30] + end + + test "date in middle of cycle: all functions work correctly" do + middle_date = ~D[2024-03-15] + + # calculate_cycle_start + assert CalendarCycles.calculate_cycle_start(middle_date, :monthly) == ~D[2024-03-01] + assert CalendarCycles.calculate_cycle_start(middle_date, :quarterly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_start(middle_date, :half_yearly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_start(middle_date, :yearly) == ~D[2024-01-01] + + # calculate_cycle_end + monthly_start = CalendarCycles.calculate_cycle_start(middle_date, :monthly) + assert CalendarCycles.calculate_cycle_end(monthly_start, :monthly) == ~D[2024-03-31] + + # next_cycle_start + assert CalendarCycles.next_cycle_start(monthly_start, :monthly) == ~D[2024-04-01] + end + + test "quarterly: all quarter boundaries correct" do + # Q1 boundaries + assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :quarterly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) == ~D[2024-03-31] + + # Q2 boundaries + assert CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) == ~D[2024-04-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) == ~D[2024-06-30] + + # Q3 boundaries + assert CalendarCycles.calculate_cycle_start(~D[2024-08-15], :quarterly) == ~D[2024-07-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :quarterly) == ~D[2024-09-30] + + # Q4 boundaries + assert CalendarCycles.calculate_cycle_start(~D[2024-11-15], :quarterly) == ~D[2024-10-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-10-01], :quarterly) == ~D[2024-12-31] + end + + test "half_yearly: both half boundaries correct" do + # First half boundaries + assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :half_yearly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) == ~D[2024-06-30] + + # Second half boundaries + assert CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) == ~D[2024-07-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :half_yearly) == ~D[2024-12-31] + end + + test "yearly: full year boundaries" do + assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :yearly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) == ~D[2024-12-31] + assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01] + 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 new file mode 100644 index 0000000..adca77a --- /dev/null +++ b/test/mv/membership_fees/cycle_generator_edge_cases_test.exs @@ -0,0 +1,644 @@ +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 new file mode 100644 index 0000000..06dd59e --- /dev/null +++ b/test/mv/membership_fees/cycle_generator_test.exs @@ -0,0 +1,428 @@ +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 diff --git a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs index cfe3145..149d441 100644 --- a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs +++ b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs @@ -52,14 +52,11 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do field: field } do conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members") - # Check that the sort button has aria-label - assert html =~ ~r/aria-label=["']Click to sort["']/i or - html =~ ~r/aria-label=["'].*sort.*["']/i - - # Check that data-testid is present for testing - assert html =~ ~r/data-testid=["']custom_field_#{field.id}["']/ + # Check that the sort button has aria-label and data-testid + test_id = "custom_field_#{field.id}" + assert has_element?(view, "[data-testid='#{test_id}'][aria-label='Click to sort']") end test "sort header component shows correct ARIA label when sorted ascending", %{ @@ -71,10 +68,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") - html = render(view) - - # Check that aria-label indicates ascending sort - assert html =~ ~r/aria-label=["'].*ascending.*["']/i + # Check that aria-label indicates ascending sort using data-testid + test_id = "custom_field_#{field.id}" + assert has_element?(view, "[data-testid='#{test_id}'][aria-label='ascending']") end test "sort header component shows correct ARIA label when sorted descending", %{ @@ -86,21 +82,21 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") - html = render(view) - - # Check that aria-label indicates descending sort - assert html =~ ~r/aria-label=["'].*descending.*["']/i + # Check that aria-label indicates descending sort using data-testid + test_id = "custom_field_#{field.id}" + assert has_element?(view, "[data-testid='#{test_id}'][aria-label='descending']") end test "custom field column header is keyboard accessible", %{conn: conn, field: field} do conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members") # Check that the sort button is a button element (keyboard accessible) - assert html =~ ~r/]*data-testid=["']custom_field_#{field.id}["']/ + test_id = "custom_field_#{field.id}" + assert has_element?(view, "button[data-testid='#{test_id}']") # Button should not have tabindex="-1" (which would remove from tab order) - refute html =~ ~r/tabindex=["']-1["']/ + refute has_element?(view, "button[data-testid='#{test_id}'][tabindex='-1']") end test "custom field column header has proper semantic structure", %{conn: conn, field: field} do diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 30b61c7..3232cc0 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -410,15 +410,17 @@ defmodule MvWeb.MemberLive.IndexTest do assert render(view) =~ "1" end - test "copy button is not visible when no members are selected", %{conn: conn} do + test "copy button is disabled when no members selected", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # Ensure no members are selected (default state) - refute has_element?(view, "#copy-emails-btn") + # Copy button should be disabled (button element) + assert has_element?(view, "#copy-emails-btn[disabled]") + # Open email button should be disabled (link with tabindex and aria-disabled) + assert has_element?(view, "#open-email-btn[tabindex='-1'][aria-disabled='true']") end - test "copy button is visible when members are selected", %{ + test "copy button is enabled after selection", %{ conn: conn, member1: member1 } do @@ -428,8 +430,13 @@ defmodule MvWeb.MemberLive.IndexTest do # Select a member by sending the select_member event directly render_click(view, "select_member", %{"id" => member1.id}) - # Button should now be visible - assert has_element?(view, "#copy-emails-btn") + # Copy button should now be enabled (no disabled attribute) + refute has_element?(view, "#copy-emails-btn[disabled]") + # Open email button should now be enabled (no tabindex=-1 or aria-disabled) + refute has_element?(view, "#open-email-btn[tabindex='-1']") + refute has_element?(view, "#open-email-btn[aria-disabled='true']") + # Counter should show correct count + assert render(view) =~ "1" end test "copy button click triggers event and shows flash", %{