Compare commits

...

38 commits

Author SHA1 Message Date
1b4154ee64
feat: add 4 example membership fee types to seed script
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 17:26:41 +01:00
ada43728d6
chore: update gettext 2025-12-16 17:26:41 +01:00
53d7a91653
docs: document require_atomic? false in MembershipFeeType actions 2025-12-16 17:26:41 +01:00
725bfec2ba
refactor: migrate MembershipFeeSettingsLive to AshPhoenix.Form 2025-12-16 17:26:40 +01:00
6261d3b286
feat: improve error handling in settings validation for default_membership_fee_type_id 2025-12-16 17:26:40 +01:00
d5e78774c5
feat: prevent deletion of membership fee type when used as default in settings 2025-12-16 17:26:40 +01:00
c12e7fc3df
refactor: use Enum.map_join instead of Enum.map |> Enum.join 2025-12-16 17:26:38 +01:00
68261fa72a
fix: improve accessibility - WCAG 2 AA contrast and select label 2025-12-16 17:25:42 +01:00
c698fc5d04
refactor: replace ContributionSettingsLive mockup with MembershipFeeSettingsLive in navigation 2025-12-16 17:25:41 +01:00
4059395534
i18n: add German translations for membership fee settings 2025-12-16 17:25:37 +01:00
cadb18c050
feat: implement full CRUD for membership fee types with settings UI
- Add interval immutability and deletion prevention validations
- Add settings validation for default_membership_fee_type_id
- Create MembershipFeeSettingsLive for admin UI with form handling
- Add comprehensive test coverage (unit, integration, settings)
2025-12-16 17:16:41 +01:00
ed083830b9 refactor: improve cycle generation code quality and documentation
All checks were successful
continuous-integration/drone/push Build is passing
- Remove Process.sleep calls from integration tests (tests run synchronously in SQL sandbox)
- Improve error handling: membership_fee_type_not_found now returns changeset error instead of just logging
- Clarify partial_failure documentation: successful_cycles are not persisted on rollback
- Update documentation: joined_at → join_date, left_at → exit_date
- Document PostgreSQL advisory locks per member (not whole table lock)
- Document gap handling: explicitly deleted cycles are not recreated
2025-12-16 16:40:11 +01:00
62a2bd41ea fix: handle Ash notifications in CycleGenerator transactions
- Use return_notifications?: true when creating cycles within transaction
- Collect notifications and send them after transaction commits
- Prevents 'Missed notifications' warnings in test output
- Notifications are now properly sent via Ash.Notifier.notify/1
2025-12-16 16:40:11 +01:00
d9ca6b1763 test: fix date dependencies in cycle generator tests
- Add create_member_with_cycles helper that uses fixed 'today' date
- Update tests to use explicit 'today:' option instead of Date.utc_today()
- Prevents test failures when current date changes (e.g., in 2026+)
- Tests now explicitly delete and regenerate cycles with fixed dates
- Ensures consistent test behavior regardless of execution date
2025-12-16 16:40:11 +01:00
de7a94ab07 fix: CycleGenerator generates from last cycle, not filling gaps
- Change algorithm to start from last existing cycle instead of start_date
- Deleted cycles (gaps) are no longer automatically filled
- Add test to verify gaps remain unfilled
- Update documentation to clarify gap handling behavior
2025-12-16 16:40:11 +01:00
539084fdf1 test: make CycleGenerator tests more robust
- Replace weak assertions (>= 0, if length > 0) with concrete expectations
- Remove unnecessary Process.sleep calls (tests run synchronously)
- Add get_member_cycles helper for direct cycle verification
- Tests now validate actual generated cycles instead of relying on async behavior
2025-12-16 16:40:11 +01:00
13790dda43 feat: improve error handling in CycleGenerator
- Handle Task crashes in async_stream with {:exit, reason}
- Return {:error, {:partial_failure, successes, errors}} when some cycles fail
- Previously returned {:ok, successful} even on partial failures
- Improves debuggability and allows callers to handle partial failures
2025-12-16 16:40:11 +01:00
d01033c720 feat: include inactive members in batch cycle generation
- Remove exit_date filter from generate_cycles_for_all_members query
- Inactive members now get cycles generated up to their exit_date
- Add tests for inactive member processing and exit_date boundary
- Document exit_date == cycle_start behavior (cycle still generated)
2025-12-16 16:40:11 +01:00
78747d7da0 refactor: improve SetMembershipFeeStartDate change module
- Add warning logging for unexpected errors (not missing prerequisites)
- Use CalendarCycles.interval() type instead of generic atom()
- Update moduledoc to reflect actual usage (no where clause needed)
2025-12-16 16:40:11 +01:00
e899004b3c feat: add error logging in after_action cycle generation hooks
- Log warnings when cycle generation fails in Member create/update
- Extract generate_fn to reduce code duplication
- Improves debuggability of silent failures
2025-12-16 16:40:11 +01:00
434bcd269f refactor: use sql_sandbox config instead of env for sync/async
- Replace Application.get_env(:mv, :env) with :sql_sandbox config
- Remove redundant :env config from test.exs
- More explicit and less error-prone for test environment detection
2025-12-16 16:40:11 +01:00
25cc41b02e feat: implement automatic cycle generation for members
- Add CycleGenerator module with advisory lock mechanism
- Add SetMembershipFeeStartDate change for auto-calculation
- Extend Settings with include_joining_cycle and default_membership_fee_type_id
- Add scheduled job skeleton for future Oban integration
2025-12-16 16:40:11 +01:00
894b9b9d5c Merge pull request 'Calendar Cycle Calculation Logic closes #276' (#284) from feature/276_cycle_calculation into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #284
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-12-16 16:39:35 +01:00
a7285915e6 docs: fix CalendarCycles documentation to match actual implementation
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 15:06:45 +01:00
da6c495d04 refactor: improve CalendarCycles API and tests based on code review 2025-12-16 15:06:45 +01:00
3fc4440bce feat: implement calendar-based cycle calculation functions
Add CalendarCycles module with functions for all interval types.
Includes comprehensive tests for edge cases.
2025-12-16 15:06:45 +01:00
0a07f4f212 Merge pull request 'Small UX fixes closes #281' (#293) from feature/281_uxfixes into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #293
2025-12-16 15:06:00 +01:00
1df1b4b238 test: use data-testids instead of regex in a11y tests
All checks were successful
continuous-integration/drone/push Build is passing
Replace regex-based aria-label assertions with data-testid-based
has_element? checks for more stable tests that are resistant to
translation changes.
2025-12-16 14:55:50 +01:00
62d04add8e fix: standardize 'Custom Field' capitalization in i18n
Change 'Save Custom field' to 'Save Custom Field' and
'Save Custom field value' to 'Save Custom Field Value' for consistency.
Update gettext files accordingly.
2025-12-16 14:54:43 +01:00
9f9d888657 test: add tests for disabled button states in member index
Add tests to verify that copy and open-email buttons are disabled
when no members are selected and enabled after selection.
Also verify that the counter shows the correct count.
2025-12-16 14:53:10 +01:00
be6ea56860 fix: improve mailto BCC encoding
Use URI.encode_www_form() instead of URI.encode() for mailto query parameters.
This is the safer choice for query parameter encoding.

Add comment about mailto URL length limits that vary by email client.
2025-12-16 14:51:42 +01:00
fb91f748c2 perf: optimize member index selection calculations
Calculate selected_count, any_selected? and mailto_bcc once in assigns
instead of recalculating Enum.any? and Enum.count multiple times in template.
This improves render performance and makes the template code more readable.
2025-12-16 14:50:52 +01:00
222af635ae fix: make disabled links more robust in CoreComponents.button
Remove navigation attributes (href, navigate, patch) when disabled=true
to prevent 'Open in new tab' and 'Copy link' from working on disabled links.
This makes the disabled state semantically stronger and independent of CSS themes.
2025-12-16 14:48:18 +01:00
dd4048669c fix: update clubname on save
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 14:35:00 +01:00
e0712d47bc chore: change payment filter text 2025-12-16 14:35:00 +01:00
4e86351e1c feat: disable email buttons instead hide them 2025-12-16 14:35:00 +01:00
8bfa5b7d1d chore: remove immutable from custom fields 2025-12-16 14:35:00 +01:00
cb82c07cbf Merge pull request 'Membership Fee - Database Schema & Ash Domain Foundation closes #275' (#283) from feature/275_member_fee_domain into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #283
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-12-16 14:06:45 +01:00
47 changed files with 5114 additions and 812 deletions

View file

@ -47,4 +47,5 @@ config :mv, :session_identifier, :unsafe
config :mv, :require_token_presence_for_authentication, false config :mv, :require_token_presence_for_authentication, false
# Enable SQL Sandbox for async LiveView tests # Enable SQL Sandbox for async LiveView tests
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
config :mv, :sql_sandbox, true config :mv, :sql_sandbox, true

View file

@ -153,8 +153,8 @@ lib/
**Existing Fields Used:** **Existing Fields Used:**
- `joined_at` - For calculating membership fee start - `join_date` - For calculating membership fee start
- `left_at` - For limiting cycle generation - `exit_date` - For limiting cycle generation
- These fields must remain member fields and should not be replaced by custom fields in the future - These fields must remain member fields and should not be replaced by custom fields in the future
### Settings Integration ### Settings Integration
@ -186,8 +186,9 @@ lib/
- Calculate which cycles should exist for a member - Calculate which cycles should exist for a member
- Generate missing cycles - 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) - Skip existing cycles (idempotent)
- Use PostgreSQL advisory locks per member to prevent race conditions
**Triggers:** **Triggers:**
@ -199,17 +200,20 @@ lib/
**Algorithm Steps:** **Algorithm Steps:**
1. Retrieve member with membership fee type and dates 1. Retrieve member with membership fee type and dates
2. Determine first cycle start (based on membership_fee_start_date) 2. Determine generation start point:
3. Calculate all cycle starts from first to today (or left_at) - If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
4. Query existing cycles for member - If cycles exist: Start from the cycle AFTER the last existing one
5. Generate missing cycles with current membership fee type's amount 3. Generate all cycle starts from the determined start point to today (or `exit_date`)
6. Insert new cycles (batch operation) 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:** **Edge Case Handling:**
- If membership_fee_start_date is NULL: Calculate from joined_at + global setting - If membership_fee_start_date is NULL: Calculate from join_date + global setting
- If left_at is set: Stop generation at left_at - If exit_date is set: Stop generation at exit_date
- If membership fee type changes: Handled separately by regeneration logic - 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 ### Calendar Cycle Calculations
@ -381,7 +385,7 @@ lib/
**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default) **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-2:** Member has membership_fee_start_date field (nullable)
**AC-M-3:** New members get default membership fee type from global setting **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-5:** Admin can manually override membership_fee_start_date
**AC-M-6:** Cannot change to membership fee type with different interval (MVP) **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-2:** Cycles generated when member created (via change hook)
**AC-CG-3:** Scheduled job generates missing cycles daily **AC-CG-3:** Scheduled job generates missing cycles daily
**AC-CG-4:** Generation respects membership_fee_start_date **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-6:** Generation is idempotent (skips existing cycles)
**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year) **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 **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_start calculation for all interval types
- Correct cycle count from start to end date - Correct cycle count from start to end date
- Respects membership_fee_start_date boundary - Respects membership_fee_start_date boundary
- Respects left_at boundary - Respects exit_date boundary
- Skips existing cycles (idempotent) - Skips existing cycles (idempotent)
- Does not fill gaps when cycles were deleted
- Handles edge dates (year boundaries, leap years) - Handles edge dates (year boundaries, leap years)
**Calendar Cycles Tests:** **Calendar Cycles Tests:**

View file

@ -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_type_id (FK → membership_fee_types.id, NOT NULL, default from settings)
- membership_fee_start_date (Date, nullable) - When to start generating membership fees - 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:** **Logic for membership_fee_start_date:**
@ -167,16 +167,17 @@ value: UUID (Required) - Default membership fee type for new members
**Algorithm:** **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 1. Get `member.membership_fee_start_date` and member's membership fee type
2. Generate cycles until today (or `left_at` if present): 2. Determine generation start point:
- If no cycle exists: - If NO cycles exist: Start from `membership_fee_start_date`
- Generate all cycles from `membership_fee_start_date` - If cycles exist: Start from the cycle AFTER the last existing one
- else: 3. Generate cycles until today (or `exit_date` if present):
- Generate all cycles from last existing cycle - Use the interval to generate the cycles
- use the interval to generate the cycles - **Note:** If cycles were explicitly deleted (gaps exist), they are NOT recreated.
3. Set `amount` to current membership fee type's amount The generator always continues from the cycle AFTER the last existing cycle.
4. Set `amount` to current membership fee type's amount
**Example (Yearly):** **Example (Yearly):**
@ -246,7 +247,7 @@ suspended → unpaid
**Logic:** **Logic:**
- Cycles only generated until `member.left_at` - Cycles only generated until `member.exit_date`
- Existing cycles remain visible - Existing cycles remain visible
- Unpaid exit cycle can be marked as "suspended" - Unpaid exit cycle can be marked as "suspended"

View file

@ -12,7 +12,6 @@ defmodule Mv.Membership.CustomField do
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
- `description` - Optional human-readable description - `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) - `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 - `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 actions do
defaults [:read, :update] 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 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 change Mv.Membership.CustomField.Changes.GenerateSlug
validate string_length(:slug, min: 1) validate string_length(:slug, min: 1)
end end
@ -113,10 +112,6 @@ defmodule Mv.Membership.CustomField do
trim?: true trim?: true
] ]
attribute :immutable, :boolean,
default: false,
allow_nil?: false
attribute :required, :boolean, attribute :required, :boolean,
default: false, default: false,
allow_nil?: false allow_nil?: false

View file

@ -80,7 +80,7 @@ defmodule Mv.Membership.Member do
argument :user, :map, allow_nil?: true argument :user, :map, allow_nil?: true
# Accept member fields plus membership_fee_type_id (belongs_to FK) # 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) change manage_relationship(:custom_field_values, type: :create)
@ -101,6 +101,42 @@ defmodule Mv.Membership.Member do
change Mv.EmailSync.Changes.SyncUserEmailToMember do change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)] where [changing(:user)]
end 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 end
update :update_member do update :update_member do
@ -114,7 +150,7 @@ defmodule Mv.Membership.Member do
argument :user, :map, allow_nil?: true argument :user, :map, allow_nil?: true
# Accept member fields plus membership_fee_type_id (belongs_to FK) # 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) 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 change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)] where [changing(:user)]
end 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 end
# Action to handle fuzzy search on specific fields # Action to handle fuzzy search on specific fields

View file

@ -4,13 +4,15 @@ defmodule Mv.Membership.Setting do
## Overview ## Overview
Settings is a singleton resource that stores global configuration for the association, 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 such as the club name, branding information, and membership fee settings. There should
record in the database. only ever be one settings record in the database.
## Attributes ## Attributes
- `club_name` - The name of the association/club (required, cannot be empty) - `club_name` - The name of the association/club (required, cannot be empty)
- `member_field_visibility` - JSONB map storing visibility configuration for member fields - `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`. (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 ## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record. 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 If set, the environment variable value is used as a fallback when no database
value exists. Database values always take precedence over environment variables. 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 ## Examples
# Get current settings # Get current settings
@ -33,6 +41,9 @@ defmodule Mv.Membership.Setting do
# Update member field visibility # Update member field visibility
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false}) {: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, use Ash.Resource,
domain: Mv.Membership, 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 # Used only as fallback in get_settings/0 if settings don't exist
# Settings should normally be created via seed script # Settings should normally be created via seed script
create :create do create :create do
accept [:club_name, :member_field_visibility] accept [
:club_name,
:member_field_visibility,
:include_joining_cycle,
:default_membership_fee_type_id
]
end end
update :update do update :update do
primary? true primary? true
require_atomic? false require_atomic? false
accept [:club_name, :member_field_visibility]
accept [
:club_name,
:member_field_visibility,
:include_joining_cycle,
:default_membership_fee_type_id
]
end end
update :update_member_field_visibility do update :update_member_field_visibility do
@ -68,6 +90,14 @@ defmodule Mv.Membership.Setting do
require_atomic? false require_atomic? false
accept [:member_field_visibility] accept [:member_field_visibility]
end 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 end
validations do validations do
@ -113,6 +143,41 @@ defmodule Mv.Membership.Setting do
end end
end, end,
on: [:create, :update] 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 end
attributes do attributes do
@ -133,6 +198,26 @@ defmodule Mv.Membership.Setting do
description: description:
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans." "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() timestamps()
end 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 end

View file

@ -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

View file

@ -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

View file

@ -36,7 +36,7 @@ defmodule Mv.MembershipFees.MembershipFeeType do
end end
actions do actions do
defaults [:read, :destroy] defaults [:read]
create :create do create :create do
primary? true primary? true
@ -45,10 +45,108 @@ defmodule Mv.MembershipFees.MembershipFeeType do
update :update do update :update do
primary? true 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 # 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] accept [:name, :amount, :description]
end 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 end
attributes do attributes do

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -95,9 +95,11 @@ defmodule MvWeb.CoreComponents do
<.button>Send!</.button> <.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button> <.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button> <.button navigate={~p"/"}>Home</.button>
<.button disabled={true}>Disabled</.button>
""" """
attr :rest, :global, include: ~w(href navigate patch method) attr :rest, :global, include: ~w(href navigate patch method)
attr :variant, :string, values: ~w(primary) attr :variant, :string, values: ~w(primary)
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
slot :inner_block, required: true slot :inner_block, required: true
def button(%{rest: rest} = assigns) do def button(%{rest: rest} = assigns) do
@ -105,14 +107,37 @@ defmodule MvWeb.CoreComponents do
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
if rest[:href] || rest[:navigate] || rest[:patch] do 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""" ~H"""
<.link class={["btn", @class]} {@rest}> <.link class={@link_class} {@link_attrs}>
{render_slot(@inner_block)} {render_slot(@inner_block)}
</.link> </.link>
""" """
else else
~H""" ~H"""
<button class={["btn", @class]} {@rest}> <button class={["btn", @class]} disabled={@disabled} {@rest}>
{render_slot(@inner_block)} {render_slot(@inner_block)}
</button> </button>
""" """

View file

@ -36,12 +36,16 @@ defmodule MvWeb.Layouts do
default: nil, default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)" 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 slot :inner_block, required: true
def app(assigns) do def app(assigns) do
~H""" ~H"""
<%= if @current_user do %> <%= if @current_user do %>
<.navbar current_user={@current_user} /> <.navbar current_user={@current_user} club_name={@club_name} />
<% end %> <% end %>
<main class="px-4 py-20 sm:px-6 lg:px-16"> <main class="px-4 py-20 sm:px-6 lg:px-16">
<div class="mx-auto max-full space-y-4"> <div class="mx-auto max-full space-y-4">

View file

@ -12,15 +12,18 @@ defmodule MvWeb.Layouts.Navbar do
required: true, required: true,
doc: "The current user - navbar is only shown when user is present" doc: "The current user - navbar is only shown when user is present"
def navbar(assigns) do attr :club_name, :string,
club_name = get_club_name() 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) assigns = assign(assigns, :club_name, club_name)
~H""" ~H"""
<header class="navbar bg-base-100 shadow-sm"> <header class="navbar bg-base-100 shadow-sm">
<div class="flex-1"> <div class="flex-1">
<a class="btn btn-ghost text-xl">{@club_name}</a> <a href="/members" class="btn btn-ghost text-xl">{@club_name}</a>
<ul class="menu menu-horizontal bg-base-200"> <ul class="menu menu-horizontal bg-base-200">
<li><.link navigate="/members">{gettext("Members")}</.link></li> <li><.link navigate="/members">{gettext("Members")}</.link></li>
<li><.link navigate="/settings">{gettext("Settings")}</.link></li> <li><.link navigate="/settings">{gettext("Settings")}</.link></li>
@ -31,7 +34,9 @@ defmodule MvWeb.Layouts.Navbar do
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48"> <ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
<li><.link navigate="/contribution_types">{gettext("Contribution Types")}</.link></li> <li><.link navigate="/contribution_types">{gettext("Contribution Types")}</.link></li>
<li> <li>
<.link navigate="/contribution_settings">{gettext("Contribution Settings")}</.link> <.link navigate="/membership_fee_settings">
{gettext("Membership Fee Settings")}
</.link>
</li> </li>
</ul> </ul>
</details> </details>

View file

@ -77,7 +77,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
phx-target={@myself} phx-target={@myself}
> >
<.icon name="hero-users" class="h-4 w-4" /> <.icon name="hero-users" class="h-4 w-4" />
{gettext("All")} {gettext("All payment statuses")}
</button> </button>
</li> </li>
<li role="none"> <li role="none">
@ -140,7 +140,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
defp parse_filter(_), do: nil defp parse_filter(_), do: nil
# Get display label for current filter # Get display label for current filter
defp filter_label(nil), do: gettext("All") defp filter_label(nil), do: gettext("All payment statuses")
defp filter_label(:paid), do: gettext("Paid") defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:not_paid), do: gettext("Not paid") defp filter_label(:not_paid), do: gettext("Not paid")
end end

View file

@ -43,7 +43,7 @@ defmodule MvWeb.ContributionPeriodLive.Show do
· {gettext("Member since")}: <span class="font-mono">{@member.joined_at}</span> · {gettext("Member since")}: <span class="font-mono">{@member.joined_at}</span>
</:subtitle> </:subtitle>
<:actions> <:actions>
<.link navigate={~p"/contribution_settings"} class="btn btn-ghost btn-sm"> <.link navigate={~p"/membership_fee_settings"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left" class="size-4" /> <.icon name="hero-arrow-left" class="size-4" />
{gettext("Back to Settings")} {gettext("Back to Settings")}
</.link> </.link>

View file

@ -1,277 +0,0 @@
defmodule MvWeb.ContributionSettingsLive do
@moduledoc """
Mock-up LiveView for Contribution Settings (Admin).
This is a preview-only page that displays the planned UI for managing
global contribution settings. It shows static mock data and is not functional.
## Planned Features (Future Implementation)
- Set default contribution type for new members
- Configure whether joining period is included in contributions
- Explanatory text with examples
## Settings
- `default_contribution_type_id` - UUID of the default contribution type
- `include_joining_period` - Boolean whether to include joining period
## Note
This page is intentionally non-functional and serves as a UI mockup
for the upcoming Membership Contributions feature.
"""
use MvWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, gettext("Contribution Settings"))
|> assign(:contribution_types, mock_contribution_types())
|> assign(:selected_type_id, "1")
|> assign(:include_joining_period, true)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.mockup_warning />
<.header>
{gettext("Contribution Settings")}
<:subtitle>
{gettext("Configure global settings for membership contributions.")}
</:subtitle>
</.header>
<div class="grid gap-6 lg:grid-cols-2">
<%!-- Settings Form --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-cog-6-tooth" class="size-5" />
{gettext("Global Settings")}
</h2>
<form class="space-y-6">
<%!-- Default Contribution Type --%>
<fieldset class="fieldset">
<label class="label">
<span class="label-text font-semibold">
{gettext("Default Contribution Type")}
</span>
</label>
<select class="select select-bordered w-full" disabled>
<option :for={ct <- @contribution_types} selected={ct.id == @selected_type_id}>
{ct.name} ({format_currency(ct.amount)}, {format_interval(ct.interval)})
</option>
</select>
<p class="text-sm text-base-content/60 mt-2">
{gettext(
"This contribution type is automatically assigned to all new members. Can be changed individually per member."
)}
</p>
</fieldset>
<%!-- Include Joining Period --%>
<fieldset class="fieldset">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={@include_joining_period}
disabled
/>
<span class="label-text font-semibold">
{gettext("Include joining period")}
</span>
</label>
<div class="ml-9 space-y-2">
<p class="text-sm text-base-content/60">
{gettext("When active: Members pay from the period of their joining.")}
</p>
<p class="text-sm text-base-content/60">
{gettext("When inactive: Members pay from the next full period after joining.")}
</p>
</div>
</fieldset>
<div class="divider"></div>
<button type="button" class="btn btn-primary w-full" disabled>
<.icon name="hero-check" class="size-5" />
{gettext("Save Settings")}
</button>
</form>
</div>
</div>
<%!-- Examples Card --%>
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-light-bulb" class="size-5" />
{gettext("Examples")}
</h2>
<.example_section
title={gettext("Yearly Interval - Joining Period 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")}
/>
<div class="divider"></div>
<.example_section
title={gettext("Yearly Interval - Joining Period 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")}
/>
<div class="divider"></div>
<.example_section
title={gettext("Quarterly Interval - Joining Period 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")}
/>
<div class="divider"></div>
<.example_section
title={gettext("Monthly Interval - Joining Period 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")}
/>
</div>
</div>
</div>
<.example_member_card />
</Layouts.app>
"""
end
# Example member card with link to period view
defp example_member_card(assigns) do
~H"""
<div class="card bg-base-100 shadow-xl mt-6">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-user" class="size-5" />
{gettext("Example: Member Contribution View")}
</h2>
<p class="text-base-content/70">
{gettext(
"See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
)}
</p>
<div class="card-actions justify-end">
<.link navigate={~p"/contributions/member/example"} class="btn btn-primary btn-sm">
<.icon name="hero-eye" class="size-4" />
{gettext("View Example Member")}
</.link>
</div>
</div>
</div>
"""
end
# Mock-up warning banner component - subtle orange style
defp mockup_warning(assigns) do
~H"""
<div class="border border-warning text-warning bg-base-100 rounded-lg px-4 py-3 mb-6 flex items-center gap-3">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<div>
<span class="font-semibold">{gettext("Preview Mockup")}</span>
<span class="text-sm text-base-content/70 ml-2">
{gettext("This page is not functional and only displays the planned features.")}
</span>
</div>
</div>
"""
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"""
<div class="space-y-2">
<h3 class="font-semibold text-sm">{@title}</h3>
<div class="bg-base-300 rounded-lg p-3 text-sm space-y-1">
<p>
<span class="text-base-content/60">{gettext("Joining date")}:</span>
<span class="font-mono">{@joining_date}</span>
</p>
<p>
<span class="text-base-content/60">{gettext("Contribution start")}:</span>
<span class="font-mono font-semibold text-primary">{@start_date}</span>
</p>
<p>
<span class="text-base-content/60">{gettext("Generated periods")}:</span>
<span class="font-mono">
{Enum.join(@periods, ", ")}
</span>
</p>
</div>
<p class="text-xs text-base-content/60 italic"> {@note}</p>
</div>
"""
end
# Mock data for demonstration
defp mock_contribution_types do
[
%{
id: "1",
name: gettext("Regular"),
amount: Decimal.new("60.00"),
interval: :yearly
},
%{
id: "2",
name: gettext("Reduced"),
amount: Decimal.new("30.00"),
interval: :yearly
},
%{
id: "3",
name: gettext("Student"),
amount: Decimal.new("5.00"),
interval: :monthly
},
%{
id: "4",
name: gettext("Family"),
amount: Decimal.new("25.00"),
interval: :quarterly
}
]
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")
end

View file

@ -6,7 +6,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
- Create new custom field definitions - Create new custom field definitions
- Edit existing custom fields - Edit existing custom fields
- Select value type from supported types - Select value type from supported types
- Set immutable and required flags - Set required flag
- Real-time validation - Real-time validation
## Props ## Props
@ -50,10 +50,10 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
label={gettext("Value type")} label={gettext("Value type")}
options={ options={
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of] Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
|> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end)
} }
/> />
<.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:description]} type="text" label={gettext("Description")} />
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input <.input
field={@form[:show_in_overview]} field={@form[:show_in_overview]}
@ -66,7 +66,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
{gettext("Cancel")} {gettext("Cancel")}
</.button> </.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom field")} {gettext("Save Custom Field")}
</.button> </.button>
</div> </div>
</.form> </.form>

View file

@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
## Features ## Features
- List all custom fields - List all custom fields
- Display type information (name, value type, description) - Display type information (name, value type, description)
- Show immutable and required flags - Show required flag
- Create new custom fields - Create new custom fields
- Edit existing custom fields - Edit existing custom fields
- Delete custom fields with confirmation (cascades to all custom field values) - Delete custom fields with confirmation (cascades to all custom field values)
@ -30,7 +30,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
phx-click="new_custom_field" phx-click="new_custom_field"
phx-target={@myself} phx-target={@myself}
> >
<.icon name="hero-plus" /> {gettext("New Custom field")} <.icon name="hero-plus" /> {gettext("New Custom Field")}
</.button> </.button>
</div> </div>
</div> </div>

View file

@ -72,7 +72,7 @@ defmodule MvWeb.CustomFieldValueLive.Form do
<% end %> <% end %>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom field value")} {gettext("Save Custom Field Value")}
</.button> </.button>
<.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")}</.button> <.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")}</.button>
</.form> </.form>

View file

@ -37,7 +37,7 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
<.header> <.header>
{gettext("Settings")} {gettext("Settings")}
<:subtitle> <:subtitle>
@ -80,10 +80,13 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true @impl true
def handle_event("save", %{"setting" => setting_params}, socket) do def handle_event("save", %{"setting" => setting_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) 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 =
socket socket
|> assign(:settings, updated_settings) |> assign(:settings, fresh_settings)
|> put_flash(:info, gettext("Settings updated successfully")) |> put_flash(:info, gettext("Settings updated successfully"))
|> assign_form() |> assign_form()

View file

@ -145,7 +145,10 @@ defmodule MvWeb.MemberLive.Index do
MapSet.put(socket.assigns.selected_members, id) MapSet.put(socket.assigns.selected_members, id)
end end
{:noreply, assign(socket, :selected_members, selected)} {:noreply,
socket
|> assign(:selected_members, selected)
|> update_selection_assigns()}
end end
@impl true @impl true
@ -159,7 +162,10 @@ defmodule MvWeb.MemberLive.Index do
all_ids all_ids
end end
{:noreply, assign(socket, :selected_members, selected)} {:noreply,
socket
|> assign(:selected_members, selected)
|> update_selection_assigns()}
end end
@impl true @impl true
@ -238,6 +244,7 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> assign(:query, q) |> assign(:query, q)
|> load_members() |> load_members()
|> update_selection_assigns()
existing_field_query = socket.assigns.sort_field existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order existing_sort_query = socket.assigns.sort_order
@ -263,6 +270,7 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> assign(:paid_filter, filter) |> assign(:paid_filter, filter)
|> load_members() |> load_members()
|> update_selection_assigns()
# Build the URL with all params including new filter # Build the URL with all params including new filter
query_params = query_params =
@ -309,6 +317,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members() |> load_members()
|> prepare_dynamic_cols() |> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url() |> push_field_selection_url()
{:noreply, socket} {:noreply, socket}
@ -338,6 +347,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members() |> load_members()
|> prepare_dynamic_cols() |> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url() |> push_field_selection_url()
{:noreply, socket} {:noreply, socket}
@ -389,6 +399,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members() |> load_members()
|> prepare_dynamic_cols() |> prepare_dynamic_cols()
|> update_selection_assigns()
{:noreply, socket} {:noreply, socket}
end end
@ -1112,4 +1123,34 @@ defmodule MvWeb.MemberLive.Index do
# Public helper function to format dates for use in templates # Public helper function to format dates for use in templates
def format_date(date), do: DateFormatter.format_date(date) 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 end

View file

@ -3,23 +3,21 @@
{gettext("Members")} {gettext("Members")}
<:actions> <:actions>
<.button <.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))} class="secondary"
id="copy-emails-btn" id="copy-emails-btn"
phx-hook="CopyToClipboard" phx-hook="CopyToClipboard"
phx-click="copy_emails" phx-click="copy_emails"
disabled={not @any_selected?}
aria-label={gettext("Copy email addresses of selected members")} aria-label={gettext("Copy email addresses of selected members")}
> >
<.icon name="hero-clipboard-document" /> <.icon name="hero-clipboard-document" />
{gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))}) {gettext("Copy email addresses")} ({@selected_count})
</.button> </.button>
<.button <.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))} class="secondary"
href={ id="open-email-btn"
"mailto:?bcc=" <> href={"mailto:?bcc=" <> @mailto_bcc}
(MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members) disabled={not @any_selected?}
|> Enum.join(", ")
|> URI.encode())
}
aria-label={gettext("Open email program with BCC recipients")} aria-label={gettext("Open email program with BCC recipients")}
> >
<.icon name="hero-envelope" /> <.icon name="hero-envelope" />

View file

@ -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"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Membership Fee Settings")}
<:subtitle>
{gettext("Configure global settings for membership fees.")}
</:subtitle>
</.header>
<div class="grid gap-6 lg:grid-cols-2">
<%!-- Settings Form --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-cog-6-tooth" class="size-5" />
{gettext("Global Settings")}
</h2>
<.form
for={@form}
phx-change="validate"
phx-submit="save"
class="space-y-6"
>
<%!-- Default Membership Fee Type --%>
<fieldset class="fieldset">
<label for="default_membership_fee_type_id" class="label">
<span class="label-text font-semibold">
{gettext("Default Membership Fee Type")}
</span>
</label>
<select
id="default_membership_fee_type_id"
name="settings[default_membership_fee_type_id]"
class={[
"select select-bordered w-full",
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
]}
phx-debounce="blur"
aria-label={gettext("Default Membership Fee Type")}
>
<option value="">{gettext("None (no default)")}</option>
<option
:for={fee_type <- @membership_fee_types}
value={fee_type.id}
selected={fee_type.id == @form[:default_membership_fee_type_id].value}
>
{fee_type.name} ({format_currency(fee_type.amount)}, {format_interval(
fee_type.interval
)})
</option>
</select>
<%= for {msg, _opts} <- @form.errors[:default_membership_fee_type_id] || [] do %>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
{gettext(
"This membership fee type is automatically assigned to all new members. Can be changed individually per member."
)}
</p>
</fieldset>
<%!-- Include Joining Cycle --%>
<fieldset class="fieldset">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="settings[include_joining_cycle]"
class="checkbox checkbox-primary"
checked={@form[:include_joining_cycle].value}
phx-debounce="blur"
/>
<span class="label-text font-semibold">
{gettext("Include joining cycle")}
</span>
</label>
<%= for {msg, _opts} <- @form.errors[:include_joining_cycle] || [] do %>
<p class="text-error text-sm ml-9 mt-1">{msg}</p>
<% end %>
<div class="ml-9 space-y-2">
<p class="text-sm text-base-content/60">
{gettext("When active: Members pay from the cycle of their joining.")}
</p>
<p class="text-sm text-base-content/60">
{gettext("When inactive: Members pay from the next full cycle after joining.")}
</p>
</div>
</fieldset>
<div class="divider"></div>
<button type="submit" class="btn btn-primary w-full">
<.icon name="hero-check" class="size-5" />
{gettext("Save Settings")}
</button>
</.form>
</div>
</div>
<%!-- Examples Card --%>
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-light-bulb" class="size-5" />
{gettext("Examples")}
</h2>
<.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")}
/>
<div class="divider"></div>
<.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")}
/>
<div class="divider"></div>
<.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")}
/>
<div class="divider"></div>
<.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")}
/>
</div>
</div>
</div>
</Layouts.app>
"""
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"""
<div class="space-y-2">
<h3 class="font-semibold text-sm">{@title}</h3>
<div class="bg-base-300 rounded-lg p-3 text-sm space-y-1">
<p>
<span class="text-base-content/80">{gettext("Joining date")}:</span>
<span class="font-mono">{@joining_date}</span>
</p>
<p>
<span class="text-base-content/80">{gettext("Membership fee start")}:</span>
<span class="font-mono font-semibold text-base-content">{@start_date}</span>
</p>
<p>
<span class="text-base-content/80">{gettext("Generated cycles")}:</span>
<span class="font-mono">
{Enum.join(@periods, ", ")}
</span>
</p>
</div>
<p class="text-xs text-base-content/80 italic"> {@note}</p>
</div>
"""
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

View file

@ -69,9 +69,11 @@ defmodule MvWeb.Router do
live "/settings", GlobalSettingsLive live "/settings", GlobalSettingsLive
# Membership Fee Settings
live "/membership_fee_settings", MembershipFeeSettingsLive
# Contribution Management (Mock-ups) # Contribution Management (Mock-ups)
live "/contribution_types", ContributionTypeLive.Index, :index live "/contribution_types", ContributionTypeLive.Index, :index
live "/contribution_settings", ContributionSettingsLive
live "/contributions/member/:id", ContributionPeriodLive.Show, :show live "/contributions/member/:id", ContributionPeriodLive.Show, :show
post "/set_locale", LocaleController, :set_locale post "/set_locale", LocaleController, :set_locale

View file

@ -26,7 +26,7 @@
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "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"}, "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"}, "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"}, "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"}, "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"}, "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
@ -39,7 +39,7 @@
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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_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"}, "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"}, "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"}, "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"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},

View file

@ -282,11 +282,6 @@ msgstr "Benutzer*in bearbeiten"
msgid "Enabled" msgid "Enabled"
msgstr "Aktiviert" 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 #: lib/mv_web/components/layouts/navbar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Logout" msgid "Logout"
@ -612,16 +607,6 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
msgid "Please select a custom field first" msgid "Please select a custom field first"
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" 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/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -687,8 +672,8 @@ msgstr "Vereinsdaten"
msgid "Manage global settings for the association." msgid "Manage global settings for the association."
msgstr "Passe übergreifende Einstellungen für den Verein an." 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/global_settings_live.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Save Settings" msgid "Save Settings"
msgstr "Einstellungen speichern" msgstr "Einstellungen speichern"
@ -760,11 +745,6 @@ msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert"
msgid "Copy email addresses of selected members" msgid "Copy email addresses of selected members"
msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren" 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 #: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No email addresses found" msgid "No email addresses found"
@ -796,7 +776,6 @@ msgid "This field cannot be empty"
msgstr "Dieses Feld darf nicht leer bleiben" msgstr "Dieses Feld darf nicht leer bleiben"
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "All" msgid "All"
msgstr "Alle" msgstr "Alle"
@ -951,17 +930,6 @@ msgstr "Löschen nicht möglich es sind Mitglieder zugewiesen"
msgid "Change Contribution Type" msgid "Change Contribution Type"
msgstr "Beitragsart ändern" 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Contribution Start" msgid "Contribution Start"
@ -973,11 +941,6 @@ msgstr "Beitragsbeginn"
msgid "Contribution Types" msgid "Contribution Types"
msgstr "Beitragsarten" 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Contribution type" msgid "Contribution type"
@ -1003,27 +966,16 @@ msgstr "Beiträge für %{name}"
msgid "Current" msgid "Current"
msgstr "Aktuell" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Deletion" msgid "Deletion"
msgstr "Löschen" msgstr "Löschen"
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_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
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Examples" msgid "Examples"
msgstr "Beispiele" msgstr "Beispiele"
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Family" msgid "Family"
@ -1034,19 +986,14 @@ msgstr "Familie"
msgid "Fixed after creation. Members can only switch between types with the same interval." 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." msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln."
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Generated periods"
msgstr "Generierte Zyklen"
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Global Settings" msgid "Global Settings"
msgstr "Vereinsdaten" msgstr "Vereinsdaten"
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Half-yearly" msgid "Half-yearly"
msgstr "Halbjährlich" msgstr "Halbjährlich"
@ -1062,18 +1009,13 @@ msgstr "Halbjährlicher Beitrag für Fördermitglieder"
msgid "Honorary" msgid "Honorary"
msgstr "Ehrenamtlich" 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_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Interval" msgid "Interval"
msgstr "Zyklus" msgstr "Zyklus"
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Joining date" msgid "Joining date"
msgstr "Beitrittsdatum" msgstr "Beitrittsdatum"
@ -1108,22 +1050,22 @@ msgstr "Als unbezahlt markieren"
msgid "Member Contributions" msgid "Member Contributions"
msgstr "Mitgliedsbeiträge" msgstr "Mitgliedsbeiträge"
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays for the year they joined" msgid "Member pays for the year they joined"
msgstr "Mitglied zahlt für das Beitrittsjahr" 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 #, elixir-autogen, elixir-format
msgid "Member pays from the joining month" msgid "Member pays from the joining month"
msgstr "Mitglied zahlt ab Beitrittsmonat" 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 #, elixir-autogen, elixir-format
msgid "Member pays from the next full quarter" msgid "Member pays from the next full quarter"
msgstr "Mitglied zahlt ab dem nächsten vollständigen Quartal" 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 #, elixir-autogen, elixir-format
msgid "Member pays from the next full year" msgid "Member pays from the next full year"
msgstr "Mitglied zahlt ab dem nächsten vollständigen Jahr" 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." 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_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/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Monthly" msgid "Monthly"
msgstr "Monatlich" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Monthly fee for students and trainees" msgid "Monthly fee for students and trainees"
@ -1186,31 +1123,24 @@ msgid "Paid via bank transfer"
msgstr "Bezahlt durch Überweisung" msgstr "Bezahlt durch Überweisung"
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Preview Mockup" msgid "Preview Mockup"
msgstr "Vorschau" msgstr "Vorschau"
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Quarterly" msgid "Quarterly"
msgstr "Vierteljährlich" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Quarterly fee for family memberships" msgid "Quarterly fee for family memberships"
msgstr "Vierteljährlicher Beitrag für Familienmitgliedschaften" msgstr "Vierteljährlicher Beitrag für Familienmitgliedschaften"
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reduced" 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" 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_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/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Regular" msgid "Regular"
@ -1233,11 +1162,6 @@ msgstr "Regulär"
msgid "Reopen" msgid "Reopen"
msgstr "Wieder öffnen" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Standard membership fee for regular members" msgid "Standard membership fee for regular members"
@ -1248,7 +1172,6 @@ msgstr "Regulärer Mitgliedsbeitrag für Vollmitglieder"
msgid "Status" msgid "Status"
msgstr "Status" msgstr "Status"
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Student" msgid "Student"
@ -1269,13 +1192,7 @@ msgstr "Pausieren"
msgid "Suspended" msgid "Suspended"
msgstr "Pausiert" 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_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/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This page is not functional and only displays the planned features." msgid "This page is not functional and only displays the planned features."
@ -1296,43 +1213,18 @@ msgstr "Gesamtbeiträge"
msgid "Unpaid" msgid "Unpaid"
msgstr "Unbezahlt" 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Why are not all contribution types shown?" msgid "Why are not all contribution types shown?"
msgstr "Warum werden nicht alle Beitragsarten angezeigt?" msgstr "Warum werden nicht alle Beitragsarten angezeigt?"
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Yearly" msgid "Yearly"
msgstr "jährlich" 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 #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Columns" msgid "Columns"
@ -1389,14 +1281,10 @@ msgid "Failed to delete custom field: %{error}"
msgstr "Konnte Feld nicht löschen: %{error}" msgstr "Konnte Feld nicht löschen: %{error}"
#: lib/mv_web/live/custom_field_live/form_component.ex #: 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 #: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "New Custom field" msgid "New Custom Field"
msgstr "Benutzerdefiniertes Feld speichern" msgstr "Neues Benutzerdefiniertes Feld"
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1438,16 +1326,127 @@ msgstr "Textfeld"
msgid "Yes/No-Selection" msgid "Yes/No-Selection"
msgstr "Ja/Nein-Auswahl" 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 #~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)" #~ msgid "Auto-generated identifier (immutable)"
#~ msgstr "Automatisch generierter Bezeichner (unveränderlich)" #~ msgstr "Automatisch generierter Bezeichner (unveränderlich)"
#~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Birth Date" #~ msgid "Configure global settings for membership contributions."
#~ msgstr "Geburtsdatum" #~ 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/form.ex
#~ #: lib/mv_web/live/member_live/show.ex #~ #: lib/mv_web/live/member_live/show.ex
@ -1455,21 +1454,45 @@ msgstr "Ja/Nein-Auswahl"
#~ msgid "Custom Field Values" #~ msgid "Custom Field Values"
#~ msgstr "Benutzerdefinierte Feldwerte" #~ msgstr "Benutzerdefinierte Feldwerte"
#~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Fields marked with an asterisk (*) cannot be empty." #~ msgid "Default Contribution Type"
#~ msgstr "Felder, die mit einem Sternchen (*) markiert sind, dürfen nicht leer bleiben." #~ msgstr "Standard-Beitragsart"
#~ #: lib/mv_web/live/custom_field_live/form.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "ID" #~ msgid "Example: Member Contribution View"
#~ msgstr "ID" #~ 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 #~ #, elixir-autogen, elixir-format
#~ msgid "Id" #~ msgid "Failed to save settings. Please check the errors below."
#~ msgstr "ID" #~ 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/form.ex
#~ #: lib/mv_web/live/user_live/show.ex #~ #: lib/mv_web/live/user_live/show.ex
@ -1477,23 +1500,42 @@ msgstr "Ja/Nein-Auswahl"
#~ msgid "Not set" #~ msgid "Not set"
#~ msgstr "Nicht gesetzt" #~ msgstr "Nicht gesetzt"
#~ #: lib/mv_web/live/user_live/index.html.heex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "OIDC ID" #~ msgid "Quarterly Interval - Joining Period Excluded"
#~ msgstr "OIDC ID" #~ msgstr "Vierteljährliches Intervall Beitrittszeitraum nicht einbezogen"
#~ #: lib/mv_web/live/custom_field_live/index_component.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Show in Overview"
#~ msgstr "In der Mitglieder-Übersicht anzeigen"
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, 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 "Dies ist ein Mitglied aus deiner Datenbank." #~ 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 #~ #, elixir-autogen, elixir-format
#~ msgid "Use this form to manage custom_field records in your database." #~ msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
#~ msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." #~ 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"

View file

@ -283,11 +283,6 @@ msgstr ""
msgid "Enabled" msgid "Enabled"
msgstr "" 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 #: lib/mv_web/components/layouts/navbar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Logout" msgid "Logout"
@ -613,16 +608,6 @@ msgstr ""
msgid "Please select a custom field first" msgid "Please select a custom field first"
msgstr "" 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/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -688,8 +673,8 @@ msgstr ""
msgid "Manage global settings for the association." msgid "Manage global settings for the association."
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
@ -761,11 +746,6 @@ msgstr[1] ""
msgid "Copy email addresses of selected members" msgid "Copy email addresses of selected members"
msgstr "" 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 #: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No email addresses found" msgid "No email addresses found"
@ -797,7 +777,6 @@ msgid "This field cannot be empty"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "All" msgid "All"
msgstr "" msgstr ""
@ -952,17 +931,6 @@ msgstr ""
msgid "Change Contribution Type" msgid "Change Contribution Type"
msgstr "" 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Contribution Start" msgid "Contribution Start"
@ -974,11 +942,6 @@ msgstr ""
msgid "Contribution Types" msgid "Contribution Types"
msgstr "" 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Contribution type" msgid "Contribution type"
@ -1004,27 +967,16 @@ msgstr ""
msgid "Current" msgid "Current"
msgstr "" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Deletion" msgid "Deletion"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Example: Member Contribution View"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Examples" msgid "Examples"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Family" msgid "Family"
@ -1035,19 +987,14 @@ msgstr ""
msgid "Fixed after creation. Members can only switch between types with the same interval." msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Generated periods"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Global Settings" msgid "Global Settings"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Half-yearly" msgid "Half-yearly"
msgstr "" msgstr ""
@ -1063,18 +1010,13 @@ msgstr ""
msgid "Honorary" msgid "Honorary"
msgstr "" 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_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Interval" msgid "Interval"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Joining date" msgid "Joining date"
msgstr "" msgstr ""
@ -1109,22 +1051,22 @@ msgstr ""
msgid "Member Contributions" msgid "Member Contributions"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays for the year they joined" msgid "Member pays for the year they joined"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays from the joining month" msgid "Member pays from the joining month"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays from the next full quarter" msgid "Member pays from the next full quarter"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays from the next full year" msgid "Member pays from the next full year"
msgstr "" msgstr ""
@ -1140,17 +1082,12 @@ msgid "Members can only switch between contribution types with the same payment
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Monthly" msgid "Monthly"
msgstr "" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Monthly fee for students and trainees" msgid "Monthly fee for students and trainees"
@ -1187,31 +1124,24 @@ msgid "Paid via bank transfer"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Preview Mockup" msgid "Preview Mockup"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Quarterly" msgid "Quarterly"
msgstr "" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Quarterly fee for family memberships" msgid "Quarterly fee for family memberships"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reduced" msgid "Reduced"
@ -1223,7 +1153,6 @@ msgid "Reduced fee for unemployed, pensioners, or low income"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Regular" msgid "Regular"
@ -1234,11 +1163,6 @@ msgstr ""
msgid "Reopen" msgid "Reopen"
msgstr "" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Standard membership fee for regular members" msgid "Standard membership fee for regular members"
@ -1249,7 +1173,6 @@ msgstr ""
msgid "Status" msgid "Status"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Student" msgid "Student"
@ -1270,13 +1193,7 @@ msgstr ""
msgid "Suspended" msgid "Suspended"
msgstr "" 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_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/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This page is not functional and only displays the planned features." msgid "This page is not functional and only displays the planned features."
@ -1297,43 +1214,18 @@ msgstr ""
msgid "Unpaid" msgid "Unpaid"
msgstr "" 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Why are not all contribution types shown?" msgid "Why are not all contribution types shown?"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yearly" msgid "Yearly"
msgstr "" 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 #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Columns" msgid "Columns"
@ -1390,13 +1282,9 @@ msgid "Failed to delete custom field: %{error}"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: 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 #: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "New Custom field" msgid "New Custom Field"
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
@ -1438,3 +1326,99 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yes/No-Selection" msgid "Yes/No-Selection"
msgstr "" 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 ""

View file

@ -283,11 +283,6 @@ msgstr ""
msgid "Enabled" msgid "Enabled"
msgstr "" 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 #: lib/mv_web/components/layouts/navbar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Logout" msgid "Logout"
@ -613,16 +608,6 @@ msgstr ""
msgid "Please select a custom field first" msgid "Please select a custom field first"
msgstr "" 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/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -688,8 +673,8 @@ msgstr ""
msgid "Manage global settings for the association." msgid "Manage global settings for the association."
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/global_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 #, elixir-autogen, elixir-format, fuzzy
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
@ -761,11 +746,6 @@ msgstr[1] ""
msgid "Copy email addresses of selected members" msgid "Copy email addresses of selected members"
msgstr "" 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 #: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No email addresses found" msgid "No email addresses found"
@ -797,7 +777,6 @@ msgid "This field cannot be empty"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "All" msgid "All"
msgstr "" msgstr ""
@ -952,17 +931,6 @@ msgstr ""
msgid "Change Contribution Type" msgid "Change Contribution Type"
msgstr "" 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Contribution Start" msgid "Contribution Start"
@ -974,11 +942,6 @@ msgstr ""
msgid "Contribution Types" msgid "Contribution Types"
msgstr "" 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Contribution type" msgid "Contribution type"
@ -1004,27 +967,16 @@ msgstr ""
msgid "Current" msgid "Current"
msgstr "" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Deletion" msgid "Deletion"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Example: Member Contribution View"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Examples" msgid "Examples"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Family" msgid "Family"
@ -1035,19 +987,14 @@ msgstr ""
msgid "Fixed after creation. Members can only switch between types with the same interval." msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Generated periods"
msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Global Settings" msgid "Global Settings"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Half-yearly" msgid "Half-yearly"
msgstr "" msgstr ""
@ -1063,18 +1010,13 @@ msgstr ""
msgid "Honorary" msgid "Honorary"
msgstr "" 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_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Interval" msgid "Interval"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Joining date" msgid "Joining date"
msgstr "" msgstr ""
@ -1109,22 +1051,22 @@ msgstr ""
msgid "Member Contributions" msgid "Member Contributions"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays for the year they joined" msgid "Member pays for the year they joined"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays from the joining month" msgid "Member pays from the joining month"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays from the next full quarter" msgid "Member pays from the next full quarter"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays from the next full year" msgid "Member pays from the next full year"
msgstr "" msgstr ""
@ -1140,17 +1082,12 @@ msgid "Members can only switch between contribution types with the same payment
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Monthly" msgid "Monthly"
msgstr "" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Monthly fee for students and trainees" msgid "Monthly fee for students and trainees"
@ -1187,31 +1124,24 @@ msgid "Paid via bank transfer"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Preview Mockup" msgid "Preview Mockup"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Quarterly" msgid "Quarterly"
msgstr "" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Quarterly fee for family memberships" msgid "Quarterly fee for family memberships"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reduced" msgid "Reduced"
@ -1223,7 +1153,6 @@ msgid "Reduced fee for unemployed, pensioners, or low income"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Regular" msgid "Regular"
@ -1234,11 +1163,6 @@ msgstr ""
msgid "Reopen" msgid "Reopen"
msgstr "" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Standard membership fee for regular members" msgid "Standard membership fee for regular members"
@ -1249,7 +1173,6 @@ msgstr ""
msgid "Status" msgid "Status"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Student" msgid "Student"
@ -1270,13 +1193,7 @@ msgstr ""
msgid "Suspended" msgid "Suspended"
msgstr "" 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_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/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This page is not functional and only displays the planned features." msgid "This page is not functional and only displays the planned features."
@ -1297,43 +1214,18 @@ msgstr ""
msgid "Unpaid" msgid "Unpaid"
msgstr "" 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Why are not all contribution types shown?" msgid "Why are not all contribution types shown?"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: 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/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yearly" msgid "Yearly"
msgstr "" 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 #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Columns" msgid "Columns"
@ -1390,13 +1282,9 @@ msgid "Failed to delete custom field: %{error}"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: 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 #: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "New Custom field" msgid "New Custom Field"
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
@ -1439,15 +1327,126 @@ msgstr ""
msgid "Yes/No-Selection" msgid "Yes/No-Selection"
msgstr "" 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 #~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)" #~ msgid "Auto-generated identifier (immutable)"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, 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 "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/member_live/form.ex
@ -1456,19 +1455,44 @@ msgstr ""
#~ msgid "Custom Field Values" #~ msgid "Custom Field Values"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Fields marked with an asterisk (*) cannot be empty." #~ msgid "Default Contribution Type"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/user_live/show.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "ID" #~ msgid "Example: Member Contribution View"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/show.ex #~ #: lib/mv_web/live/membership_fee_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, 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 "" #~ msgstr ""
#~ #: lib/mv_web/live/user_live/show.ex #~ #: lib/mv_web/live/user_live/show.ex
@ -1476,23 +1500,42 @@ msgstr ""
#~ msgid "Not set" #~ msgid "Not set"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/user_live/index.html.heex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "OIDC ID" #~ msgid "Quarterly Interval - Joining Period Excluded"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/custom_field_live/index_component.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Show in Overview"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, 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 "" #~ msgstr ""
#~ #: lib/mv_web/live/custom_field_live/form.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format
#~ msgid "Use this form to manage custom_field records in your database." #~ 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 "" #~ msgstr ""

View file

@ -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

View file

@ -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

View file

@ -5,6 +5,39 @@
alias Mv.Membership alias Mv.Membership
alias Mv.Accounts 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 <- [ for attrs <- [
# Basic example fields (for testing) # Basic example fields (for testing)
@ -12,28 +45,24 @@ for attrs <- [
name: "String Field", name: "String Field",
value_type: :string, value_type: :string,
description: "Example for a field of type string", description: "Example for a field of type string",
immutable: true,
required: false required: false
}, },
%{ %{
name: "Date Field", name: "Date Field",
value_type: :date, value_type: :date,
description: "Example for a field of type date", description: "Example for a field of type date",
immutable: true,
required: false required: false
}, },
%{ %{
name: "Boolean Field", name: "Boolean Field",
value_type: :boolean, value_type: :boolean,
description: "Example for a field of type boolean", description: "Example for a field of type boolean",
immutable: true,
required: false required: false
}, },
%{ %{
name: "Email Field", name: "Email Field",
value_type: :email, value_type: :email,
description: "Example for a field of type email", description: "Example for a field of type email",
immutable: true,
required: false required: false
}, },
# Realistic custom fields # Realistic custom fields
@ -41,56 +70,48 @@ for attrs <- [
name: "Membership Number", name: "Membership Number",
value_type: :string, value_type: :string,
description: "Unique membership identification number", description: "Unique membership identification number",
immutable: false,
required: false required: false
}, },
%{ %{
name: "Emergency Contact", name: "Emergency Contact",
value_type: :string, value_type: :string,
description: "Emergency contact person name and phone", description: "Emergency contact person name and phone",
immutable: false,
required: false required: false
}, },
%{ %{
name: "T-Shirt Size", name: "T-Shirt Size",
value_type: :string, value_type: :string,
description: "T-Shirt size for events (XS, S, M, L, XL, XXL)", description: "T-Shirt size for events (XS, S, M, L, XL, XXL)",
immutable: false,
required: false required: false
}, },
%{ %{
name: "Newsletter Subscription", name: "Newsletter Subscription",
value_type: :boolean, value_type: :boolean,
description: "Whether member wants to receive newsletter", description: "Whether member wants to receive newsletter",
immutable: false,
required: false required: false
}, },
%{ %{
name: "Date of Last Medical Check", name: "Date of Last Medical Check",
value_type: :date, value_type: :date,
description: "Date of last medical examination", description: "Date of last medical examination",
immutable: false,
required: false required: false
}, },
%{ %{
name: "Secondary Email", name: "Secondary Email",
value_type: :email, value_type: :email,
description: "Alternative email address", description: "Alternative email address",
immutable: false,
required: false required: false
}, },
%{ %{
name: "Membership Type", name: "Membership Type",
value_type: :string, value_type: :string,
description: "Type of membership (e.g., Regular, Student, Senior)", description: "Type of membership (e.g., Regular, Student, Senior)",
immutable: false,
required: false required: false
}, },
%{ %{
name: "Parking Permit", name: "Parking Permit",
value_type: :boolean, value_type: :boolean,
description: "Whether member has parking permit", description: "Whether member has parking permit",
immutable: false,
required: false required: false
} }
] do ] do
@ -332,6 +353,7 @@ end
IO.puts("✅ Seeds completed successfully!") IO.puts("✅ Seeds completed successfully!")
IO.puts("📝 Created sample data:") IO.puts("📝 Created sample data:")
IO.puts(" - Global settings: club_name = #{default_club_name}") 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(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)")
IO.puts(" - Admin user: admin@mv.local (password: testpassword)") IO.puts(" - Admin user: admin@mv.local (password: testpassword)")
IO.puts(" - Sample members: Hans, Greta, Friedrich") IO.puts(" - Sample members: Hans, Greta, Friedrich")

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -155,6 +155,95 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
assert {:ok, updated} = Ash.update(fee_type, %{description: nil}) assert {:ok, updated} = Ash.update(fee_type, %{description: nil})
assert updated.description == nil assert updated.description == nil
end 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 end
# Helper to check if an error occurred on a specific field # Helper to check if an error occurred on a specific field
@ -169,4 +258,15 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
end end
defp error_on_field?(_, _), do: false 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 end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -52,14 +52,11 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
field: field field: field
} do } do
conn = conn_with_oidc_user(conn) 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 # Check that the sort button has aria-label and data-testid
assert html =~ ~r/aria-label=["']Click to sort["']/i or test_id = "custom_field_#{field.id}"
html =~ ~r/aria-label=["'].*sort.*["']/i assert has_element?(view, "[data-testid='#{test_id}'][aria-label='Click to sort']")
# Check that data-testid is present for testing
assert html =~ ~r/data-testid=["']custom_field_#{field.id}["']/
end end
test "sort header component shows correct ARIA label when sorted ascending", %{ test "sort header component shows correct ARIA label when sorted ascending", %{
@ -71,10 +68,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
{:ok, view, _html} = {:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
html = render(view) # Check that aria-label indicates ascending sort using data-testid
test_id = "custom_field_#{field.id}"
# Check that aria-label indicates ascending sort assert has_element?(view, "[data-testid='#{test_id}'][aria-label='ascending']")
assert html =~ ~r/aria-label=["'].*ascending.*["']/i
end end
test "sort header component shows correct ARIA label when sorted descending", %{ test "sort header component shows correct ARIA label when sorted descending", %{
@ -86,21 +82,21 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
{:ok, view, _html} = {:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
html = render(view) # Check that aria-label indicates descending sort using data-testid
test_id = "custom_field_#{field.id}"
# Check that aria-label indicates descending sort assert has_element?(view, "[data-testid='#{test_id}'][aria-label='descending']")
assert html =~ ~r/aria-label=["'].*descending.*["']/i
end end
test "custom field column header is keyboard accessible", %{conn: conn, field: field} do test "custom field column header is keyboard accessible", %{conn: conn, field: field} do
conn = conn_with_oidc_user(conn) 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) # Check that the sort button is a button element (keyboard accessible)
assert html =~ ~r/<button[^>]*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) # 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 end
test "custom field column header has proper semantic structure", %{conn: conn, field: field} do test "custom field column header has proper semantic structure", %{conn: conn, field: field} do

View file

@ -410,15 +410,17 @@ defmodule MvWeb.MemberLive.IndexTest do
assert render(view) =~ "1" assert render(view) =~ "1"
end 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) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
# Ensure no members are selected (default state) # Copy button should be disabled (button element)
refute has_element?(view, "#copy-emails-btn") 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 end
test "copy button is visible when members are selected", %{ test "copy button is enabled after selection", %{
conn: conn, conn: conn,
member1: member1 member1: member1
} do } do
@ -428,8 +430,13 @@ defmodule MvWeb.MemberLive.IndexTest do
# Select a member by sending the select_member event directly # Select a member by sending the select_member event directly
render_click(view, "select_member", %{"id" => member1.id}) render_click(view, "select_member", %{"id" => member1.id})
# Button should now be visible # Copy button should now be enabled (no disabled attribute)
assert has_element?(view, "#copy-emails-btn") 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 end
test "copy button click triggers event and shows flash", %{ test "copy button click triggers event and shows flash", %{