Implements settings for member fields closes #223 #300

Merged
carla merged 26 commits from feature/223_memberfields_settings into main 2026-01-12 13:24:54 +01:00
121 changed files with 20360 additions and 2522 deletions
Showing only changes of commit 909d4af2a2 - Show all commits

View file

@ -4,7 +4,7 @@ name: check
services: services:
- name: postgres - name: postgres
image: docker.io/library/postgres:17.6 image: docker.io/library/postgres:18.1
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
@ -57,7 +57,7 @@ steps:
- mix gettext.extract --check-up-to-date - mix gettext.extract --check-up-to-date
- name: wait_for_postgres - name: wait_for_postgres
image: docker.io/library/postgres:17.6 image: docker.io/library/postgres:18.1
commands: commands:
# Wait for postgres to become available # Wait for postgres to become available
- | - |
@ -166,7 +166,7 @@ environment:
steps: steps:
- name: renovate - name: renovate
image: renovate/renovate:41.173 image: renovate/renovate:42.71
environment: environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN: RENOVATE_TOKEN:

1
.gitignore vendored
View file

@ -44,3 +44,4 @@ npm-debug.log
# Docker secrets directory (generated by `just init-secrets`) # Docker secrets directory (generated by `just init-secrets`)
/secrets/ /secrets/
notes.md

View file

@ -1,3 +1,3 @@
elixir 1.18.3-otp-27 elixir 1.18.3-otp-27
erlang 27.3.4 erlang 27.3.4
just 1.43.1 just 1.46.0

View file

@ -0,0 +1,318 @@
---
name: Code Review Fixes - Membership Fee Features
overview: Umsetzung der validen Code Review Punkte aus beiden Reviews mit Priorisierung nach Kritikalität. Fokus auf Transaktionssicherheit, Code-Qualität, Performance und UX-Verbesserungen.
todos:
- id: fix-after-action-tasks
content: "after_action mit Task.start → after_transaction + Task.Supervisor: Task.Supervisor zu application.ex hinzufügen, after_action Hooks in after_transaction umwandeln, Task.Supervisor.async_nolink verwenden"
status: pending
- id: reduce-code-duplication
content: "Code-Duplikation reduzieren: handle_cycle_generation/2 private Funktion extrahieren, alle drei Stellen (Create, Type Change, Date Change) verwenden"
status: pending
dependencies:
- fix-after-action-tasks
- id: fix-join-date-validation
content: "join_date Validierung: Entweder Validierung wieder hinzufügen (validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0)) oder Dokumentation anpassen"
status: pending
- id: fix-load-cycles-docs
content: "load_cycles_for_members: Entweder Dokumentation korrigieren (ehrlich machen) oder echte Filterung implementieren (z.B. nur letzte 2 Intervalle)"
status: pending
- id: fix-get-current-cycle-sort
content: "get_current_cycle nondeterministisch: Vor List.first() nach cycle_start sortieren (desc) in MembershipFeeHelpers.get_current_cycle"
status: pending
- id: fix-n1-query-member-count
content: "N+1 Query beheben: Aggregate auf MembershipFeeType definieren oder member_count einmalig vorab laden und in assigns cachen"
status: pending
- id: fix-assign-new-stale
content: "assign_new → assign: In MembershipFeesComponent.update/2 immer assign(:cycles, cycles) und assign(:available_fee_types, available_fee_types) setzen"
status: pending
- id: fix-regenerating-flag
content: "@regenerating auf true setzen: Direkt beim Event-Start in handle_event(\"regenerate_cycles\", ...) socket |> assign(:regenerating, true) setzen"
status: pending
- id: fix-create-cycle-parsing
content: "Create-cycle parsing Fix: Decimal.parse explizit behandeln und {:error, :invalid_amount} zurückgeben statt :error"
status: pending
- id: fix-delete-all-atomic
content: "Delete all cycles atomar: Bulk Delete Query verwenden (Ash bulk destroy oder Query-basiert) statt Enum.map"
status: pending
- id: improve-async-error-handling
content: "Fehlerbehandlung bei async Tasks: Strukturierte Error-Logs mit Context, optional Retry-Mechanismus oder Event-System für Benachrichtigung"
status: pending
- id: improve-format-currency
content: "format_currency Robustheit: Number.Currency verwenden oder robusteres Pattern Matching + Tests für Edge Cases (negative Zahlen, sehr große Zahlen)"
status: pending
- id: add-missing-typespecs
content: "Fehlende Typespecs: @spec für SetDefaultMembershipFeeType.change/3 hinzufügen"
status: pending
- id: fix-race-condition
content: "Potenzielle Race Condition: Prüfen ob Ash doppelte Auslösung verhindert, ggf. Logik anpassen (beide Änderungen in einem Hook zusammenfassen)"
status: pending
- id: extract-magic-values
content: "Magic Numbers/Strings: Application.get_env(:mv, :sql_sandbox, false) in Konstante/Helper extrahieren (z.B. Mv.Config.sql_sandbox?/0)"
status: pending
- id: fix-domain-consistency
content: "Domain-Konsistenz: Überall in MembershipFeesComponent domain: MembershipFees explizit angeben"
status: pending
- id: fix-test-helper
content: "Test-Helper Fix: create_cycle/3 Helper - Cleanup nur einmal im Setup oder gezielt nur auto-generierte löschen"
status: pending
- id: fix-date-utc-today-param
content: "Date.utc_today() Parameter: today Parameter durchgeben in get_cycle_status_for_member und Helper-Funktionen"
status: pending
- id: fix-ui-locale-input
content: "UI/Locale Input Fix: type=\"number\" → type=\"text\" + inputmode=\"decimal\" + serverseitig \",\" → \".\" normalisieren"
status: pending
- id: fix-delete-confirmation
content: "Delete-all-Confirmation robuster: String.trim() + case-insensitive Vergleich oder \"type DELETE\" Pattern"
status: pending
- id: fix-warning-state
content: "Warning-State Fix: Bei Decimal.parse(:error) explizit hide_amount_warning(socket) aufrufen"
status: pending
- id: fix-double-toggle
content: "Toggle entfernen: Toggle-Button im Spalten-Header entfernen (nur in Toolbar behalten)"
status: pending
- id: fix-format-consistency
content: "Format-Konsistenz: Inputs ebenfalls auf Komma ausrichten oder serverseitig normalisieren"
status: pending
dependencies:
- fix-ui-locale-input
---
# Code Review Fixes - Membership Fee Features
## Kritische Probleme (Müssen vor Merge behoben werden)
### 1. after_action mit Task.start - Transaktionsprobleme
**Dateien:** `lib/membership/member.ex` (Zeilen 142, 279)
**Problem:** `Task.start/1` wird innerhalb von `after_action` Hooks verwendet. `after_action` läuft innerhalb der DB-Transaktion, daher:
- Tasks sehen möglicherweise noch nicht committed state
- Tasks werden auch bei Rollback gestartet
- Keine Supervision → Memory Leaks möglich
**Lösung:**
- `after_transaction` Hook verwenden (Ash Best Practice)
- `Task.Supervisor` zum Supervision Tree hinzufügen (`lib/mv/application.ex`)
- `Task.Supervisor.async_nolink/3` statt `Task.start/1` verwenden
**Betroffene Stellen:**
- Member Creation (Zeile 116-164)
- Join/Exit Date Change (Zeile 250-301)
### 2. Code-Duplikation in Cycle-Generation-Logik
**Datei:** `lib/membership/member.ex`
**Problem:** Cycle-Generation-Logik ist dreimal dupliziert (Create, Type Change, Date Change)
**Lösung:** Extrahiere in private Funktion `handle_cycle_generation/2`
## Wichtige Probleme (Sollten behoben werden)
### 3. join_date Validierung entfernt, aber Dokumentation behauptet Gegenteil
**Datei:** `lib/membership/member.ex` (Zeile 27, 516-518)
**Problem:** Dokumentation sagt "join_date not in future", aber Validierung fehlt
**Lösung:** Dokumentation anpassen
### 4. load_cycles_for_members overpromises
**Datei:** `lib/mv_web/member_live/index/membership_fee_status.ex` (Zeile 36-40)
**Problem:** Dokumentation sagt "Only loads the relevant cycle per member" und "Filters cycles at database level", aber lädt alle Cycles
**Lösung:** echte Filterung implementieren (z.B. nur letzte 2 Intervalle)
### 5. get_current_cycle nondeterministisch
**Datei:** `lib/mv_web/helpers/membership_fee_helpers.ex` (Zeile 178-182)
**Problem:** `List.first()` ohne explizite Sortierung → Ergebnis hängt von Reihenfolge ab
**Lösung:** Vor `List.first()` nach `cycle_start` sortieren (desc)
### 6. N+1 Query durch get_member_count
**Datei:** `lib/mv_web/live/membership_fee_type_live/index.ex` (Zeile 134-140)
**Problem:** `get_member_count/1` wird pro Row aufgerufen → N+1 Query
**Lösung:** Aggregate auf MembershipFeeType definieren oder einmalig vorab laden
### 7. assign_new kann stale werden
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 402-403)
**Problem:** `assign_new(:cycles, ...)` und `assign_new(:available_fee_types, ...)` werden nur gesetzt, wenn Assign noch nicht existiert
**Lösung:** In `update/2` immer `assign(:cycles, cycles)` / `assign(:available_fee_types, available_fee_types)` setzen
### 8. @regenerating wird nie auf true gesetzt
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 526-561)
**Problem:** `regenerating` wird nur auf `false` gesetzt, nie auf `true` → Button/Spinner werden nie disabled
**Lösung:** Direkt beim Event-Start `socket |> assign(:regenerating, true)` setzen
### 9. Create-cycle parsing: invalid amount zeigt falsche Fehlermeldung
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 748-812)
**Problem:** `Decimal.parse/1` gibt `:error` zurück, aber `with` behandelt es als `:error` → landet in "Invalid date format" Branch
**Lösung:** Explizit `{:error, :invalid_amount}` zurückgeben:
```elixir
amount = case Decimal.parse(amount_str) do
{d, _} -> {:ok, d}
:error -> {:error, :invalid_amount}
end
```
### 10. Delete all cycles: nicht atomar
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 666-714)
**Problem:** `Enum.map(cycles, &Ash.destroy/1)` → nicht atomar, teilweise gelöscht möglich
**Lösung:** Bulk Delete Query verwenden (Ash bulk destroy oder Query-basiert)
### 11. Fehlerbehandlung bei async Tasks
**Datei:** `lib/membership/member.ex`
**Problem:** Bei Fehlern in async Tasks wird nur geloggt, aber der Benutzer erhält keine Rückmeldung. Die Member-Aktion wird als erfolgreich zurückgegeben, auch wenn die Cycle-Generierung fehlschlägt. Keine Retry-Logik oder Monitoring.
**Lösung:**
- Für kritische Fälle: synchron ausführen oder Retry-Mechanismus implementieren
- Für nicht-kritische Fälle: Event-System für spätere Benachrichtigung
- Strukturierte Error-Logs mit Context
- Optional: Error-Tracking (Sentry, etc.)
### 12. format_currency Robustheit
**Datei:** `lib/mv_web/helpers/membership_fee_helpers.ex` (Zeilen 27-51)
**Problem:** Die Funktion verwendet String-Manipulation für Formatierung. Edge Cases könnten problematisch sein (z.B. sehr große Zahlen, negative Werte).
**Lösung:**
- `Number.Currency` oder ähnliche Bibliothek verwenden
- Oder: Robusteres Pattern Matching für Edge Cases
- Tests für Edge Cases hinzufügen (negative Zahlen, sehr große Zahlen)
### 13. Fehlende Typespecs
**Datei:** `lib/membership/member/changes/set_default_membership_fee_type.ex`
**Problem:** Keine `@spec` für die `change/3` Funktion.
**Lösung:** Typespecs hinzufügen für bessere Dokumentation und Dialyzer-Support.
### 14. Potenzielle Race Condition
**Datei:** `lib/membership/member.ex` (Zeile 250-301)
**Problem:** Wenn `join_date` und `exit_date` gleichzeitig geändert werden, könnte die Cycle-Generierung zweimal ausgelöst werden (einmal pro Änderung).
**Lösung:** Prüfen, ob Ash dies bereits verhindert, oder Logik anpassen (z.B. beide Änderungen in einem Hook zusammenfassen).
### 15. Magic Numbers/Strings
**Problem:** `Application.get_env(:mv, :sql_sandbox, false)` wird mehrfach verwendet.
**Lösung:** Extrahiere in Konstante oder Helper-Funktion (z.B. `Mv.Config.sql_sandbox?/0`).
## Mittlere Probleme (Nice-to-have)
### 16. Inconsistent use of domain
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 819-821)
**Problem:** Einige Actions verwenden `domain: MembershipFees`, andere nicht
**Lösung:** Konsistent `domain` überall verwenden
### 17. Tests: create_cycle/3 löscht jedes Mal alle Cycles
**Datei:** `test/mv_web/member_live/index/membership_fee_status_test.exs` (Zeile 45-52)
**Problem:** Helper löscht vor jedem Create alle Cycles → Tests prüfen nicht, was sie denken
**Lösung:** Cleanup nur einmal im Setup oder gezielt nur auto-generierte löschen
### 18. Tests/Design: Date.utc_today() macht Tests flaky
**Problem:** Tests hängen von `Date.utc_today()` ab → nicht deterministisch
**Lösung:** `today` Parameter durchgeben (z.B. `get_cycle_status_for_member(member, show_current, today \\ Date.utc_today())`)
### 19. UI/Locale: input type="number" + Decimal/Komma
**Problem:** `type="number"` funktioniert nicht zuverlässig mit Komma als Dezimaltrenner
**Lösung:** `type="text"` + `inputmode="decimal"` + serverseitig "," → "." normalisieren
### 20. Delete-all-Confirmation: String-Vergleich ist fragil
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 296-298)
**Problem:** String-Vergleich gegen `gettext("Yes")` und `"Yes"` → fragil bei Whitespace/Locale
**Lösung:** `String.trim()` + case-insensitive Vergleich oder "type DELETE" Pattern
### 21. MembershipFeeType Form: Warning-State kann hängen bleiben
**Datei:** `lib/mv_web/live/membership_fee_type_live/form.ex` (Zeile 367-378)
**Problem:** Bei `Decimal.parse(:error)` wird nur `socket` zurückgegeben → Warning kann stehen bleiben
**Lösung:** Bei `:error` explizit `hide_amount_warning(socket)` aufrufen
### 22. UI/UX: Toggle ist doppelt vorhanden
**Datei:** `lib/mv_web/live/member_live/index.html.heex` (Zeile 45-72, 284-296)
**Problem:** Toggle-Button sowohl in Toolbar als auch im Spalten-Header
**Lösung:** Toggle im Spalten-Header entfernen (nur in Toolbar behalten)
### 23. Konsistenz: format_currency vs Inputs
**Problem:** `format_currency` formatiert deutsch (Komma), aber Inputs erwarten Punkt
**Lösung:** Inputs ebenfalls auf Komma ausrichten oder serverseitig normalisieren
## Implementierungsreihenfolge
1. **Kritisch:** after_action → after_transaction + Task.Supervisor
2. **Kritisch:** Code-Duplikation reduzieren
3. **Wichtig:** join_date Validierung/Dokumentation
4. **Wichtig:** load_cycles_for_members Dokumentation/Implementierung
5. **Wichtig:** get_current_cycle Sortierung
6. **Wichtig:** N+1 Query beheben
7. **Wichtig:** assign_new → assign
8. **Wichtig:** @regenerating auf true setzen
9. **Wichtig:** Create-cycle parsing Fix
10. **Wichtig:** Delete all cycles atomar
11. **Wichtig:** Fehlerbehandlung bei async Tasks
12. **Wichtig:** format_currency Robustheit
13. **Wichtig:** Fehlende Typespecs
14. **Wichtig:** Potenzielle Race Condition prüfen/beheben
15. **Wichtig:** Magic Numbers/Strings extrahieren
16. **Mittel:** Domain-Konsistenz
17. **Mittel:** Test-Helper Fix
18. **Mittel:** Date.utc_today() Parameter
19. **Mittel:** UI/Locale Fixes
20. **Mittel:** String-Vergleich robuster
21. **Mittel:** Warning-State Fix
22. **Mittel:** Toggle entfernen
23. **Mittel:** Format-Konsistenz

View file

@ -49,7 +49,7 @@ config :spark,
config :mv, config :mv,
ecto_repos: [Mv.Repo], ecto_repos: [Mv.Repo],
generators: [timestamp_type: :utc_datetime], generators: [timestamp_type: :utc_datetime],
ash_domains: [Mv.Membership, Mv.Accounts] ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
# Configures the endpoint # Configures the endpoint
config :mv, MvWeb.Endpoint, config :mv, MvWeb.Endpoint,
@ -95,7 +95,16 @@ config :tailwind,
# Configures Elixir's Logger # Configures Elixir's Logger
config :logger, :default_formatter, config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n", format: "$time $metadata[$level] $message\n",
metadata: [:request_id] metadata: [
:request_id,
:user_id,
:member_id,
:member_email,
:error,
:error_type,
:cycles_count,
:notifications_count
]
# Use Jason for JSON parsing in Phoenix # Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason config :phoenix, :json_library, Jason

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

@ -33,7 +33,7 @@ services:
restart: unless-stopped restart: unless-stopped
db-prod: db-prod:
image: postgres:16-alpine image: postgres:18.1-alpine
container_name: mv-prod-db container_name: mv-prod-db
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres

View file

@ -4,7 +4,7 @@ networks:
services: services:
db: db:
image: postgres:17.6-alpine image: postgres:18.1-alpine
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
@ -29,7 +29,7 @@ services:
rauthy: rauthy:
container_name: rauthy-dev container_name: rauthy-dev
image: ghcr.io/sebadob/rauthy:0.32.0 image: ghcr.io/sebadob/rauthy:0.33.4
environment: environment:
- LOCAL_TEST=true - LOCAL_TEST=true
- SMTP_URL=mailcrab - SMTP_URL=mailcrab

View file

@ -1,653 +0,0 @@
# Membership Contributions - Technical Architecture
**Project:** Mila - Membership Management System
**Feature:** Membership Contribution Management
**Version:** 1.0
**Last Updated:** 2025-11-27
**Status:** Architecture Design - Ready for Implementation
---
## Purpose
This document defines the technical architecture for the Membership Contributions system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details.
**Related Documents:**
- [contributions-overview.md](./contributions-overview.md) - Business logic and requirements
- [database-schema-readme.md](./database-schema-readme.md) - Database documentation
- [database_schema.dbml](./database_schema.dbml) - Database schema definition
---
## Table of Contents
1. [Architecture Principles](#architecture-principles)
2. [Domain Structure](#domain-structure)
3. [Data Architecture](#data-architecture)
4. [Business Logic Architecture](#business-logic-architecture)
5. [Integration Points](#integration-points)
6. [Acceptance Criteria](#acceptance-criteria)
7. [Testing Strategy](#testing-strategy)
8. [Security Considerations](#security-considerations)
9. [Performance Considerations](#performance-considerations)
---
## Architecture Principles
### Core Design Decisions
1. **Single Responsibility:**
- Each module has one clear responsibility
- Period generation separated from status management
- Calendar logic isolated in dedicated module
2. **No Redundancy:**
- No `period_end` field (calculated from `period_start` + `interval`)
- No `interval_type` field (read from `contribution_type.interval`)
- Eliminates data inconsistencies
3. **Immutability Where Important:**
- `contribution_type.interval` cannot be changed after creation
- Prevents complex migration scenarios
- Enforced via Ash change validation
4. **Historical Accuracy:**
- `amount` stored per period for audit trail
- Enables tracking of contribution changes over time
- Old periods retain original amounts
5. **Calendar-Based Periods:**
- All periods aligned to calendar boundaries
- Simplifies date calculations
- Predictable period generation
---
## Domain Structure
### Ash Domain: `Mv.Contributions`
**Purpose:** Encapsulates all contribution-related resources and logic
**Resources:**
- `ContributionType` - Contribution type definitions (admin-managed)
- `ContributionPeriod` - Individual contribution periods per member
**Extensions:**
- Member resource extended with contribution fields
### Module Organization
```
lib/
├── contributions/
│ ├── contributions.ex # Ash domain definition
│ ├── contribution_type.ex # ContributionType resource
│ ├── contribution_period.ex # ContributionPeriod resource
│ └── changes/
│ ├── prevent_interval_change.ex # Validates interval immutability
│ ├── set_contribution_start_date.ex # Auto-sets start date
│ └── validate_same_interval.ex # Validates interval match on type change
├── mv/
│ └── contributions/
│ ├── period_generator.ex # Period generation algorithm
│ └── calendar_periods.ex # Calendar period calculations
└── membership/
└── member.ex # Extended with contribution relationships
```
### Separation of Concerns
**Domain Layer (Ash Resources):**
- Data validation
- Relationship management
- Policy enforcement
- Action definitions
**Business Logic Layer (`Mv.Contributions`):**
- Period generation algorithm
- Calendar calculations
- Date boundary handling
- Status transitions
**UI Layer (LiveView):**
- User interaction
- Display logic
- Authorization checks
- Form handling
---
## Data Architecture
### Database Schema Extensions
**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation.
### New Tables
1. **`contribution_types`**
- Purpose: Define contribution types with fixed intervals
- Key Constraint: `interval` field immutable after creation
- Relationships: has_many members, has_many contribution_periods
2. **`contribution_periods`**
- Purpose: Individual contribution periods for members
- Key Design: NO `period_end` or `interval_type` fields (calculated)
- Relationships: belongs_to member, belongs_to contribution_type
- Composite uniqueness: One period per member per period_start
### Member Table Extensions
**Fields Added:**
- `contribution_type_id` (FK, NOT NULL with default from settings)
- `contribution_start_date` (Date, nullable)
**Existing Fields Used:**
- `joined_at` - For calculating contribution start
- `left_at` - For limiting period generation
- These fields must remain member fields and should not be replaced by custom fields in the future
### Settings Integration
**Global Settings:**
- `contributions.include_joining_period` (Boolean)
- `contributions.default_contribution_type_id` (UUID)
**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource)
### Foreign Key Behaviors
| Relationship | On Delete | Rationale |
|--------------|-----------|-----------|
| `contribution_periods.member_id → members.id` | CASCADE | Remove periods when member deleted |
| `contribution_periods.contribution_type_id → contribution_types.id` | RESTRICT | Prevent type deletion if periods exist |
| `members.contribution_type_id → contribution_types.id` | RESTRICT | Prevent type deletion if assigned to members |
---
## Business Logic Architecture
### Period Generation System
**Component:** `Mv.Contributions.PeriodGenerator`
**Responsibilities:**
- Calculate which periods should exist for a member
- Generate missing periods
- Respect contribution_start_date and left_at boundaries
- Skip existing periods (idempotent)
**Triggers:**
1. Member contribution type assigned (via Ash change)
2. Member created with contribution type (via Ash change)
3. Scheduled job runs (daily/weekly cron)
4. Admin manual regeneration (UI action)
**Algorithm Steps:**
1. Retrieve member with contribution_type and dates
2. Determine first period start (based on contribution_start_date)
3. Calculate all period starts from first to today (or left_at)
4. Query existing periods for member
5. Generate missing periods with current contribution_type.amount
6. Insert new periods (batch operation)
**Edge Case Handling:**
- If contribution_start_date is NULL: Calculate from joined_at + global setting
- If left_at is set: Stop generation at left_at
- If contribution_type changes: Handled separately by regeneration logic
### Calendar Period Calculations
**Component:** `Mv.Contributions.CalendarPeriods`
**Responsibilities:**
- Calculate period boundaries based on interval type
- Determine current period
- Determine last completed period
- Calculate period_end from period_start + interval
**Functions (high-level):**
- `calculate_period_start/3` - Given date and interval, find period start
- `calculate_period_end/2` - Given period_start and interval, calculate end
- `next_period_start/2` - Given period_start and interval, find next
- `is_current_period?/2` - Check if period contains today
- `is_last_completed_period?/2` - Check if period just ended
**Interval Logic:**
- **Monthly:** Start = 1st of month, End = last day of month
- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter
- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half
- **Yearly:** Start = Jan 1st, End = Dec 31st
### Status Management
**Component:** Ash actions on `ContributionPeriod`
**Status Transitions:**
- Simple state machine: unpaid ↔ paid ↔ suspended
- No complex validation (all transitions allowed)
- Permissions checked via Ash policies
**Actions Required:**
- `mark_as_paid` - Set status to :paid
- `mark_as_suspended` - Set status to :suspended
- `mark_as_unpaid` - Set status to :unpaid (error correction)
**Bulk Operations:**
- `bulk_mark_as_paid` - Mark multiple periods as paid (efficiency)
- low priority, can be a future issue
### Contribution Type Change Handling
**Component:** Ash change on `Member.contribution_type_id`
**Validation:**
- Check if new type has same interval as old type
- If different: Reject change (MVP constraint)
- If same: Allow change
**Side Effects on Allowed Change:**
1. Keep all existing periods unchanged
2. Find future unpaid periods
3. Delete future unpaid periods
4. Regenerate periods with new contribution_type_id and amount
**Implementation Pattern:**
- Use Ash change module to validate
- Use after_action hook to trigger regeneration
- Use transaction to ensure atomicity
---
## Integration Points
### Member Resource Integration
**Extension Points:**
1. Add fields via migration
2. Add relationships (belongs_to, has_many)
3. Add calculations (current_period_status, overdue_count)
4. Add changes (auto-set contribution_start_date, validate interval)
**Backward Compatibility:**
- New fields nullable or with defaults
- Existing members get default contribution type from settings
- No breaking changes to existing member functionality
### Settings System Integration
**Requirements:**
- Store two global settings
- Provide UI for admin to modify
- Default values if not set
- Validation (e.g., default_contribution_type_id must exist)
**Access Pattern:**
- Read settings during period generation
- Read settings during member creation
- Write settings only via admin UI
### Permission System Integration
**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
**Required Permissions:**
- `ContributionType.create/update/destroy` - Admin only
- `ContributionType.read` - Admin, Treasurer, Board
- `ContributionPeriod.update` (status changes) - Admin, Treasurer
- `ContributionPeriod.read` - Admin, Treasurer, Board, Own member
**Policy Patterns:**
- Use existing HasPermission check
- Leverage existing roles (Admin, Kassenwart)
- Member can read own periods (linked via member_id)
### LiveView Integration
**New LiveViews Required:**
1. ContributionType index/form (admin)
2. ContributionPeriod table component (member detail view)
3. Settings form section (admin)
4. Member list column (contribution status)
**Existing LiveViews to Extend:**
- Member detail view: Add contributions section
- Member list view: Add status column
- Settings page: Add contributions section
**Authorization Helpers:**
- Use existing `can?/3` helper for UI conditionals
- Check permissions before showing actions
---
## Acceptance Criteria
### ContributionType Resource
**AC-CT-1:** Admin can create contribution type with name, amount, interval, description
**AC-CT-2:** Interval field is immutable after creation (validation error on change attempt)
**AC-CT-3:** Admin can update name, amount, description (but not interval)
**AC-CT-4:** Cannot delete contribution type if assigned to members
**AC-CT-5:** Cannot delete contribution type if periods exist referencing it
**AC-CT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly
### ContributionPeriod Resource
**AC-CP-1:** Period has period_start, status, amount, notes, member_id, contribution_type_id
**AC-CP-2:** Period_end is calculated, not stored
**AC-CP-3:** Status defaults to :unpaid
**AC-CP-4:** One period per member per period_start (uniqueness constraint)
**AC-CP-5:** Amount is set at generation time from contribution_type.amount
**AC-CP-6:** Periods cascade delete when member deleted
**AC-CP-7:** Admin/Treasurer can change status
**AC-CP-8:** Member can read own periods
### Member Extensions
**AC-M-1:** Member has contribution_type_id field (NOT NULL with default)
**AC-M-2:** Member has contribution_start_date field (nullable)
**AC-M-3:** New members get default contribution type from global setting
**AC-M-4:** contribution_start_date auto-set based on joined_at and global setting
**AC-M-5:** Admin can manually override contribution_start_date
**AC-M-6:** Cannot change to contribution type with different interval (MVP)
### Period Generation
**AC-PG-1:** Periods generated when member gets contribution type
**AC-PG-2:** Periods generated when member created (via change hook)
**AC-PG-3:** Scheduled job generates missing periods daily
**AC-PG-4:** Generation respects contribution_start_date
**AC-PG-5:** Generation stops at left_at if member exited
**AC-PG-6:** Generation is idempotent (skips existing periods)
**AC-PG-7:** Periods align to calendar boundaries (1st of month/quarter/half/year)
**AC-PG-8:** Amount comes from contribution_type at generation time
### Calendar Logic
**AC-CL-1:** Monthly periods: 1st to last day of month
**AC-CL-2:** Quarterly periods: 1st of Jan/Apr/Jul/Oct to last day of quarter
**AC-CL-3:** Half-yearly periods: 1st of Jan/Jul to last day of half
**AC-CL-4:** Yearly periods: Jan 1 to Dec 31
**AC-CL-5:** Period_end calculated correctly for all interval types
**AC-CL-6:** Current period determined correctly based on today's date
**AC-CL-7:** Last completed period determined correctly
### Contribution Type Change
**AC-TC-1:** Can change to type with same interval
**AC-TC-2:** Cannot change to type with different interval (error message)
**AC-TC-3:** On allowed change: future unpaid periods regenerated
**AC-TC-4:** On allowed change: paid/suspended periods unchanged
**AC-TC-5:** On allowed change: amount updated to new type's amount
**AC-TC-6:** Change is atomic (transaction)
### Settings
**AC-S-1:** Global setting: include_joining_period (boolean, default true)
**AC-S-2:** Global setting: default_contribution_type_id (UUID, required)
**AC-S-3:** Admin can modify settings via UI
**AC-S-4:** Settings validated (e.g., default type must exist)
**AC-S-5:** Settings applied to new members immediately
### UI - Member List
**AC-UI-ML-1:** New column shows contribution status
**AC-UI-ML-2:** Default: Shows last completed period status
**AC-UI-ML-3:** Optional: Toggle to show current period status
**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended)
**AC-UI-ML-5:** Filter: Unpaid in last period
**AC-UI-ML-6:** Filter: Unpaid in current period
### UI - Member Detail
**AC-UI-MD-1:** Contributions section shows all periods
**AC-UI-MD-2:** Table columns: Period, Interval, Amount, Status, Actions
**AC-UI-MD-3:** Checkbox per period for bulk marking (low prio)
**AC-UI-MD-4:** "Mark selected as paid" button
**AC-UI-MD-5:** Dropdown to change contribution type (same interval only)
**AC-UI-MD-6:** Warning if different interval selected
**AC-UI-MD-7:** Only show actions if user has permission
### UI - Contribution Types Admin
**AC-UI-CTA-1:** List all contribution types
**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count
**AC-UI-CTA-3:** Create new contribution type form
**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable
**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable)
**AC-UI-CTA-6:** Warning on amount change (explain impact)
**AC-UI-CTA-7:** Cannot delete if members assigned
**AC-UI-CTA-8:** Only admin can access
### UI - Settings Admin
**AC-UI-SA-1:** Contributions section in settings
**AC-UI-SA-2:** Dropdown to select default contribution type
**AC-UI-SA-3:** Checkbox: Include joining period
**AC-UI-SA-4:** Explanatory text with examples
**AC-UI-SA-5:** Save button with validation
---
## Testing Strategy
### Unit Testing
**Period Generator Tests:**
- Correct period_start calculation for all interval types
- Correct period count from start to end date
- Respects contribution_start_date boundary
- Respects left_at boundary
- Skips existing periods (idempotent)
- Handles edge dates (year boundaries, leap years)
**Calendar Periods Tests:**
- Period boundaries correct for all intervals
- Period_end calculation correct
- Current period detection
- Last completed period detection
- Next period calculation
**Validation Tests:**
- Interval immutability enforced
- Same interval validation on type change
- Status transitions allowed
- Uniqueness constraints enforced
### Integration Testing
**Period Generation Flow:**
- Member creation triggers generation
- Type assignment triggers generation
- Type change regenerates future periods
- Scheduled job generates missing periods
- Left member stops generation
**Status Management Flow:**
- Mark single period as paid
- Bulk mark multiple periods (low prio)
- Status transitions work
- Permissions enforced
**Contribution Type Management:**
- Create type
- Update amount (regeneration triggered)
- Cannot update interval
- Cannot delete if in use
### LiveView Testing
**Member List:**
- Status column displays correctly
- Toggle between last/current works
- Filters work correctly
- Color coding applied
**Member Detail:**
- Periods table displays all periods
- Checkboxes work
- Bulk marking works (low prio)
- Type change validation works
- Actions only shown with permission
**Admin UI:**
- Type CRUD works
- Settings save correctly
- Validations display errors
- Only authorized users can access
### Edge Case Testing
**Interval Change Attempt:**
- Error message displayed
- No data modified
- User can cancel/choose different type
**Exit with Unpaid:**
- Warning shown
- Option to suspend offered
- Exit completes correctly
**Amount Change:**
- Warning displayed
- Only future unpaid regenerated
- Historical periods unchanged
**Date Boundaries:**
- Today = period start handled
- Today = period end handled
- Leap year handled
### Performance Testing
**Period Generation:**
- Generate 10 years of monthly periods: < 100ms
- Generate for 1000 members: < 5 seconds
- Idempotent check efficient (no full scan)
**Member List Query:**
- With status column: < 200ms for 1000 members
- Filters applied efficiently
- No N+1 queries
---
## Security Considerations
### Authorization
**Permissions Required:**
- ContributionType management: Admin only
- ContributionPeriod status changes: Admin + Treasurer
- View all periods: Admin + Treasurer + Board
- View own periods: All authenticated users
**Policy Enforcement:**
- All actions protected by Ash policies
- UI shows/hides based on permissions
- Backend validates permissions (never trust UI alone)
### Data Integrity
**Validation Layers:**
1. Database constraints (NOT NULL, UNIQUE, CHECK)
2. Ash validations (business rules)
3. UI validations (user experience)
**Immutability Protection:**
- Interval change prevented at multiple layers
- Period amounts immutable (audit trail)
- Settings changes logged (future)
### Audit Trail
**Tracked Information:**
- Period status changes (who, when) - future enhancement
- Type amount changes (implicit via period amounts)
- Member type assignments (via timestamps)
---
## Performance Considerations
### Database Indexes
**Required Indexes:**
- `contribution_periods(member_id)` - For member period lookups
- `contribution_periods(contribution_type_id)` - For type queries
- `contribution_periods(status)` - For unpaid filters
- `contribution_periods(period_start)` - For date range queries
- `contribution_periods(member_id, period_start)` - Composite unique index
- `members(contribution_type_id)` - For type membership count
### Query Optimization
**Preloading:**
- Load contribution_type with periods (avoid N+1)
- Load periods when displaying member detail
- Use Ash's load for efficient preloading
**Calculated Fields:**
- period_end calculated on-demand (not stored)
- current_period_status calculated when needed
- Use Ash calculations for lazy evaluation
**Pagination:**
- Period list paginated if > 50 periods
- Member list already paginated
### Caching Strategy
**No caching needed in MVP:**
- Contribution types rarely change
- Period queries are fast
- Settings read infrequently
**Future caching if needed:**
- Cache settings in application memory
- Cache contribution types list
- Invalidate on change
### Scheduled Job Performance
**Period Generation Job:**
- Run daily or weekly (not hourly)
- Batch members (process 100 at a time)
- Skip members with no changes
- Log failures for retry
---
## Future Enhancements
### Phase 2: Interval Change Support
**Architecture Changes:**
- Add logic to handle period overlaps
- Calculate prorata amounts if needed
- More complex validation
- Migration path for existing periods
### Phase 3: Payment Details
**Architecture Changes:**
- Add PaymentTransaction resource
- Link transactions to periods
- Support multiple payments per period
- Reconciliation logic
### Phase 4: vereinfacht.digital Integration
**Architecture Changes:**
- External API client module
- Webhook handling for transactions
- Automatic matching logic
- Manual review interface
---
**End of Architecture Document**

View file

@ -0,0 +1,666 @@
# CSV Member Import v1 - Implementation Plan
**Version:** 1.0
**Date:** 2025-01-XX
**Status:** Ready for Implementation
**Related Documents:**
- [Feature Roadmap](./feature-roadmap.md) - Overall feature planning
---
## Table of Contents
- [Overview & Scope](#overview--scope)
- [UX Flow](#ux-flow)
- [CSV Specification](#csv-specification)
- [Technical Design Notes](#technical-design-notes)
- [Implementation Issues](#implementation-issues)
- [Rollout & Risks](#rollout--risks)
---
## Overview & Scope
### What We're Building
A **basic CSV member import feature** that allows administrators to upload a CSV file and import new members into the system. This is a **v1 minimal implementation** focused on establishing the import structure without advanced features.
**Core Functionality (v1 Minimal):**
- Upload CSV file via LiveView file upload
- Parse CSV with bilingual header support for core member fields (English/German)
- Auto-detect delimiter (`;` or `,`) using header recognition
- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`)
- **Import custom field values** - Map CSV columns to existing custom fields by name (unknown custom field columns will be ignored with a warning)
- Validate each row (required field: `email`)
- Create members via Ash resource (one-by-one, **no background jobs**, processed in chunks of 200 rows via LiveView messages)
- Display import results: success count, error count, and error details
- Provide static CSV templates (EN/DE)
**Key Constraints (v1):**
- ✅ **Admin-only feature**
- ✅ **No upsert** (create only)
- ✅ **No deduplication** (duplicate emails fail and show as errors)
- ✅ **No mapping wizard** (fixed header mapping via bilingual variants)
- ✅ **No background jobs** (progress via LiveView `handle_info`)
- ✅ **Best-effort import** (row-by-row, no rollback)
- ✅ **UI-only error display** (no error CSV export)
- ✅ **Safety limits** (10 MB, 1,000 rows, chunks of 200)
### Out of Scope (v1)
**Deferred to Future Versions:**
- ❌ Upsert/update existing members
- ❌ Advanced deduplication strategies
- ❌ Column mapping wizard UI
- ❌ Background job processing (Oban/GenStage)
- ❌ Transactional all-or-nothing import
- ❌ Error CSV export/download
- ❌ Batch validation preview before import
- ❌ Dynamic template generation
- ❌ Import history/audit log
- ❌ Import templates for other entities
---
## UX Flow
### Access & Location
**Entry Point:**
- **Location:** Global Settings page (`/settings`)
- **UI Element:** New section "Import Members (CSV)" below "Custom Fields" section
- **Access Control:** Admin-only (enforced at LiveView event level, not entire `/settings` route)
### User Journey
1. **Navigate to Global Settings**
2. **Access Import Section**
- **Important notice:** Custom fields should be created in Mila before importing CSV files with custom field columns (unknown columns will be ignored with a warning)
- Upload area (drag & drop or file picker)
- Template download links (English / German)
- Help text explaining CSV format and custom field requirements
3. **Ensure Custom Fields Exist (if importing custom fields)**
- Navigate to Custom Fields section and create required custom fields
- Note the name/identifier for each custom field (used as CSV header)
4. **Download Template (Optional)**
5. **Prepare CSV File**
- Include custom field columns using the custom field name as header (e.g., `membership_number`, `birth_date`)
6. **Upload CSV**
7. **Start Import**
- Runs server-side via LiveView messages (may take up to ~30 seconds for large files)
- Warning messages if custom field columns reference non-existent custom fields (columns will be ignored)
8. **View Results**
- Success count
- Error count
- First 50 errors, each with:
- **CSV line number** (header is line 1, first data record begins at line 2)
- Error message
- Field name (if applicable)
### Error Handling
- **File too large:** Flash error before upload starts
- **Too many rows:** Flash error before import starts
- **Invalid CSV format:** Error shown in results
- **Partial success:** Results show both success and error counts
---
## CSV Specification
### Delimiter
**Recommended:** Semicolon (`;`)
**Supported:** `;` and `,`
**Auto-Detection (Header Recognition):**
- Remove UTF-8 BOM *first*
- Extract header record and try parsing with both delimiters
- For each delimiter, count how many recognized headers are present (via normalized variants)
- Choose delimiter with higher recognition; prefer `;` if tied
- If neither yields recognized headers, default to `;`
### Quoting Rules
- Fields may be quoted with double quotes (`"`)
- Escaped quotes: `""` inside quoted field represents a single `"`
- **v1 assumption:** CSV records do **not** contain embedded newlines inside quoted fields. (If they do, parsing may fail or line numbers may be inaccurate.)
### Column Headers
**v1 Supported Fields:**
**Core Member Fields:**
- `first_name` / `Vorname` (optional)
- `last_name` / `Nachname` (optional)
- `email` / `E-Mail` (required)
- `street` / `Straße` (optional)
- `postal_code` / `PLZ` / `Postleitzahl` (optional)
- `city` / `Stadt` (optional)
**Custom Fields:**
- Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`)
- **Important:** Custom fields must be created in Mila before importing. The CSV header must match the custom field name exactly (same normalization as member fields).
- **Behavior:** If the CSV contains custom field columns that don't exist in Mila, a warning message will be shown and those columns will be ignored during import.
**Member Field Header Mapping:**
| Canonical Field | English Variants | German Variants |
|---|---|---|
| `first_name` | `first_name`, `firstname` | `Vorname`, `vorname` |
| `last_name` | `last_name`, `lastname`, `surname` | `Nachname`, `nachname`, `Familienname` |
| `email` | `email`, `e-mail`, `e_mail` | `E-Mail`, `e-mail`, `e_mail` |
| `street` | `street`, `address` | `Straße`, `strasse`, `Strasse` |
| `postal_code` | `postal_code`, `zip`, `postcode` | `PLZ`, `plz`, `Postleitzahl`, `postleitzahl` |
| `city` | `city`, `town` | `Stadt`, `stadt`, `Ort` |
**Header Normalization (used consistently for both input headers AND mapping variants):**
- Trim whitespace
- Convert to lowercase
- Normalize Unicode: `ß``ss` (e.g., `Straße``strasse`)
- Replace hyphens/whitespace with underscores: `E-Mail``e_mail`, `phone number``phone_number`
- Collapse multiple underscores: `e__mail``e_mail`
- Case-insensitive matching
**Unknown columns:** ignored (no error)
**Required fields:** `email`
**Custom Field Columns:**
- Custom field columns are identified by matching the normalized CSV header to the custom field `name` (not slug)
- Same normalization rules apply as for member fields (trim, lowercase, Unicode normalization, underscore replacement)
- Unknown custom field columns (non-existent names) will be ignored with a warning message
### CSV Template Files
**Location:**
- `priv/static/templates/member_import_en.csv`
- `priv/static/templates/member_import_de.csv`
**Content:**
- Header row with required + common optional fields
- **Note:** Custom field columns are not included in templates by default (users add them based on their custom field configuration)
- One example row
- Uses semicolon delimiter (`;`)
- UTF-8 encoding **with BOM** (Excel compatibility)
**Template Access:**
- Templates are static files in `priv/static/templates/`
- Served at:
- `/templates/member_import_en.csv`
- `/templates/member_import_de.csv`
- In LiveView, link them using Phoenix static path helpers (e.g. `~p` or `Routes.static_path/2`, depending on Phoenix version).
### File Limits
- **Max file size:** 10 MB
- **Max rows:** 1,000 rows (excluding header)
- **Processing:** chunks of 200 (via LiveView messages)
- **Encoding:** UTF-8 (BOM handled)
---
## Technical Design Notes
### Architecture Overview
```
┌─────────────────┐
│ LiveView UI │ (GlobalSettingsLive or component)
│ - Upload area │
│ - Progress │
│ - Results │
└────────┬────────┘
│ prepare
┌─────────────────────────────┐
│ Import Service │ (Mv.Membership.Import.MemberCSV)
│ - parse + map + limit checks│ -> returns import_state
│ - process_chunk(chunk) │ -> returns chunk results
└────────┬────────────────────┘
│ create
┌─────────────────┐
│ Ash Resource │ (Mv.Membership.Member)
│ - Create │
└─────────────────┘
```
### Technology Stack
- **Phoenix LiveView:** file upload via `allow_upload/3`
- **NimbleCSV:** CSV parsing (add explicit dependency if missing)
- **Ash Resource:** member creation via `Membership.create_member/1`
- **Gettext:** bilingual UI/error messages
### Module Structure
**New Modules:**
- `lib/mv/membership/import/member_csv.ex` - import orchestration + chunk processing + custom field handling
- `lib/mv/membership/import/csv_parser.ex` - delimiter detection + parsing + BOM handling
- `lib/mv/membership/import/header_mapper.ex` - normalization + header mapping (core fields + custom fields)
**Modified Modules:**
- `lib/mv_web/live/global_settings_live.ex` - render import section, handle upload/events/messages
### Data Flow
1. **Upload:** LiveView receives file via `allow_upload`
2. **Consume:** `consume_uploaded_entries/3` reads file content
3. **Prepare:** `MemberCSV.prepare/2`
- Strip BOM
- Detect delimiter (header recognition)
- Parse header + rows
- Map headers to canonical fields (core member fields)
- **Query existing custom fields and map custom field columns by name** (using same normalization as member fields)
- **Warn about unknown custom field columns** (non-existent names will be ignored with warning)
- Early abort if required headers missing
- Row count check
- Return `import_state` containing chunks, column_map, and custom_field_map
4. **Process:** LiveView drives chunk processing via `handle_info`
- For each chunk: validate + create member + create custom field values + collect errors
5. **Results:** LiveView shows progress + final summary
### Types & Key Consistency
- **Raw CSV parsing:** returns headers as list of strings, and rows **with csv line numbers**
- **Header mapping:** operates on normalized strings; mapping table variants are normalized once
- **Ash attrs:** built as atom-keyed map (`%{first_name: ..., ...}`)
### Error Model
```elixir
%{
csv_line_number: 5, # physical line number in the CSV file
field: :email, # optional
message: "is not a valid email"
}
```
### CSV Line Numbers (Important)
To keep error reporting user-friendly and accurate, **row errors must reference the physical line number in the original file**, even if empty lines are skipped.
**Design decision:** the parser returns rows as:
```elixir
rows :: [{csv_line_number :: pos_integer(), row_map :: map()}]
```
Downstream logic must **not** recompute line numbers from row indexes.
### Authorization
**Enforcement points:**
1. **LiveView event level:** check admin permission in `handle_event("start_import", ...)`
2. **UI level:** render import section only for admin users
3. **Static templates:** public assets (no authorization needed)
Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string checks where possible.
### Safety Limits
- File size enforced by `allow_upload` (`max_file_size`)
- Row count enforced in `MemberCSV.prepare/2` before processing starts
- Chunking is done via **LiveView `handle_info` loop** (sequential, cooperative scheduling)
---
## Implementation Issues
### Issue #1: CSV Specification & Static Template Files
**Dependencies:** None
**Goal:** Define CSV contract and add static templates.
**Tasks:**
- [ ] Finalize header mapping variants
- [ ] Document normalization rules
- [ ] Document delimiter detection strategy
- [ ] Create templates in `priv/static/templates/` (UTF-8 with BOM)
- [ ] Document template URLs and how to link them from LiveView
- [ ] Document line number semantics (physical CSV line numbers)
**Definition of Done:**
- [ ] Templates open cleanly in Excel/LibreOffice
- [ ] CSV spec section complete
---
### Issue #2: Import Service Module Skeleton
**Dependencies:** None
**Goal:** Create service API and error types.
**API (recommended):**
- `prepare/2` — parse + map + limit checks, returns import_state
- `process_chunk/3` — process one chunk (pure-ish), returns per-chunk results
**Tasks:**
- [ ] Create `lib/mv/membership/import/member_csv.ex`
- [ ] Define public function: `prepare/2 (file_content, opts \\ [])`
- [ ] Define public function: `process_chunk/3 (chunk_rows_with_lines, column_map, opts \\ [])`
- [ ] Define error struct: `%MemberCSV.Error{csv_line_number: integer, field: atom | nil, message: String.t}`
- [ ] Document module + API
---
### Issue #3: CSV Parsing + Delimiter Auto-Detection + BOM Handling
**Dependencies:** Issue #2
**Goal:** Parse CSV robustly with correct delimiter detection and BOM handling.
**Tasks:**
- [ ] Verify/add NimbleCSV dependency (`{:nimble_csv, "~> 1.0"}`)
- [ ] Create `lib/mv/membership/import/csv_parser.ex`
- [ ] Implement `strip_bom/1` and apply it **before** any header handling
- [ ] Handle `\r\n` and `\n` line endings (trim `\r` on header record)
- [ ] Detect delimiter via header recognition (try `;` and `,`)
- [ ] Parse CSV and return:
- `headers :: [String.t()]`
- `rows :: [{csv_line_number, [String.t()]}]` or directly `[{csv_line_number, row_map}]`
- [ ] Skip completely empty records (but preserve correct physical line numbers)
- [ ] Return `{:ok, headers, rows}` or `{:error, reason}`
**Definition of Done:**
- [ ] BOM handling works (Excel exports)
- [ ] Delimiter detection works reliably
- [ ] Rows carry correct `csv_line_number`
---
### Issue #4: Header Normalization + Per-Header Mapping (No Language Detection)
**Dependencies:** Issue #3
**Goal:** Map each header individually to canonical fields (normalized comparison).
**Tasks:**
- [ ] Create `lib/mv/membership/import/header_mapper.ex`
- [ ] Implement `normalize_header/1`
- [ ] Normalize mapping variants once and compare normalized strings
- [ ] Build `column_map` (canonical field -> column index)
- [ ] **Early abort if required headers missing** (`email`)
- [ ] Ignore unknown columns (member fields only)
- [ ] **Separate custom field column detection** (by name, with normalization)
**Definition of Done:**
- [ ] English/German headers map correctly
- [ ] Missing required columns fails fast
---
### Issue #5: Validation (Required Fields) + Error Formatting
**Dependencies:** Issue #4
**Goal:** Validate each row and return structured, translatable errors.
**Tasks:**
- [ ] Implement `validate_row/3 (row_map, csv_line_number, opts)`
- [ ] Required field presence (`email`)
- [ ] Email format validation (EctoCommons.EmailValidator)
- [ ] Trim values before validation
- [ ] Gettext-backed error messages
---
### Issue #6: Persistence via Ash Create + Per-Row Error Capture (Chunked Processing)
**Dependencies:** Issue #5
**Goal:** Create members and capture errors per row with correct CSV line numbers.
**Tasks:**
- [ ] Implement `process_chunk/3` in service:
- Input: `[{csv_line_number, row_map}]`
- Validate + create sequentially
- Collect counts + first 50 errors (per import overall; LiveView enforces cap across chunks)
- [ ] Implement Ash error formatter helper:
- Convert `Ash.Error.Invalid` into `%MemberCSV.Error{}`
- Prefer field-level errors where possible (attach `field` atom)
- Handle unique email constraint error as user-friendly message
- [ ] Map row_map to Ash attrs (`%{first_name: ..., ...}`)
**Important:** **Do not recompute line numbers** in this layer—use the ones provided by the parser.
---
### Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results + Template Links)
**Dependencies:** Issue #6
**Goal:** UI section with upload, progress, results, and template links.
**Tasks:**
- [ ] Render import section only for admins
- [ ] **Add prominent UI notice about custom fields:**
- Display alert/info box: "Custom fields must be created in Mila before importing CSV files with custom field columns"
- Explain: "Use the custom field name as the CSV column header (same normalization as member fields applies)"
- Add link to custom fields management section
- [ ] Configure `allow_upload/3`:
- `.csv` only, `max_entries: 1`, `max_file_size: 10MB`, `auto_upload: false`
- [ ] `handle_event("start_import", ...)`:
- Admin permission check
- Consume upload -> read file content
- Call `MemberCSV.prepare/2`
- Store `import_state` in assigns (chunks + column_map + metadata)
- Initialize progress assigns
- `send(self(), {:process_chunk, 0})`
- [ ] `handle_info({:process_chunk, idx}, socket)`:
- Fetch chunk from `import_state`
- Call `MemberCSV.process_chunk/3`
- Merge counts/errors into progress assigns (cap errors at 50 overall)
- Schedule next chunk (or finish and show results)
- [ ] Results UI:
- Success count
- Failure count
- Error list (line number + message + field)
- **Warning messages for unknown custom field columns** (non-existent names) shown in results
**Template links:**
- Link `/templates/member_import_en.csv` and `/templates/member_import_de.csv` via Phoenix static path helpers.
---
### Issue #8: Authorization + Limits
**Dependencies:** None (can be parallelized)
**Goal:** Ensure admin-only access and enforce limits.
**Tasks:**
- [ ] Admin check in start import event handler
- [ ] File size enforced in upload config
- [ ] Row limit enforced in `MemberCSV.prepare/2` (max_rows from config)
- [ ] Configuration:
```elixir
config :mv, csv_import: [
max_file_size_mb: 10,
max_rows: 1000
]
```
---
### Issue #9: End-to-End LiveView Tests + Fixtures
**Dependencies:** Issue #7 and #8
**Tasks:**
- [ ] Fixtures:
- valid EN/DE (core fields only)
- valid with custom fields
- invalid
- unknown custom field name (non-existent, should show warning)
- too many rows (1,001)
- BOM + `;` delimiter fixture
- fixture with empty line(s) to validate correct line numbers
- [ ] LiveView tests:
- admin sees section, non-admin does not
- upload + start import
- success + error rendering
- row limit + file size errors
- custom field import success
- custom field import warning (non-existent name, column ignored)
---
### Issue #10: Documentation Polish (Inline Help Text + Docs)
**Dependencies:** Issue #9
**Tasks:**
- [ ] UI help text + translations
- [ ] CHANGELOG entry
- [ ] Ensure moduledocs/docs
---
### Issue #11: Custom Field Import
**Dependencies:** Issue #6 (Persistence)
**Priority:** High (Core v1 Feature)
**Goal:** Support importing custom field values from CSV columns. Custom fields should exist in Mila before import for best results.
**Important Requirements:**
- **Custom fields should be created in Mila first** - Unknown custom field columns will be ignored with a warning message
- CSV headers for custom fields must match the custom field **name** exactly (same normalization as member fields applies)
- Custom field values are validated according to the custom field type (string, integer, boolean, date, email)
- Unknown custom field columns (non-existent names) will be ignored with a warning - import continues
**Tasks:**
- [ ] Extend `header_mapper.ex` to detect custom field columns by name (using same normalization as member fields)
- [ ] Query existing custom fields during `prepare/2` to map custom field columns
- [ ] Collect unknown custom field columns and add warning messages (don't fail import)
- [ ] Map custom field CSV values to `CustomFieldValue` creation in `process_chunk/3`
- [ ] Handle custom field type validation (string, integer, boolean, date, email)
- [ ] Create `CustomFieldValue` records linked to members during import
- [ ] Update error messages to include custom field validation errors
- [ ] Add UI help text explaining custom field requirements:
- "Custom fields must be created in Mila before importing"
- "Use the custom field name as the CSV column header (same normalization as member fields)"
- Link to custom fields management section
- [ ] Update CSV templates documentation to explain custom field columns
- [ ] Add tests for custom field import (valid, invalid name, type validation, warning for unknown)
**Definition of Done:**
- [ ] Custom field columns are recognized by name (with normalization)
- [ ] Warning messages shown for unknown custom field columns (import continues)
- [ ] Custom field values are created and linked to members
- [ ] Type validation works for all custom field types
- [ ] UI clearly explains custom field requirements
- [ ] Tests cover custom field import scenarios (including warning for unknown names)
---
## Rollout & Risks
### Rollout Strategy
- Dev → Staging → Production (with anonymized real-world CSV tests)
### Risks & Mitigations
| Risk | Impact | Likelihood | Mitigation |
|---|---:|---:|---|
| Large import timeout | High | Medium | 10 MB + 1,000 rows, chunking via `handle_info` |
| Encoding issues | Medium | Medium | BOM stripping, templates with BOM |
| Invalid CSV format | Medium | High | Clear errors + templates |
| Duplicate emails | Low | High | Ash constraint error -> user-friendly message |
| Performance (no background jobs) | Medium | Low | Small limits, sequential chunk processing |
| Admin access bypass | High | Low | Event-level auth + UI hiding |
| Data corruption | High | Low | Per-row validation + best-effort |
---
## Appendix
### Module File Structure
```
lib/
├── mv/
│ └── membership/
│ └── import/
│ ├── member_csv.ex # prepare + process_chunk
│ ├── csv_parser.ex # delimiter detection + parsing + BOM handling
│ └── header_mapper.ex # normalization + header mapping
└── mv_web/
└── live/
└── global_settings_live.ex # add import section + LV message loop
priv/
└── static/
└── templates/
├── member_import_en.csv
└── member_import_de.csv
test/
├── mv/
│ └── membership/
│ └── import/
│ ├── member_csv_test.exs
│ ├── csv_parser_test.exs
│ └── header_mapper_test.exs
└── fixtures/
├── member_import_en.csv
├── member_import_de.csv
├── member_import_invalid.csv
├── member_import_large.csv
└── member_import_empty_lines.csv
```
### Example Usage (LiveView)
```elixir
def handle_event("start_import", _params, socket) do
assert_admin!(socket.assigns.current_user)
[{_name, content}] =
consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry ->
{:ok, File.read!(path)}
end)
case Mv.Membership.Import.MemberCSV.prepare(content) do
{:ok, import_state} ->
socket =
socket
|> assign(:import_state, import_state)
|> assign(:import_progress, %{processed: 0, inserted: 0, failed: 0, errors: []})
|> assign(:importing?, true)
send(self(), {:process_chunk, 0})
{:noreply, socket}
{:error, reason} ->
{:noreply, put_flash(socket, :error, reason)}
end
end
def handle_info({:process_chunk, idx}, socket) do
%{chunks: chunks, column_map: column_map} = socket.assigns.import_state
case Enum.at(chunks, idx) do
nil ->
{:noreply, assign(socket, importing?: false)}
chunk_rows_with_lines ->
{:ok, chunk_result} =
Mv.Membership.Import.MemberCSV.process_chunk(chunk_rows_with_lines, column_map)
socket = merge_progress(socket, chunk_result) # caps errors at 50 overall
send(self(), {:process_chunk, idx + 1})
{:noreply, socket}
end
end
```
---
**End of Implementation Plan**

View file

@ -0,0 +1,243 @@
# Performance Analysis: Custom Fields in Search Vector
## Current Implementation
The search vector includes custom field values via database triggers that:
1. Aggregate all custom field values for a member
2. Extract values from JSONB format
3. Add them to the search_vector with weight 'C'
## Performance Considerations
### 1. Trigger Performance on Member Updates
**Current Implementation:**
- `members_search_vector_trigger()` executes a subquery on every INSERT/UPDATE:
```sql
SELECT string_agg(...) FROM custom_field_values WHERE member_id = NEW.id
```
**Performance Impact:**
- ✅ **Good:** Index on `member_id` exists (`custom_field_values_member_id_idx`)
- ✅ **Good:** Subquery only runs for the affected member
- ⚠️ **Potential Issue:** With many custom fields per member (e.g., 50+), aggregation could be slower
- ⚠️ **Potential Issue:** JSONB extraction (`value->>'_union_value'`) is relatively fast but adds overhead
**Expected Performance:**
- **Small scale (< 10 custom fields per member):** Negligible impact (< 5ms per operation)
- **Medium scale (10-30 custom fields):** Minor impact (5-20ms per operation)
- **Large scale (30+ custom fields):** Noticeable impact (20-50ms+ per operation)
### 2. Trigger Performance on Custom Field Value Changes
**Current Implementation:**
- `update_member_search_vector_from_custom_field_value()` executes on every INSERT/UPDATE/DELETE on `custom_field_values`
- **Optimized:** Only fetches required member fields (not full record) to reduce overhead
- **Optimized:** Skips re-aggregation on UPDATE if value hasn't actually changed
- Aggregates all custom field values, then updates member search_vector
**Performance Impact:**
- ✅ **Good:** Index on `member_id` ensures fast lookup
- ✅ **Optimized:** Only required fields are fetched (first_name, last_name, email, etc.) instead of full record
- ✅ **Optimized:** UPDATE operations that don't change the value skip expensive re-aggregation (early return)
- ⚠️ **Note:** Re-aggregation is still necessary when values change (required for search_vector consistency)
- ⚠️ **Critical:** Bulk operations (e.g., importing 1000 members with custom fields) will trigger this for each row
**Expected Performance:**
- **Single operation (value changed):** 3-10ms per custom field value change (improved from 5-15ms)
- **Single operation (value unchanged):** <1ms (early return, no aggregation)
- **Bulk operations:** Could be slow (consider disabling trigger temporarily)
### 3. Search Vector Size
**Current Constraints:**
- String values: max 10,000 characters per custom field
- No limit on number of custom fields per member
- tsvector has no explicit size limit, but very large vectors can cause issues
**Potential Issues:**
- **Theoretical maximum:** If a member has 100 custom fields with 10,000 char strings each, the aggregated text could be ~1MB
- **Practical concern:** Very large search vectors (> 100KB) can slow down:
- Index updates (GIN index maintenance)
- Search queries (tsvector operations)
- Trigger execution time
**Recommendation:**
- Monitor search_vector size in production
- Consider limiting total custom field content per member if needed
- PostgreSQL can handle large tsvectors, but performance degrades gradually
### 4. Initial Migration Performance
**Current Implementation:**
- Updates ALL members in a single transaction:
```sql
UPDATE members m SET search_vector = ... (subquery for each member)
```
**Performance Impact:**
- ⚠️ **Potential Issue:** With 10,000+ members, this could take minutes
- ⚠️ **Potential Issue:** Single transaction locks the members table
- ⚠️ **Potential Issue:** If migration fails, entire rollback required
**Recommendation:**
- For large datasets (> 10,000 members), consider:
- Batch updates (e.g., 1000 members at a time)
- Run during maintenance window
- Monitor progress
### 5. Search Query Performance
**Current Implementation:**
- Full-text search uses GIN index on `search_vector` (fast)
- Additional LIKE queries on `custom_field_values` for substring matching:
```sql
EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND ... LIKE ...)
```
**Performance Impact:**
- ✅ **Good:** GIN index on `search_vector` is very fast
- ⚠️ **Potential Issue:** LIKE queries on JSONB are not indexed (sequential scan)
- ⚠️ **Potential Issue:** EXISTS subquery runs for every search, even if search_vector match is found
- ⚠️ **Potential Issue:** With many custom fields, the LIKE queries could be slow
**Expected Performance:**
- **With GIN index match:** Very fast (< 10ms for typical queries)
- **Without GIN index match (fallback to LIKE):** Slower (10-100ms depending on data size)
- **Worst case:** Sequential scan of all custom_field_values for all members
## Recommendations
### Short-term (Current Implementation)
1. **Monitor Performance:**
- Add logging for trigger execution time
- Monitor search_vector size distribution
- Track search query performance
2. **Index Verification:**
- Ensure `custom_field_values_member_id_idx` exists and is used
- Verify GIN index on `search_vector` is maintained
3. **Bulk Operations:**
- For bulk imports, consider temporarily disabling the custom_field_values trigger
- Re-enable and update search_vectors in batch after import
### Medium-term Optimizations
1. **✅ Optimize Trigger Function (FULLY IMPLEMENTED):**
- ✅ Only fetch required member fields instead of full record (reduces overhead)
- ✅ Skip re-aggregation on UPDATE if value hasn't actually changed (early return optimization)
2. **Limit Search Vector Size:**
- Truncate very long custom field values (e.g., first 1000 chars)
- Add warning if aggregated text exceeds threshold
3. **Optimize LIKE Queries:**
- Consider adding a generated column for searchable text
- Or use a materialized view for custom field search
### Long-term Considerations
1. **Alternative Approaches:**
- Separate search index table for custom fields
- Use Elasticsearch or similar for advanced search
- Materialized view for search optimization
2. **Scaling Strategy:**
- If performance becomes an issue with 100+ custom fields per member:
- Consider limiting which custom fields are searchable
- Use a separate search service
- Implement search result caching
## Performance Benchmarks (Estimated)
Based on typical PostgreSQL performance:
| Scenario | Members | Custom Fields/Member | Expected Impact |
|----------|---------|---------------------|-----------------|
| Small | < 1,000 | < 10 | Negligible (< 5ms per operation) |
| Medium | 1,000-10,000 | 10-30 | Minor (5-20ms per operation) |
| Large | 10,000-100,000 | 30-50 | Noticeable (20-50ms per operation) |
| Very Large | > 100,000 | 50+ | Significant (50-200ms+ per operation) |
## Monitoring Queries
```sql
-- Check search_vector size distribution
SELECT
pg_size_pretty(octet_length(search_vector::text)) as size,
COUNT(*) as member_count
FROM members
WHERE search_vector IS NOT NULL
GROUP BY octet_length(search_vector::text)
ORDER BY octet_length(search_vector::text) DESC
LIMIT 20;
-- Check average custom fields per member
SELECT
AVG(custom_field_count) as avg_custom_fields,
MAX(custom_field_count) as max_custom_fields
FROM (
SELECT member_id, COUNT(*) as custom_field_count
FROM custom_field_values
GROUP BY member_id
) subq;
-- Check trigger execution time (requires pg_stat_statements)
SELECT
mean_exec_time,
calls,
query
FROM pg_stat_statements
WHERE query LIKE '%members_search_vector_trigger%'
ORDER BY mean_exec_time DESC;
```
## Code Quality Improvements (Post-Review)
### Refactored Search Implementation
The search query has been refactored for better maintainability and clarity:
**Before:** Single large OR-chain with mixed search types (hard to maintain)
**After:** Modular functions grouped by search type:
- `build_fts_filter/1` - Full-text search (highest priority, fastest)
- `build_substring_filter/2` - Substring matching on structured fields
- `build_custom_field_filter/1` - Custom field value search (JSONB LIKE)
- `build_fuzzy_filter/2` - Trigram/fuzzy matching for names and streets
**Benefits:**
- ✅ Clear separation of concerns
- ✅ Easier to maintain and test
- ✅ Better documentation of search priority
- ✅ Easier to optimize individual search types
**Search Priority Order:**
1. **FTS (Full-Text Search)** - Fastest, uses GIN index on search_vector
2. **Substring** - For structured fields (postal_code, phone_number, etc.)
3. **Custom Fields** - JSONB LIKE queries (fallback for substring matching)
4. **Fuzzy Matching** - Trigram similarity for names and streets
## Conclusion
The current implementation is **well-optimized for typical use cases** (< 30 custom fields per member, < 10,000 members). For larger scales, monitoring and potential optimizations may be needed.
**Key Strengths:**
- Indexed lookups (member_id index)
- Efficient GIN index for search
- Trigger-based automatic updates
- Modular, maintainable search code structure
**Key Weaknesses:**
- LIKE queries on JSONB (not indexed)
- Re-aggregation on every custom field change (necessary for consistency)
- Potential size issues with many/large custom fields
- Substring searches (contains/ILIKE) not index-optimized
**Recent Optimizations:**
- ✅ Trigger function optimized to fetch only required fields (reduces overhead by ~30-50%)
- ✅ Early return on UPDATE when value hasn't changed (skips expensive re-aggregation, <1ms vs 3-10ms)
- ✅ Improved performance for custom field value updates (3-10ms vs 5-15ms when value changes)

View file

@ -168,9 +168,16 @@ Member (1) → (N) Properties
### Weighted Fields ### Weighted Fields
- **Weight A (highest):** first_name, last_name - **Weight A (highest):** first_name, last_name
- **Weight B:** email, notes - **Weight B:** email, notes
- **Weight C:** phone_number, city, street, house_number, postal_code - **Weight C:** phone_number, city, street, house_number, postal_code, custom_field_values
- **Weight D (lowest):** join_date, exit_date - **Weight D (lowest):** join_date, exit_date
### Custom Field Values in Search
Custom field values are automatically included in the search vector:
- All custom field values (string, integer, boolean, date, email) are aggregated and added to the search vector
- Values are converted to text format for indexing
- Custom field values receive weight 'C' (same as phone_number, city, etc.)
- The search vector is automatically updated when custom field values are created, updated, or deleted via database triggers
### Usage Example ### Usage Example
```sql ```sql
SELECT * FROM members SELECT * FROM members

View file

@ -6,8 +6,8 @@
// - https://dbdocs.io // - https://dbdocs.io
// - VS Code Extensions: "DBML Language" or "dbdiagram.io" // - VS Code Extensions: "DBML Language" or "dbdiagram.io"
// //
// Version: 1.2 // Version: 1.3
// Last Updated: 2025-11-13 // Last Updated: 2025-12-11
Project mila_membership_management { Project mila_membership_management {
database_type: 'PostgreSQL' database_type: 'PostgreSQL'
@ -27,6 +27,7 @@ Project mila_membership_management {
## Domains: ## Domains:
- **Accounts**: User authentication and session management - **Accounts**: User authentication and session management
- **Membership**: Club member data and custom fields - **Membership**: Club member data and custom fields
- **MembershipFees**: Membership fee types and billing cycles
## Required PostgreSQL Extensions: ## Required PostgreSQL Extensions:
- uuid-ossp (UUID generation) - uuid-ossp (UUID generation)
@ -132,6 +133,8 @@ Table members {
house_number text [null, note: 'House number'] house_number text [null, note: 'House number']
postal_code text [null, note: '5-digit German postal code'] postal_code text [null, note: '5-digit German postal code']
search_vector tsvector [null, note: 'Full-text search index (auto-generated)'] search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type']
membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated']
indexes { indexes {
email [unique, name: 'members_unique_email_index'] email [unique, name: 'members_unique_email_index']
@ -146,6 +149,7 @@ Table members {
last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting'] last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting']
join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters'] join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters']
(paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL'] (paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL']
membership_fee_type_id [name: 'members_membership_fee_type_id_index', note: 'B-tree index for fee type lookups']
} }
Note: ''' Note: '''
@ -178,6 +182,8 @@ Table members {
**Relationships:** **Relationships:**
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account - Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
- 1:N with custom_field_values (custom dynamic fields) - 1:N with custom_field_values (custom dynamic fields)
- Optional N:1 with membership_fee_types - assigned fee type
- 1:N with membership_fee_cycles - billing history
**Validation Rules:** **Validation Rules:**
- first_name, last_name: min 1 character - first_name, last_name: min 1 character
@ -281,6 +287,98 @@ Table custom_fields {
''' '''
} }
// ============================================
// MEMBERSHIP_FEES DOMAIN
// ============================================
Table membership_fee_types {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
name text [not null, unique, note: 'Unique name for the fee type (e.g., "Standard", "Reduced")']
amount numeric(10,2) [not null, note: 'Fee amount in default currency (CHECK: >= 0)']
interval text [not null, note: 'Billing interval (CHECK: IN monthly, quarterly, half_yearly, yearly) - immutable']
description text [null, note: 'Optional description for the fee type']
indexes {
name [unique, name: 'membership_fee_types_unique_name_index']
}
Note: '''
**Membership Fee Type Definitions**
Defines the different types of membership fees with fixed billing intervals.
**Attributes:**
- `name`: Unique identifier for the fee type
- `amount`: Default fee amount (stored per cycle for audit trail)
- `interval`: Billing cycle - immutable after creation
- `description`: Optional documentation
**Interval Values:**
- `monthly`: 1st to last day of month
- `quarterly`: 1st of Jan/Apr/Jul/Oct to last day of quarter
- `half_yearly`: 1st of Jan/Jul to last day of half
- `yearly`: Jan 1 to Dec 31
**Immutability:**
The `interval` field cannot be changed after creation to prevent
complex migration scenarios. Create a new fee type to change intervals.
**Relationships:**
- 1:N with members - members assigned to this fee type
- 1:N with membership_fee_cycles - all cycles using this fee type
**Deletion Behavior:**
- ON DELETE RESTRICT: Cannot delete if members or cycles reference it
'''
}
Table membership_fee_cycles {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
cycle_start date [not null, note: 'Start date of the billing cycle']
amount numeric(10,2) [not null, note: 'Fee amount for this cycle (CHECK: >= 0)']
status text [not null, default: 'unpaid', note: 'Payment status (CHECK: IN unpaid, paid, suspended)']
notes text [null, note: 'Optional notes for this cycle']
member_id uuid [not null, note: 'FK to members - the member this cycle belongs to']
membership_fee_type_id uuid [not null, note: 'FK to membership_fee_types - fee type for this cycle']
indexes {
member_id [name: 'membership_fee_cycles_member_id_index']
membership_fee_type_id [name: 'membership_fee_cycles_membership_fee_type_id_index']
status [name: 'membership_fee_cycles_status_index']
cycle_start [name: 'membership_fee_cycles_cycle_start_index']
(member_id, cycle_start) [unique, name: 'membership_fee_cycles_unique_cycle_per_member_index', note: 'One cycle per member per cycle_start']
}
Note: '''
**Individual Membership Fee Cycles**
Represents a single billing cycle for a member with payment tracking.
**Design Decisions:**
- `cycle_end` is NOT stored - calculated from cycle_start + interval
- `amount` is stored per cycle to preserve historical values when fee type amount changes
- Cycles are aligned to calendar boundaries
**Status Values:**
- `unpaid`: Payment pending (default)
- `paid`: Payment received
- `suspended`: Payment suspended (e.g., hardship case)
**Constraints:**
- Unique: One cycle per member per cycle_start date
- member_id: Required (belongs_to)
- membership_fee_type_id: Required (belongs_to)
**Relationships:**
- N:1 with members - the member this cycle belongs to
- N:1 with membership_fee_types - the fee type for this cycle
**Deletion Behavior:**
- ON DELETE CASCADE (member_id): Cycles deleted when member deleted
- ON DELETE RESTRICT (membership_fee_type_id): Cannot delete fee type if cycles exist
'''
}
// ============================================ // ============================================
// RELATIONSHIPS // RELATIONSHIPS
// ============================================ // ============================================
@ -306,6 +404,22 @@ Ref: custom_field_values.member_id > members.id [delete: cascade]
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist // - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict] Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict]
// Member → MembershipFeeType (N:1)
// - Many members can be assigned to one fee type
// - Optional relationship (member can have no fee type)
// - ON DELETE RESTRICT: Cannot delete fee type if members are assigned
Ref: members.membership_fee_type_id > membership_fee_types.id [delete: restrict]
// MembershipFeeCycle → Member (N:1)
// - Many cycles belong to one member
// - ON DELETE CASCADE: Cycles deleted when member deleted
Ref: membership_fee_cycles.member_id > members.id [delete: cascade]
// MembershipFeeCycle → MembershipFeeType (N:1)
// - Many cycles reference one fee type
// - ON DELETE RESTRICT: Cannot delete fee type if cycles reference it
Ref: membership_fee_cycles.membership_fee_type_id > membership_fee_types.id [delete: restrict]
// ============================================ // ============================================
// ENUMS // ENUMS
// ============================================ // ============================================
@ -328,6 +442,21 @@ Enum token_purpose {
email_confirmation [note: 'Email verification tokens'] email_confirmation [note: 'Email verification tokens']
} }
// Billing interval for membership fee types
Enum membership_fee_interval {
monthly [note: '1st to last day of month']
quarterly [note: '1st of Jan/Apr/Jul/Oct to last day of quarter']
half_yearly [note: '1st of Jan/Jul to last day of half']
yearly [note: 'Jan 1 to Dec 31']
}
// Payment status for membership fee cycles
Enum membership_fee_status {
unpaid [note: 'Payment pending (default)']
paid [note: 'Payment received']
suspended [note: 'Payment suspended']
}
// ============================================ // ============================================
// TABLE GROUPS // TABLE GROUPS
// ============================================ // ============================================
@ -357,3 +486,17 @@ TableGroup membership_domain {
''' '''
} }
TableGroup membership_fees_domain {
membership_fee_types
membership_fee_cycles
Note: '''
**Membership Fees Domain**
Handles membership fee management including:
- Fee type definitions with intervals
- Individual billing cycles per member
- Payment status tracking
'''
}

View file

@ -187,16 +187,16 @@
**Current State:** **Current State:**
- ✅ Basic "paid" boolean field on members - ✅ Basic "paid" boolean field on members
- ✅ **UI Mock-ups for Contribution Types & Settings** (2025-12-02) - ✅ **UI Mock-ups for Membership Fee Types & Settings** (2025-12-02)
- ⚠️ No payment tracking - ⚠️ No payment tracking
**Open Issues:** **Open Issues:**
- [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority) - [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority)
- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Contribution Mockup Pages (Preview) - [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Membership Fee Mockup Pages (Preview)
**Mock-Up Pages (Non-Functional Preview):** **Mock-Up Pages (Non-Functional Preview):**
- `/contribution_types` - Contribution Types Management - `/membership_fee_types` - Membership Fee Types Management
- `/contribution_settings` - Global Contribution Settings - `/membership_fee_settings` - Global Membership Fee Settings
**Missing Features:** **Missing Features:**
- ❌ Membership fee configuration - ❌ Membership fee configuration

View file

@ -0,0 +1,723 @@
# Membership Fees - Technical Architecture
**Project:** Mila - Membership Management System
**Feature:** Membership Fee Management
**Version:** 1.0
**Last Updated:** 2025-11-27
**Status:** Architecture Design - Ready for Implementation
---
## Purpose
This document defines the technical architecture for the Membership Fees system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details.
**Related Documents:**
- [membership-fee-overview.md](./membership-fee-overview.md) - Business logic and requirements
- [database-schema-readme.md](./database-schema-readme.md) - Database documentation
- [database_schema.dbml](./database_schema.dbml) - Database schema definition
---
## Table of Contents
1. [Architecture Principles](#architecture-principles)
2. [Domain Structure](#domain-structure)
3. [Data Architecture](#data-architecture)
4. [Business Logic Architecture](#business-logic-architecture)
5. [Integration Points](#integration-points)
6. [Acceptance Criteria](#acceptance-criteria)
7. [Testing Strategy](#testing-strategy)
8. [Security Considerations](#security-considerations)
9. [Performance Considerations](#performance-considerations)
---
## Architecture Principles
### Core Design Decisions
1. **Single Responsibility:**
- Each module has one clear responsibility
- Cycle generation separated from status management
- Calendar logic isolated in dedicated module
2. **No Redundancy:**
- No `cycle_end` field (calculated from `cycle_start` + `interval`)
- No `interval_type` field (read from `membership_fee_type.interval`)
- Eliminates data inconsistencies
3. **Immutability Where Important:**
- `membership_fee_type.interval` cannot be changed after creation
- Prevents complex migration scenarios
- Enforced via Ash change validation
4. **Historical Accuracy:**
- `amount` stored per cycle for audit trail
- Enables tracking of membership fee changes over time
- Old cycles retain original amounts
5. **Calendar-Based Cycles:**
- All cycles aligned to calendar boundaries
- Simplifies date calculations
- Predictable cycle generation
---
## Domain Structure
### Ash Domain: `Mv.MembershipFees`
**Purpose:** Encapsulates all membership fee-related resources and logic
**Resources:**
- `MembershipFeeType` - Membership fee type definitions (admin-managed)
- `MembershipFeeCycle` - Individual membership fee cycles per member
**Extensions:**
- Member resource extended with membership fee fields
### Module Organization
```
lib/
├── membership_fees/
│ ├── membership_fees.ex # Ash domain definition
│ ├── membership_fee_type.ex # MembershipFeeType resource
│ ├── membership_fee_cycle.ex # MembershipFeeCycle resource
│ └── changes/
│ ├── prevent_interval_change.ex # Validates interval immutability
│ ├── set_membership_fee_start_date.ex # Auto-sets start date
│ └── validate_same_interval.ex # Validates interval match on type change
├── mv/
│ └── membership_fees/
│ ├── cycle_generator.ex # Cycle generation algorithm
│ └── calendar_cycles.ex # Calendar cycle calculations
└── membership/
└── member.ex # Extended with membership fee relationships
```
### Separation of Concerns
**Domain Layer (Ash Resources):**
- Data validation
- Relationship management
- Policy enforcement
- Action definitions
**Business Logic Layer (`Mv.MembershipFees`):**
- Cycle generation algorithm
- Calendar calculations
- Date boundary handling
- Status transitions
**UI Layer (LiveView):**
- User interaction
- Display logic
- Authorization checks
- Form handling
---
## Data Architecture
### Database Schema Extensions
**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation.
### New Tables
1. **`membership_fee_types`**
- Purpose: Define membership fee types with fixed intervals
- Key Constraint: `interval` field immutable after creation
- Relationships: has_many members, has_many membership_fee_cycles
2. **`membership_fee_cycles`**
- Purpose: Individual membership fee cycles for members
- Key Design: NO `cycle_end` or `interval_type` fields (calculated)
- Relationships: belongs_to member, belongs_to membership_fee_type
- Composite uniqueness: One cycle per member per cycle_start
### Member Table Extensions
**Fields Added:**
- `membership_fee_type_id` (FK, NOT NULL with default from settings)
- `membership_fee_start_date` (Date, nullable)
**Existing Fields Used:**
- `join_date` - For calculating membership fee start
- `exit_date` - For limiting cycle generation
- These fields must remain member fields and should not be replaced by custom fields in the future
### Settings Integration
**Global Settings:**
- `membership_fees.include_joining_cycle` (Boolean)
- `membership_fees.default_membership_fee_type_id` (UUID)
**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource)
### Foreign Key Behaviors
| Relationship | On Delete | Rationale |
|--------------|-----------|-----------|
| `membership_fee_cycles.member_id → members.id` | CASCADE | Remove membership fee cycles when member deleted |
| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if cycles exist |
| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if assigned to members |
---
## Business Logic Architecture
### Cycle Generation System
**Component:** `Mv.MembershipFees.CycleGenerator`
**Responsibilities:**
- Calculate which cycles should exist for a member
- Generate missing cycles
- Respect membership_fee_start_date and exit_date boundaries
- Skip existing cycles (idempotent)
- Use PostgreSQL advisory locks per member to prevent race conditions
**Triggers:**
1. Member membership fee type assigned (via Ash change)
2. Member created with membership fee type (via Ash change)
3. Scheduled job runs (daily/weekly cron)
4. Admin manual regeneration (UI action)
**Algorithm Steps:**
1. Retrieve member with membership fee type and dates
2. Determine generation start point:
- If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
- If cycles exist: Start from the cycle AFTER the last existing one
3. Generate all cycle starts from the determined start point to today (or `exit_date`)
4. Create new cycles with current membership fee type's amount
5. Use PostgreSQL advisory locks per member to prevent race conditions
**Edge Case Handling:**
- If membership_fee_start_date is NULL: Calculate from join_date + global setting
- If exit_date is set: Stop generation at exit_date
- If membership fee 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
**Component:** `Mv.MembershipFees.CalendarCycles`
**Responsibilities:**
- Calculate cycle boundaries based on interval type
- Determine current cycle
- Determine last completed cycle
- Calculate cycle_end from cycle_start + interval
**Functions (high-level):**
- `calculate_cycle_start/3` - Given date and interval, find cycle start
- `calculate_cycle_end/2` - Given cycle_start and interval, calculate end
- `next_cycle_start/2` - Given cycle_start and interval, find next
- `is_current_cycle?/2` - Check if cycle contains today
- `is_last_completed_cycle?/2` - Check if cycle just ended
**Interval Logic:**
- **Monthly:** Start = 1st of month, End = last day of month
- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter
- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half
- **Yearly:** Start = Jan 1st, End = Dec 31st
### Status Management
**Component:** Ash actions on `MembershipFeeCycle`
**Status Transitions:**
- Simple state machine: unpaid ↔ paid ↔ suspended
- No complex validation (all transitions allowed)
- Permissions checked via Ash policies
**Actions Required:**
- `mark_as_paid` - Set status to :paid
- `mark_as_suspended` - Set status to :suspended
- `mark_as_unpaid` - Set status to :unpaid (error correction)
**Bulk Operations:**
- `bulk_mark_as_paid` - Mark multiple cycles as paid (efficiency)
- low priority, can be a future issue
### Membership Fee Type Change Handling
**Component:** Ash change on `Member.membership_fee_type_id`
**Validation:**
- Check if new type has same interval as old type
- If different: Reject change (MVP constraint)
- If same: Allow change
**Side Effects on Allowed Change:**
1. Keep all existing cycles unchanged
2. Find future unpaid cycles
3. Delete future unpaid cycles
4. Regenerate cycles with new membership_fee_type_id and amount
**Implementation Pattern:**
- Use Ash change module to validate
- Use after_action hook to trigger regeneration synchronously
- Regeneration runs in the same transaction as the member update to ensure atomicity
- CycleGenerator uses advisory locks and transactions internally to prevent race conditions
**Validation Behavior:**
- Fail-closed: If membership fee types cannot be loaded during validation, the change is rejected with a validation error
- Nil assignment prevention: Attempts to set membership_fee_type_id to nil are rejected when a current type exists
---
## Integration Points
### Member Resource Integration
**Extension Points:**
1. Add fields via migration
2. Add relationships (belongs_to, has_many)
3. Add calculations (current_cycle_status, overdue_count)
4. Add changes (auto-set membership_fee_start_date, validate interval)
**Backward Compatibility:**
- New fields nullable or with defaults
- Existing members get default membership fee type from settings
- No breaking changes to existing member functionality
### Settings System Integration
**Requirements:**
- Store two global settings
- Provide UI for admin to modify
- Default values if not set
- Validation (e.g., default membership fee type must exist)
**Access Pattern:**
- Read settings during cycle generation
- Read settings during member creation
- Write settings only via admin UI
### Permission System Integration
**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
**Required Permissions:**
- `MembershipFeeType.create/update/destroy` - Admin only
- `MembershipFeeType.read` - Admin, Treasurer, Board
- `MembershipFeeCycle.update` (status changes) - Admin, Treasurer
- `MembershipFeeCycle.read` - Admin, Treasurer, Board, Own member
**Policy Patterns:**
- Use existing HasPermission check
- Leverage existing roles (Admin, Kassenwart)
- Member can read own cycles (linked via member_id)
### LiveView Integration
**New LiveViews Required:**
1. MembershipFeeType index/form (admin)
2. MembershipFeeCycle table component (member detail view)
3. Settings form section (admin)
4. Member list column (membership fee status)
**Existing LiveViews to Extend:**
- Member detail view: Add membership fees section
- Member list view: Add status column
- Settings page: Add membership fees section
**Authorization Helpers:**
- Use existing `can?/3` helper for UI conditionals
- Check permissions before showing actions
---
## Acceptance Criteria
### MembershipFeeType Resource
**AC-MFT-1:** Admin can create membership fee type with name, amount, interval, description
**AC-MFT-2:** Interval field is immutable after creation (validation error on change attempt)
**AC-MFT-3:** Admin can update name, amount, description (but not interval)
**AC-MFT-4:** Cannot delete membership fee type if assigned to members
**AC-MFT-5:** Cannot delete membership fee type if cycles exist referencing it
**AC-MFT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly
### MembershipFeeCycle Resource
**AC-MFC-1:** Cycle has cycle_start, status, amount, notes, member_id, membership_fee_type_id
**AC-MFC-2:** cycle_end is calculated, not stored
**AC-MFC-3:** Status defaults to :unpaid
**AC-MFC-4:** One cycle per member per cycle_start (uniqueness constraint)
**AC-MFC-5:** Amount is set at generation time from membership_fee_type.amount
**AC-MFC-6:** Cycles cascade delete when member deleted
**AC-MFC-7:** Admin/Treasurer can change status
**AC-MFC-8:** Member can read own cycles
### Member Extensions
**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default)
**AC-M-2:** Member has membership_fee_start_date field (nullable)
**AC-M-3:** New members get default membership fee type from global setting
**AC-M-4:** membership_fee_start_date auto-set based on join_date and global setting
**AC-M-5:** Admin can manually override membership_fee_start_date
**AC-M-6:** Cannot change to membership fee type with different interval (MVP)
### Cycle Generation
**AC-CG-1:** Cycles generated when member gets membership fee type
**AC-CG-2:** Cycles generated when member created (via change hook)
**AC-CG-3:** Scheduled job generates missing cycles daily
**AC-CG-4:** Generation respects membership_fee_start_date
**AC-CG-5:** Generation stops at exit_date if member exited
**AC-CG-6:** Generation is idempotent (skips existing cycles)
**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year)
**AC-CG-8:** Amount comes from membership_fee_type at generation time
### Calendar Logic
**AC-CL-1:** Monthly cycles: 1st to last day of month
**AC-CL-2:** Quarterly cycles: 1st of Jan/Apr/Jul/Oct to last day of quarter
**AC-CL-3:** Half-yearly cycles: 1st of Jan/Jul to last day of half
**AC-CL-4:** Yearly cycles: Jan 1 to Dec 31
**AC-CL-5:** cycle_end calculated correctly for all interval types
**AC-CL-6:** Current cycle determined correctly based on today's date
**AC-CL-7:** Last completed cycle determined correctly
### Membership Fee Type Change
**AC-TC-1:** Can change to type with same interval
**AC-TC-2:** Cannot change to type with different interval (error message)
**AC-TC-3:** On allowed change: future unpaid cycles regenerated
**AC-TC-4:** On allowed change: paid/suspended cycles unchanged
**AC-TC-5:** On allowed change: amount updated to new type's amount
**AC-TC-6:** Change is atomic (transaction) - ✅ Implemented: Regeneration runs synchronously in the same transaction as the member update
### Settings
**AC-S-1:** Global setting: include_joining_cycle (boolean, default true)
**AC-S-2:** Global setting: default_membership_fee_type_id (UUID, required)
**AC-S-3:** Admin can modify settings via UI
**AC-S-4:** Settings validated (e.g., default membership fee type must exist)
**AC-S-5:** Settings applied to new members immediately
### UI - Member List
**AC-UI-ML-1:** New column shows membership fee status
**AC-UI-ML-2:** Default: Shows last completed cycle status
**AC-UI-ML-3:** Optional: Toggle to show current cycle status
**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended)
**AC-UI-ML-5:** Filter: Unpaid in last cycle
**AC-UI-ML-6:** Filter: Unpaid in current cycle
### UI - Member Detail
**AC-UI-MD-1:** Membership fees section shows all cycles
**AC-UI-MD-2:** Table columns: Cycle, Interval, Amount, Status, Actions
**AC-UI-MD-3:** Checkbox per cycle for bulk marking (low prio)
**AC-UI-MD-4:** "Mark selected as paid" button
**AC-UI-MD-5:** Dropdown to change membership fee type (same interval only)
**AC-UI-MD-6:** Warning if different interval selected
**AC-UI-MD-7:** Only show actions if user has permission
### UI - Membership Fee Types Admin
**AC-UI-CTA-1:** List all membership fee types
**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count
**AC-UI-CTA-3:** Create new membership fee type form
**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable
**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable)
**AC-UI-CTA-6:** Warning on amount change (explain impact)
**AC-UI-CTA-7:** Cannot delete if members assigned
**AC-UI-CTA-8:** Only admin can access
### UI - Settings Admin
**AC-UI-SA-1:** Membership fees section in settings
**AC-UI-SA-2:** Dropdown to select default membership fee type
**AC-UI-SA-3:** Checkbox: Include joining cycle
**AC-UI-SA-4:** Explanatory text with examples
**AC-UI-SA-5:** Save button with validation
---
## Testing Strategy
### Unit Testing
**Cycle Generator Tests:**
- Correct cycle_start calculation for all interval types
- Correct cycle count from start to end date
- Respects membership_fee_start_date boundary
- Respects exit_date boundary
- Skips existing cycles (idempotent)
- Does not fill gaps when cycles were deleted
- Handles edge dates (year boundaries, leap years)
**Calendar Cycles Tests:**
- Cycle boundaries correct for all intervals
- cycle_end calculation correct
- Current cycle detection
- Last completed cycle detection
- Next cycle calculation
**Validation Tests:**
- Interval immutability enforced
- Same interval validation on type change
- Status transitions allowed
- Uniqueness constraints enforced
### Integration Testing
**Cycle Generation Flow:**
- Member creation triggers generation
- Type assignment triggers generation
- Type change regenerates future cycles
- Scheduled job generates missing cycles
- Left member stops generation
**Status Management Flow:**
- Mark single cycle as paid
- Bulk mark multiple cycles (low prio)
- Status transitions work
- Permissions enforced
**Membership Fee Type Management:**
- Create type
- Update amount (regeneration triggered)
- Cannot update interval
- Cannot delete if in use
### LiveView Testing
**Member List:**
- Status column displays correctly
- Toggle between last/current works
- Filters work correctly
- Color coding applied
**Member Detail:**
- Cycles table displays all cycles
- Checkboxes work
- Bulk marking works (low prio)
- Membership fee type change validation works
- Actions only shown with permission
**Admin UI:**
- Type CRUD works
- Settings save correctly
- Validations display errors
- Only authorized users can access
### Edge Case Testing
**Interval Change Attempt:**
- Error message displayed
- No data modified
- User can cancel/choose different type
**Exit with Unpaid:**
- Warning shown
- Option to suspend offered
- Exit completes correctly
**Amount Change:**
- Warning displayed
- Only future unpaid regenerated
- Historical cycles unchanged
**Date Boundaries:**
- Today = cycle start handled
- Today = cycle end handled
- Leap year handled
### Performance Testing
**Cycle Generation:**
- Generate 10 years of monthly cycles: < 100ms
- Generate for 1000 members: < 5 seconds
- Idempotent check efficient (no full scan)
**Member List Query:**
- With status column: < 200ms for 1000 members
- Filters applied efficiently
- No N+1 queries
---
## Security Considerations
### Authorization
**Permissions Required:**
- Membership fee type management: Admin only
- Membership fee cycle status changes: Admin + Treasurer
- View all cycles: Admin + Treasurer + Board
- View own cycles: All authenticated users
**Policy Enforcement:**
- All actions protected by Ash policies
- UI shows/hides based on permissions
- Backend validates permissions (never trust UI alone)
### Data Integrity
**Validation Layers:**
1. Database constraints (NOT NULL, UNIQUE, CHECK)
2. Ash validations (business rules)
3. UI validations (user experience)
**Immutability Protection:**
- Interval change prevented at multiple layers
- Cycle amounts immutable (audit trail)
- Settings changes logged (future)
### Audit Trail
**Tracked Information:**
- Cycle status changes (who, when) - future enhancement
- Membership fee type amount changes (implicit via cycle amounts)
---
## Performance Considerations
### Database Indexes
**Required Indexes:**
- `membership_fee_cycles(member_id)` - For member cycle lookups
- `membership_fee_cycles(membership_fee_type_id)` - For type queries
- `membership_fee_cycles(status)` - For unpaid filters
- `membership_fee_cycles(cycle_start)` - For date range queries
- `membership_fee_cycles(member_id, cycle_start)` - Composite unique index
- `members(membership_fee_type_id)` - For type membership count
### Query Optimization
**Preloading:**
- Load membership_fee_type with cycles (avoid N+1)
- Load cycles when displaying member detail
- Use Ash's load for efficient preloading
**Calculated Fields:**
- cycle_end calculated on-demand (not stored)
- current_cycle_status calculated when needed
- Use Ash calculations for lazy evaluation
**Pagination:**
- Cycle list paginated if > 50 cycles
- Member list already paginated
### Caching Strategy
**No caching needed in MVP:**
- Membership fee types rarely change
- Cycle queries are fast
- Settings read infrequently
**Future caching if needed:**
- Cache settings in application memory
- Cache membership fee types list
- Invalidate on change
### Scheduled Job Performance
**Cycle Generation Job:**
- Run daily or weekly (not hourly)
- Batch members (process 100 at a time)
- Skip members with no changes
- Log failures for retry
---
## Future Enhancements
### Phase 2: Interval Change Support
**Architecture Changes:**
- Add logic to handle cycle overlaps
- Calculate prorata amounts if needed
- More complex validation
- Migration path for existing cycles
### Phase 3: Payment Details
**Architecture Changes:**
- Add PaymentTransaction resource
- Link transactions to cycles
- Support multiple payments per cycle
- Reconciliation logic
### Phase 4: vereinfacht.digital Integration
**Architecture Changes:**
- External API client module
- Webhook handling for transactions
- Automatic matching logic
- Manual review interface
---
**End of Architecture Document**

View file

@ -1,7 +1,7 @@
# Membership Contributions - Overview # Membership Fees - Overview
**Project:** Mila - Membership Management System **Project:** Mila - Membership Management System
**Feature:** Membership Contribution Management **Feature:** Membership Fee Management
**Version:** 1.0 **Version:** 1.0
**Last Updated:** 2025-11-27 **Last Updated:** 2025-11-27
**Status:** Concept - Ready for Review **Status:** Concept - Ready for Review
@ -10,9 +10,9 @@
## Purpose ## Purpose
This document provides a comprehensive overview of the Membership Contributions system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format. This document provides a comprehensive overview of the Membership Fees system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format.
**For detailed implementation:** See [contributions-implementation-plan.md](./contributions-implementation-plan.md) (created after concept iterations) **For detailed implementation:** See [membership-fee-implementation-plan.md](./membership-fee-implementation-plan.md) (created after concept iterations)
--- ---
@ -36,7 +36,7 @@ This document provides a comprehensive overview of the Membership Contributions
- Minimal complexity - Minimal complexity
- Clear data model without redundancies - Clear data model without redundancies
- Intuitive operation - Intuitive operation
- Calendar period-based (Month/Quarter/Half-Year/Year) - Calendar cycle-based (Month/Quarter/Half-Year/Year)
--- ---
@ -46,9 +46,9 @@ This document provides a comprehensive overview of the Membership Contributions
**Core Entities:** **Core Entities:**
- Beitragsart ↔ Contribution Type / Membership Fee Type - Beitragsart ↔ Membership Fee Type
- Beitragsintervall ↔ Contribution Period - Beitragszyklus ↔ Membership Fee Cycle
- Mitgliedsbeitrag ↔ Membership Fee / Contribution - Mitgliedsbeitrag ↔ Membership Fee
**Status:** **Status:**
@ -56,7 +56,7 @@ This document provides a comprehensive overview of the Membership Contributions
- unbezahlt ↔ unpaid - unbezahlt ↔ unpaid
- ausgesetzt ↔ suspended / waived - ausgesetzt ↔ suspended / waived
**Intervals:** **Intervals (Frequenz / Payment Frequency):**
- monatlich ↔ monthly - monatlich ↔ monthly
- quartalsweise ↔ quarterly - quartalsweise ↔ quarterly
@ -65,8 +65,8 @@ This document provides a comprehensive overview of the Membership Contributions
**UI Elements:** **UI Elements:**
- "Letztes Intervall" ↔ "Last Period" (e.g., 2023 when in 2024) - "Letzter Zyklus" ↔ "Last Cycle" (e.g., 2023 when in 2024)
- "Aktuelles Intervall" ↔ "Current Period" (e.g., 2024) - "Aktueller Zyklus" ↔ "Current Cycle" (e.g., 2024)
- "Als bezahlt markieren" ↔ "Mark as paid" - "Als bezahlt markieren" ↔ "Mark as paid"
- "Aussetzen" ↔ "Suspend" / "Waive" - "Aussetzen" ↔ "Suspend" / "Waive"
@ -74,43 +74,41 @@ This document provides a comprehensive overview of the Membership Contributions
## Data Model ## Data Model
### Contribution Type (ContributionType) ### Membership Fee Type (MembershipFeeType)
``` ```
- id (UUID) - id (UUID)
- name (String) - e.g., "Regular", "Reduced", "Student" - name (String) - e.g., "Regular", "Reduced", "Student"
- amount (Decimal) - Contribution amount in Euro - amount (Decimal) - Membership fee amount in Euro
- interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly - interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly
- description (Text, optional) - description (Text, optional)
- timestamps
``` ```
**Important:** **Important:**
- `interval` is **IMMUTABLE** after creation! - `interval` is **IMMUTABLE** after creation!
- Admin can only change `name`, `amount`, `description` - Admin can only change `name`, `amount`, `description`
- On change: Future unpaid periods regenerated with new amount - On change: Future unpaid cycles regenerated with new amount
### Contribution Period (ContributionPeriod) ### Membership Fee Cycle (MembershipFeeCycle)
``` ```
- id (UUID) - id (UUID)
- member_id (FK → members.id) - member_id (FK → members.id)
- contribution_type_id (FK → contribution_types.id) - membership_fee_type_id (FK → membership_fee_types.id)
- period_start (Date) - Calendar period start (01.01., 01.04., 01.07., 01.10., etc.) - cycle_start (Date) - Calendar cycle start (01.01., 01.04., 01.07., 01.10., etc.)
- status (Enum) - :unpaid (default), :paid, :suspended - status (Enum) - :unpaid (default), :paid, :suspended
- amount (Decimal) - Amount at generation time (history when type changes) - amount (Decimal) - Membership fee amount at generation time (history when type changes)
- notes (Text, optional) - Admin notes - notes (Text, optional) - Admin notes
- timestamps
``` ```
**Important:** **Important:**
- **NO** `period_end` - calculated from `period_start` + `interval` - **NO** `cycle_end` - calculated from `cycle_start` + `interval`
- **NO** `interval_type` - read from `contribution_type.interval` - **NO** `interval_type` - read from `membership_fee_type.interval`
- Avoids redundancy and inconsistencies! - Avoids redundancy and inconsistencies!
**Calendar Period Logic:** **Calendar Cycle Logic:**
- Monthly: 01.01. - 31.01., 01.02. - 28./29.02., etc. - Monthly: 01.01. - 31.01., 01.02. - 28./29.02., etc.
- Quarterly: 01.01. - 31.03., 01.04. - 30.06., 01.07. - 30.09., 01.10. - 31.12. - Quarterly: 01.01. - 31.03., 01.04. - 30.06., 01.07. - 30.09., 01.10. - 31.12.
@ -120,70 +118,76 @@ This document provides a comprehensive overview of the Membership Contributions
### Member (Extensions) ### Member (Extensions)
``` ```
- contribution_type_id (FK → contribution_types.id, NOT NULL, default from settings) - membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings)
- contribution_start_date (Date, nullable) - When to start generating contributions - 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 contribution_start_date:** **Logic for membership_fee_start_date:**
- Auto-set based on global setting `include_joining_period` - Auto-set based on global setting `include_joining_cycle`
- If `include_joining_period = true`: First day of joining month/quarter/year - If `include_joining_cycle = true`: First day of joining month/quarter/year
- If `include_joining_period = false`: First day of NEXT period after joining - If `include_joining_cycle = false`: First day of NEXT cycle after joining
- Can be manually overridden by admin - Can be manually overridden by admin
**NO** `include_joining_period` field on Member - unnecessary due to `contribution_start_date`! **NO** `include_joining_cycle` field on Member - unnecessary due to `membership_fee_start_date`!
### Global Settings ### Global Settings
``` ```
key: "contributions.include_joining_period" key: "membership_fees.include_joining_cycle"
value: Boolean (Default: true) value: Boolean (Default: true)
key: "contributions.default_contribution_type_id" key: "membership_fees.default_membership_fee_type_id"
value: UUID (Required) - Default contribution type for new members value: UUID (Required) - Default membership fee type for new members
``` ```
**Meaning include_joining_period:** **Meaning include_joining_cycle:**
- `true`: Joining period is included (member pays from joining period) - `true`: Joining cycle is included (member pays from joining cycle)
- `false`: Only from next full period after joining - `false`: Only from next full cycle after joining
**Meaning default_contribution_type_id:** **Meaning of default membership fee type setting:**
- Every new member automatically gets this contribution type - Every new member automatically gets this membership fee type
- Must be configured in admin settings - Must be configured in admin settings
- Prevents: Members without contribution type - Prevents: Members without membership fee type
--- ---
## Business Logic ## Business Logic
### Period Generation ### Cycle Generation
**Triggers:** **Triggers:**
- Member gets contribution type assigned (also during member creation) - Member gets membership fee type assigned (also during member creation)
- New period begins (Cron job daily/weekly) - New cycle begins (Cron job daily/weekly)
- Admin requests manual regeneration - Admin requests manual regeneration
**Algorithm:** **Algorithm:**
1. Get `member.contribution_start_date` and `member.contribution_type` Use PostgreSQL advisory locks per member to prevent race conditions
2. Calculate first period based on `contribution_start_date`
3. Generate all periods from start to today (or `left_at` if present) 1. Get `member.membership_fee_start_date` and member's membership fee type
4. Skip existing periods 2. Determine generation start point:
5. Set `amount` to current `contribution_type.amount` - If NO cycles exist: Start from `membership_fee_start_date`
- If cycles exist: Start from the cycle AFTER the last existing one
3. Generate cycles until today (or `exit_date` if present):
- Use the interval to generate the cycles
- **Note:** If cycles were explicitly deleted (gaps exist), they are NOT recreated.
The generator always continues from the cycle AFTER the last existing cycle.
4. Set `amount` to current membership fee type's amount
**Example (Yearly):** **Example (Yearly):**
``` ```
Joining date: 15.03.2023 Joining date: 15.03.2023
include_joining_period: true include_joining_cycle: true
contribution_start_date: 01.01.2023 membership_fee_start_date: 01.01.2023
Generated periods: Generated cycles:
- 01.01.2023 - 31.12.2023 (joining period) - 01.01.2023 - 31.12.2023 (joining cycle)
- 01.01.2024 - 31.12.2024 - 01.01.2024 - 31.12.2024
- 01.01.2025 - 31.12.2025 (current year) - 01.01.2025 - 31.12.2025 (current year)
``` ```
@ -192,10 +196,10 @@ Generated periods:
``` ```
Joining date: 15.03.2023 Joining date: 15.03.2023
include_joining_period: false include_joining_cycle: false
contribution_start_date: 01.04.2023 membership_fee_start_date: 01.04.2023
Generated periods: Generated cycles:
- 01.04.2023 - 30.06.2023 (first full quarter) - 01.04.2023 - 30.06.2023 (first full quarter)
- 01.07.2023 - 30.09.2023 - 01.07.2023 - 30.09.2023
- 01.10.2023 - 31.12.2023 - 01.10.2023 - 31.12.2023
@ -218,44 +222,44 @@ suspended → unpaid
- Admin + Treasurer (Kassenwart) can change status - Admin + Treasurer (Kassenwart) can change status
- Uses existing permission system - Uses existing permission system
### Contribution Type Change ### Membership Fee Type Change
**MVP - Same Interval Only:** **MVP - Same Cycle Only:**
- Member can only choose contribution type with **same interval** - Member can only choose membership fee type with **same cycle**
- Example: From "Regular (yearly)" to "Reduced (yearly)" ✓ - Example: From "Regular (yearly)" to "Reduced (yearly)" ✓
- Example: From "Regular (yearly)" to "Reduced (monthly)" ✗ - Example: From "Regular (yearly)" to "Reduced (monthly)" ✗
**Logic on Change:** **Logic on Change:**
1. Check: New contribution type has same interval 1. Check: New membership fee type has same interval
2. If yes: Set `member.contribution_type_id` 2. If yes: Set `member.membership_fee_type_id`
3. Future **unpaid** periods: Delete and regenerate with new amount 3. Future **unpaid** cycles: Delete and regenerate with new amount
4. Paid/suspended periods: Remain unchanged (historical amount) 4. Paid/suspended cycles: Remain unchanged (historical amount)
**Future - Different Intervals:** **Future - Different Intervals:**
- Enable interval switching (e.g., yearly → monthly) - Enable interval switching (e.g., yearly → monthly)
- More complex logic for period overlaps - More complex logic for cycle overlaps
- Needs additional validation - Needs additional validation
### Member Exit ### Member Exit
**Logic:** **Logic:**
- Periods only generated until `member.left_at` - Cycles only generated until `member.exit_date`
- Existing periods remain visible - Existing cycles remain visible
- Unpaid exit period can be marked as "suspended" - Unpaid exit cycle can be marked as "suspended"
**Example:** **Example:**
``` ```
Exit: 15.08.2024 Exit: 15.08.2024
Yearly period: 01.01.2024 - 31.12.2024 Yearly cycle: 01.01.2024 - 31.12.2024
Period 2024 is shown (Status: unpaid) Cycle 2024 is shown (Status: unpaid)
→ Admin can set to "suspended" → Admin can set to "suspended"
→ No periods for 2025+ generated → No cycles for 2025+ generated
``` ```
--- ---
@ -264,46 +268,46 @@ Yearly period: 01.01.2024 - 31.12.2024
### Member List View ### Member List View
**New Column: "Contribution Status"** **New Column: "Membership Fee Status"**
**Default Display (Last Period):** **Default Display (Last Cycle):**
- Shows status of **last completed** period - Shows status of **last completed** cycle
- Example in 2024: Shows contribution for 2023 - Example in 2024: Shows membership fee for 2023
- Color coding: - Color coding:
- Green: paid ✓ - Green: paid ✓
- Red: unpaid ✗ - Red: unpaid ✗
- Gray: suspended ⊘ - Gray: suspended ⊘
**Optional: Show Current Period** **Optional: Show Current Cycle**
- Toggle: "Show current period" (2024) - Toggle: "Show current cycle" (2024)
- Admin decides what to display - Admin decides what to display
**Filters:** **Filters:**
- "Unpaid contributions in last period" - "Unpaid membership fees in last cycle"
- "Unpaid contributions in current period" - "Unpaid membership fees in current cycle"
### Member Detail View ### Member Detail View
**Section: "Contributions"** **Section: "Membership Fees"**
**Contribution Type Assignment:** **Membership Fee Type Assignment:**
``` ```
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
Contribution Type: [Dropdown] Membership Fee Type: [Dropdown]
│ ⚠ Only types with same interval │ ⚠ Only types with same interval │
│ can be selected │ can be selected │
└─────────────────────────────────────┘ └─────────────────────────────────────┘
``` ```
**Period Table:** **Cycle Table:**
``` ```
┌───────────────┬──────────┬────────┬──────────┬─────────┐ ┌───────────────┬──────────┬────────┬──────────┬─────────┐
Period │ Interval │ Amount │ Status │ Action │ Cycle │ Interval │ Amount │ Status │ Action │
├───────────────┼──────────┼────────┼──────────┼─────────┤ ├───────────────┼──────────┼────────┼──────────┼─────────┤
│ 01.01.2023- │ Yearly │ 50 € │ ☑ Paid │ │ │ 01.01.2023- │ Yearly │ 50 € │ ☑ Paid │ │
│ 31.12.2023 │ │ │ │ │ │ 31.12.2023 │ │ │ │ │
@ -322,9 +326,9 @@ Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended
- Checkbox in each row for fast marking - Checkbox in each row for fast marking
- Button: "Mark selected as paid/unpaid/suspended" - Button: "Mark selected as paid/unpaid/suspended"
- Bulk action for multiple periods - Bulk action for multiple cycles
### Admin: Contribution Types Management ### Admin: Membership Fee Types Management
**List:** **List:**
@ -352,37 +356,37 @@ Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended
Impact: Impact:
- 45 members affected - 45 members affected
- Future unpaid periods will be generated with 65 € - Future unpaid cycles will be generated with 65 €
- Already paid periods remain with old amount - Already paid cycles remain with old amount
[Cancel] [Confirm] [Cancel] [Confirm]
``` ```
### Admin: Settings ### Admin: Settings
**Contribution Configuration:** **Membership Fee Configuration:**
``` ```
Default Contribution Type: [Dropdown: Contribution Types] Default Membership Fee Type: [Dropdown: Membership Fee Types]
Selected: "Regular (60 €, Yearly)" Selected: "Regular (60 €, Yearly)"
This contribution type is automatically assigned to all new members. This membership fee type is automatically assigned to all new members.
Can be changed individually per member. Can be changed individually per member.
--- ---
☐ Include joining period ☐ Include joining cycle
When active: When active:
Members pay from the period of their joining. Members pay from the cycle of their joining.
Example (Yearly): Example (Yearly):
Joining: 15.03.2023 Joining: 15.03.2023
→ Pays from 2023 → Pays from 2023
When inactive: When inactive:
Members pay from the next full period. Members pay from the next full cycle.
Example (Yearly): Example (Yearly):
Joining: 15.03.2023 Joining: 15.03.2023
@ -393,7 +397,7 @@ Joining: 15.03.2023
## Edge Cases ## Edge Cases
### 1. Contribution Type Change with Different Interval ### 1. Membership Fee Type Change with Different Interval
**MVP:** Blocked (only same interval allowed) **MVP:** Blocked (only same interval allowed)
@ -402,11 +406,11 @@ Joining: 15.03.2023
``` ```
Error: Interval change not possible Error: Interval change not possible
Current contribution type: "Regular (Yearly)" Current membership fee type: "Regular (Yearly)"
Selected contribution type: "Student (Monthly)" Selected membership fee type: "Student (Monthly)"
Changing the interval is currently not possible. Changing the interval is currently not possible.
Please select a contribution type with interval "Yearly". Please select a membership fee type with interval "Yearly".
[OK] [OK]
``` ```
@ -415,32 +419,32 @@ Please select a contribution type with interval "Yearly".
- Allow interval switching - Allow interval switching
- Calculate overlaps - Calculate overlaps
- Generate new periods without duplicates - Generate new cycles without duplicates
### 2. Exit with Unpaid Contributions ### 2. Exit with Unpaid Membership Fees
**Scenario:** **Scenario:**
``` ```
Member exits: 15.08.2024 Member exits: 15.08.2024
Yearly period 2024: unpaid Yearly cycle 2024: unpaid
``` ```
**UI Notice on Exit: (Low Prio)** **UI Notice on Exit: (Low Prio)**
``` ```
⚠ Unpaid contributions present ⚠ Unpaid membership fees present
This member has 1 unpaid period(s): This member has 1 unpaid cycle(s):
- 2024: 60 € (unpaid) - 2024: 60 € (unpaid)
Do you want to continue? Do you want to continue?
[ ] Mark contribution as "suspended" [ ] Mark membership fee as "suspended"
[Cancel] [Confirm Exit] [Cancel] [Confirm Exit]
``` ```
### 3. Multiple Unpaid Periods ### 3. Multiple Unpaid Cycles
**Scenario:** Member hasn't paid for 2 years **Scenario:** Member hasn't paid for 2 years
@ -467,9 +471,9 @@ Do you want to continue?
**Result:** **Result:**
- Period 2023: Saved with 50 € (history) - Cycle 2023: Saved with 50 € (history)
- Period 2024: Generated with 60 € (current) - Cycle 2024: Generated with 60 € (current)
- Both periods show correct historical amount - Both cycles show correct historical amount
### 5. Date Boundaries ### 5. Date Boundaries
@ -477,7 +481,7 @@ Do you want to continue?
**Solution:** **Solution:**
- Current period (2025) is generated - Current cycle (2025) is generated
- Status: unpaid (open) - Status: unpaid (open)
- Shown in overview - Shown in overview
@ -489,17 +493,17 @@ Do you want to continue?
**Included:** **Included:**
- ✓ Contribution types (CRUD) - ✓ Membership fee types (CRUD)
- ✓ Automatic period generation - ✓ Automatic cycle generation
- ✓ Status management (paid/unpaid/suspended) - ✓ Status management (paid/unpaid/suspended)
- ✓ Member overview with contribution status - ✓ Member overview with membership fee status
- ✓ Period view per member - ✓ Cycle view per member
- ✓ Quick checkbox marking - ✓ Quick checkbox marking
- ✓ Bulk actions - ✓ Bulk actions
- ✓ Amount history - ✓ Amount history
- ✓ Same-interval type change - ✓ Same-interval type change
- ✓ Default contribution type - ✓ Default membership fee type
- ✓ Joining period configuration - ✓ Joining cycle configuration
**NOT Included:** **NOT Included:**
@ -515,7 +519,7 @@ Do you want to continue?
**Phase 2:** **Phase 2:**
- Payment details (date, amount, method) - Payment details (date, amount, method)
- Interval change for future unpaid periods - Interval change for future unpaid cycles
- Manual vereinfacht.digital links per member - Manual vereinfacht.digital links per member
- Extended filter options - Extended filter options

View file

@ -93,8 +93,8 @@ Five predefined roles stored in the `roles` table:
Control CRUD operations on: Control CRUD operations on:
- User (credentials, profile) - User (credentials, profile)
- Member (member data) - Member (member data)
- Property (custom field values) - CustomFieldValue (custom field values)
- PropertyType (custom field definitions) - CustomField (custom field definitions)
- Role (role management) - Role (role management)
**4. Page-Level Permissions** **4. Page-Level Permissions**
@ -111,7 +111,7 @@ Three scope levels for permissions:
- **:own** - Only records where `record.id == user.id` (for User resource) - **:own** - Only records where `record.id == user.id` (for User resource)
- **:linked** - Only records linked to user via relationships - **:linked** - Only records linked to user via relationships
- Member: `member.user_id == user.id` - Member: `member.user_id == user.id`
- Property: `property.member.user_id == user.id` - CustomFieldValue: `custom_field_value.member.user_id == user.id`
- **:all** - All records, no filtering - **:all** - All records, no filtering
**6. Special Cases** **6. Special Cases**
@ -414,7 +414,7 @@ defmodule Mv.Authorization.PermissionSets do
## Permission Sets ## Permission Sets
1. **own_data** - Default for "Mitglied" role 1. **own_data** - Default for "Mitglied" role
- Can only access own user data and linked member/properties - Can only access own user data and linked member/custom field values
- Cannot create new members or manage system - Cannot create new members or manage system
2. **read_only** - For "Vorstand" and "Buchhaltung" roles 2. **read_only** - For "Vorstand" and "Buchhaltung" roles
@ -423,11 +423,11 @@ defmodule Mv.Authorization.PermissionSets do
3. **normal_user** - For "Kassenwart" role 3. **normal_user** - For "Kassenwart" role
- Create/Read/Update members (no delete), full CRUD on properties - Create/Read/Update members (no delete), full CRUD on properties
- Cannot manage property types or users - Cannot manage custom fields or users
4. **admin** - For "Admin" role 4. **admin** - For "Admin" role
- Unrestricted access to all resources - Unrestricted access to all resources
- Can manage users, roles, property types - Can manage users, roles, custom fields
## Usage ## Usage
@ -500,12 +500,12 @@ defmodule Mv.Authorization.PermissionSets do
%{resource: "Member", action: :read, scope: :linked, granted: true}, %{resource: "Member", action: :read, scope: :linked, granted: true},
%{resource: "Member", action: :update, scope: :linked, granted: true}, %{resource: "Member", action: :update, scope: :linked, granted: true},
# Property: Can read/update properties of linked member # CustomFieldValue: Can read/update custom field values of linked member
%{resource: "Property", action: :read, scope: :linked, granted: true}, %{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
%{resource: "Property", action: :update, scope: :linked, granted: true}, %{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
# PropertyType: Can read all (needed for forms) # CustomField: Can read all (needed for forms)
%{resource: "PropertyType", action: :read, scope: :all, granted: true} %{resource: "CustomField", action: :read, scope: :all, granted: true}
], ],
pages: [ pages: [
"/", # Home page "/", # Home page
@ -525,17 +525,17 @@ defmodule Mv.Authorization.PermissionSets do
# Member: Can read all members, no modifications # Member: Can read all members, no modifications
%{resource: "Member", action: :read, scope: :all, granted: true}, %{resource: "Member", action: :read, scope: :all, granted: true},
# Property: Can read all properties # CustomFieldValue: Can read all custom field values
%{resource: "Property", action: :read, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
# PropertyType: Can read all # CustomField: Can read all
%{resource: "PropertyType", action: :read, scope: :all, granted: true} %{resource: "CustomField", action: :read, scope: :all, granted: true}
], ],
pages: [ pages: [
"/", "/",
"/members", # Member list "/members", # Member list
"/members/:id", # Member detail "/members/:id", # Member detail
"/properties", # Property overview "/custom_field_values" # Custom field values overview
"/profile" # Own profile "/profile" # Own profile
] ]
} }
@ -554,14 +554,14 @@ defmodule Mv.Authorization.PermissionSets do
%{resource: "Member", action: :update, scope: :all, granted: true}, %{resource: "Member", action: :update, scope: :all, granted: true},
# Note: destroy intentionally omitted for safety # Note: destroy intentionally omitted for safety
# Property: Full CRUD # CustomFieldValue: Full CRUD
%{resource: "Property", action: :read, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
%{resource: "Property", action: :create, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
%{resource: "Property", action: :update, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
%{resource: "Property", action: :destroy, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# PropertyType: Read only (admin manages definitions) # CustomField: Read only (admin manages definitions)
%{resource: "PropertyType", action: :read, scope: :all, granted: true} %{resource: "CustomField", action: :read, scope: :all, granted: true}
], ],
pages: [ pages: [
"/", "/",
@ -569,9 +569,9 @@ defmodule Mv.Authorization.PermissionSets do
"/members/new", # Create member "/members/new", # Create member
"/members/:id", "/members/:id",
"/members/:id/edit", # Edit member "/members/:id/edit", # Edit member
"/properties", "/custom_field_values",
"/properties/new", "/custom_field_values/new",
"/properties/:id/edit", "/custom_field_values/:id/edit",
"/profile" "/profile"
] ]
} }
@ -592,17 +592,17 @@ defmodule Mv.Authorization.PermissionSets do
%{resource: "Member", action: :update, scope: :all, granted: true}, %{resource: "Member", action: :update, scope: :all, granted: true},
%{resource: "Member", action: :destroy, scope: :all, granted: true}, %{resource: "Member", action: :destroy, scope: :all, granted: true},
# Property: Full CRUD # CustomFieldValue: Full CRUD
%{resource: "Property", action: :read, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
%{resource: "Property", action: :create, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
%{resource: "Property", action: :update, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
%{resource: "Property", action: :destroy, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# PropertyType: Full CRUD (admin manages custom field definitions) # CustomField: Full CRUD (admin manages custom field definitions)
%{resource: "PropertyType", action: :read, scope: :all, granted: true}, %{resource: "CustomField", action: :read, scope: :all, granted: true},
%{resource: "PropertyType", action: :create, scope: :all, granted: true}, %{resource: "CustomField", action: :create, scope: :all, granted: true},
%{resource: "PropertyType", action: :update, scope: :all, granted: true}, %{resource: "CustomField", action: :update, scope: :all, granted: true},
%{resource: "PropertyType", action: :destroy, scope: :all, granted: true}, %{resource: "CustomField", action: :destroy, scope: :all, granted: true},
# Role: Full CRUD (admin manages roles) # Role: Full CRUD (admin manages roles)
%{resource: "Role", action: :read, scope: :all, granted: true}, %{resource: "Role", action: :read, scope: :all, granted: true},
@ -677,9 +677,9 @@ Quick reference table showing what each permission set allows:
| **User** (all) | - | - | - | R, C, U, D | | **User** (all) | - | - | - | R, C, U, D |
| **Member** (linked) | R, U | - | - | - | | **Member** (linked) | R, U | - | - | - |
| **Member** (all) | - | R | R, C, U | R, C, U, D | | **Member** (all) | - | R | R, C, U | R, C, U, D |
| **Property** (linked) | R, U | - | - | - | | **CustomFieldValue** (linked) | R, U | - | - | - |
| **Property** (all) | - | R | R, C, U, D | R, C, U, D | | **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
| **PropertyType** (all) | R | R | R | R, C, U, D | | **CustomField** (all) | R | R | R | R, C, U, D |
| **Role** (all) | - | - | - | R, C, U, D | | **Role** (all) | - | - | - | R, C, U, D |
**Legend:** R=Read, C=Create, U=Update, D=Destroy **Legend:** R=Read, C=Create, U=Update, D=Destroy
@ -715,7 +715,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
- **:own** - Filters to records where record.id == actor.id - **:own** - Filters to records where record.id == actor.id
- **:linked** - Filters based on resource type: - **:linked** - Filters based on resource type:
- Member: member.user_id == actor.id - Member: member.user_id == actor.id
- Property: property.member.user_id == actor.id (traverses relationship!) - CustomFieldValue: custom_field_value.member.user_id == actor.id (traverses relationship!)
## Error Handling ## Error Handling
@ -802,8 +802,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
# Member.user_id == actor.id (direct relationship) # Member.user_id == actor.id (direct relationship)
{:filter, expr(user_id == ^actor.id)} {:filter, expr(user_id == ^actor.id)}
"Property" -> "CustomFieldValue" ->
# Property.member.user_id == actor.id (traverse through member!) # CustomFieldValue.member.user_id == actor.id (traverse through member!)
{:filter, expr(member.user_id == ^actor.id)} {:filter, expr(member.user_id == ^actor.id)}
_ -> _ ->
@ -832,7 +832,7 @@ end
**Key Design Decisions:** **Key Design Decisions:**
1. **Resource-Specific :linked Scope:** Property needs to traverse `member` relationship to check `user_id` 1. **Resource-Specific :linked Scope:** CustomFieldValue needs to traverse `member` relationship to check `user_id`
2. **Error Handling:** All errors log for debugging but return generic forbidden to user 2. **Error Handling:** All errors log for debugging but return generic forbidden to user
3. **Module Name Extraction:** Uses `Module.split() |> List.last()` to match against PermissionSets strings 3. **Module Name Extraction:** Uses `Module.split() |> List.last()` to match against PermissionSets strings
4. **Pure Function:** No side effects, deterministic, easily testable 4. **Pure Function:** No side effects, deterministic, easily testable
@ -966,21 +966,21 @@ end
*Email editing has additional validation (see Special Cases) *Email editing has additional validation (see Special Cases)
### Property Resource Policies ### CustomFieldValue Resource Policies
**Location:** `lib/mv/membership/property.ex` **Location:** `lib/mv/membership/custom_field_value.ex`
**Special Case:** Users can access properties of their linked member. **Special Case:** Users can access custom field values of their linked member.
```elixir ```elixir
defmodule Mv.Membership.Property do defmodule Mv.Membership.CustomFieldValue do
use Ash.Resource, ... use Ash.Resource, ...
policies do policies do
# SPECIAL CASE: Users can access properties of their linked member # SPECIAL CASE: Users can access custom field values of their linked member
# Note: This traverses the member relationship! # Note: This traverses the member relationship!
policy action_type([:read, :update]) do policy action_type([:read, :update]) do
description "Users can access properties of their linked member" description "Users can access custom field values of their linked member"
authorize_if expr(member.user_id == ^actor(:id)) authorize_if expr(member.user_id == ^actor(:id))
end end
@ -1010,18 +1010,18 @@ end
| Create | ❌ | ❌ | ✅ | ❌ | ✅ | | Create | ❌ | ❌ | ✅ | ❌ | ✅ |
| Destroy | ❌ | ❌ | ✅ | ❌ | ✅ | | Destroy | ❌ | ❌ | ✅ | ❌ | ✅ |
### PropertyType Resource Policies ### CustomField Resource Policies
**Location:** `lib/mv/membership/property_type.ex` **Location:** `lib/mv/membership/custom_field.ex`
**No Special Cases:** All users can read, only admin can write. **No Special Cases:** All users can read, only admin can write.
```elixir ```elixir
defmodule Mv.Membership.PropertyType do defmodule Mv.Membership.CustomField do
use Ash.Resource, ... use Ash.Resource, ...
policies do policies do
# All authenticated users can read property types (needed for forms) # All authenticated users can read custom fields (needed for forms)
# Write operations are admin-only # Write operations are admin-only
policy action_type([:read, :create, :update, :destroy]) do policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role" description "Check permissions from user's role"
@ -1308,12 +1308,12 @@ end
- ❌ Cannot access: `/members`, `/members/new`, `/admin/roles` - ❌ Cannot access: `/members`, `/members/new`, `/admin/roles`
**Vorstand (read_only):** **Vorstand (read_only):**
- ✅ Can access: `/`, `/members`, `/members/123`, `/properties`, `/profile` - ✅ Can access: `/`, `/members`, `/members/123`, `/custom_field_values`, `/profile`
- ❌ Cannot access: `/members/new`, `/members/123/edit`, `/admin/roles` - ❌ Cannot access: `/members/new`, `/members/123/edit`, `/admin/roles`
**Kassenwart (normal_user):** **Kassenwart (normal_user):**
- ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/properties`, `/profile` - ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/custom_field_values`, `/profile`
- ❌ Cannot access: `/admin/roles`, `/admin/property_types/new` - ❌ Cannot access: `/admin/roles`, `/admin/custom_fields/new`
**Admin:** **Admin:**
- ✅ Can access: `*` (all pages, including `/admin/roles`) - ✅ Can access: `*` (all pages, including `/admin/roles`)
@ -1479,9 +1479,9 @@ defmodule MvWeb.Authorization do
# Direct relationship: member.user_id # Direct relationship: member.user_id
Map.get(record, :user_id) == user.id Map.get(record, :user_id) == user.id
"Property" -> "CustomFieldValue" ->
# Need to traverse: property.member.user_id # Need to traverse: custom_field_value.member.user_id
# Note: In UI, property should have member preloaded # Note: In UI, custom_field_value should have member preloaded
case Map.get(record, :member) do case Map.get(record, :member) do
%{user_id: member_user_id} -> member_user_id == user.id %{user_id: member_user_id} -> member_user_id == user.id
_ -> false _ -> false
@ -1569,7 +1569,7 @@ end
<span>Admin</span> <span>Admin</span>
<ul> <ul>
<li><.link navigate="/admin/roles">Roles</.link></li> <li><.link navigate="/admin/roles">Roles</.link></li>
<li><.link navigate="/admin/property_types">Property Types</.link></li> <li><.link navigate="/admin/custom_fields">Custom Fields</.link></li>
</ul> </ul>
</div> </div>
<% end %> <% end %>
@ -2409,8 +2409,8 @@ The `HasPermission` check extracts resource names via `Module.split() |> List.la
|------------|------------------------| |------------|------------------------|
| `Mv.Accounts.User` | "User" | | `Mv.Accounts.User` | "User" |
| `Mv.Membership.Member` | "Member" | | `Mv.Membership.Member` | "Member" |
| `Mv.Membership.Property` | "Property" | | `Mv.Membership.CustomFieldValue` | "CustomFieldValue" |
| `Mv.Membership.PropertyType` | "PropertyType" | | `Mv.Membership.CustomField` | "CustomField" |
| `Mv.Authorization.Role` | "Role" | | `Mv.Authorization.Role` | "Role" |
These strings must match exactly in `PermissionSets` module. These strings must match exactly in `PermissionSets` module.
@ -2450,7 +2450,7 @@ These strings must match exactly in `PermissionSets` module.
**Integration:** **Integration:**
- [ ] One complete user journey per role - [ ] One complete user journey per role
- [ ] Cross-resource scenarios (e.g., Member -> Property) - [ ] Cross-resource scenarios (e.g., Member -> CustomFieldValue)
- [ ] Special cases in context (e.g., linked member email during full edit flow) - [ ] Special cases in context (e.g., linked member email during full edit flow)
### Useful Commands ### Useful Commands

View file

@ -53,7 +53,7 @@ This document defines the implementation plan for the **MVP (Phase 1)** of the R
Hardcoded in `Mv.Authorization.PermissionSets` module: Hardcoded in `Mv.Authorization.PermissionSets` module:
1. **own_data** - User can only access their own data (default for "Mitglied") 1. **own_data** - User can only access their own data (default for "Mitglied")
2. **read_only** - Read access to all members/properties (for "Vorstand", "Buchhaltung") 2. **read_only** - Read access to all members/custom field values (for "Vorstand", "Buchhaltung")
3. **normal_user** - Create/Read/Update members (no delete), full CRUD on properties (for "Kassenwart") 3. **normal_user** - Create/Read/Update members (no delete), full CRUD on properties (for "Kassenwart")
4. **admin** - Unrestricted access including user/role management (for "Admin") 4. **admin** - Unrestricted access including user/role management (for "Admin")
@ -77,7 +77,7 @@ Stored in database `roles` table, each referencing a `permission_set_name`:
- ✅ Hardcoded PermissionSets module with 4 permission sets - ✅ Hardcoded PermissionSets module with 4 permission sets
- ✅ Role database table and CRUD interface - ✅ Role database table and CRUD interface
- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets - ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets
- ✅ Policies on all resources (Member, User, Property, PropertyType, Role) - ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role)
- ✅ Page-level permissions via Phoenix Plug - ✅ Page-level permissions via Phoenix Plug
- ✅ UI authorization helpers for conditional rendering - ✅ UI authorization helpers for conditional rendering
- ✅ Special case: Member email validation for linked users - ✅ Special case: Member email validation for linked users
@ -228,32 +228,32 @@ Create the core `PermissionSets` module that defines all four permission sets wi
- Resources: - Resources:
- User: read/update :own - User: read/update :own
- Member: read/update :linked - Member: read/update :linked
- Property: read/update :linked - CustomFieldValue: read/update :linked
- PropertyType: read :all - CustomField: read :all
- Pages: `["/", "/profile", "/members/:id"]` - Pages: `["/", "/profile", "/members/:id"]`
**2. read_only (Vorstand, Buchhaltung):** **2. read_only (Vorstand, Buchhaltung):**
- Resources: - Resources:
- User: read :own, update :own - User: read :own, update :own
- Member: read :all - Member: read :all
- Property: read :all - CustomFieldValue: read :all
- PropertyType: read :all - CustomField: read :all
- Pages: `["/", "/members", "/members/:id", "/properties"]` - Pages: `["/", "/members", "/members/:id", "/custom_field_values"]`
**3. normal_user (Kassenwart):** **3. normal_user (Kassenwart):**
- Resources: - Resources:
- User: read/update :own - User: read/update :own
- Member: read/create/update :all (no destroy for safety) - Member: read/create/update :all (no destroy for safety)
- Property: read/create/update/destroy :all - CustomFieldValue: read/create/update/destroy :all
- PropertyType: read :all - CustomField: read :all
- Pages: `["/", "/members", "/members/new", "/members/:id", "/members/:id/edit", "/properties", "/properties/new", "/properties/:id/edit"]` - Pages: `["/", "/members", "/members/new", "/members/:id", "/members/:id/edit", "/custom_field_values", "/custom_field_values/new", "/custom_field_values/:id/edit"]`
**4. admin:** **4. admin:**
- Resources: - Resources:
- User: read/update/destroy :all - User: read/update/destroy :all
- Member: read/create/update/destroy :all - Member: read/create/update/destroy :all
- Property: read/create/update/destroy :all - CustomFieldValue: read/create/update/destroy :all
- PropertyType: read/create/update/destroy :all - CustomField: read/create/update/destroy :all
- Role: read/create/update/destroy :all - Role: read/create/update/destroy :all
- Pages: `["*"]` (wildcard = all pages) - Pages: `["*"]` (wildcard = all pages)
@ -276,10 +276,10 @@ Create the core `PermissionSets` module that defines all four permission sets wi
**Permission Content Tests:** **Permission Content Tests:**
- `:own_data` allows User read/update with scope :own - `:own_data` allows User read/update with scope :own
- `:own_data` allows Member/Property read/update with scope :linked - `:own_data` allows Member/CustomFieldValue read/update with scope :linked
- `:read_only` allows Member/Property read with scope :all - `:read_only` allows Member/CustomFieldValue read with scope :all
- `:read_only` does NOT allow Member/Property create/update/destroy - `:read_only` does NOT allow Member/CustomFieldValue create/update/destroy
- `:normal_user` allows Member/Property full CRUD with scope :all - `:normal_user` allows Member/CustomFieldValue full CRUD with scope :all
- `:admin` allows everything with scope :all - `:admin` allows everything with scope :all
- `:admin` has wildcard page permission "*" - `:admin` has wildcard page permission "*"
@ -387,7 +387,7 @@ Create the core custom Ash Policy Check that reads permissions from the `Permiss
- `:own``{:filter, expr(id == ^actor.id)}` - `:own``{:filter, expr(id == ^actor.id)}`
- `:linked` → resource-specific logic: - `:linked` → resource-specific logic:
- Member: `{:filter, expr(user_id == ^actor.id)}` - Member: `{:filter, expr(user_id == ^actor.id)}`
- Property: `{:filter, expr(member.user_id == ^actor.id)}` (traverse relationship!) - CustomFieldValue: `{:filter, expr(member.user_id == ^actor.id)}` (traverse relationship!)
6. Handle errors gracefully: 6. Handle errors gracefully:
- No actor → `{:error, :no_actor}` - No actor → `{:error, :no_actor}`
- No role → `{:error, :no_role}` - No role → `{:error, :no_role}`
@ -401,7 +401,7 @@ Create the core custom Ash Policy Check that reads permissions from the `Permiss
- [ ] Check module implements `Ash.Policy.Check` behavior - [ ] Check module implements `Ash.Policy.Check` behavior
- [ ] `match?/3` correctly evaluates permissions from PermissionSets - [ ] `match?/3` correctly evaluates permissions from PermissionSets
- [ ] Scope filters work correctly (:all, :own, :linked) - [ ] Scope filters work correctly (:all, :own, :linked)
- [ ] `:linked` scope handles Member and Property differently - [ ] `:linked` scope handles Member and CustomFieldValue differently
- [ ] Errors are handled gracefully (no crashes) - [ ] Errors are handled gracefully (no crashes)
- [ ] Authorization failures are logged - [ ] Authorization failures are logged
- [ ] Module is well-documented - [ ] Module is well-documented
@ -425,7 +425,7 @@ Create the core custom Ash Policy Check that reads permissions from the `Permiss
**Scope Application Tests - :linked:** **Scope Application Tests - :linked:**
- Actor with scope :linked can access Member where member.user_id == actor.id - Actor with scope :linked can access Member where member.user_id == actor.id
- Actor with scope :linked can access Property where property.member.user_id == actor.id (relationship traversal!) - Actor with scope :linked can access CustomFieldValue where custom_field_value.member.user_id == actor.id (relationship traversal!)
- Actor with scope :linked cannot access unlinked member - Actor with scope :linked cannot access unlinked member
- Query correctly filters based on user_id relationship - Query correctly filters based on user_id relationship
@ -581,7 +581,7 @@ Add authorization policies to the User resource. Special case: Users can always
--- ---
#### Issue #9: Property Resource Policies #### Issue #9: CustomFieldValue Resource Policies
**Size:** M (2 days) **Size:** M (2 days)
**Dependencies:** #6 (HasPermission check) **Dependencies:** #6 (HasPermission check)
@ -590,20 +590,20 @@ Add authorization policies to the User resource. Special case: Users can always
**Description:** **Description:**
Add authorization policies to the Property resource. Properties are linked to members, which are linked to users. Add authorization policies to the CustomFieldValue resource. CustomFieldValues are linked to members, which are linked to users.
**Tasks:** **Tasks:**
1. Open `lib/mv/membership/property.ex` 1. Open `lib/mv/membership/custom_field_value.ex`
2. Add `policies` block 2. Add `policies` block
3. Add special policy: Allow user to read/update properties of their linked member 3. Add special policy: Allow user to read/update custom field values of their linked member
```elixir ```elixir
policy action_type([:read, :update]) do policy action_type([:read, :update]) do
authorize_if expr(member.user_id == ^actor(:id)) authorize_if expr(member.user_id == ^actor(:id))
end end
``` ```
4. Add general policy: Check HasPermission 4. Add general policy: Check HasPermission
5. Ensure Property preloads :member relationship for scope checks 5. Ensure CustomFieldValue preloads :member relationship for scope checks
6. Preload :role relationship for actor 6. Preload :role relationship for actor
**Policy Order:** **Policy Order:**
@ -620,27 +620,27 @@ Add authorization policies to the Property resource. Properties are linked to me
**Test Strategy (TDD):** **Test Strategy (TDD):**
**Linked Properties Tests (:own_data):** **Linked CustomFieldValues Tests (:own_data):**
- User can read properties of their linked member - User can read custom field values of their linked member
- User can update properties of their linked member - User can update custom field values of their linked member
- User cannot read properties of unlinked members - User cannot read custom field values of unlinked members
- Verify relationship traversal works (property.member.user_id) - Verify relationship traversal works (custom_field_value.member.user_id)
**Read-Only Tests:** **Read-Only Tests:**
- User with :read_only can read all properties - User with :read_only can read all custom field values
- User with :read_only cannot create/update properties - User with :read_only cannot create/update custom field values
**Normal User Tests:** **Normal User Tests:**
- User with :normal_user can CRUD properties - User with :normal_user can CRUD custom field values
**Admin Tests:** **Admin Tests:**
- Admin can perform all operations - Admin can perform all operations
**Test File:** `test/mv/membership/property_policies_test.exs` **Test File:** `test/mv/membership/custom_field_value_policies_test.exs`
--- ---
#### Issue #10: PropertyType Resource Policies #### Issue #10: CustomField Resource Policies
**Size:** S (1 day) **Size:** S (1 day)
**Dependencies:** #6 (HasPermission check) **Dependencies:** #6 (HasPermission check)
@ -649,11 +649,11 @@ Add authorization policies to the Property resource. Properties are linked to me
**Description:** **Description:**
Add authorization policies to the PropertyType resource. PropertyTypes are admin-managed, but readable by all. Add authorization policies to the CustomField resource. CustomFields are admin-managed, but readable by all.
**Tasks:** **Tasks:**
1. Open `lib/mv/membership/property_type.ex` 1. Open `lib/mv/membership/custom_field.ex`
2. Add `policies` block 2. Add `policies` block
3. Add read policy: All authenticated users can read (scope :all) 3. Add read policy: All authenticated users can read (scope :all)
4. Add write policies: Only admin can create/update/destroy 4. Add write policies: Only admin can create/update/destroy
@ -661,27 +661,27 @@ Add authorization policies to the PropertyType resource. PropertyTypes are admin
**Acceptance Criteria:** **Acceptance Criteria:**
- [ ] All users can read property types - [ ] All users can read custom fields
- [ ] Only admin can create/update/destroy property types - [ ] Only admin can create/update/destroy custom fields
- [ ] Policies tested - [ ] Policies tested
**Test Strategy (TDD):** **Test Strategy (TDD):**
**Read Access (All Roles):** **Read Access (All Roles):**
- User with :own_data can read all property types - User with :own_data can read all custom fields
- User with :read_only can read all property types - User with :read_only can read all custom fields
- User with :normal_user can read all property types - User with :normal_user can read all custom fields
- User with :admin can read all property types - User with :admin can read all custom fields
**Write Access (Admin Only):** **Write Access (Admin Only):**
- Non-admin cannot create property type (Forbidden) - Non-admin cannot create custom field (Forbidden)
- Non-admin cannot update property type (Forbidden) - Non-admin cannot update custom field (Forbidden)
- Non-admin cannot destroy property type (Forbidden) - Non-admin cannot destroy custom field (Forbidden)
- Admin can create property type - Admin can create custom field
- Admin can update property type - Admin can update custom field
- Admin can destroy property type - Admin can destroy custom field
**Test File:** `test/mv/membership/property_type_policies_test.exs` **Test File:** `test/mv/membership/custom_field_policies_test.exs`
--- ---
@ -924,7 +924,7 @@ Create helper functions for UI-level authorization checks. These will be used in
``` ```
5. All functions use `PermissionSets.get_permissions/1` (same logic as HasPermission) 5. All functions use `PermissionSets.get_permissions/1` (same logic as HasPermission)
6. All functions handle nil user gracefully (return false) 6. All functions handle nil user gracefully (return false)
7. Implement resource-specific scope checking (Member vs Property for :linked) 7. Implement resource-specific scope checking (Member vs CustomFieldValue for :linked)
8. Add comprehensive `@doc` with template examples 8. Add comprehensive `@doc` with template examples
9. Import helper in `mv_web.ex` `html_helpers` section 9. Import helper in `mv_web.ex` `html_helpers` section
@ -957,9 +957,9 @@ Create helper functions for UI-level authorization checks. These will be used in
**can?/3 with Record Struct - Scope :linked:** **can?/3 with Record Struct - Scope :linked:**
- User can update linked Member (member.user_id == user.id) - User can update linked Member (member.user_id == user.id)
- User cannot update unlinked Member - User cannot update unlinked Member
- User can update Property of linked Member (property.member.user_id == user.id) - User can update CustomFieldValue of linked Member (custom_field_value.member.user_id == user.id)
- User cannot update Property of unlinked Member - User cannot update CustomFieldValue of unlinked Member
- Scope checking is resource-specific (Member vs Property) - Scope checking is resource-specific (Member vs CustomFieldValue)
**can_access_page?/2:** **can_access_page?/2:**
- User with page in list can access (returns true) - User with page in list can access (returns true)
@ -1046,7 +1046,7 @@ Update Role management LiveViews to use authorization helpers for conditional re
**Description:** **Description:**
Update all existing LiveViews (Member, User, Property, PropertyType) to use authorization helpers for conditional rendering. Update all existing LiveViews (Member, User, CustomFieldValue, CustomField) to use authorization helpers for conditional rendering.
**Tasks:** **Tasks:**
@ -1061,10 +1061,10 @@ Update all existing LiveViews (Member, User, Property, PropertyType) to use auth
- Show: Only show other users if admin, always show own profile - Show: Only show other users if admin, always show own profile
- Edit: Only allow editing own profile or admin editing anyone - Edit: Only allow editing own profile or admin editing anyone
3. **Property LiveViews:** 3. **CustomFieldValue LiveViews:**
- Similar to Member (hide create/edit/delete based on permissions) - Similar to Member (hide create/edit/delete based on permissions)
4. **PropertyType LiveViews:** 4. **CustomField LiveViews:**
- All users can view - All users can view
- Only admin can create/edit/delete - Only admin can create/edit/delete
@ -1110,13 +1110,13 @@ Update all existing LiveViews (Member, User, Property, PropertyType) to use auth
- Vorstand: Sees "Home", "Members" (read-only), "Profile" - Vorstand: Sees "Home", "Members" (read-only), "Profile"
- Kassenwart: Sees "Home", "Members", "Properties", "Profile" - Kassenwart: Sees "Home", "Members", "Properties", "Profile"
- Buchhaltung: Sees "Home", "Members" (read-only), "Profile" - Buchhaltung: Sees "Home", "Members" (read-only), "Profile"
- Admin: Sees "Home", "Members", "Properties", "Property Types", "Admin", "Profile" - Admin: Sees "Home", "Members", "Custom Field Values", "Custom Fields", "Admin", "Profile"
**Test Files:** **Test Files:**
- `test/mv_web/live/member_live_authorization_test.exs` - `test/mv_web/live/member_live_authorization_test.exs`
- `test/mv_web/live/user_live_authorization_test.exs` - `test/mv_web/live/user_live_authorization_test.exs`
- `test/mv_web/live/property_live_authorization_test.exs` - `test/mv_web/live/custom_field_value_live_authorization_test.exs`
- `test/mv_web/live/property_type_live_authorization_test.exs` - `test/mv_web/live/custom_field_live_authorization_test.exs`
- `test/mv_web/components/navbar_authorization_test.exs` - `test/mv_web/components/navbar_authorization_test.exs`
--- ---
@ -1192,7 +1192,7 @@ Write comprehensive integration tests that follow complete user journeys for eac
4. Can edit any member (except email if linked - see special case) 4. Can edit any member (except email if linked - see special case)
5. Cannot delete member 5. Cannot delete member
6. Can manage properties 6. Can manage properties
7. Cannot manage property types (read-only) 7. Cannot manage custom fields (read-only)
8. Cannot access /admin/roles 8. Cannot access /admin/roles
**Buchhaltung Journey:** **Buchhaltung Journey:**
@ -1266,7 +1266,7 @@ Write comprehensive integration tests that follow complete user journeys for eac
│ │ │ │ │ │
┌────▼─────┐ ┌──────▼──────┐ │ ┌────▼─────┐ ┌──────▼──────┐ │
│ Issue #9 │ │ Issue #10 │ │ │ Issue #9 │ │ Issue #10 │ │
Property │ │ PropType │ │ CustomFieldValue │ │ CustomField │ │
│ Policies │ │ Policies │ │ │ Policies │ │ Policies │ │
└────┬─────┘ └──────┬──────┘ │ └────┬─────┘ └──────┬──────┘ │
│ │ │ │ │ │
@ -1384,8 +1384,8 @@ test/
├── mv/membership/ ├── mv/membership/
│ ├── member_policies_test.exs # Issue #7 │ ├── member_policies_test.exs # Issue #7
│ ├── member_email_validation_test.exs # Issue #12 │ ├── member_email_validation_test.exs # Issue #12
│ ├── property_policies_test.exs # Issue #9 │ ├── custom_field_value_policies_test.exs # Issue #9
│ └── property_type_policies_test.exs # Issue #10 │ └── custom_field_policies_test.exs # Issue #10
├── mv_web/ ├── mv_web/
│ ├── authorization_test.exs # Issue #14 │ ├── authorization_test.exs # Issue #14
│ ├── plugs/ │ ├── plugs/
@ -1395,8 +1395,8 @@ test/
│ ├── role_live_authorization_test.exs # Issue #15 │ ├── role_live_authorization_test.exs # Issue #15
│ ├── member_live_authorization_test.exs # Issue #16 │ ├── member_live_authorization_test.exs # Issue #16
│ ├── user_live_authorization_test.exs # Issue #16 │ ├── user_live_authorization_test.exs # Issue #16
│ ├── property_live_authorization_test.exs # Issue #16 │ ├── custom_field_value_live_authorization_test.exs # Issue #16
│ └── property_type_live_authorization_test.exs # Issue #16 │ └── custom_field_live_authorization_test.exs # Issue #16
├── integration/ ├── integration/
│ ├── mitglied_journey_test.exs # Issue #17 │ ├── mitglied_journey_test.exs # Issue #17
│ ├── vorstand_journey_test.exs # Issue #17 │ ├── vorstand_journey_test.exs # Issue #17

View file

@ -201,7 +201,7 @@ When runtime permission editing becomes a business requirement, migrate to Appro
**Resource Level (MVP):** **Resource Level (MVP):**
- Controls create, read, update, destroy actions on resources - Controls create, read, update, destroy actions on resources
- Resources: Member, User, Property, PropertyType, Role - Resources: Member, User, CustomFieldValue, CustomField, Role
**Page Level (MVP):** **Page Level (MVP):**
- Controls access to LiveView pages - Controls access to LiveView pages
@ -280,7 +280,7 @@ Contains:
Each Permission Set contains: Each Permission Set contains:
**Resources:** List of resource permissions **Resources:** List of resource permissions
- resource: "Member", "User", "Property", etc. - resource: "Member", "User", "CustomFieldValue", etc.
- action: :read, :create, :update, :destroy - action: :read, :create, :update, :destroy
- scope: :own, :linked, :all - scope: :own, :linked, :all
- granted: true/false - granted: true/false

View file

@ -0,0 +1,137 @@
# Test Status: Membership Fee UI Components
**Date:** 2025-01-XX
**Status:** Tests Written - Implementation Complete
## Übersicht
Alle Tests für die Membership Fee UI-Komponenten wurden geschrieben. Die Tests sind TDD-konform geschrieben und sollten erfolgreich laufen, da die Implementation bereits vorhanden ist.
## Test-Dateien
### Helper Module Tests
**Datei:** `test/mv_web/helpers/membership_fee_helpers_test.exs`
- ✅ format_currency/1 formats correctly
- ✅ format_interval/1 formats all interval types
- ✅ format_cycle_range/2 formats date ranges correctly
- ✅ get_last_completed_cycle/2 returns correct cycle
- ✅ get_current_cycle/2 returns correct cycle
- ✅ status_color/1 returns correct color classes
- ✅ status_icon/1 returns correct icon names
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
**Datei:** `test/mv_web/member_live/index/membership_fee_status_test.exs`
- ✅ load_cycles_for_members/2 efficiently loads cycles
- ✅ get_cycle_status_for_member/2 returns correct status
- ✅ format_cycle_status_badge/1 returns correct badge
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Member List View Tests
**Datei:** `test/mv_web/member_live/index_membership_fee_status_test.exs`
- ✅ Status column displays correctly
- ✅ Shows last completed cycle status by default
- ✅ Toggle switches to current cycle view
- ✅ Color coding for paid/unpaid/suspended
- ✅ Filter "Unpaid in last cycle" works
- ✅ Filter "Unpaid in current cycle" works
- ✅ Handles members without cycles gracefully
- ✅ Loads cycles efficiently without N+1 queries
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Member Detail View Tests
**Datei:** `test/mv_web/member_live/show_membership_fees_test.exs`
- ✅ Cycles table displays all cycles
- ✅ Table columns show correct data
- ✅ Membership fee type dropdown shows only same-interval types
- ✅ Warning displayed if different interval selected
- ✅ Status change actions work (mark as paid/suspended/unpaid)
- ✅ Cycle regeneration works
- ✅ Handles members without membership fee type gracefully
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Membership Fee Types Admin Tests
**Datei:** `test/mv_web/live/membership_fee_type_live/index_test.exs`
- ✅ List displays all types with correct data
- ✅ Member count column shows correct count
- ✅ Create button navigates to form
- ✅ Edit button per row navigates to edit form
- ✅ Delete button disabled if type is in use
- ✅ Delete button works if type is not in use
- ✅ Only admin can access
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
**Datei:** `test/mv_web/live/membership_fee_type_live/form_test.exs`
- ✅ Create form works
- ✅ Edit form loads existing type data
- ✅ Interval field editable on create
- ✅ Interval field grayed out on edit
- ✅ Amount change warning displays on edit
- ✅ Amount change warning shows correct affected member count
- ✅ Amount change can be confirmed
- ✅ Amount change can be cancelled
- ✅ Validation errors display correctly
- ✅ Only admin can access
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Member Form Tests
**Datei:** `test/mv_web/member_live/form_membership_fee_type_test.exs`
- ✅ Membership fee type dropdown displays in form
- ✅ Shows available types
- ✅ Filters to same interval types if member has type
- ✅ Warning displayed if different interval selected
- ✅ Warning cleared if same interval selected
- ✅ Form saves with selected membership fee type
- ✅ New members get default membership fee type
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Integration Tests
**Datei:** `test/mv_web/member_live/membership_fee_integration_test.exs`
- ✅ End-to-end: Create type → Assign to member → View cycles → Change status
- ✅ End-to-end: Change member type → Cycles regenerate
- ✅ End-to-end: Update settings → New members get default type
- ✅ End-to-end: Delete cycle → Confirmation → Cycle deleted
- ✅ End-to-end: Edit cycle amount → Modal → Amount updated
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
## Test-Ausführung
Alle Tests können mit folgenden Befehlen ausgeführt werden:
```bash
# Alle Tests
mix test
# Nur Membership Fee Tests
mix test test/mv_web/helpers/membership_fee_helpers_test.exs
mix test test/mv_web/member_live/
mix test test/mv_web/live/membership_fee_type_live/
# Mit Coverage
mix test --cover
```
## Bekannte Probleme
Keine bekannten Probleme. Alle Tests sollten erfolgreich laufen, da die Implementation bereits vorhanden ist.
## Nächste Schritte
1. ✅ Tests geschrieben
2. ⏳ Tests ausführen und verifizieren
3. ⏳ Eventuelle Anpassungen basierend auf Test-Ergebnissen
4. ⏳ Code-Review durchführen

View file

@ -17,6 +17,10 @@ defmodule Mv.Accounts.User do
# When a member is deleted, set the user's member_id to NULL # When a member is deleted, set the user's member_id to NULL
# This allows users to continue existing even if their linked member is removed # This allows users to continue existing even if their linked member is removed
reference :member, on_delete: :nilify reference :member, on_delete: :nilify
# When a role is deleted, prevent deletion if users are assigned to it
# This protects critical roles from accidental deletion
reference :role, on_delete: :restrict
end end
end end
@ -357,6 +361,12 @@ defmodule Mv.Accounts.User do
# This automatically creates a `member_id` attribute in the User table # This automatically creates a `member_id` attribute in the User table
# The relationship is optional (allow_nil? true by default) # The relationship is optional (allow_nil? true by default)
belongs_to :member, Mv.Membership.Member belongs_to :member, Mv.Membership.Member
# 1:1 relationship - User belongs to a Role
# This automatically creates a `role_id` attribute in the User table
# The relationship is optional (allow_nil? true by default)
# Foreign key constraint: on_delete: :restrict (prevents deleting roles assigned to users)
belongs_to :role, Mv.Authorization.Role
end end
identities do identities do

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

@ -29,7 +29,9 @@ defmodule Mv.Membership.Member do
## Full-Text Search ## Full-Text Search
Members have a `search_vector` attribute (tsvector) that is automatically Members have a `search_vector` attribute (tsvector) that is automatically
updated via database trigger. Search includes name, email, notes, and contact fields. updated via database trigger. Search includes name, email, notes, contact fields,
and all custom field values. Custom field values are automatically included in
the search vector with weight 'C' (same as phone_number, city, etc.).
""" """
use Ash.Resource, use Ash.Resource,
domain: Mv.Membership, domain: Mv.Membership,
@ -37,9 +39,25 @@ defmodule Mv.Membership.Member do
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
require Logger
# Module constants # Module constants
@member_search_limit 10 @member_search_limit 10
# Similarity threshold for fuzzy name/address matching.
# Lower value = more results but less accurate (0.1-0.9)
#
# Fuzzy matching uses two complementary strategies:
# 1. % operator: Fast GIN-index-based matching using server-wide threshold (default 0.3)
# - Catches exact trigram matches quickly via index
# 2. similarity/word_similarity functions: Precise matching with this configurable threshold
# - Catches partial matches that % operator might miss
#
# Value 0.2 chosen based on testing with typical German names:
# - "Müller" vs "Mueller": similarity ~0.65 ✓
# - "Schmidt" vs "Schmitt": similarity ~0.75 ✓
# - "Wagner" vs "Wegner": similarity ~0.55 ✓
# - Random unrelated names: similarity ~0.15 ✗
@default_similarity_threshold 0.2 @default_similarity_threshold 0.2
# Use constants from Mv.Constants for member fields # Use constants from Mv.Constants for member fields
@ -56,13 +74,17 @@ defmodule Mv.Membership.Member do
create :create_member do create :create_member do
primary? true primary? true
# Note: Custom validation function cannot be done atomically (queries DB for required custom fields)
# In Ash 3.0, require_atomic? is not available for create actions, but the validation will still work
# Custom field values can be created along with member # Custom field values can be created along with member
argument :custom_field_values, {:array, :map} argument :custom_field_values, {:array, :map}
# Allow user to be passed as argument for relationship management # Allow user to be passed as argument for relationship management
# user_id is NOT in accept list to prevent direct foreign key manipulation # user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true argument :user, :map, allow_nil?: true
accept @member_fields # Accept member fields plus membership_fee_type_id (belongs_to FK)
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
change manage_relationship(:custom_field_values, type: :create) change manage_relationship(:custom_field_values, type: :create)
@ -83,6 +105,31 @@ 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-assign default membership fee type if not explicitly set
change Mv.Membership.Member.Changes.SetDefaultMembershipFeeType
# 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_transaction(fn _changeset, result, _context ->
case result do
{:ok, member} ->
if member.membership_fee_type_id && member.join_date do
handle_cycle_generation(member)
end
{:error, _} ->
:ok
end
result
end)
end end
update :update_member do update :update_member do
@ -95,7 +142,8 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation # user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true argument :user, :map, allow_nil?: true
accept @member_fields # Accept member fields plus membership_fee_type_id (belongs_to FK)
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
@ -122,6 +170,69 @@ 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
# Validate that membership fee type changes only allow same-interval types
change Mv.MembershipFees.Changes.ValidateSameInterval do
where [changing(:membership_fee_type_id)]
end
# Auto-calculate membership_fee_start_date when membership_fee_type_id is set
# and membership_fee_start_date is not already set
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
where [changing(:membership_fee_type_id)]
end
# Trigger cycle regeneration when membership_fee_type_id changes
# This deletes future unpaid cycles and regenerates them with the new type/amount
# Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
# CycleGenerator uses advisory locks and transactions internally to prevent race conditions
# Notifications are returned to Ash and sent automatically after commit
change after_action(fn changeset, member, _context ->
fee_type_changed =
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
if fee_type_changed && member.membership_fee_type_id && member.join_date do
case regenerate_cycles_on_type_change(member) do
{:ok, notifications} ->
# Return notifications to Ash - they will be sent automatically after commit
{:ok, member, notifications}
{:error, reason} ->
require Logger
Logger.warning(
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
)
{:ok, member}
end
else
{:ok, member}
end
end)
# Trigger cycle regeneration when join_date or exit_date changes
# Regenerates cycles based on new dates
# Note: Cycle generation runs synchronously in test environment, asynchronously in production
# CycleGenerator uses advisory locks and transactions internally to prevent race conditions
# If both join_date and exit_date are changed simultaneously, this hook runs only once
# (Ash ensures each after_transaction hook runs once per action, regardless of how many attributes changed)
change after_transaction(fn changeset, result, _context ->
case result do
{:ok, member} ->
join_date_changed = Ash.Changeset.changing_attribute?(changeset, :join_date)
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
handle_cycle_generation(member)
end
{:error, _} ->
:ok
end
result
end)
end end
# Action to handle fuzzy search on specific fields # Action to handle fuzzy search on specific fields
@ -139,30 +250,21 @@ defmodule Mv.Membership.Member do
if is_binary(q) and String.trim(q) != "" do if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q) q2 = String.trim(q)
pat = "%" <> q2 <> "%" # Sanitize for LIKE patterns (escape % and _), limit length to 100 chars
q2_sanitized = sanitize_search_query(q2)
pat = "%" <> q2_sanitized <> "%"
# Build search filters grouped by search type for maintainability
# Priority: FTS > Substring > Custom Fields > Fuzzy Matching
# Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized)
fts_match = build_fts_filter(q2)
substring_match = build_substring_filter(q2_sanitized, pat)
custom_field_match = build_custom_field_filter(pat)
fuzzy_match = build_fuzzy_filter(q2, threshold)
# FTS as main filter and fuzzy search just for first name, last name and strees
query query
|> Ash.Query.filter( |> Ash.Query.filter(
expr( expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
# Substring on numeric-like fields (best effort, supports middle substrings)
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or
contains(postal_code, ^q2) or
contains(house_number, ^q2) or
contains(phone_number, ^q2) or
contains(email, ^q2) or
contains(city, ^q2) or ilike(city, ^pat) or
fragment("? % first_name", ^q2) or
fragment("? % last_name", ^q2) or
fragment("? % street", ^q2) or
fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or
fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or
fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or
fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or
fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or
fragment("similarity(street, ?) > ?", ^q2, ^threshold)
)
) )
else else
query query
@ -284,7 +386,7 @@ defmodule Mv.Membership.Member do
end end
end end
# Join date not in the future # Join date not in future
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:join_date)], where: [present(:join_date)],
message: "cannot be in the future" message: "cannot be in the future"
@ -319,6 +421,32 @@ defmodule Mv.Membership.Member do
{:error, field: :email, message: "is not a valid email"} {:error, field: :email, message: "is not a valid email"}
end end
end end
# Validate required custom fields
validate fn changeset, _ ->
provided_values = provided_custom_field_values(changeset)
case Mv.Membership.list_required_custom_fields() do
{:ok, required_custom_fields} ->
missing_fields = missing_required_fields(required_custom_fields, provided_values)
if Enum.empty?(missing_fields) do
:ok
else
build_custom_field_validation_error(missing_fields)
end
{:error, error} ->
Logger.error(
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
)
{:error,
field: :custom_field_values,
message:
"Unable to validate required custom fields. Please try again or contact support."}
end
end
end end
attributes do attributes do
@ -346,10 +474,6 @@ defmodule Mv.Membership.Member do
constraints min_length: 5, max_length: 254 constraints min_length: 5, max_length: 254
end end
attribute :paid, :boolean do
allow_nil? true
end
attribute :phone_number, :string do attribute :phone_number, :string do
allow_nil? true allow_nil? true
end end
@ -386,6 +510,15 @@ defmodule Mv.Membership.Member do
writable?: false, writable?: false,
public?: false, public?: false,
select_by_default?: false select_by_default?: false
# Membership fee fields
# membership_fee_start_date: Date from which membership fees should be calculated
# If nil, calculated from join_date + global setting
attribute :membership_fee_start_date, :date do
allow_nil? true
public? true
description "Date from which membership fees should be calculated"
end
end end
relationships do relationships do
@ -394,6 +527,60 @@ defmodule Mv.Membership.Member do
# This references the User's member_id attribute # This references the User's member_id attribute
# The relationship is optional (allow_nil? true by default) # The relationship is optional (allow_nil? true by default)
has_one :user, Mv.Accounts.User has_one :user, Mv.Accounts.User
# Membership fee relationships
# belongs_to: The fee type assigned to this member
# Optional for MVP - can be nil if no fee type assigned yet
belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do
allow_nil? true
end
# has_many: All fee cycles for this member
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
end
calculations do
calculate :current_cycle_status, :atom do
description "Status of the current cycle (the one that is active today)"
# Automatically load cycles with all attributes and membership_fee_type
load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
calculation fn [member], _context ->
case get_current_cycle(member) do
nil -> [nil]
cycle -> [cycle.status]
end
end
constraints one_of: [:unpaid, :paid, :suspended]
end
calculate :last_cycle_status, :atom do
description "Status of the last completed cycle (the most recent cycle that has ended)"
# Automatically load cycles with all attributes and membership_fee_type
load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
calculation fn [member], _context ->
case get_last_completed_cycle(member) do
nil -> [nil]
cycle -> [cycle.status]
end
end
constraints one_of: [:unpaid, :paid, :suspended]
end
calculate :overdue_count, :integer do
description "Count of unpaid cycles that have already ended (cycle_end < today)"
# Automatically load cycles with all attributes and membership_fee_type
load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
calculation fn [member], _context ->
overdue = get_overdue_cycles(member)
count = if is_list(overdue), do: length(overdue), else: 0
[count]
end
end
end end
# Define identities for upsert operations # Define identities for upsert operations
@ -442,6 +629,345 @@ defmodule Mv.Membership.Member do
def show_in_overview?(_), do: true def show_in_overview?(_), do: true
# Helper functions for cycle status calculations
#
# These functions expect membership_fee_cycles to be loaded with membership_fee_type
# preloaded. The calculations explicitly load this relationship, but if called
# directly, ensure membership_fee_type is loaded or the functions will return
# nil/[] when membership_fee_type is missing.
@doc false
@spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
def get_current_cycle(member) do
today = Date.utc_today()
# Check if cycles are already loaded
cycles = Map.get(member, :membership_fee_cycles)
if is_list(cycles) and cycles != [] do
Enum.find(cycles, &current_cycle?(&1, today))
else
nil
end
end
# Checks if a cycle is the current cycle (active today)
defp current_cycle?(cycle, today) do
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
Date.compare(today, cycle_end) in [:lt, :eq]
_ ->
false
end
end
@doc false
@spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
def get_last_completed_cycle(member) do
today = Date.utc_today()
# Check if cycles are already loaded
cycles = Map.get(member, :membership_fee_cycles)
if is_list(cycles) and cycles != [] do
cycles
|> filter_completed_cycles(today)
|> sort_cycles_by_end_date()
|> List.first()
else
nil
end
end
# Filters cycles that have ended (cycle_end < today)
defp filter_completed_cycles(cycles, today) do
Enum.filter(cycles, fn cycle ->
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(today, cycle_end) == :gt
_ ->
false
end
end)
end
# Sorts cycles by end date in descending order
defp sort_cycles_by_end_date(cycles) do
Enum.sort_by(
cycles,
fn cycle ->
interval = Map.get(cycle, :membership_fee_type).interval
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
end,
{:desc, Date}
)
end
@doc false
@spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
def get_overdue_cycles(member) do
today = Date.utc_today()
# Check if cycles are already loaded
cycles = Map.get(member, :membership_fee_cycles)
if is_list(cycles) and cycles != [] do
filter_overdue_cycles(cycles, today)
else
[]
end
end
# Filters cycles that are unpaid and have ended (cycle_end < today)
defp filter_overdue_cycles(cycles, today) do
Enum.filter(cycles, fn cycle ->
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
_ ->
false
end
end)
end
# Regenerates cycles when membership fee type changes
# Deletes future unpaid cycles and regenerates them with the new type/amount
# Uses advisory lock to prevent concurrent modifications
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
# to be sent after transaction commits
@doc false
def regenerate_cycles_on_type_change(member) do
today = Date.utc_today()
lock_key = :erlang.phash2(member.id)
# Use advisory lock to prevent concurrent deletion and regeneration
# This ensures atomicity when multiple updates happen simultaneously
if Mv.Repo.in_transaction?() do
regenerate_cycles_in_transaction(member, today, lock_key)
else
regenerate_cycles_new_transaction(member, today, lock_key)
end
end
# Already in transaction: use advisory lock directly
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles_in_transaction(member, today, lock_key) do
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
end
# Not in transaction: start new transaction with advisory lock
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
defp regenerate_cycles_new_transaction(member, today, lock_key) do
Mv.Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
{:ok, notifications} ->
# Return notifications - they will be sent by the caller
notifications
{:error, reason} ->
Mv.Repo.rollback(reason)
end
end)
|> case do
{:ok, notifications} -> {:ok, notifications}
{:error, reason} -> {:error, reason}
end
end
# Performs the actual cycle deletion and regeneration
# Returns {:ok, notifications} or {:error, reason}
# notifications are collected to be sent after transaction commits
defp do_regenerate_cycles_on_type_change(member, today, opts) do
require Ash.Query
skip_lock? = Keyword.get(opts, :skip_lock?, false)
# Find all unpaid cycles for this member
# We need to check cycle_end for each cycle using its own interval
all_unpaid_cycles_query =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.Query.filter(status == :unpaid)
|> Ash.Query.load([:membership_fee_type])
case Ash.read(all_unpaid_cycles_query) do
{:ok, all_unpaid_cycles} ->
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?)
{:error, reason} ->
{:error, reason}
end
end
# Filters cycles that haven't ended yet (cycle_end >= today)
# These are the "future" cycles that should be regenerated
defp filter_future_cycles(all_unpaid_cycles, today) do
Enum.filter(all_unpaid_cycles, fn cycle ->
case cycle.membership_fee_type do
%{interval: interval} ->
cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(today, cycle_end) in [:lt, :eq]
_ ->
false
end
end)
end
# Deletes future cycles and regenerates them with the new type/amount
# Passes today to ensure consistent date across deletion and regeneration
# Returns {:ok, notifications} or {:error, reason}
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false)
if Enum.empty?(cycles_to_delete) do
# No cycles to delete, just regenerate
regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
else
case delete_cycles(cycles_to_delete) do
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
{:error, reason} -> {:error, reason}
end
end
end
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
defp delete_cycles(cycles_to_delete) do
delete_results =
Enum.map(cycles_to_delete, fn cycle ->
Ash.destroy(cycle)
end)
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
{:error, :deletion_failed}
else
:ok
end
end
# Regenerates cycles with new type/amount
# Passes today to ensure consistent date across deletion and regeneration
# skip_lock?: true means advisory lock is already set by caller
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles(member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false)
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member_id,
today: today,
skip_lock?: skip_lock?
) do
{:ok, _cycles, notifications} when is_list(notifications) ->
{:ok, notifications}
{:error, reason} ->
{:error, reason}
end
end
# Handles cycle generation for a member, choosing sync or async execution
# based on environment (test vs production)
# This function encapsulates the common logic for cycle generation
# to avoid code duplication across different hooks
defp handle_cycle_generation(member) do
if Mv.Config.sql_sandbox?() do
handle_cycle_generation_sync(member)
else
handle_cycle_generation_async(member)
end
end
# Runs cycle generation synchronously (for test environment)
defp handle_cycle_generation_sync(member) do
require Logger
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member.id,
today: Date.utc_today()
) do
{:ok, cycles, notifications} ->
send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications, sync: true)
{:error, reason} ->
log_cycle_generation_error(member, reason, sync: true)
end
end
# Runs cycle generation asynchronously (for production environment)
defp handle_cycle_generation_async(member) do
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, cycles, notifications} ->
send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications, sync: false)
{:error, reason} ->
log_cycle_generation_error(member, reason, sync: false)
end
end)
end
# Sends notifications if any are present
defp send_notifications_if_any(notifications) do
if Enum.any?(notifications) do
Ash.Notifier.notify(notifications)
end
end
# Logs successful cycle generation
defp log_cycle_generation_success(member, cycles, notifications, sync: sync?) do
require Logger
sync_label = if sync?, do: "", else: " (async)"
Logger.debug(
"Successfully generated cycles for member#{sync_label}",
member_id: member.id,
cycles_count: length(cycles),
notifications_count: length(notifications)
)
end
# Logs cycle generation errors
defp log_cycle_generation_error(member, reason, sync: sync?) do
require Logger
sync_label = if sync?, do: "", else: " (async)"
Logger.error(
"Failed to generate cycles for member#{sync_label}",
member_id: member.id,
member_email: member.email,
error: inspect(reason),
error_type: error_type(reason)
)
end
# Helper to extract error type for structured logging
defp error_type(%{__struct__: struct_name}), do: struct_name
defp error_type(error) when is_atom(error), do: error
defp error_type(_), do: :unknown
# Normalizes visibility config map keys from strings to atoms. # Normalizes visibility config map keys from strings to atoms.
# JSONB in PostgreSQL converts atom keys to string keys when storing. # JSONB in PostgreSQL converts atom keys to string keys when storing.
defp normalize_visibility_config(config) when is_map(config) do defp normalize_visibility_config(config) when is_map(config) do
@ -476,7 +1002,6 @@ defmodule Mv.Membership.Member do
- `query` - Ash.Query.t() to apply search to - `query` - Ash.Query.t() to apply search to
- `opts` - Keyword list or map with search options: - `opts` - Keyword list or map with search options:
- `:query` or `"query"` - Search string - `:query` or `"query"` - Search string
- `:fields` or `"fields"` - Optional field restrictions
## Returns ## Returns
- Modified Ash.Query.t() with search filters applied - Modified Ash.Query.t() with search filters applied
@ -497,16 +1022,103 @@ defmodule Mv.Membership.Member do
if String.trim(q) == "" do if String.trim(q) == "" do
query query
else else
args = Ash.Query.for_read(query, :search, %{query: q})
case opts[:fields] || opts["fields"] do
nil -> %{query: q}
fields -> %{query: q, fields: fields}
end
Ash.Query.for_read(query, :search, args)
end end
end end
# ============================================================================
# Search Input Sanitization
# ============================================================================
# Sanitizes search input to prevent LIKE pattern injection.
# Escapes SQL LIKE wildcards (% and _) and limits query length.
#
# ## Examples
#
# iex> sanitize_search_query("test%injection")
# "test\\%injection"
#
# iex> sanitize_search_query("very_long_search")
# "very\\_long\\_search"
#
defp sanitize_search_query(query) when is_binary(query) do
query
|> String.slice(0, 100)
|> String.replace("\\", "\\\\")
|> String.replace("%", "\\%")
|> String.replace("_", "\\_")
end
defp sanitize_search_query(_), do: ""
# ============================================================================
# Search Filter Builders
# ============================================================================
# These functions build search filters grouped by search type for maintainability.
# Priority order: FTS > Substring > Custom Fields > Fuzzy Matching
# Builds full-text search filter using tsvector (highest priority, fastest)
# Uses GIN index on search_vector for optimal performance
defp build_fts_filter(query) do
expr(
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^query) or
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^query)
)
end
# Builds substring search filter for structured fields
# Note: contains/2 uses ILIKE '%value%' which is not index-optimized
# Performance: Good for small datasets, may be slow on large tables
defp build_substring_filter(query, _pattern) do
expr(
contains(postal_code, ^query) or
contains(house_number, ^query) or
contains(phone_number, ^query) or
contains(email, ^query) or
contains(city, ^query)
)
end
# Builds search filter for custom field values using ILIKE on JSONB
# Note: ILIKE on JSONB is not index-optimized, may be slow with many custom fields
# This is a fallback for substring matching in custom fields (e.g., phone numbers)
# Uses ->> operator which always returns TEXT directly (no need for -> + ::text fallback)
# Important: `id` must be passed as parameter to correctly reference the outer members table
defp build_custom_field_filter(pattern) do
expr(
fragment(
"EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = ? AND (value->>'_union_value' ILIKE ? OR value->>'value' ILIKE ?))",
id,
^pattern,
^pattern
)
)
end
# Builds fuzzy/trigram matching filter for name and street fields.
# Uses pg_trgm extension with GIN indexes for performance.
#
# Two-tier matching strategy:
# - % operator: Uses server-wide pg_trgm.similarity_threshold (typically 0.3)
# for fast index-based initial filtering
# - similarity/word_similarity: Uses @default_similarity_threshold (0.2)
# for more lenient matching to catch edge cases
#
# Note: Requires trigram GIN indexes on first_name, last_name, street.
defp build_fuzzy_filter(query, threshold) do
expr(
fragment("? % first_name", ^query) or
fragment("? % last_name", ^query) or
fragment("? % street", ^query) or
fragment("word_similarity(?, first_name) > ?", ^query, ^threshold) or
fragment("word_similarity(?, last_name) > ?", ^query, ^threshold) or
fragment("word_similarity(?, street) > ?", ^query, ^threshold) or
fragment("similarity(first_name, ?) > ?", ^query, ^threshold) or
fragment("similarity(last_name, ?) > ?", ^query, ^threshold) or
fragment("similarity(street, ?) > ?", ^query, ^threshold)
)
end
# Private helper to apply filters for :available_for_linking action # Private helper to apply filters for :available_for_linking action
# user_email: may be nil/empty when creating new user, or populated when editing # user_email: may be nil/empty when creating new user, or populated when editing
# search_query: optional search term for fuzzy matching # search_query: optional search term for fuzzy matching
@ -515,9 +1127,9 @@ defmodule Mv.Membership.Member do
# - Empty user_email ("") → email == "" is always false → only fuzzy search matches # - Empty user_email ("") → email == "" is always false → only fuzzy search matches
# - This allows a single filter expression instead of duplicating fuzzy search logic # - This allows a single filter expression instead of duplicating fuzzy search logic
# #
# Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires # Note: Custom field search is intentionally excluded from linking to optimize
# multiple OR conditions for good search quality (FTS + trigram similarity + substring) # autocomplete performance. Custom fields are still searchable via the main
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity # member search which uses the indexed search_vector.
defp apply_linking_filters(query, user_email, search_query) do defp apply_linking_filters(query, user_email, search_query) do
has_search = search_query && String.trim(search_query) != "" has_search = search_query && String.trim(search_query) != ""
# Use empty string instead of nil to simplify filter logic # Use empty string instead of nil to simplify filter logic
@ -526,35 +1138,23 @@ defmodule Mv.Membership.Member do
if has_search do if has_search do
# Search query provided: return email-match OR fuzzy-search candidates # Search query provided: return email-match OR fuzzy-search candidates
trimmed_search = String.trim(search_query) trimmed_search = String.trim(search_query)
# Sanitize for LIKE patterns (contains uses ILIKE internally)
sanitized_search = sanitize_search_query(trimmed_search)
# Build search filters - excluding custom_field_filter for performance
fts_match = build_fts_filter(trimmed_search)
fuzzy_match = build_fuzzy_filter(trimmed_search, @default_similarity_threshold)
email_substring_match = expr(contains(email, ^sanitized_search))
query query
|> Ash.Query.filter( |> Ash.Query.filter(
expr( expr(
# Email match candidate (for filter_by_email_match priority) # Email exact match has highest priority (for filter_by_email_match)
# If email is "", this is always false and fuzzy search takes over # If email is "", this is always false and search filters take over
# Fuzzy search candidates
email == ^trimmed_email or email == ^trimmed_email or
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or ^fts_match or
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or ^fuzzy_match or
fragment("? % first_name", ^trimmed_search) or ^email_substring_match
fragment("? % last_name", ^trimmed_search) or
fragment("word_similarity(?, first_name) > 0.2", ^trimmed_search) or
fragment(
"word_similarity(?, last_name) > ?",
^trimmed_search,
^@default_similarity_threshold
) or
fragment(
"similarity(first_name, ?) > ?",
^trimmed_search,
^@default_similarity_threshold
) or
fragment(
"similarity(last_name, ?) > ?",
^trimmed_search,
^@default_similarity_threshold
) or
contains(email, ^trimmed_search)
) )
) )
else else
@ -562,4 +1162,127 @@ defmodule Mv.Membership.Member do
query query
end end
end end
# Extracts provided custom field values from changeset
# Handles both create (from argument) and update (from existing data) scenarios
defp provided_custom_field_values(changeset) do
custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values)
if is_nil(custom_field_values_arg) do
extract_existing_values(changeset.data)
else
extract_argument_values(custom_field_values_arg)
end
end
# Extracts custom field values from existing member data (update scenario)
defp extract_existing_values(member_data) do
case Ash.load(member_data, :custom_field_values) do
{:ok, %{custom_field_values: existing_values}} ->
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
_ ->
%{}
end
end
# Extracts value from a CustomFieldValue struct
defp extract_value_from_cfv(cfv, acc) do
value = extract_union_value(cfv.value)
Map.put(acc, cfv.custom_field_id, value)
end
# Extracts value from union type (map or direct value)
defp extract_union_value(value) when is_map(value), do: Map.get(value, :value)
defp extract_union_value(value), do: value
# Extracts custom field values from provided argument (create/update scenario)
defp extract_argument_values(custom_field_values_arg) do
Enum.reduce(custom_field_values_arg, %{}, &extract_value_from_arg/2)
end
# Extracts value from argument map
defp extract_value_from_arg(cfv, acc) do
custom_field_id = Map.get(cfv, "custom_field_id")
value_map = Map.get(cfv, "value", %{})
actual_value = extract_value_from_map(value_map)
Map.put(acc, custom_field_id, actual_value)
end
# Extracts value from map, supporting both "value" and "_union_value" keys
# Also handles Ash.Union structs (which have atom keys :value and :type)
# Uses cond instead of || to preserve false values
defp extract_value_from_map(value_map) do
cond do
# Handle Ash.Union struct - check if it's a struct with __struct__ == Ash.Union
match?({:ok, Ash.Union}, Map.fetch(value_map, :__struct__)) ->
Map.get(value_map, :value)
# Handle map with string keys
Map.has_key?(value_map, "value") ->
Map.get(value_map, "value")
Map.has_key?(value_map, "_union_value") ->
Map.get(value_map, "_union_value")
# Handle map with atom keys
Map.has_key?(value_map, :value) ->
Map.get(value_map, :value)
true ->
nil
end
end
# Finds which required custom fields are missing from provided values
defp missing_required_fields(required_custom_fields, provided_values) do
Enum.filter(required_custom_fields, fn cf ->
value = Map.get(provided_values, cf.id)
not value_present?(value, cf.value_type)
end)
end
# Builds validation error message for missing required custom fields
defp build_custom_field_validation_error(missing_fields) do
# Sort missing fields alphabetically for consistent error messages
sorted_missing_fields = Enum.sort_by(missing_fields, & &1.name)
missing_names = Enum.map_join(sorted_missing_fields, ", ", & &1.name)
{:error,
field: :custom_field_values,
message:
Gettext.dgettext(MvWeb.Gettext, "default", "Required custom fields missing: %{fields}",
fields: missing_names
)}
end
# Helper function to check if a value is present for a given custom field type
# Boolean: false is valid, only nil is invalid
# String: nil or empty strings are invalid
# Integer: nil or empty strings are invalid, 0 is valid
# Date: nil or empty strings are invalid
# Email: nil or empty strings are invalid
defp value_present?(nil, _type), do: false
defp value_present?(value, :boolean), do: not is_nil(value)
defp value_present?(value, :string), do: is_binary(value) and String.trim(value) != ""
defp value_present?(value, :integer) when is_integer(value), do: true
defp value_present?(value, :integer) when is_binary(value), do: String.trim(value) != ""
defp value_present?(_value, :integer), do: false
defp value_present?(value, :date) when is_struct(value, Date), do: true
defp value_present?(value, :date) when is_binary(value), do: String.trim(value) != ""
defp value_present?(_value, :date), do: false
defp value_present?(value, :email) when is_binary(value), do: String.trim(value) != ""
defp value_present?(_value, :email), do: false
defp value_present?(_value, _type), do: false
end end

View file

@ -0,0 +1,42 @@
defmodule Mv.Membership.Member.Changes.SetDefaultMembershipFeeType do
@moduledoc """
Ash change that automatically assigns the default membership fee type to new members
if no membership_fee_type_id is explicitly provided.
This change reads the default_membership_fee_type_id from global settings and
assigns it to the member if membership_fee_type_id is nil.
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
# Only set default if membership_fee_type_id is not already set
current_type_id = Ash.Changeset.get_attribute(changeset, :membership_fee_type_id)
if is_nil(current_type_id) do
apply_default_membership_fee_type(changeset)
else
changeset
end
end
defp apply_default_membership_fee_type(changeset) do
case Mv.Membership.get_settings() do
{:ok, settings} ->
if settings.default_membership_fee_type_id do
Ash.Changeset.force_change_attribute(
changeset,
:membership_fee_type_id,
settings.default_membership_fee_type_id
)
else
changeset
end
{:error, _error} ->
# If settings can't be loaded, continue without default
# This prevents member creation from failing if settings are misconfigured
changeset
end
end
end

View file

@ -21,6 +21,9 @@ defmodule Mv.Membership do
use Ash.Domain, use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix] extensions: [AshAdmin.Domain, AshPhoenix]
require Ash.Query
import Ash.Expr
admin do admin do
show? true show? true
end end
@ -125,6 +128,29 @@ defmodule Mv.Membership do
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__)
end end
@doc """
Lists only required custom fields.
This is an optimized version that filters at the database level instead of
loading all custom fields and filtering in memory.
## Returns
- `{:ok, required_custom_fields}` - List of required custom fields
- `{:error, error}` - Error reading custom fields
## Examples
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields()
iex> Enum.all?(required_fields, & &1.required)
true
"""
def list_required_custom_fields do
Mv.Membership.CustomField
|> Ash.Query.filter(expr(required == true))
|> Ash.read(domain: __MODULE__)
end
@doc """ @doc """
Updates the member field visibility configuration. Updates the member field visibility configuration.

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

@ -0,0 +1,148 @@
defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
@moduledoc """
Validates that membership fee type changes only allow same-interval types.
Prevents changing from yearly to monthly, etc. (MVP constraint).
## Usage
In a Member action:
update :update_member do
# ...
change Mv.MembershipFees.Changes.ValidateSameInterval
end
The change module only executes when `membership_fee_type_id` is being changed.
If the new type has a different interval than the current type, a validation error is returned.
"""
use Ash.Resource.Change
@impl true
def change(changeset, _opts, _context) do
if changing_membership_fee_type?(changeset) do
validate_interval_match(changeset)
else
changeset
end
end
# Check if membership_fee_type_id is being changed
defp changing_membership_fee_type?(changeset) do
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
end
# Validate that the new type has the same interval as the current type
defp validate_interval_match(changeset) do
current_type_id = get_current_type_id(changeset)
new_type_id = get_new_type_id(changeset)
cond do
# If no current type, allow any change (first assignment)
is_nil(current_type_id) ->
changeset
# If new type is nil, reject the change (membership_fee_type_id is required)
is_nil(new_type_id) ->
add_nil_type_error(changeset)
# Both types exist - validate intervals match
true ->
validate_intervals_match(changeset, current_type_id, new_type_id)
end
end
# Validates that intervals match when both types exist
defp validate_intervals_match(changeset, current_type_id, new_type_id) do
case get_intervals(current_type_id, new_type_id) do
{:ok, current_interval, new_interval} ->
if current_interval == new_interval do
changeset
else
add_interval_mismatch_error(changeset, current_interval, new_interval)
end
{:error, reason} ->
# Fail closed: If we can't load the types, reject the change
# This prevents inconsistent data states
add_type_validation_error(changeset, reason)
end
end
# Get current type ID from changeset data
defp get_current_type_id(changeset) do
case changeset.data do
%{membership_fee_type_id: type_id} -> type_id
_ -> nil
end
end
# Get new type ID from changeset
defp get_new_type_id(changeset) do
case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do
{:ok, type_id} -> type_id
:error -> nil
end
end
# Get intervals for both types
defp get_intervals(current_type_id, new_type_id) do
alias Mv.MembershipFees.MembershipFeeType
case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} do
{{:ok, current_type}, {:ok, new_type}} ->
{:ok, current_type.interval, new_type.interval}
_ ->
{:error, :type_not_found}
end
end
# Add validation error for interval mismatch
defp add_interval_mismatch_error(changeset, current_interval, new_interval) do
current_interval_name = format_interval(current_interval)
new_interval_name = format_interval(new_interval)
message =
"Cannot change membership fee type: current type uses #{current_interval_name} interval, " <>
"new type uses #{new_interval_name} interval. Only same-interval changes are allowed."
Ash.Changeset.add_error(
changeset,
field: :membership_fee_type_id,
message: message
)
end
# Add validation error when types cannot be loaded
defp add_type_validation_error(changeset, _reason) do
message =
"Could not validate membership fee type intervals. " <>
"The current or new membership fee type no longer exists. " <>
"This may indicate a data consistency issue."
Ash.Changeset.add_error(
changeset,
field: :membership_fee_type_id,
message: message
)
end
# Add validation error when trying to set membership_fee_type_id to nil
defp add_nil_type_error(changeset) do
message = "Cannot remove membership fee type. A membership fee type is required."
Ash.Changeset.add_error(
changeset,
field: :membership_fee_type_id,
message: message
)
end
# Format interval atom to human-readable string
defp format_interval(:monthly), do: "monthly"
defp format_interval(:quarterly), do: "quarterly"
defp format_interval(:half_yearly), do: "half-yearly"
defp format_interval(:yearly), do: "yearly"
defp format_interval(interval), do: to_string(interval)
end

View file

@ -0,0 +1,132 @@
defmodule Mv.MembershipFees.MembershipFeeCycle do
@moduledoc """
Ash resource representing an individual membership fee cycle for a member.
## Overview
MembershipFeeCycle represents a single billing cycle for a member. Each cycle
tracks the payment status and amount for a specific time period.
## Attributes
- `cycle_start` - Start date of the billing cycle (aligned to calendar boundaries)
- `amount` - The fee amount for this cycle (stored for audit trail)
- `status` - Payment status: unpaid, paid, or suspended
- `notes` - Optional notes for this cycle
## Design Decisions
- **No cycle_end field**: Calculated from cycle_start + interval (from fee type)
- **Amount stored per cycle**: Preserves historical amounts when fee type changes
- **Calendar-aligned cycles**: All cycles start on calendar boundaries
## Relationships
- `belongs_to :member` - The member this cycle belongs to
- `belongs_to :membership_fee_type` - The fee type for this cycle
## Constraints
- Unique constraint on (member_id, cycle_start) - one cycle per period per member
- CASCADE delete when member is deleted
- RESTRICT delete on membership_fee_type if cycles exist
"""
use Ash.Resource,
domain: Mv.MembershipFees,
data_layer: AshPostgres.DataLayer
postgres do
table "membership_fee_cycles"
repo Mv.Repo
end
resource do
description "Individual membership fee cycle for a member"
end
actions do
defaults [:read, :destroy]
create :create do
primary? true
accept [:cycle_start, :amount, :status, :notes, :member_id, :membership_fee_type_id]
end
update :update do
primary? true
accept [:status, :notes, :amount]
end
update :mark_as_paid do
description "Mark cycle as paid"
require_atomic? false
accept [:notes]
change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :paid)
end
end
update :mark_as_suspended do
description "Mark cycle as suspended"
require_atomic? false
accept [:notes]
change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :suspended)
end
end
update :mark_as_unpaid do
description "Mark cycle as unpaid (for error correction)"
require_atomic? false
accept [:notes]
change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :unpaid)
end
end
end
attributes do
uuid_v7_primary_key :id
attribute :cycle_start, :date do
allow_nil? false
public? true
description "Start date of the billing cycle"
end
attribute :amount, :decimal do
allow_nil? false
public? true
description "Fee amount for this cycle (stored for audit trail, non-negative, max 2 decimal places)"
constraints min: 0, scale: 2
end
attribute :status, :atom do
allow_nil? false
public? true
default :unpaid
description "Payment status of this cycle"
constraints one_of: [:unpaid, :paid, :suspended]
end
attribute :notes, :string do
allow_nil? true
public? true
description "Optional notes for this cycle"
end
end
relationships do
belongs_to :member, Mv.Membership.Member do
allow_nil? false
end
belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do
allow_nil? false
end
end
identities do
identity :unique_cycle_per_member, [:member_id, :cycle_start]
end
end

View file

@ -0,0 +1,190 @@
defmodule Mv.MembershipFees.MembershipFeeType do
@moduledoc """
Ash resource representing a membership fee type definition.
## Overview
MembershipFeeType defines the different types of membership fees that can be
assigned to members. Each type has a fixed interval (billing cycle) and a
default amount.
## Attributes
- `name` - Unique name for the fee type (e.g., "Standard", "Reduced", "Family")
- `amount` - The fee amount in the default currency (decimal)
- `interval` - Billing interval: monthly, quarterly, half_yearly, or yearly
- `description` - Optional description for the fee type
## Immutability
The `interval` field is immutable after creation. This prevents complex
migration scenarios when changing billing cycles. To change intervals,
create a new fee type and migrate members.
## Relationships
- `has_many :members` - Members assigned to this fee type
- `has_many :membership_fee_cycles` - All cycles using this fee type
"""
use Ash.Resource,
domain: Mv.MembershipFees,
data_layer: AshPostgres.DataLayer
postgres do
table "membership_fee_types"
repo Mv.Repo
end
resource do
description "Membership fee type definition with interval and amount"
end
actions do
defaults [:read]
create :create do
primary? true
accept [:name, :amount, :interval, :description]
end
update :update do
primary? true
# require_atomic? false because validation queries (member/cycle counts) are not atomic
# DB constraints serve as the final safeguard if data changes between validation and update
require_atomic? false
# Note: interval is NOT in accept list - it's immutable after creation
accept [:name, :amount, :description]
end
destroy :destroy do
primary? true
# require_atomic? false because validation queries (member/cycle/settings counts) are not atomic
# DB constraints serve as the final safeguard if data changes between validation and delete
require_atomic? false
end
end
validations do
# Prevent interval changes after creation
validate fn changeset, _context ->
if Ash.Changeset.changing_attribute?(changeset, :interval) do
case changeset.data do
# Creating new resource, interval can be set
nil ->
:ok
_existing ->
{:error,
field: :interval, message: "Interval cannot be changed after creation"}
end
else
:ok
end
end,
on: [:update]
# Prevent deletion if assigned to members
validate fn changeset, _context ->
if changeset.action_type == :destroy do
require Ash.Query
member_count =
Mv.Membership.Member
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|> Ash.count!()
if member_count > 0 do
{:error,
message:
"Cannot delete membership fee type: #{member_count} member(s) are assigned to it"}
else
:ok
end
else
:ok
end
end,
on: [:destroy]
# Prevent deletion if cycles exist
validate fn changeset, _context ->
if changeset.action_type == :destroy do
require Ash.Query
cycle_count =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|> Ash.count!()
if cycle_count > 0 do
{:error,
message:
"Cannot delete membership fee type: #{cycle_count} cycle(s) reference it"}
else
:ok
end
else
:ok
end
end,
on: [:destroy]
# Prevent deletion if used as default in settings
validate fn changeset, _context ->
if changeset.action_type == :destroy do
require Ash.Query
setting_count =
Mv.Membership.Setting
|> Ash.Query.filter(default_membership_fee_type_id == ^changeset.data.id)
|> Ash.count!()
if setting_count > 0 do
{:error,
message: "Cannot delete membership fee type: it's used as default in settings"}
else
:ok
end
else
:ok
end
end,
on: [:destroy]
end
attributes do
uuid_v7_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
description "Unique name for the membership fee type"
end
attribute :amount, :decimal do
allow_nil? false
public? true
description "Fee amount in default currency (non-negative, max 2 decimal places)"
constraints min: 0, scale: 2
end
attribute :interval, :atom do
allow_nil? false
public? true
description "Billing interval (immutable after creation)"
constraints one_of: [:monthly, :quarterly, :half_yearly, :yearly]
end
attribute :description, :string do
allow_nil? true
public? true
description "Optional description for the fee type"
end
end
relationships do
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
has_many :members, Mv.Membership.Member
end
identities do
identity :unique_name, [:name]
end
end

View file

@ -0,0 +1,42 @@
defmodule Mv.MembershipFees do
@moduledoc """
Ash Domain for membership fee management.
## Resources
- `MembershipFeeType` - Defines membership fee types with intervals and amounts
- `MembershipFeeCycle` - Individual membership fee cycles per member
## Overview
This domain handles the complete membership fee lifecycle including:
- Fee type definitions (monthly, quarterly, half-yearly, yearly)
- Individual fee cycles for each member
- Payment status tracking (unpaid, paid, suspended)
## Architecture Decisions
- `interval` field on MembershipFeeType is immutable after creation
- `cycle_end` is calculated, not stored (from cycle_start + interval)
- `amount` is stored per cycle for audit trail when prices change
"""
use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix]
admin do
show? true
end
resources do
resource Mv.MembershipFees.MembershipFeeType do
define :create_membership_fee_type, action: :create
define :list_membership_fee_types, action: :read
define :update_membership_fee_type, action: :update
define :destroy_membership_fee_type, action: :destroy
end
resource Mv.MembershipFees.MembershipFeeCycle do
define :create_membership_fee_cycle, action: :create
define :list_membership_fee_cycles, action: :read
define :update_membership_fee_cycle, action: :update
define :destroy_membership_fee_cycle, action: :destroy
end
end
end

View file

@ -10,6 +10,7 @@ defmodule Mv.Application do
children = [ children = [
MvWeb.Telemetry, MvWeb.Telemetry,
Mv.Repo, Mv.Repo,
{Task.Supervisor, name: Mv.TaskSupervisor},
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mv.PubSub}, {Phoenix.PubSub, name: Mv.PubSub},
{AshAuthentication.Supervisor, otp_app: :my}, {AshAuthentication.Supervisor, otp_app: :my},

View file

@ -0,0 +1,31 @@
defmodule Mv.Authorization do
@moduledoc """
Ash Domain for authorization and role management.
## Resources
- `Role` - User roles that reference permission sets
## Public API
The domain exposes these main actions:
- Role CRUD: `create_role/1`, `list_roles/0`, `update_role/2`, `destroy_role/1`
## Admin Interface
The domain is configured with AshAdmin for management UI.
"""
use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix]
admin do
show? true
end
resources do
resource Mv.Authorization.Role do
define :create_role, action: :create_role
define :list_roles, action: :read
define :get_role, action: :read, get_by: [:id]
define :update_role, action: :update_role
define :destroy_role, action: :destroy
end
end
end

View file

@ -0,0 +1,294 @@
defmodule Mv.Authorization.PermissionSets do
@moduledoc """
Defines the four hardcoded permission sets for the application.
Each permission set specifies:
- Resource permissions (what CRUD operations on which resources)
- Page permissions (which LiveView pages can be accessed)
- Scopes (own, linked, all)
## Permission Sets
1. **own_data** - Default for "Mitglied" role
- Can only access own user data and linked member/custom field values
- Cannot create new members or manage system
2. **read_only** - For "Vorstand" and "Buchhaltung" roles
- Can read all member data
- Cannot create, update, or delete
3. **normal_user** - For "Kassenwart" role
- Create/Read/Update members (no delete for safety), full CRUD on custom field values
- Cannot manage custom fields or users
4. **admin** - For "Admin" role
- Unrestricted access to all resources
- Can manage users, roles, custom fields
## Usage
# Get permissions for a role's permission set
permissions = PermissionSets.get_permissions(:admin)
# Check if a permission set name is valid
PermissionSets.valid_permission_set?("read_only") # => true
# Convert string to atom safely
{:ok, atom} = PermissionSets.permission_set_name_to_atom("own_data")
## Performance
All functions are pure and intended to be constant-time. Permission lookups
are very fast (typically < 1 microsecond in practice) as they are simple
pattern matches and map lookups with no database queries or external calls.
"""
@type scope :: :own | :linked | :all
@type action :: :read | :create | :update | :destroy
@type resource_permission :: %{
resource: String.t(),
action: action(),
scope: scope(),
granted: boolean()
}
@type permission_set :: %{
resources: [resource_permission()],
pages: [String.t()]
}
@doc """
Returns the list of all valid permission set names.
## Examples
iex> PermissionSets.all_permission_sets()
[:own_data, :read_only, :normal_user, :admin]
"""
@spec all_permission_sets() :: [atom()]
def all_permission_sets do
[:own_data, :read_only, :normal_user, :admin]
end
@doc """
Returns permissions for the given permission set.
## Examples
iex> permissions = PermissionSets.get_permissions(:admin)
iex> Enum.any?(permissions.resources, fn p ->
...> p.resource == "User" and p.action == :destroy
...> end)
true
iex> PermissionSets.get_permissions(:invalid)
** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin]
"""
@spec get_permissions(atom()) :: permission_set()
def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do
raise ArgumentError,
"invalid permission set: #{inspect(set)}. Must be one of: #{inspect(all_permission_sets())}"
end
def get_permissions(:own_data) do
%{
resources: [
# User: Can always read/update own credentials
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
# Member: Can read/update linked member
%{resource: "Member", action: :read, scope: :linked, granted: true},
%{resource: "Member", action: :update, scope: :linked, granted: true},
# CustomFieldValue: Can read/update custom field values of linked member
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
# CustomField: Can read all (needed for forms)
%{resource: "CustomField", action: :read, scope: :all, granted: true}
],
pages: [
# Home page
"/",
# Own profile
"/profile",
# Linked member detail (filtered by policy)
"/members/:id"
]
}
end
def get_permissions(:read_only) do
%{
resources: [
# User: Can read/update own credentials only
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
# Member: Can read all members, no modifications
%{resource: "Member", action: :read, scope: :all, granted: true},
# CustomFieldValue: Can read all custom field values
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
# CustomField: Can read all
%{resource: "CustomField", action: :read, scope: :all, granted: true}
],
pages: [
"/",
# Own profile
"/profile",
# Member list
"/members",
# Member detail
"/members/:id",
# Custom field values overview
"/custom_field_values",
# Custom field value detail
"/custom_field_values/:id"
]
}
end
def get_permissions(:normal_user) do
%{
resources: [
# User: Can read/update own credentials only
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
# Member: Full CRUD except destroy (safety)
%{resource: "Member", action: :read, scope: :all, granted: true},
%{resource: "Member", action: :create, scope: :all, granted: true},
%{resource: "Member", action: :update, scope: :all, granted: true},
# Note: destroy intentionally omitted for safety
# CustomFieldValue: Full CRUD
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# CustomField: Read only (admin manages definitions)
%{resource: "CustomField", action: :read, scope: :all, granted: true}
],
pages: [
"/",
# Own profile
"/profile",
"/members",
# Create member
"/members/new",
"/members/:id",
# Edit member
"/members/:id/edit",
"/custom_field_values",
# Custom field value detail
"/custom_field_values/:id",
"/custom_field_values/new",
"/custom_field_values/:id/edit"
]
}
end
def get_permissions(:admin) do
%{
resources: [
# User: Full management including other users
%{resource: "User", action: :read, scope: :all, granted: true},
%{resource: "User", action: :create, scope: :all, granted: true},
%{resource: "User", action: :update, scope: :all, granted: true},
%{resource: "User", action: :destroy, scope: :all, granted: true},
# Member: Full CRUD
%{resource: "Member", action: :read, scope: :all, granted: true},
%{resource: "Member", action: :create, scope: :all, granted: true},
%{resource: "Member", action: :update, scope: :all, granted: true},
%{resource: "Member", action: :destroy, scope: :all, granted: true},
# CustomFieldValue: Full CRUD
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# CustomField: Full CRUD (admin manages custom field definitions)
%{resource: "CustomField", action: :read, scope: :all, granted: true},
%{resource: "CustomField", action: :create, scope: :all, granted: true},
%{resource: "CustomField", action: :update, scope: :all, granted: true},
%{resource: "CustomField", action: :destroy, scope: :all, granted: true},
# Role: Full CRUD (admin manages roles)
%{resource: "Role", action: :read, scope: :all, granted: true},
%{resource: "Role", action: :create, scope: :all, granted: true},
%{resource: "Role", action: :update, scope: :all, granted: true},
%{resource: "Role", action: :destroy, scope: :all, granted: true}
],
pages: [
# Wildcard: Admin can access all pages
"*"
]
}
end
def get_permissions(invalid) do
raise ArgumentError,
"invalid permission set: #{inspect(invalid)}. Must be one of: #{inspect(all_permission_sets())}"
end
@doc """
Checks if a permission set name (string or atom) is valid.
## Examples
iex> PermissionSets.valid_permission_set?("admin")
true
iex> PermissionSets.valid_permission_set?(:read_only)
true
iex> PermissionSets.valid_permission_set?("invalid")
false
"""
@spec valid_permission_set?(any()) :: boolean()
def valid_permission_set?(name) when is_binary(name) do
case permission_set_name_to_atom(name) do
{:ok, _atom} -> true
{:error, _} -> false
end
end
def valid_permission_set?(name) when is_atom(name) do
name in all_permission_sets()
end
def valid_permission_set?(_), do: false
@doc """
Converts a permission set name string to atom safely.
## Examples
iex> PermissionSets.permission_set_name_to_atom("admin")
{:ok, :admin}
iex> PermissionSets.permission_set_name_to_atom("invalid")
{:error, :invalid_permission_set}
"""
@spec permission_set_name_to_atom(String.t()) ::
{:ok, atom()} | {:error, :invalid_permission_set}
def permission_set_name_to_atom(name) when is_binary(name) do
atom = String.to_existing_atom(name)
if valid_permission_set?(atom) do
{:ok, atom}
else
{:error, :invalid_permission_set}
end
rescue
ArgumentError -> {:error, :invalid_permission_set}
end
end

View file

@ -0,0 +1,142 @@
defmodule Mv.Authorization.Role do
@moduledoc """
Represents a user role that references a permission set.
Roles are stored in the database and link users to permission sets.
Each role has a `permission_set_name` that references one of the four
hardcoded permission sets defined in `Mv.Authorization.PermissionSets`.
## Fields
- `name` - Unique role name (e.g., "Vorstand", "Admin")
- `description` - Human-readable description of the role
- `permission_set_name` - Must be one of: "own_data", "read_only", "normal_user", "admin"
- `is_system_role` - If true, role cannot be deleted (protects critical roles like "Mitglied")
## Relationships
- `has_many :users` - Users assigned to this role
## Validations
- `permission_set_name` must be a valid permission set (checked against PermissionSets.all_permission_sets/0)
- `name` must be unique
- System roles cannot be deleted (enforced via validation)
## Examples
# Create a new role
{:ok, role} = Mv.Authorization.create_role(%{
name: "Vorstand",
description: "Board member with read access",
permission_set_name: "read_only"
})
# List all roles
{:ok, roles} = Mv.Authorization.list_roles()
"""
use Ash.Resource,
domain: Mv.Authorization,
data_layer: AshPostgres.DataLayer
postgres do
table "roles"
repo Mv.Repo
references do
# Prevent deletion of roles that are assigned to users
reference :users, on_delete: :restrict
end
end
code_interface do
define :create_role
define :list_roles, action: :read
define :update_role
define :destroy_role, action: :destroy
end
actions do
defaults [:read]
create :create_role do
primary? true
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
accept [:name, :description, :permission_set_name]
# Note: In Ash 3.0, require_atomic? is not available for create actions
# Custom validations will still work
end
update :update_role do
primary? true
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
accept [:name, :description, :permission_set_name]
# Required because custom validation functions cannot be executed atomically
require_atomic? false
end
destroy :destroy do
# Required because custom validation functions cannot be executed atomically
require_atomic? false
end
end
validations do
validate one_of(
:permission_set_name,
Mv.Authorization.PermissionSets.all_permission_sets()
|> Enum.map(&Atom.to_string/1)
),
message:
"must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
validate fn changeset, _context ->
if changeset.data.is_system_role do
{:error,
field: :is_system_role,
message:
"Cannot delete system role. System roles are required for the application to function."}
else
:ok
end
end,
on: [:destroy]
end
attributes do
uuid_v7_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
end
attribute :description, :string do
allow_nil? true
public? true
end
attribute :permission_set_name, :string do
allow_nil? false
public? true
end
attribute :is_system_role, :boolean do
allow_nil? false
default false
public? true
end
timestamps()
end
relationships do
has_many :users, Mv.Accounts.User do
destination_attribute :role_id
end
end
identities do
identity :unique_name, [:name]
end
end

24
lib/mv/config.ex Normal file
View file

@ -0,0 +1,24 @@
defmodule Mv.Config do
@moduledoc """
Configuration helper functions for the application.
Provides centralized access to configuration values to avoid
magic strings/atoms scattered throughout the codebase.
"""
@doc """
Returns whether SQL sandbox mode is enabled.
SQL sandbox mode is typically enabled in test environments
to allow concurrent database access in tests.
## Returns
- `true` if SQL sandbox is enabled
- `false` otherwise
"""
@spec sql_sandbox?() :: boolean()
def sql_sandbox? do
Application.get_env(:mv, :sql_sandbox, false)
end
end

View file

@ -7,7 +7,6 @@ defmodule Mv.Constants do
:first_name, :first_name,
:last_name, :last_name,
:email, :email,
:paid,
:phone_number, :phone_number,
:join_date, :join_date,
:exit_date, :exit_date,
@ -15,7 +14,8 @@ defmodule Mv.Constants do
:city, :city,
:street, :street,
:house_number, :house_number,
:postal_code :postal_code,
:membership_fee_start_date
] ]
@custom_field_prefix "custom_field_" @custom_field_prefix "custom_field_"

View file

@ -0,0 +1,337 @@
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
# Ensure cycle_start is aligned to quarter boundary
# This handles cases where cycle_start might not be at the correct quarter start (e.g., month 12)
aligned_start = quarterly_cycle_start(cycle_start)
case aligned_start.month do
1 -> Date.new!(aligned_start.year, 3, 31)
4 -> Date.new!(aligned_start.year, 6, 30)
7 -> Date.new!(aligned_start.year, 9, 30)
10 -> Date.new!(aligned_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
# Ensure cycle_start is aligned to half-year boundary
# This handles cases where cycle_start might not be at the correct half-year start (e.g., month 10)
aligned_start = half_yearly_cycle_start(cycle_start)
case aligned_start.month do
1 -> Date.new!(aligned_start.year, 6, 30)
7 -> Date.new!(aligned_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,460 @@
defmodule Mv.MembershipFees.CycleGenerator do
@moduledoc """
Module for generating membership fee cycles for members.
This module provides functions to automatically generate membership fee cycles
based on a member's fee type, start date, and exit date.
## Algorithm
1. Load member with relationships (membership_fee_type, membership_fee_cycles)
2. Determine the generation start point:
- If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
- If cycles exist: Start from the cycle AFTER the last existing one
3. Generate all cycle starts from the determined start point to today (or `exit_date`)
4. Create new cycles with the current amount from `membership_fee_type`
## Important: Gap Handling
**Gaps are NOT filled.** If a cycle was explicitly deleted (e.g., 2023 was deleted
but 2022 and 2024 exist), the generator will NOT recreate the deleted cycle.
It always continues from the LAST existing cycle, regardless of any gaps.
This behavior ensures that manually deleted cycles remain deleted and prevents
unwanted automatic recreation of intentionally removed cycles.
## Concurrency
Uses PostgreSQL advisory locks to prevent race conditions when generating
cycles for the same member concurrently.
## Examples
# Generate cycles for a single member
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member)
# Generate cycles for all active members
{:ok, results} = CycleGenerator.generate_cycles_for_all_members()
"""
alias Mv.Membership.Member
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Repo
require Ash.Query
require Logger
@type generate_result ::
{:ok, [MembershipFeeCycle.t()], [Ash.Notifier.Notification.t()]} | {:error, term()}
@doc """
Generates membership fee cycles for a single member.
Uses an advisory lock to prevent concurrent generation for the same member.
## Parameters
- `member` - The member struct or member ID
- `opts` - Options:
- `:today` - Override today's date (useful for testing)
## Returns
- `{:ok, cycles, notifications}` - List of newly created cycles and notifications
- `{:error, reason}` - Error with reason
## Examples
{:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member)
{:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member_id)
{:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member, today: ~D[2024-12-31])
"""
@spec generate_cycles_for_member(Member.t() | String.t(), keyword()) :: generate_result()
def generate_cycles_for_member(member_or_id, opts \\ [])
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
case load_member(member_id) do
{:ok, member} -> generate_cycles_for_member(member, opts)
{:error, reason} -> {:error, reason}
end
end
def generate_cycles_for_member(%Member{} = member, opts) do
today = Keyword.get(opts, :today, Date.utc_today())
skip_lock? = Keyword.get(opts, :skip_lock?, false)
do_generate_cycles_with_lock(member, today, skip_lock?)
end
# Generate cycles with lock handling
# Returns {:ok, cycles, notifications} - notifications are never sent here,
# they should be returned to the caller (e.g., via after_action hook)
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
# Just generate cycles without additional locking
do_generate_cycles(member, today)
end
defp do_generate_cycles_with_lock(member, today, false) do
lock_key = :erlang.phash2(member.id)
Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today) do
{:ok, cycles, notifications} ->
# Return cycles and notifications - do NOT send notifications here
# They will be sent by the caller (e.g., via after_action hook)
{cycles, notifications}
{:error, reason} ->
Repo.rollback(reason)
end
end)
|> case do
{:ok, {cycles, notifications}} -> {:ok, cycles, notifications}
{:error, reason} -> {:error, reason}
end
end
@doc """
Generates membership fee cycles for all members with a fee type assigned.
This includes both active and inactive (left) members. Inactive members
will have cycles generated up to their exit_date if they don't have cycles
for that period yet. This allows for catch-up generation of missing cycles.
Members processed are those who:
- Have a membership_fee_type assigned
- Have a join_date set
The exit_date boundary is respected during generation (not in the query),
so inactive members will get cycles up to their exit date.
## Parameters
- `opts` - Options:
- `:today` - Override today's date (useful for testing)
- `:batch_size` - Number of members to process in parallel (default: 10)
## Returns
- `{:ok, results}` - Map with :success and :failed counts
- `{:error, reason}` - Error with reason
"""
@spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()}
def generate_cycles_for_all_members(opts \\ []) do
today = Keyword.get(opts, :today, Date.utc_today())
batch_size = Keyword.get(opts, :batch_size, 10)
# Query ALL members with fee type assigned (including inactive/left members)
# The exit_date boundary is applied during cycle generation, not here.
# This allows catch-up generation for members who left but are missing cycles.
query =
Member
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|> Ash.Query.filter(not is_nil(join_date))
case Ash.read(query) do
{:ok, members} ->
results = process_members_in_batches(members, batch_size, today)
{:ok, build_results_summary(results)}
{:error, reason} ->
{:error, reason}
end
end
defp process_members_in_batches(members, batch_size, today) do
members
|> Enum.chunk_every(batch_size)
|> Enum.flat_map(&process_batch(&1, today))
end
defp process_batch(batch, today) do
batch
|> Task.async_stream(fn member ->
process_member_cycle_generation(member, today)
end)
|> Enum.map(fn
{:ok, result} ->
result
{:exit, reason} ->
# Task crashed - log and return error tuple
Logger.error("Task crashed during cycle generation: #{inspect(reason)}")
{nil, {:error, {:task_exit, reason}}}
end)
end
# Process cycle generation for a single member in batch job
# Returns {member_id, result} tuple where result is {:ok, cycles, notifications} or {:error, reason}
defp process_member_cycle_generation(member, today) do
case generate_cycles_for_member(member, today: today) do
{:ok, _cycles, notifications} = ok ->
send_notifications_for_batch_job(notifications)
{member.id, ok}
{:error, _reason} = err ->
{member.id, err}
end
end
# Send notifications for batch job
# This is a top-level job, so we need to send notifications explicitly
defp send_notifications_for_batch_job(notifications) do
if Enum.any?(notifications) do
Ash.Notifier.notify(notifications)
end
end
defp build_results_summary(results) do
success_count = Enum.count(results, fn {_id, result} -> match?({:ok, _, _}, result) end)
failed_count = Enum.count(results, fn {_id, result} -> match?({:error, _}, result) end)
%{success: success_count, failed: failed_count, total: length(results)}
end
# Private functions
defp load_member(member_id) do
Member
|> Ash.Query.filter(id == ^member_id)
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|> Ash.read_one()
|> case do
{:ok, nil} -> {:error, :member_not_found}
{:ok, member} -> {:ok, member}
{:error, reason} -> {:error, reason}
end
end
defp do_generate_cycles(member, today) do
# Reload member with relationships to ensure fresh data
case load_member(member.id) do
{:ok, member} ->
cond do
is_nil(member.membership_fee_type_id) ->
{:error, :no_membership_fee_type}
is_nil(member.join_date) ->
{:error, :no_join_date}
true ->
generate_missing_cycles(member, today)
end
{:error, reason} ->
{:error, reason}
end
end
defp generate_missing_cycles(member, today) do
fee_type = member.membership_fee_type
interval = fee_type.interval
amount = fee_type.amount
existing_cycles = member.membership_fee_cycles || []
# Determine start point based on existing cycles
# Note: We do NOT fill gaps - only generate from the last existing cycle onwards
start_date = determine_generation_start(member, existing_cycles, interval)
# Determine end date (today or exit_date, whichever is earlier)
end_date = determine_end_date(member, today)
# Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
create_cycles(cycle_starts, member.id, fee_type.id, amount)
else
{:ok, [], []}
end
end
# No existing cycles: start from membership_fee_start_date
defp determine_generation_start(member, [], interval) do
determine_start_date(member, interval)
end
# Has existing cycles: start from the cycle AFTER the last one
# This ensures gaps (deleted cycles) are NOT filled
defp determine_generation_start(_member, existing_cycles, interval) do
last_cycle_start =
existing_cycles
|> Enum.map(& &1.cycle_start)
|> Enum.max(Date)
CalendarCycles.next_cycle_start(last_cycle_start, interval)
end
defp determine_start_date(member, interval) do
if member.membership_fee_start_date do
member.membership_fee_start_date
else
# Calculate from join_date using global settings
include_joining_cycle = get_include_joining_cycle()
SetMembershipFeeStartDate.calculate_start_date(
member.join_date,
interval,
include_joining_cycle
)
end
end
defp determine_end_date(member, today) do
if member.exit_date && Date.compare(member.exit_date, today) == :lt do
# Member has left - use the exit date as boundary
# Note: If exit_date == cycle_start, the cycle IS still generated.
# This means the member is considered a member on the first day of that cycle.
# Example: exit_date = 2025-01-01, yearly interval
# -> The 2025 cycle (starting 2025-01-01) WILL be generated
member.exit_date
else
today
end
end
defp get_include_joining_cycle do
case Mv.Membership.get_settings() do
{:ok, %{include_joining_cycle: include}} -> include
{:error, _} -> true
end
end
@doc """
Generates all cycle start dates from a start date to an end date.
## Parameters
- `start_date` - The first cycle start date
- `end_date` - The date up to which cycles should be generated
- `interval` - The billing interval
## Returns
List of cycle start dates.
## Examples
iex> generate_cycle_starts(~D[2024-01-01], ~D[2024-12-31], :quarterly)
[~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01], ~D[2024-10-01]]
"""
@spec generate_cycle_starts(Date.t(), Date.t(), atom()) :: [Date.t()]
def generate_cycle_starts(start_date, end_date, interval) do
# Ensure start_date is aligned to cycle boundary
aligned_start = CalendarCycles.calculate_cycle_start(start_date, interval)
generate_cycle_starts_acc(aligned_start, end_date, interval, [])
end
defp generate_cycle_starts_acc(current_start, end_date, interval, acc) do
if Date.compare(current_start, end_date) == :gt do
# Current cycle start is after end date - stop
Enum.reverse(acc)
else
# Include this cycle and continue to next
next_start = CalendarCycles.next_cycle_start(current_start, interval)
generate_cycle_starts_acc(next_start, end_date, interval, [current_start | acc])
end
end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
# Always use return_notifications?: true to collect notifications
# Notifications will be returned to the caller, who is responsible for
# sending them (e.g., via after_action hook returning {:ok, result, notifications})
results =
Enum.map(cycle_starts, fn cycle_start ->
attrs = %{
cycle_start: cycle_start,
member_id: member_id,
membership_fee_type_id: fee_type_id,
amount: amount,
status: :unpaid
}
handle_cycle_creation_result(
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true),
cycle_start
)
end)
{successes, skips, errors} =
Enum.reduce(results, {[], [], []}, fn
{:ok, cycle, notifications}, {successes, skips, errors} ->
{[{:ok, cycle, notifications} | successes], skips, errors}
{:skip, cycle_start}, {successes, skips, errors} ->
{successes, [cycle_start | skips], errors}
{:error, error}, {successes, skips, errors} ->
{successes, skips, [error | errors]}
end)
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)
if Enum.any?(skips) do
Logger.debug("Skipped #{length(skips)} cycles that already exist for member #{member_id}")
end
{:ok, successful_cycles, all_notifications}
else
Logger.warning("Some cycles failed to create: #{inspect(errors)}")
# Return partial failure with errors
# Note: When this error occurs, the transaction will be rolled back,
# so no cycles were actually persisted in the database
{:error, {:partial_failure, errors}}
end
end
defp handle_cycle_creation_result({:ok, cycle, notifications}, _cycle_start)
when is_list(notifications) do
{:ok, cycle, notifications}
end
defp handle_cycle_creation_result({:ok, cycle}, _cycle_start) do
{:ok, cycle, []}
end
defp handle_cycle_creation_result(
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Changes.InvalidAttribute{
private_vars: %{constraint: constraint, constraint_type: :unique}
}
]
}} = error,
cycle_start
) do
# Cycle already exists (unique constraint violation) - skip it silently
# This makes the function idempotent and prevents errors on server restart
handle_unique_constraint_violation(constraint, cycle_start, error)
end
defp handle_cycle_creation_result({:error, reason}, cycle_start) do
{:error, {cycle_start, reason}}
end
defp handle_unique_constraint_violation(
"membership_fee_cycles_unique_cycle_per_member_index",
cycle_start,
_error
) do
{:skip, cycle_start}
end
defp handle_unique_constraint_violation(_constraint, cycle_start, error) do
{:error, {cycle_start, error}}
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>
""" """
@ -308,7 +333,8 @@ defmodule MvWeb.CoreComponents do
attr :error_class, :string, default: nil, doc: "the input error class to use over defaults" attr :error_class, :string, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global, attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength include:
~w(accept autocomplete aria-required capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step) multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
@ -328,6 +354,24 @@ defmodule MvWeb.CoreComponents do
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end) end)
# For checkboxes, we don't use HTML required attribute (means "must be checked")
# Instead, we use aria-required for screen readers (WCAG 2.1, Success Criterion 3.3.2)
# Extract required from rest and remove it, but keep aria-required if provided
rest = assigns.rest || %{}
is_required = Map.get(rest, :required, false)
aria_required = Map.get(rest, :aria_required, if(is_required, do: "true", else: nil))
# Remove required from rest (we don't want HTML required on checkbox)
rest_without_required = Map.delete(rest, :required)
# Ensure aria-required is set if field is required
rest_final =
if aria_required,
do: Map.put(rest_without_required, :aria_required, aria_required),
else: rest_without_required
assigns = assign(assigns, :rest, rest_final)
assigns = assign(assigns, :is_required, is_required)
~H""" ~H"""
<fieldset class="mb-2 fieldset"> <fieldset class="mb-2 fieldset">
<label> <label>
@ -342,9 +386,9 @@ defmodule MvWeb.CoreComponents do
class={@class || "checkbox checkbox-sm"} class={@class || "checkbox checkbox-sm"}
{@rest} {@rest}
/>{@label}<span />{@label}<span
:if={@rest[:required]} :if={@is_required}
class="text-red-700 tooltip tooltip-right" class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")} data-tip={gettext("This field is required")}
>*</span> >*</span>
</span> </span>
</label> </label>

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>
@ -29,9 +32,13 @@ defmodule MvWeb.Layouts.Navbar do
<details> <details>
<summary>{gettext("Contributions")}</summary> <summary>{gettext("Contributions")}</summary>
<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> <li>
<.link navigate="/contribution_settings">{gettext("Contribution Settings")}</.link> <.link navigate="/membership_fee_types">{gettext("Membership Fee Types")}</.link>
</li>
<li>
<.link navigate="/membership_fee_settings">
{gettext("Membership Fee Settings")}
</.link>
</li> </li>
</ul> </ul>
</details> </details>

View file

@ -0,0 +1,241 @@
defmodule MvWeb.Helpers.MembershipFeeHelpers do
@moduledoc """
Helper functions for membership fee UI components.
Provides formatting and utility functions for displaying membership fee
information in LiveViews and templates.
"""
use Gettext, backend: MvWeb.Gettext
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Membership.Member
@doc """
Formats a decimal amount as currency string.
## Examples
iex> MvWeb.Helpers.MembershipFeeHelpers.format_currency(Decimal.new("60.00"))
"60,00 €"
iex> MvWeb.Helpers.MembershipFeeHelpers.format_currency(Decimal.new("5.5"))
"5,50 €"
"""
@spec format_currency(Decimal.t()) :: String.t()
def format_currency(%Decimal{} = amount) do
# Use German format: comma as decimal separator, always 2 decimal places
normalized = Decimal.round(amount, 2)
normalized_str = Decimal.to_string(normalized, :normal)
format_currency_parts(normalized_str)
end
# Formats currency string with comma as decimal separator
defp format_currency_parts(normalized_str) do
case String.split(normalized_str, ".") do
[int_part, dec_part] ->
format_with_decimal_part(int_part, dec_part)
[int_part] ->
"#{int_part},00 €"
_ ->
# Fallback for unexpected split results
"#{String.replace(normalized_str, ".", ",")}"
end
end
# Formats currency with decimal part, ensuring exactly 2 decimal places
defp format_with_decimal_part(int_part, dec_part) do
dec_size = byte_size(dec_part)
formatted_dec =
cond do
dec_size == 1 -> "#{dec_part}0"
dec_size == 2 -> dec_part
dec_size > 2 -> String.slice(dec_part, 0, 2)
true -> "00"
end
"#{int_part},#{formatted_dec}"
end
@doc """
Formats an interval atom as a translated string.
## Examples
iex> MvWeb.Helpers.MembershipFeeHelpers.format_interval(:monthly)
"Monthly"
iex> MvWeb.Helpers.MembershipFeeHelpers.format_interval(:yearly)
"Yearly"
"""
@spec format_interval(:monthly | :quarterly | :half_yearly | :yearly) :: String.t()
def format_interval(:monthly), do: gettext("Monthly")
def format_interval(:quarterly), do: gettext("Quarterly")
def format_interval(:half_yearly), do: gettext("Half-yearly")
def format_interval(:yearly), do: gettext("Yearly")
@doc """
Formats a cycle date range as a string.
Calculates the cycle end date from cycle_start and interval, then formats
both dates in European format (dd.mm.yyyy).
## Examples
iex> cycle_start = ~D[2024-01-01]
iex> MvWeb.Helpers.MembershipFeeHelpers.format_cycle_range(cycle_start, :yearly)
"01.01.2024 - 31.12.2024"
iex> cycle_start = ~D[2024-03-01]
iex> MvWeb.Helpers.MembershipFeeHelpers.format_cycle_range(cycle_start, :monthly)
"01.03.2024 - 31.03.2024"
"""
@spec format_cycle_range(Date.t(), :monthly | :quarterly | :half_yearly | :yearly) :: String.t()
def format_cycle_range(cycle_start, interval) do
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
start_str = format_date(cycle_start)
end_str = format_date(cycle_end)
"#{start_str} - #{end_str}"
end
@doc """
Gets the last completed cycle for a member.
Returns the cycle that was most recently completed (ended before today).
Returns `nil` if no completed cycles exist.
## Parameters
- `member` - Member struct with loaded membership_fee_cycles and membership_fee_type
- `today` - Optional date to use as reference (defaults to today)
## Returns
- `%MembershipFeeCycle{}` if found
- `nil` if no completed cycle exists
## Examples
# Member with cycles from 2023 and 2024, today is 2025-01-15
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_last_completed_cycle(member)
# => %MembershipFeeCycle{cycle_start: ~D[2024-01-01], ...}
"""
@spec get_last_completed_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
def get_last_completed_cycle(member, today \\ nil)
def get_last_completed_cycle(%Member{} = member, today) do
today = today || Date.utc_today()
case member.membership_fee_type do
nil ->
nil
fee_type ->
cycles = member.membership_fee_cycles || []
# Get all completed cycles (cycle_end < today)
completed_cycles =
cycles
|> Enum.filter(fn cycle ->
cycle_end = CalendarCycles.calculate_cycle_end(cycle.cycle_start, fee_type.interval)
Date.compare(today, cycle_end) == :gt
end)
# Return the most recent completed cycle (highest cycle_start)
completed_cycles
|> Enum.max_by(& &1.cycle_start, Date, fn -> nil end)
end
end
@doc """
Gets the current cycle for a member.
Returns the cycle that contains today's date.
Returns `nil` if no current cycle exists.
## Parameters
- `member` - Member struct with loaded membership_fee_cycles and membership_fee_type
- `today` - Optional date to use as reference (defaults to today)
## Returns
- `%MembershipFeeCycle{}` if found
- `nil` if no current cycle exists
## Examples
# Member with cycles, today is 2024-06-15 (within Q2 2024)
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_current_cycle(member)
# => %MembershipFeeCycle{cycle_start: ~D[2024-04-01], ...}
"""
@spec get_current_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
def get_current_cycle(member, today \\ nil)
def get_current_cycle(%Member{} = member, today) do
today = today || Date.utc_today()
case member.membership_fee_type do
nil ->
nil
fee_type ->
cycles = member.membership_fee_cycles || []
cycles
|> Enum.filter(fn cycle ->
CalendarCycles.current_cycle?(cycle.cycle_start, fee_type.interval, today)
end)
|> Enum.sort_by(& &1.cycle_start, {:desc, Date})
|> List.first()
end
end
@doc """
Gets the CSS color class for a status badge.
## Examples
iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:paid)
"badge-success"
iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:unpaid)
"badge-error"
iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:suspended)
"badge-ghost"
"""
@spec status_color(:paid | :unpaid | :suspended) :: String.t()
def status_color(:paid), do: "badge-success"
def status_color(:unpaid), do: "badge-error"
def status_color(:suspended), do: "badge-ghost"
@doc """
Gets the icon name for a status.
## Examples
iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:paid)
"hero-check-circle"
iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:unpaid)
"hero-x-circle"
iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:suspended)
"hero-pause-circle"
"""
@spec status_icon(:paid | :unpaid | :suspended) :: String.t()
def status_icon(:paid), do: "hero-check-circle"
def status_icon(:unpaid), do: "hero-x-circle"
def status_icon(:suspended), do: "hero-pause-circle"
# Private helper function for date formatting
defp format_date(%Date{} = date) do
Calendar.strftime(date, "%d.%m.%Y")
end
end

View file

@ -2,11 +2,12 @@ defmodule MvWeb.Components.PaymentFilterComponent do
@moduledoc """ @moduledoc """
Provides the PaymentFilter Live-Component. Provides the PaymentFilter Live-Component.
A dropdown filter for filtering members by payment status (paid/not paid/all). A dropdown filter for filtering members by cycle payment status (paid/unpaid/all).
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView. Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
Filter is based on cycle status (last or current cycle, depending on cycle view toggle).
## Props ## Props
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid` - `:cycle_status_filter` - Current filter state: `nil` (all), `:paid`, or `:unpaid`
- `:id` - Component ID (required) - `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0) - `:member_count` - Number of filtered members to display in badge (optional, default: 0)
@ -25,7 +26,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
socket = socket =
socket socket
|> assign(:id, assigns.id) |> assign(:id, assigns.id)
|> assign(:paid_filter, assigns[:paid_filter]) |> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:member_count, assigns[:member_count] || 0) |> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket} {:ok, socket}
@ -45,7 +46,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
type="button" type="button"
class={[ class={[
"btn gap-2", "btn gap-2",
@paid_filter && "btn-active" @cycle_status_filter && "btn-active"
]} ]}
phx-click="toggle_dropdown" phx-click="toggle_dropdown"
phx-target={@myself} phx-target={@myself}
@ -54,8 +55,8 @@ defmodule MvWeb.Components.PaymentFilterComponent do
aria-label={gettext("Filter by payment status")} aria-label={gettext("Filter by payment status")}
> >
<.icon name="hero-funnel" class="h-5 w-5" /> <.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">{filter_label(@paid_filter)}</span> <span class="hidden sm:inline">{filter_label(@cycle_status_filter)}</span>
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span> <span :if={@cycle_status_filter} class="badge badge-primary badge-sm">{@member_count}</span>
</button> </button>
<ul <ul
@ -70,8 +71,8 @@ defmodule MvWeb.Components.PaymentFilterComponent do
<button <button
type="button" type="button"
role="menuitemradio" role="menuitemradio"
aria-checked={to_string(@paid_filter == nil)} aria-checked={to_string(@cycle_status_filter == nil)}
class={@paid_filter == nil && "active"} class={@cycle_status_filter == nil && "active"}
phx-click="select_filter" phx-click="select_filter"
phx-value-filter="" phx-value-filter=""
phx-target={@myself} phx-target={@myself}
@ -84,8 +85,8 @@ defmodule MvWeb.Components.PaymentFilterComponent do
<button <button
type="button" type="button"
role="menuitemradio" role="menuitemradio"
aria-checked={to_string(@paid_filter == :paid)} aria-checked={to_string(@cycle_status_filter == :paid)}
class={@paid_filter == :paid && "active"} class={@cycle_status_filter == :paid && "active"}
phx-click="select_filter" phx-click="select_filter"
phx-value-filter="paid" phx-value-filter="paid"
phx-target={@myself} phx-target={@myself}
@ -98,14 +99,14 @@ defmodule MvWeb.Components.PaymentFilterComponent do
<button <button
type="button" type="button"
role="menuitemradio" role="menuitemradio"
aria-checked={to_string(@paid_filter == :not_paid)} aria-checked={to_string(@cycle_status_filter == :unpaid)}
class={@paid_filter == :not_paid && "active"} class={@cycle_status_filter == :unpaid && "active"}
phx-click="select_filter" phx-click="select_filter"
phx-value-filter="not_paid" phx-value-filter="unpaid"
phx-target={@myself} phx-target={@myself}
> >
<.icon name="hero-x-circle" class="h-4 w-4 text-error" /> <.icon name="hero-x-circle" class="h-4 w-4 text-error" />
{gettext("Not paid")} {gettext("Unpaid")}
</button> </button>
</li> </li>
</ul> </ul>
@ -136,11 +137,11 @@ defmodule MvWeb.Components.PaymentFilterComponent do
# Parse filter string to atom # Parse filter string to atom
defp parse_filter("paid"), do: :paid defp parse_filter("paid"), do: :paid
defp parse_filter("not_paid"), do: :not_paid defp parse_filter("unpaid"), do: :unpaid
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")
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(:unpaid), do: gettext("Unpaid")
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)
@ -29,7 +29,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>
@ -88,10 +88,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

@ -21,6 +21,10 @@ defmodule MvWeb.MemberLive.Form do
""" """
use MvWeb, :live_view use MvWeb, :live_view
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true @impl true
def render(assigns) do def render(assigns) do
# Sort custom fields by name for display only # Sort custom fields by name for display only
@ -144,6 +148,7 @@ defmodule MvWeb.MemberLive.Form do
field={value_form[:value]} field={value_form[:value]}
label={cf.name} label={cf.name}
type={custom_field_input_type(cf.value_type)} type={custom_field_input_type(cf.value_type)}
required={cf.required}
/> />
</.inputs_for> </.inputs_for>
<input <input
@ -161,42 +166,46 @@ defmodule MvWeb.MemberLive.Form do
<% end %> <% end %>
</div> </div>
<%!-- Payment Data Section (Mockup) --%> <%!-- Membership Fee Section --%>
<div class="max-w-xl"> <div class="max-w-xl">
<.form_section title={gettext("Payment Data")}> <.form_section title={gettext("Membership Fee")}>
<div role="alert" class="alert alert-info mb-4"> <div class="space-y-4">
<.icon name="hero-information-circle" class="size-5" /> <div>
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span> <label class="label">
</div> <span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
<div class="flex gap-8">
<div class="w-24">
<label for="mock-contribution" class="label text-sm font-medium">
{gettext("Contribution")}
</label> </label>
<input <select
type="text" class="select select-bordered w-full"
id="mock-contribution" name={@form[:membership_fee_type_id].name}
value="72 €" phx-change="validate_membership_fee_type"
disabled value={@form[:membership_fee_type_id].value || ""}
class="input input-bordered w-full bg-base-200" >
/> <option value="">{gettext("None")}</option>
</div> <%= for fee_type <- @available_fee_types do %>
<div class="w-40"> <option
<label class="label text-sm font-medium">{gettext("Payment Cycle")}</label> value={fee_type.id}
<div class="flex gap-3 mt-2"> selected={fee_type.id == @form[:membership_fee_type_id].value}
<label class="flex items-center gap-1 cursor-not-allowed opacity-60"> >
<input type="radio" name="mock_cycle" checked disabled class="radio radio-sm" /> {fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
<span class="text-sm">{gettext("monthly")}</span> fee_type.interval
</label> )})
<label class="flex items-center gap-1 cursor-not-allowed opacity-60"> </option>
<input type="radio" name="mock_cycle" disabled class="radio radio-sm" /> <% end %>
<span class="text-sm">{gettext("yearly")}</span> </select>
</label> <%= for {msg, _opts} <- @form.errors[:membership_fee_type_id] || [] do %>
</div> <p class="text-error text-sm mt-1">{msg}</p>
</div> <% end %>
<div class="w-24 flex items-end"> <%= if @interval_warning do %>
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" /> <div class="alert alert-warning mt-2">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>{@interval_warning}</span>
</div>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
{gettext(
"Select a membership fee type for this member. Members can only switch between types with the same interval."
)}
</p>
</div> </div>
</div> </div>
</.form_section> </.form_section>
@ -235,12 +244,15 @@ defmodule MvWeb.MemberLive.Form do
member = member =
case params["id"] do case params["id"] do
nil -> nil nil -> nil
id -> Ash.get!(Mv.Membership.Member, id) id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type])
end end
page_title = page_title =
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member") if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
# Load available membership fee types
available_fee_types = load_available_fee_types(member)
{:ok, {:ok,
socket socket
|> assign(:return_to, return_to(params["return_to"])) |> assign(:return_to, return_to(params["return_to"]))
@ -248,6 +260,8 @@ defmodule MvWeb.MemberLive.Form do
|> assign(:initial_custom_field_values, initial_custom_field_values) |> assign(:initial_custom_field_values, initial_custom_field_values)
|> assign(member: member) |> assign(member: member)
|> assign(:page_title, page_title) |> assign(:page_title, page_title)
|> assign(:available_fee_types, available_fee_types)
|> assign(:interval_warning, nil)
|> assign_form()} |> assign_form()}
end end
@ -256,7 +270,21 @@ defmodule MvWeb.MemberLive.Form do
@impl true @impl true
def handle_event("validate", %{"member" => member_params}, socket) do def handle_event("validate", %{"member" => member_params}, socket) do
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, member_params))} validated_form = AshPhoenix.Form.validate(socket.assigns.form, member_params)
# Check for interval mismatch if membership_fee_type_id changed
socket = check_interval_change(socket, member_params)
{:noreply, assign(socket, form: validated_form)}
end
def handle_event(
"validate_membership_fee_type",
%{"member" => %{"membership_fee_type_id" => fee_type_id}},
socket
) do
# Same validation as above, but triggered by select change
handle_event("validate", %{"member" => %{"membership_fee_type_id" => fee_type_id}}, socket)
end end
def handle_event("save", %{"member" => member_params}, socket) do def handle_event("save", %{"member" => member_params}, socket) do
@ -348,6 +376,77 @@ defmodule MvWeb.MemberLive.Form do
defp return_path("show", nil), do: ~p"/members" defp return_path("show", nil), do: ~p"/members"
defp return_path("show", member), do: ~p"/members/#{member.id}" defp return_path("show", member), do: ~p"/members/#{member.id}"
# -----------------------------------------------------------------
# Helper Functions
# -----------------------------------------------------------------
defp load_available_fee_types(member) do
all_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: MembershipFees)
# If member has a fee type, filter to same interval
if member && member.membership_fee_type do
Enum.filter(all_types, fn type ->
type.interval == member.membership_fee_type.interval
end)
else
all_types
end
end
# Checks if membership fee type interval changed and updates socket assigns
defp check_interval_change(socket, member_params) do
if Map.has_key?(member_params, "membership_fee_type_id") &&
socket.assigns.member &&
socket.assigns.member.membership_fee_type do
handle_interval_change(socket, member_params["membership_fee_type_id"])
else
socket
end
end
# Handles interval change validation
defp handle_interval_change(socket, new_fee_type_id) do
if new_fee_type_id != "" &&
new_fee_type_id != socket.assigns.member.membership_fee_type_id do
validate_interval_match(socket, new_fee_type_id)
else
assign(socket, :interval_warning, nil)
end
end
# Validates that new fee type has same interval as current
defp validate_interval_match(socket, new_fee_type_id) do
new_fee_type = find_fee_type(socket.assigns.available_fee_types, new_fee_type_id)
if new_fee_type &&
new_fee_type.interval != socket.assigns.member.membership_fee_type.interval do
show_interval_warning(socket, new_fee_type)
else
assign(socket, :interval_warning, nil)
end
end
# Shows interval mismatch warning
defp show_interval_warning(socket, new_fee_type) do
assign(
socket,
:interval_warning,
gettext(
"Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval.",
old_interval:
MembershipFeeHelpers.format_interval(socket.assigns.member.membership_fee_type.interval),
new_interval: MembershipFeeHelpers.format_interval(new_fee_type.interval)
)
)
end
defp find_fee_type(fee_types, fee_type_id) do
Enum.find(fee_types, &(&1.id == fee_type_id))
end
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Helper Functions for Custom Fields # Helper Functions for Custom Fields
# ----------------------------------------------------------------- # -----------------------------------------------------------------

View file

@ -35,6 +35,7 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.Helpers.DateFormatter alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility alias MvWeb.MemberLive.Index.FieldVisibility
alias MvWeb.MemberLive.Index.MembershipFeeStatus
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>") # Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
@ -97,7 +98,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:query, "") |> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end) |> assign_new(:sort_order, fn -> :asc end)
|> assign(:paid_filter, nil) |> assign(:cycle_status_filter, nil)
|> assign(:selected_members, MapSet.new()) |> assign(:selected_members, MapSet.new())
|> assign(:settings, settings) |> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible) |> assign(:custom_fields_visible, custom_fields_visible)
@ -108,6 +109,8 @@ defmodule MvWeb.MemberLive.Index do
:member_fields_visible, :member_fields_visible,
FieldVisibility.get_visible_member_fields(initial_selection) FieldVisibility.get_visible_member_fields(initial_selection)
) )
|> assign(:show_current_cycle, false)
|> assign(:membership_fee_status_filter, nil)
# We call handle params to use the query from the URL # We call handle params to use the query from the URL
{:ok, socket} {:ok, socket}
@ -145,7 +148,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 +165,35 @@ 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
@impl true
def handle_event("toggle_cycle_view", _params, socket) do
new_show_current = !socket.assigns.show_current_cycle
socket =
socket
|> assign(:show_current_cycle, new_show_current)
|> load_members()
|> update_selection_assigns()
# Update URL to reflect cycle view change
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
new_show_current
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end end
@impl true @impl true
@ -238,13 +272,20 @@ 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
# Build the URL with queries # Build the URL with queries
query_params = query_params =
build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter) build_query_params(
q,
existing_field_query,
existing_sort_query,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
# Set the new path with params # Set the new path with params
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
@ -261,8 +302,9 @@ defmodule MvWeb.MemberLive.Index do
def handle_info({:payment_filter_changed, filter}, socket) do def handle_info({:payment_filter_changed, filter}, socket) do
socket = socket =
socket socket
|> assign(:paid_filter, filter) |> assign(:cycle_status_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 =
@ -270,7 +312,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.query, socket.assigns.query,
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
filter filter,
socket.assigns.show_current_cycle
) )
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
@ -309,6 +352,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 +382,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}
@ -382,13 +427,15 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> maybe_update_search(params) |> maybe_update_search(params)
|> maybe_update_sort(params) |> maybe_update_sort(params)
|> maybe_update_paid_filter(params) |> maybe_update_cycle_status_filter(params)
|> maybe_update_show_current_cycle(params)
|> assign(:query, params["query"]) |> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection) |> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields) |> assign(:member_fields_visible, visible_member_fields)
|> 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
@ -490,7 +537,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.query, socket.assigns.query,
field_str, field_str,
Atom.to_string(order), Atom.to_string(order),
socket.assigns.paid_filter socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
) )
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
@ -502,16 +550,6 @@ defmodule MvWeb.MemberLive.Index do
)} )}
end end
# Builds query parameters including field selection
defp build_query_params(socket, base_params) do
# Use query from base_params if provided, otherwise fall back to socket.assigns.query
query_value = Map.get(base_params, "query") || socket.assigns.query || ""
base_params
|> Map.put("query", query_value)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
end
# Adds field selection to query params if present # Adds field selection to query params if present
defp maybe_add_field_selection(params, nil), do: params defp maybe_add_field_selection(params, nil), do: params
@ -524,29 +562,21 @@ defmodule MvWeb.MemberLive.Index do
# Pushes URL with updated field selection # Pushes URL with updated field selection
defp push_field_selection_url(socket) do defp push_field_selection_url(socket) do
base_params = %{ query_params =
"sort_field" => field_to_string(socket.assigns.sort_field), build_query_params(
"sort_order" => Atom.to_string(socket.assigns.sort_order) socket.assigns.query,
} socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
# Include paid_filter if set
base_params =
case socket.assigns.paid_filter do
nil -> base_params
:paid -> Map.put(base_params, "paid_filter", "paid")
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
end
query_params = build_query_params(socket, base_params)
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
push_patch(socket, to: new_path, replace: true) push_patch(socket, to: new_path, replace: true)
end end
# Converts field to string
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
defp field_to_string(field) when is_binary(field), do: field
# Updates session field selection (stored in socket for now, actual session update via controller) # Updates session field selection (stored in socket for now, actual session update via controller)
defp update_session_field_selection(socket, selection) do defp update_session_field_selection(socket, selection) do
# Store in socket for now - actual session persistence would require a controller # Store in socket for now - actual session persistence would require a controller
@ -555,8 +585,14 @@ defmodule MvWeb.MemberLive.Index do
end end
# Builds URL query parameters map including all filter/sort state. # Builds URL query parameters map including all filter/sort state.
# Converts paid_filter atom to string for URL. # Converts cycle_status_filter atom to string for URL.
defp build_query_params(query, sort_field, sort_order, paid_filter) do defp build_query_params(
query,
sort_field,
sort_order,
cycle_status_filter,
show_current_cycle
) do
field_str = field_str =
if is_atom(sort_field) do if is_atom(sort_field) do
Atom.to_string(sort_field) Atom.to_string(sort_field)
@ -577,11 +613,19 @@ defmodule MvWeb.MemberLive.Index do
"sort_order" => order_str "sort_order" => order_str
} }
# Only add paid_filter to URL if it's set # Only add cycle_status_filter to URL if it's set
case paid_filter do base_params =
nil -> base_params case cycle_status_filter do
:paid -> Map.put(base_params, "paid_filter", "paid") nil -> base_params
:not_paid -> Map.put(base_params, "paid_filter", "not_paid") :paid -> Map.put(base_params, "cycle_status_filter", "paid")
:unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
end
# Add show_current_cycle if true
if show_current_cycle do
Map.put(base_params, "show_current_cycle", "true")
else
base_params
end end
end end
@ -616,12 +660,12 @@ defmodule MvWeb.MemberLive.Index do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
query = load_custom_field_values(query, visible_custom_field_ids) query = load_custom_field_values(query, visible_custom_field_ids)
# Load membership fee cycles for status display
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
# Apply the search filter first # Apply the search filter first
query = apply_search_filter(query, search_query) query = apply_search_filter(query, search_query)
# Apply payment status filter
query = apply_paid_filter(query, socket.assigns.paid_filter)
# Apply sorting based on current socket state # Apply sorting based on current socket state
# For custom fields, we sort after loading # For custom fields, we sort after loading
{query, sort_after_load} = {query, sort_after_load} =
@ -639,6 +683,14 @@ defmodule MvWeb.MemberLive.Index do
# Custom field values are already filtered at the database level in load_custom_field_values/2 # Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore # No need for in-memory filtering anymore
# Apply cycle status filter if set
members =
apply_cycle_status_filter(
members,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
# Sort in memory if needed (for custom fields) # Sort in memory if needed (for custom fields)
members = members =
if sort_after_load do if sort_after_load do
@ -668,7 +720,7 @@ defmodule MvWeb.MemberLive.Index do
query query
end end
defp load_custom_field_values(query, custom_field_ids) when length(custom_field_ids) > 0 do defp load_custom_field_values(query, custom_field_ids) do
# Filter custom field values at the database level using Ash relationship query # Filter custom field values at the database level using Ash relationship query
# This ensures only visible custom field values are loaded # This ensures only visible custom field values are loaded
custom_field_values_query = custom_field_values_query =
@ -696,22 +748,17 @@ defmodule MvWeb.MemberLive.Index do
end end
end end
# Applies payment status filter to the query. # Applies cycle status filter to members list.
# #
# Filter values: # Filter values:
# - nil: No filter, return all members # - nil: No filter, return all members
# - :paid: Only members with paid == true # - :paid: Only members with paid status in the selected cycle (last or current)
# - :not_paid: Members with paid == false or paid == nil (not paid) # - :unpaid: Only members with unpaid status in the selected cycle (last or current)
defp apply_paid_filter(query, nil), do: query defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_paid_filter(query, :paid) do defp apply_cycle_status_filter(members, status, show_current)
Ash.Query.filter(query, expr(paid == true)) when status in [:paid, :unpaid] do
end MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
defp apply_paid_filter(query, :not_paid) do
# Include both false and nil as "not paid"
# Note: paid != true doesn't work correctly with NULL values in SQL
Ash.Query.filter(query, expr(paid == false or is_nil(paid)))
end end
# Functions to toggle sorting order # Functions to toggle sorting order
@ -745,7 +792,7 @@ defmodule MvWeb.MemberLive.Index do
defp valid_sort_field?(field) when is_atom(field) do defp valid_sort_field?(field) when is_atom(field) do
# All member fields are sortable, but we exclude some that don't make sense # All member fields are sortable, but we exclude some that don't make sense
# :id is not in member_fields, but we don't want to sort by it anyway # :id is not in member_fields, but we don't want to sort by it anyway
non_sortable_fields = [:notes, :paid] non_sortable_fields = [:notes]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field) field in valid_fields or custom_field_sort?(field)
@ -1016,28 +1063,36 @@ defmodule MvWeb.MemberLive.Index do
socket socket
end end
# Updates paid filter from URL parameters if present. # Updates cycle status filter from URL parameters if present.
# #
# Validates the filter value, falling back to nil (no filter) if invalid. # Validates the filter value, falling back to nil (no filter) if invalid.
defp maybe_update_paid_filter(socket, %{"paid_filter" => filter_str}) do defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do
filter = determine_paid_filter(filter_str) filter = determine_cycle_status_filter(filter_str)
assign(socket, :paid_filter, filter) assign(socket, :cycle_status_filter, filter)
end end
defp maybe_update_paid_filter(socket, _params) do defp maybe_update_cycle_status_filter(socket, _params) do
# Reset filter if not in URL params # Reset filter if not in URL params
assign(socket, :paid_filter, nil) assign(socket, :cycle_status_filter, nil)
end end
# Determines valid paid filter from URL parameter. # Determines valid cycle status filter from URL parameter.
# #
# SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid" # SECURITY: This function whitelists allowed filter values. Only "paid" and "unpaid"
# are accepted - all other input (including malicious strings) falls back to nil. # are accepted - all other input (including malicious strings) falls back to nil.
# This ensures no raw user input is ever passed to Ash.Query.filter/2, following # This ensures no raw user input is ever passed to filter functions.
# Ash's security recommendation to never pass untrusted input directly to filters. defp determine_cycle_status_filter("paid"), do: :paid
defp determine_paid_filter("paid"), do: :paid defp determine_cycle_status_filter("unpaid"), do: :unpaid
defp determine_paid_filter("not_paid"), do: :not_paid defp determine_cycle_status_filter(_), do: nil
defp determine_paid_filter(_), do: nil
# Updates show_current_cycle from URL parameters if present.
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
assign(socket, :show_current_cycle, true)
end
defp maybe_update_show_current_cycle(socket, _params) do
socket
end
# ------------------------------------------------------------- # -------------------------------------------------------------
# Helper Functions for Custom Field Values # Helper Functions for Custom Field Values
@ -1112,4 +1167,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" />
@ -41,9 +39,37 @@
<.live_component <.live_component
module={MvWeb.Components.PaymentFilterComponent} module={MvWeb.Components.PaymentFilterComponent}
id="payment-filter" id="payment-filter"
paid_filter={@paid_filter} cycle_status_filter={@cycle_status_filter}
member_count={length(@members)} member_count={length(@members)}
/> />
<button
type="button"
phx-click="toggle_cycle_view"
class={[
"btn gap-2",
@show_current_cycle && "btn-active"
]}
aria-label={
if(@show_current_cycle,
do: gettext("Current Cycle Payment Status"),
else: gettext("Last Cycle Payment Status")
)
}
title={
if(@show_current_cycle,
do: gettext("Current Cycle Payment Status"),
else: gettext("Last Cycle Payment Status")
)
}
>
<.icon name="hero-arrow-path" class="h-5 w-5" />
<span class="hidden sm:inline">
{if(@show_current_cycle,
do: gettext("Current Cycle Payment Status"),
else: gettext("Last Cycle Payment Status")
)}
</span>
</button>
<.live_component <.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent} module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown" id="field-visibility-dropdown"
@ -249,13 +275,20 @@
> >
{MvWeb.MemberLive.Index.format_date(member.join_date)} {MvWeb.MemberLive.Index.format_date(member.join_date)}
</:col> </:col>
<:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}> <:col
<span class={[ :let={member}
"badge", label={gettext("Membership Fee Status")}
if(member.paid == true, do: "badge-success", else: "badge-error") >
]}> <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
{if member.paid == true, do: gettext("Yes"), else: gettext("No")} MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
</span> ) do %>
<span class={["badge", badge.color]}>
<.icon name={badge.icon} class="size-4" />
{badge.label}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycle")}</span>
<% end %>
</:col> </:col>
<:action :let={member}> <:action :let={member}>
<div class="sr-only"> <div class="sr-only">

View file

@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.Show do
use MvWeb, :live_view use MvWeb, :live_view
import Ash.Query import Ash.Query
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -43,156 +45,243 @@ defmodule MvWeb.MemberLive.Show do
<%!-- Tab Navigation --%> <%!-- Tab Navigation --%>
<div role="tablist" class="tabs tabs-bordered mb-6"> <div role="tablist" class="tabs tabs-bordered mb-6">
<button role="tab" class="tab tab-active" aria-selected="true"> <button
role="tab"
class={[
"tab",
if(@active_tab == :contact, do: "tab-active", else: "!text-gray-800")
]}
aria-selected={@active_tab == :contact}
phx-click="switch_tab"
phx-value-tab="contact"
>
<.icon name="hero-identification" class="size-4 mr-2" /> <.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")} {gettext("Contact Data")}
</button> </button>
<button role="tab" class="tab" disabled aria-disabled="true" title={gettext("Coming soon")}> <button
role="tab"
class={[
"tab",
if(@active_tab == :membership_fees, do: "tab-active", else: "!text-gray-800")
]}
aria-selected={@active_tab == :membership_fees}
phx-click="switch_tab"
phx-value-tab="membership_fees"
>
<.icon name="hero-credit-card" class="size-4 mr-2" /> <.icon name="hero-credit-card" class="size-4 mr-2" />
{gettext("Payments")} {gettext("Membership Fees")}
</button> </button>
</div> </div>
<%!-- Personal Data and Custom Fields Row --%> <%= if @active_tab == :contact do %>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <%!-- Contact Data Tab Content --%>
<%!-- Personal Data Section --%> <%!-- Personal Data and Custom Fields Row --%>
<div> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<.section_box title={gettext("Personal Data")}> <%!-- Personal Data Section --%>
<div class="space-y-4"> <div>
<%!-- Name Row --%> <.section_box title={gettext("Personal Data")}>
<div class="flex gap-6"> <div class="space-y-4">
<.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" /> <%!-- Name Row --%>
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" /> <div class="flex gap-6">
</div> <.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" />
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" />
</div>
<%!-- Address --%> <%!-- Address --%>
<div>
<.data_field label={gettext("Address")} value={format_address(@member)} />
</div>
<%!-- Email --%>
<div>
<.data_field label={gettext("Email")}>
<a
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
class="text-blue-700 hover:text-blue-800 underline"
>
{@member.email}
</a>
</.data_field>
</div>
<%!-- Phone --%>
<div>
<.data_field label={gettext("Phone")} value={@member.phone_number} />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-6">
<.data_field
label={gettext("Join Date")}
value={format_date(@member.join_date)}
class="w-28"
/>
<.data_field
label={gettext("Exit Date")}
value={format_date(@member.exit_date)}
class="w-28"
/>
</div>
<%!-- Linked User --%>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
<% end %>
</.data_field>
</div>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
<div> <div>
<.data_field label={gettext("Notes")}> <.data_field label={gettext("Address")} value={format_address(@member)} />
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p> </div>
<%!-- Email --%>
<div>
<.data_field label={gettext("Email")}>
<a
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
class="text-blue-700 hover:text-blue-800 underline"
>
{@member.email}
</a>
</.data_field> </.data_field>
</div> </div>
<% end %>
</div>
</.section_box>
</div>
<%!-- Custom Fields Section --%> <%!-- Phone --%>
<%= if Enum.any?(@member.custom_field_values) do %> <div>
<div> <.data_field label={gettext("Phone")} value={@member.phone_number} />
<.section_box title={gettext("Custom Fields")}> </div>
<div class="grid grid-cols-2 gap-4">
<%= for cfv <- sort_custom_field_values(@member.custom_field_values) do %> <%!-- Membership Dates Row --%>
<% custom_field = cfv.custom_field %> <div class="flex gap-6">
<% value_type = custom_field && custom_field.value_type %> <.data_field
<.data_field label={custom_field && custom_field.name}> label={gettext("Join Date")}
{format_custom_field_value(cfv.value, value_type)} value={format_date(@member.join_date)}
class="w-28"
/>
<.data_field
label={gettext("Exit Date")}
value={format_date(@member.exit_date)}
class="w-28"
/>
</div>
<%!-- Linked User --%>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
<% end %>
</.data_field> </.data_field>
</div>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
<div>
<.data_field label={gettext("Notes")}>
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
</.data_field>
</div>
<% end %> <% end %>
</div> </div>
</.section_box> </.section_box>
</div> </div>
<% end %>
</div>
<%!-- Payment Data Section (Mockup) --%> <%!-- Custom Fields Section --%>
<div class="max-w-xl"> <%= if Enum.any?(@custom_fields) do %>
<.section_box title={gettext("Payment Data")}> <div>
<div role="alert" class="alert alert-info mb-4"> <.section_box title={gettext("Custom Fields")}>
<.icon name="hero-information-circle" class="size-5" /> <div class="grid grid-cols-2 gap-4">
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span> <%= for custom_field <- @custom_fields do %>
</div> <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
<.data_field label={custom_field.name}>
{format_custom_field_value(cfv, custom_field.value_type)}
</.data_field>
<% end %>
</div>
</.section_box>
</div>
<% end %>
</div>
<div class="flex gap-6"> <%!-- Payment Data Section --%>
<.data_field label={gettext("Contribution")} value="72 €" class="w-24" /> <div class="w-full">
<.data_field label={gettext("Payment Cycle")} value={gettext("monthly")} class="w-28" /> <.section_box title={gettext("Payment Data")}>
<.data_field label={gettext("Paid")} class="w-24"> <%= if @member.membership_fee_type do %>
<%= if @member.paid do %> <div class="flex gap-6 flex-wrap">
<span class="badge badge-success">{gettext("Paid")}</span> <.data_field
<% else %> label={gettext("Type")}
<span class="badge badge-warning">{gettext("Pending")}</span> value={@member.membership_fee_type.name}
<% end %> class="min-w-32"
</.data_field> />
</div> <.data_field
</.section_box> label={gettext("Membership Fee")}
</div> value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
class="min-w-24"
/>
<.data_field
label={gettext("Payment Interval")}
value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)}
class="min-w-32"
/>
<.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %>
<% status = @member.last_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %>
<% status = @member.current_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
</div>
<% else %>
<div class="text-base-content/70 italic">
{gettext("No membership fee type assigned")}
</div>
<% end %>
</.section_box>
</div>
<% end %>
<%= if @active_tab == :membership_fees do %>
<%!-- Membership Fees Tab Content --%>
<.live_component
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"}
member={@member}
/>
<% end %>
</Layouts.app> </Layouts.app>
""" """
end end
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, socket} {:ok, assign(socket, :active_tab, :contact)}
end end
@impl true @impl true
def handle_params(%{"id" => id}, _, socket) do def handle_params(%{"id" => id}, _, socket) do
# Load custom fields once using assign_new to avoid repeated queries
socket =
assign_new(socket, :custom_fields, fn ->
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
end)
query = query =
Mv.Membership.Member Mv.Membership.Member
|> filter(id == ^id) |> filter(id == ^id)
|> load([:user, custom_field_values: [:custom_field]]) |> load([
:user,
:membership_fee_type,
custom_field_values: [:custom_field],
membership_fee_cycles: [:membership_fee_type]
])
member = Ash.read_one!(query) member = Ash.read_one!(query)
# Calculate last and current cycle status from loaded cycles
last_cycle_status = get_last_cycle_status(member)
current_cycle_status = get_current_cycle_status(member)
member =
member
|> Map.put(:last_cycle_status, last_cycle_status)
|> Map.put(:current_cycle_status, current_cycle_status)
{:noreply, {:noreply,
socket socket
|> assign(:page_title, page_title(socket.assigns.live_action)) |> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:member, member)} |> assign(:member, member)}
end end
@impl true
def handle_event("switch_tab", %{"tab" => "contact"}, socket) do
{:noreply, assign(socket, :active_tab, :contact)}
end
def handle_event("switch_tab", %{"tab" => "membership_fees"}, socket) do
{:noreply, assign(socket, :active_tab, :membership_fees)}
end
defp page_title(:show), do: gettext("Show Member") defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member") defp page_title(:edit), do: gettext("Edit Member")
@ -236,14 +325,56 @@ defmodule MvWeb.MemberLive.Show do
""" """
end end
# Renders a mailto link if email is present, otherwise renders empty value placeholder
attr :email, :string, required: true
attr :display, :string, default: nil
defp mailto_link(assigns) do
display_text = assigns.display || assigns.email
if assigns.email && String.trim(assigns.email) != "" do
assigns = %{email: assigns.email, display: display_text}
~H"""
<a
href={"mailto:#{@email}"}
class="text-blue-700 hover:text-blue-800 underline"
>
{@display}
</a>
"""
else
render_empty_value()
end
end
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Helper Functions # Helper Functions
# ----------------------------------------------------------------- # -----------------------------------------------------------------
defp display_value(nil), do: "" defp display_value(nil), do: render_empty_value()
defp display_value(""), do: "" defp display_value(""), do: render_empty_value()
defp display_value(value), do: value defp display_value(value), do: value
defp format_status_label(:paid), do: gettext("Paid")
defp format_status_label(:unpaid), do: gettext("Unpaid")
defp format_status_label(:suspended), do: gettext("Suspended")
defp format_status_label(nil), do: gettext("No status")
defp get_last_cycle_status(member) do
case MembershipFeeHelpers.get_last_completed_cycle(member) do
nil -> nil
cycle -> cycle.status
end
end
defp get_current_cycle_status(member) do
case MembershipFeeHelpers.get_current_cycle(member) do
nil -> nil
cycle -> cycle.status
end
end
defp format_address(member) do defp format_address(member) do
street_part = street_part =
[member.street, member.house_number] [member.street, member.house_number]
@ -272,20 +403,34 @@ defmodule MvWeb.MemberLive.Show do
defp format_date(date), do: to_string(date) defp format_date(date), do: to_string(date)
# Sorts custom field values by custom field name # Finds custom field value for a given custom field id
defp sort_custom_field_values(custom_field_values) do # Returns the value (not the CustomFieldValue struct) or nil
Enum.sort_by(custom_field_values, fn cfv -> defp find_custom_field_value(nil, _custom_field_id), do: nil
(cfv.custom_field && cfv.custom_field.name) || ""
defp find_custom_field_value(custom_field_values, custom_field_id)
when is_list(custom_field_values) do
Enum.find_value(custom_field_values, fn cfv ->
if cfv.custom_field_id == custom_field_id or
(cfv.custom_field && cfv.custom_field.id == custom_field_id) do
cfv.value
end
end) end)
end end
defp find_custom_field_value(_custom_field_values, _custom_field_id), do: nil
# Formats custom field value based on type # Formats custom field value based on type
# Handles both CustomFieldValue structs and direct values
defp format_custom_field_value(nil, _type), do: render_empty_value()
defp format_custom_field_value(%Mv.Membership.CustomFieldValue{} = cfv, value_type) do
format_custom_field_value(cfv.value, value_type)
end
defp format_custom_field_value(%Ash.Union{value: value, type: type}, _expected_type) do defp format_custom_field_value(%Ash.Union{value: value, type: type}, _expected_type) do
format_custom_field_value(value, type) format_custom_field_value(value, type)
end end
defp format_custom_field_value(nil, _type), do: ""
defp format_custom_field_value(value, :boolean) when is_boolean(value) do defp format_custom_field_value(value, :boolean) when is_boolean(value) do
if value, do: gettext("Yes"), else: gettext("No") if value, do: gettext("Yes"), else: gettext("No")
end end
@ -295,11 +440,15 @@ defmodule MvWeb.MemberLive.Show do
end end
defp format_custom_field_value(value, :email) when is_binary(value) do defp format_custom_field_value(value, :email) when is_binary(value) do
assigns = %{email: value} if String.trim(value) == "" do
render_empty_value()
else
assigns = %{email: value}
~H""" ~H"""
<a href={"mailto:#{@email}"} class="text-blue-700 hover:text-blue-800 underline">{@email}</a> <.mailto_link email={@email} display={@email} />
""" """
end
end end
defp format_custom_field_value(value, :integer) when is_integer(value) do defp format_custom_field_value(value, :integer) when is_integer(value) do
@ -307,8 +456,22 @@ defmodule MvWeb.MemberLive.Show do
end end
defp format_custom_field_value(value, _type) when is_binary(value) do defp format_custom_field_value(value, _type) when is_binary(value) do
if String.trim(value) == "", do: "", else: value if String.trim(value) == "", do: render_empty_value(), else: value
end end
defp format_custom_field_value(value, _type), do: to_string(value) defp format_custom_field_value(value, _type), do: to_string(value)
# Renders accessible placeholder for empty values
# Uses translated text for screen readers while maintaining visual consistency
# The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers
defp render_empty_value do
assigns = %{text: gettext("Not set")}
~H"""
<span class="text-base-content/50 italic">
<span aria-hidden="true"></span>
<span class="sr-only">{@text}</span>
</span>
"""
end
end end

View file

@ -0,0 +1,927 @@
defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
@moduledoc """
LiveComponent for displaying and managing membership fees for a member.
## Features
- Display all membership fee cycles in a table
- Change membership fee type (with same-interval validation)
- Change cycle status (paid/unpaid/suspended)
- Regenerate cycles manually
- Delete cycles (with confirmation)
- Edit cycle amount (with modal)
"""
use MvWeb, :live_component
require Ash.Query
alias Mv.Membership
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.CalendarCycles
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
def render(assigns) do
~H"""
<div id={@id}>
<.section_box title={gettext("Membership Fees")}>
<%!-- Membership Fee Type Display --%>
<div class="mb-6">
<label class="label">
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
</label>
<%= if @member.membership_fee_type do %>
<div class="flex items-center gap-2">
<span class="font-medium">{@member.membership_fee_type.name}</span>
<span class="text-base-content/60">
({MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}, {MembershipFeeHelpers.format_interval(
@member.membership_fee_type.interval
)})
</span>
</div>
<% else %>
<span class="text-base-content/60 italic">
{gettext("No membership fee type assigned")}
</span>
<% end %>
</div>
<%!-- Action Buttons --%>
<div class="flex gap-2 mb-4">
<.button
phx-click="regenerate_cycles"
phx-target={@myself}
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
title={gettext("Generate cycles from the last existing cycle to today")}
>
<.icon name="hero-arrow-path" class="size-4" />
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
</.button>
<.button
:if={Enum.any?(@cycles)}
phx-click="delete_all_cycles"
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete all cycles")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete All Cycles")}
</.button>
<.button
:if={@member.membership_fee_type}
phx-click="open_create_cycle_modal"
phx-target={@myself}
class="btn btn-sm btn-primary"
title={gettext("Create a new cycle manually")}
>
<.icon name="hero-plus" class="size-4" />
{gettext("Create Cycle")}
</.button>
</div>
<%!-- Cycles Table --%>
<%= if Enum.any?(@cycles) do %>
<.table
id="membership-fee-cycles"
rows={@cycles}
row_id={fn cycle -> "cycle-#{cycle.id}" end}
>
<:col :let={cycle} label={gettext("Cycle")}>
{MembershipFeeHelpers.format_cycle_range(
cycle.cycle_start,
cycle.membership_fee_type.interval
)}
</:col>
<:col :let={cycle} label={gettext("Interval")}>
<span class="badge badge-outline">
{MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)}
</span>
</:col>
<:col :let={cycle} label={gettext("Amount")}>
<span
class="font-mono cursor-pointer hover:text-primary"
phx-click="edit_cycle_amount"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
title={gettext("Click to edit amount")}
>
{MembershipFeeHelpers.format_currency(cycle.amount)}
</span>
</:col>
<:col :let={cycle} label={gettext("Status")}>
<% badge = MembershipFeeHelpers.status_color(cycle.status) %>
<% icon = MembershipFeeHelpers.status_icon(cycle.status) %>
<span class={["badge", badge]}>
<.icon name={icon} class="size-4" />
{format_status_label(cycle.status)}
</span>
</:col>
<:action :let={cycle}>
<div class="flex gap-1">
<button
:if={cycle.status != :paid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="paid"
phx-target={@myself}
class="btn btn-sm btn-success"
title={gettext("Mark as paid")}
>
<.icon name="hero-check-circle" class="size-4" />
{gettext("Paid")}
</button>
<button
:if={cycle.status != :suspended}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="suspended"
phx-target={@myself}
class="btn btn-sm btn-outline btn-warning"
title={gettext("Mark as suspended")}
>
<.icon name="hero-pause-circle" class="size-4" />
{gettext("Suspended")}
</button>
<button
:if={cycle.status != :unpaid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="unpaid"
phx-target={@myself}
class="btn btn-sm btn-error"
title={gettext("Mark as unpaid")}
>
<.icon name="hero-x-circle" class="size-4" />
{gettext("Unpaid")}
</button>
<button
type="button"
phx-click="delete_cycle"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete cycle")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
</div>
</:action>
</.table>
<% else %>
<div class="alert alert-info">
<.icon name="hero-information-circle" class="size-5" />
<span>
{gettext(
"No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
)}
</span>
</div>
<% end %>
</.section_box>
<%!-- Edit Cycle Amount Modal --%>
<%= if @editing_cycle do %>
<dialog id="edit-cycle-amount-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Edit Cycle Amount")}</h3>
<form phx-submit="save_cycle_amount" phx-target={@myself}>
<input type="hidden" name="cycle_id" value={@editing_cycle.id} />
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Amount")}</span>
</label>
<input
type="text"
inputmode="decimal"
name="amount"
step="0.01"
min="0"
value={Decimal.to_string(@editing_cycle.amount) |> String.replace(".", ",")}
class="input input-bordered w-full"
required
/>
</div>
<div class="modal-action">
<button type="button" phx-click="cancel_edit_amount" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button type="submit" class="btn btn-primary">{gettext("Save")}</button>
</div>
</form>
</div>
</dialog>
<% end %>
<%!-- Delete Cycle Confirmation Modal --%>
<%= if @deleting_cycle do %>
<dialog id="delete-cycle-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
<p class="py-4">
{gettext("Are you sure you want to delete this cycle?")}
</p>
<p class="text-sm text-base-content/70 mb-4">
{MembershipFeeHelpers.format_cycle_range(
@deleting_cycle.cycle_start,
@deleting_cycle.membership_fee_type.interval
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
</p>
<div class="modal-action">
<button phx-click="cancel_delete_cycle" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete_cycle"
phx-value-cycle_id={@deleting_cycle.id}
phx-target={@myself}
class="btn btn-error"
>
{gettext("Delete")}
</button>
</div>
</div>
</dialog>
<% end %>
<%!-- Delete All Cycles Confirmation Modal --%>
<%= if @deleting_all_cycles do %>
<dialog id="delete-all-cycles-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold text-error">{gettext("Delete All Cycles")}</h3>
<div class="alert alert-warning mt-4">
<.icon name="hero-exclamation-triangle" class="size-5" />
<div>
<h4 class="font-bold">{gettext("Warning")}</h4>
<p>
{gettext("You are about to delete all %{count} cycles for this member.",
count: length(@cycles)
)}
</p>
<p class="mt-2">
{gettext("This action cannot be undone.")}
</p>
</div>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">
{gettext("Type '%{confirmation}' to confirm", confirmation: gettext("Yes"))}
</span>
</label>
<input
type="text"
phx-keyup="update_delete_all_confirmation"
phx-target={@myself}
value={@delete_all_confirmation || ""}
class="input input-bordered w-full"
placeholder={gettext("Yes")}
/>
</div>
<div class="modal-action">
<button phx-click="cancel_delete_all_cycles" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete_all_cycles"
phx-target={@myself}
class="btn btn-error"
disabled={
String.trim(String.downcase(@delete_all_confirmation)) !=
String.downcase(gettext("Yes"))
}
>
{gettext("Delete All")}
</button>
</div>
</div>
</dialog>
<% end %>
<%!-- Create Cycle Modal --%>
<%= if @creating_cycle do %>
<dialog id="create-cycle-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Create Cycle")}</h3>
<form phx-submit="create_cycle" phx-target={@myself}>
<div class="form-control w-full mt-4">
<label class="label" for="create-cycle-date">
<span class="label-text">{gettext("Date")}</span>
</label>
<input
type="date"
id="create-cycle-date"
name="date"
value={@create_cycle_date || ""}
phx-change="update_create_cycle_date"
phx-target={@myself}
class="input input-bordered w-full"
required
aria-label={gettext("Date")}
/>
<label class="label">
<span class="label-text-alt">
{gettext(
"The cycle period will be calculated based on this date and the interval."
)}
</span>
</label>
</div>
<%= if @create_cycle_date do %>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Cycle Period")}</span>
</label>
<div class="text-sm text-base-content/70">
{format_create_cycle_period(
@create_cycle_date,
@member.membership_fee_type.interval
)}
</div>
</div>
<% end %>
<div class="form-control w-full mt-4">
<label class="label" for="create-cycle-amount">
<span class="label-text">{gettext("Amount")}</span>
</label>
<input
type="text"
inputmode="decimal"
id="create-cycle-amount"
name="amount"
step="0.01"
min="0"
value={
Decimal.to_string(@member.membership_fee_type.amount) |> String.replace(".", ",")
}
class="input input-bordered w-full"
required
aria-label={gettext("Amount")}
/>
</div>
<%= if @create_cycle_error do %>
<div class="alert alert-error mt-4">
<.icon name="hero-exclamation-circle" class="size-5" />
<span>{@create_cycle_error}</span>
</div>
<% end %>
<div class="modal-action">
<button type="button" phx-click="cancel_create_cycle" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button type="submit" class="btn btn-primary">{gettext("Create")}</button>
</div>
</form>
</div>
</dialog>
<% end %>
</div>
"""
end
@impl true
def update(assigns, socket) do
member = assigns.member
# Load cycles if not already loaded
cycles =
case member.membership_fee_cycles do
nil -> []
cycles when is_list(cycles) -> cycles
_ -> []
end
# Sort cycles by cycle_start descending (newest first)
cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
# Get available fee types (filtered to same interval if member has a type)
available_fee_types = get_available_fee_types(member)
{:ok,
socket
|> assign(assigns)
|> assign(:cycles, cycles)
|> assign(:available_fee_types, available_fee_types)
|> assign_new(:interval_warning, fn -> nil end)
|> assign_new(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_cycle, fn -> nil end)
|> assign_new(:deleting_all_cycles, fn -> false end)
|> assign_new(:delete_all_confirmation, fn -> "" end)
|> assign_new(:creating_cycle, fn -> false end)
|> assign_new(:create_cycle_date, fn -> nil end)
|> assign_new(:create_cycle_error, fn -> nil end)
|> assign_new(:regenerating, fn -> false end)}
end
@impl true
def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
# Remove membership fee type
case update_member_fee_type(socket.assigns.member, nil) do
{:ok, updated_member} ->
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, [])
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
member = socket.assigns.member
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees)
# Check if interval matches
interval_warning =
if member.membership_fee_type &&
member.membership_fee_type.interval != new_fee_type.interval do
gettext(
"Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval.",
old_interval: MembershipFeeHelpers.format_interval(member.membership_fee_type.interval),
new_interval: MembershipFeeHelpers.format_interval(new_fee_type.interval)
)
else
nil
end
if interval_warning do
{:noreply, assign(socket, :interval_warning, interval_warning)}
else
case update_member_fee_type(member, fee_type_id) do
{:ok, updated_member} ->
# Reload member with cycles
updated_member =
updated_member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
end
def handle_event("mark_cycle_status", %{"cycle_id" => cycle_id, "status" => status_str}, socket) do
status = String.to_existing_atom(status_str)
cycle = find_cycle(socket.assigns.cycles, cycle_id)
action =
case status do
:paid -> :mark_as_paid
:unpaid -> :mark_as_unpaid
:suspended -> :mark_as_suspended
end
case Ash.update(cycle, action: action, domain: MembershipFees) do
{:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
{:noreply,
socket
|> assign(:cycles, updated_cycles)
|> put_flash(:info, gettext("Cycle status updated"))}
{:error, %Ash.Error.Invalid{} = error} ->
error_msg =
Enum.map_join(error.errors, ", ", fn e -> e.message end)
{:noreply,
socket
|> put_flash(
:error,
gettext("Failed to update cycle status: %{errors}", errors: error_msg)
)}
{:error, error} ->
{:noreply,
socket
|> put_flash(:error, format_error(error))}
end
end
def handle_event("regenerate_cycles", _params, socket) do
socket = assign(socket, :regenerating, true)
member = socket.assigns.member
case CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _new_cycles, _notifications} ->
# Reload member with cycles
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:regenerating, false)
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
{:error, error} ->
{:noreply,
socket
|> assign(:regenerating, false)
|> put_flash(:error, format_error(error))}
end
end
def handle_event("edit_cycle_amount", %{"cycle_id" => cycle_id}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type)
{:noreply, assign(socket, :editing_cycle, cycle)}
end
def handle_event("cancel_edit_amount", _params, socket) do
{:noreply, assign(socket, :editing_cycle, nil)}
end
def handle_event("save_cycle_amount", %{"cycle_id" => cycle_id, "amount" => amount_str}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Normalize comma to dot for decimal parsing (German locale support)
normalized_amount_str = String.replace(amount_str, ",", ".")
case Decimal.parse(normalized_amount_str) do
{amount, _} when is_struct(amount, Decimal) ->
case cycle
|> Ash.Changeset.for_update(:update, %{amount: amount})
|> Ash.update(domain: MembershipFees) do
{:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
{:noreply,
socket
|> assign(:cycles, updated_cycles)
|> assign(:editing_cycle, nil)
|> put_flash(:info, gettext("Cycle amount updated"))}
{:error, error} ->
{:noreply,
socket
|> put_flash(:error, format_error(error))}
end
:error ->
{:noreply, put_flash(socket, :error, gettext("Invalid amount format"))}
end
end
def handle_event("delete_cycle", %{"cycle_id" => cycle_id}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type)
{:noreply, assign(socket, :deleting_cycle, cycle)}
end
def handle_event("cancel_delete_cycle", _params, socket) do
{:noreply, assign(socket, :deleting_cycle, nil)}
end
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
case Ash.destroy(cycle, domain: MembershipFees) do
:ok ->
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
{:noreply,
socket
|> assign(:cycles, updated_cycles)
|> assign(:deleting_cycle, nil)
|> put_flash(:info, gettext("Cycle deleted"))}
{:ok, _destroyed} ->
# Handle case where return_destroyed? is true
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
{:noreply,
socket
|> assign(:cycles, updated_cycles)
|> assign(:deleting_cycle, nil)
|> put_flash(:info, gettext("Cycle deleted"))}
{:error, error} ->
{:noreply,
socket
|> assign(:deleting_cycle, nil)
|> put_flash(:error, format_error(error))}
end
end
def handle_event("delete_all_cycles", _params, socket) do
{:noreply,
socket
|> assign(:deleting_all_cycles, true)
|> assign(:delete_all_confirmation, "")}
end
def handle_event("cancel_delete_all_cycles", _params, socket) do
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")}
end
def handle_event("update_delete_all_confirmation", %{"value" => value}, socket) do
{:noreply, assign(socket, :delete_all_confirmation, value)}
end
def handle_event("confirm_delete_all_cycles", _params, socket) do
# Validate confirmation (case-insensitive, trimmed)
confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation))
expected = String.downcase(gettext("Yes"))
if confirmation != expected do
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:error, gettext("Confirmation text does not match"))}
else
member = socket.assigns.member
# Delete all cycles atomically using Ecto query
import Ecto.Query
deleted_count =
Mv.Repo.delete_all(
from c in Mv.MembershipFees.MembershipFeeCycle,
where: c.member_id == ^member.id
)
if deleted_count > 0 do
# Reload member to get updated cycles
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
updated_cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, updated_cycles)
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:info, gettext("All cycles deleted"))}
else
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:info, gettext("No cycles to delete"))}
end
end
end
def handle_event("open_create_cycle_modal", _params, socket) do
{:noreply,
socket
|> assign(:creating_cycle, true)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)}
end
def handle_event("cancel_create_cycle", _params, socket) do
{:noreply,
socket
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)}
end
def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do
date =
case Date.from_iso8601(date_str) do
{:ok, date} -> date
_ -> nil
end
{:noreply,
socket
|> assign(:create_cycle_date, date)
|> assign(:create_cycle_error, nil)}
end
def handle_event("create_cycle", %{"date" => date_str, "amount" => amount_str}, socket) do
member = socket.assigns.member
# Normalize comma to dot for decimal parsing (German locale support)
normalized_amount_str = String.replace(amount_str, ",", ".")
amount =
case Decimal.parse(normalized_amount_str) do
{d, _} when is_struct(d, Decimal) -> {:ok, d}
:error -> {:error, :invalid_amount}
end
with {:ok, date} <- Date.from_iso8601(date_str),
{:ok, amount} <- amount,
cycle_start <-
CalendarCycles.calculate_cycle_start(date, member.membership_fee_type.interval),
:ok <- validate_cycle_not_exists(socket.assigns.cycles, cycle_start) do
attrs = %{
cycle_start: cycle_start,
amount: amount,
status: :unpaid,
member_id: member.id,
membership_fee_type_id: member.membership_fee_type_id
}
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees) do
{:ok, _new_cycle} ->
# Reload member with cycles
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)
|> put_flash(:info, gettext("Cycle created successfully"))}
{:error, error} ->
{:noreply,
socket
|> assign(:create_cycle_error, format_error(error))}
end
else
:error ->
{:noreply,
socket
|> assign(:create_cycle_error, gettext("Invalid date format"))}
{:error, :invalid_amount} ->
{:noreply,
socket
|> assign(:create_cycle_error, gettext("Invalid amount format"))}
{:error, :cycle_exists} ->
{:noreply,
socket
|> assign(
:create_cycle_error,
gettext("A cycle for this period already exists")
)}
end
end
# Helper functions
defp get_available_fee_types(member) do
all_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
# If member has a fee type, filter to same interval
if member.membership_fee_type do
Enum.filter(all_types, fn type ->
type.interval == member.membership_fee_type.interval
end)
else
all_types
end
end
defp update_member_fee_type(member, fee_type_id) do
attrs = %{membership_fee_type_id: fee_type_id}
member
|> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
|> Ash.update(domain: Membership)
end
defp find_cycle(cycles, cycle_id) do
case Enum.find(cycles, &(&1.id == cycle_id)) do
nil -> raise "Cycle not found: #{cycle_id}"
cycle -> cycle
end
end
defp replace_cycle(cycles, updated_cycle) do
Enum.map(cycles, fn cycle ->
if cycle.id == updated_cycle.id, do: updated_cycle, else: cycle
end)
end
defp format_status_label(:paid), do: gettext("Paid")
defp format_status_label(:unpaid), do: gettext("Unpaid")
defp format_status_label(:suspended), do: gettext("Suspended")
defp format_error(%Ash.Error.Invalid{} = error) do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred")
defp validate_cycle_not_exists(cycles, cycle_start) do
if Enum.any?(cycles, &(&1.cycle_start == cycle_start)) do
{:error, :cycle_exists}
else
:ok
end
end
defp format_create_cycle_period(date, interval) when is_struct(date, Date) do
cycle_start = CalendarCycles.calculate_cycle_start(date, interval)
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
MembershipFeeHelpers.format_cycle_range(cycle_start, interval) <>
" (#{Calendar.strftime(cycle_start, "%d.%m.%Y")} - #{Calendar.strftime(cycle_end, "%d.%m.%Y")})"
end
defp format_create_cycle_period(_date, _interval), do: ""
# Helper component for section box
attr :title, :string, required: true
slot :inner_block, required: true
defp section_box(assigns) do
~H"""
<section class="mb-6">
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
{render_slot(@inner_block)}
</div>
</section>
"""
end
end

View file

@ -0,0 +1,296 @@
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
# Normalize checkbox value: "on" -> true, missing -> false
normalized_params =
if Map.has_key?(params, "include_joining_cycle") do
params
|> Map.update("include_joining_cycle", false, fn
"on" -> true
"true" -> true
true -> true
_ -> false
end)
else
Map.put(params, "include_joining_cycle", false)
end
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, normalized_params))}
end
def handle_event("save", %{"settings" => params}, socket) do
# Normalize checkbox value: "on" -> true, missing -> false
normalized_params =
if Map.has_key?(params, "include_joining_cycle") do
params
|> Map.update("include_joining_cycle", false, fn
"on" -> true
"true" -> true
true -> true
_ -> false
end)
else
Map.put(params, "include_joining_cycle", false)
end
case AshPhoenix.Form.submit(socket.assigns.form, params: normalized_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>
<%= if @form.errors[:default_membership_fee_type_id] do %>
<%= for error <- List.wrap(@form.errors[:default_membership_fee_type_id]) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<% 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>
<%= if @form.errors[:include_joining_cycle] do %>
<%= for error <- List.wrap(@form.errors[:include_joining_cycle]) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="text-error text-sm ml-9 mt-1">{msg}</p>
<% end %>
<% 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

@ -0,0 +1,455 @@
defmodule MvWeb.MembershipFeeTypeLive.Form do
@moduledoc """
LiveView form for creating and editing membership fee types (Admin).
## Features
- Create new membership fee types
- Edit existing membership fee types (name, amount, description - NOT interval)
- Amount change warning modal (shows impact on members)
- Interval field grayed out on edit
## Permissions
- Admin only
"""
use MvWeb, :live_view
require Ash.Query
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>
{gettext("Use this form to manage membership fee types in your database.")}
</:subtitle>
</.header>
<.form
class="max-w-xl"
for={@form}
id="membership-fee-type-form"
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:name]} type="text" label={gettext("Name")} required />
<.input
field={@form[:amount]}
label={gettext("Amount")}
required
phx-debounce="blur"
/>
<div class="form-control">
<label class="label" for="membership-fee-type-form_interval">
<span class="label-text font-semibold">
{gettext("Interval")}
<span
:if={is_nil(@membership_fee_type)}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>
*
</span>
</span>
</label>
<select
class={[
"select select-bordered w-full",
@form.errors[:interval] && "select-error"
]}
disabled={!is_nil(@membership_fee_type)}
name="membership_fee_type[interval]"
id="membership-fee-type-form_interval"
required={is_nil(@membership_fee_type)}
aria-label={gettext("Interval")}
>
<option value="">{gettext("Select interval")}</option>
<option
value="monthly"
selected={@form[:interval].value == :monthly || @form[:interval].value == "monthly"}
>
{gettext("Monthly")}
</option>
<option
value="quarterly"
selected={@form[:interval].value == :quarterly || @form[:interval].value == "quarterly"}
>
{gettext("Quarterly")}
</option>
<option
value="half_yearly"
selected={
@form[:interval].value == :half_yearly || @form[:interval].value == "half_yearly"
}
>
{gettext("Half-yearly")}
</option>
<option
value="yearly"
selected={@form[:interval].value == :yearly || @form[:interval].value == "yearly"}
>
{gettext("Yearly")}
</option>
</select>
<%= if @form.errors[:interval] do %>
<%= for error <- List.wrap(@form.errors[:interval]) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{msg}
</p>
<% end %>
<% end %>
<%= if !is_nil(@membership_fee_type) do %>
<label class="label">
<span class="label-text-alt text-base-content/60">
{gettext("Interval cannot be changed after creation.")}
</span>
</label>
<% end %>
</div>
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="3"
/>
<div class="mt-4">
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Membership Fee Type")}
</.button>
<.button navigate={return_path(@return_to, @membership_fee_type)} type="button">
{gettext("Cancel")}
</.button>
</div>
</.form>
<%!-- Amount Change Warning Modal --%>
<%= if @show_amount_warning do %>
<dialog id="amount-warning-modal" class="modal modal-open">
<div class="modal-box">
<h2 class="text-lg font-bold">{gettext("Change Amount?")}</h2>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="size-5" />
<div>
<p class="font-semibold">
{gettext("Changing the amount will affect %{count} member(s).",
count: @affected_member_count
)}
</p>
<p class="mt-2 text-sm">
{gettext("Future unpaid cycles will be regenerated with the new amount.")}
</p>
<p class="mt-2 text-sm">
{gettext("Already paid cycles will remain with the old amount.")}
</p>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-base-content/70">{gettext("Current amount")}:</span>
<span class="font-mono font-semibold">
{MembershipFeeHelpers.format_currency(@old_amount)}
</span>
</div>
<div class="flex justify-between">
<span class="text-base-content/70">{gettext("New amount")}:</span>
<span class="font-mono font-semibold text-base-content">
{MembershipFeeHelpers.format_currency(@new_amount)}
</span>
</div>
</div>
</div>
<div class="modal-action">
<button
type="button"
phx-click="cancel_amount_change"
class="btn"
>
{gettext("Cancel")}
</button>
<button
type="button"
phx-click="confirm_amount_change"
class="btn btn-primary"
>
{gettext("Confirm Change")}
</button>
</div>
</div>
</dialog>
<% end %>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
membership_fee_type =
case params["id"] do
nil -> nil
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees)
end
page_title =
if is_nil(membership_fee_type),
do: gettext("New Membership Fee Type"),
else: gettext("Edit Membership Fee Type")
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:membership_fee_type, membership_fee_type)
|> assign(:page_title, page_title)
|> assign(:show_amount_warning, false)
|> assign(:old_amount, nil)
|> assign(:new_amount, nil)
|> assign(:affected_member_count, 0)
|> assign(:pending_amount, nil)
|> assign_form()}
end
defp return_to("index"), do: "index"
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"membership_fee_type" => params}, socket) do
# Merge with existing form values to preserve unchanged fields
# Extract values directly from form fields to get current state
existing_values = get_existing_form_values(socket.assigns.form)
# Merge existing values with new params (new params take precedence)
merged_params = Map.merge(existing_values, params)
# Convert interval string to atom if present
merged_params =
if Map.has_key?(merged_params, "interval") && is_binary(merged_params["interval"]) &&
merged_params["interval"] != "" do
Map.update!(merged_params, "interval", fn val ->
String.to_existing_atom(val)
end)
else
merged_params
end
# Let Ash handle validation automatically - it will validate Decimal format
validated_form = AshPhoenix.Form.validate(socket.assigns.form, merged_params)
# Check if amount changed on edit
socket = check_amount_change(socket, merged_params)
{:noreply, assign(socket, form: validated_form)}
end
def handle_event("cancel_amount_change", _params, socket) do
# Reset form to original amount
form = socket.assigns.form
original_amount =
if socket.assigns.membership_fee_type do
socket.assigns.membership_fee_type.amount
else
Decimal.new("0")
end
# Update form with original amount
updated_form =
AshPhoenix.Form.validate(form, %{
"amount" => Decimal.to_string(original_amount)
})
{:noreply,
socket
|> assign(:form, updated_form)
|> assign(:show_amount_warning, false)
|> assign(:pending_amount, nil)}
end
def handle_event("confirm_amount_change", _params, socket) do
# Update form with pending amount and hide warning
# Preserve all existing form values (name, description, etc.)
form = socket.assigns.form
existing_values = get_existing_form_values(form)
updated_form =
if socket.assigns.pending_amount do
# Merge existing values with confirmed amount to preserve all fields
merged_params = Map.put(existing_values, "amount", socket.assigns.pending_amount)
AshPhoenix.Form.validate(form, merged_params)
else
form
end
{:noreply,
socket
|> assign(:form, updated_form)
|> assign(:show_amount_warning, false)
|> assign(:pending_amount, nil)}
end
def handle_event("save", %{"membership_fee_type" => params}, socket) do
# If amount warning was shown but not confirmed, don't save
if socket.assigns.show_amount_warning do
{:noreply, put_flash(socket, :error, gettext("Please confirm the amount change first"))}
else
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
{:ok, membership_fee_type} ->
notify_parent({:saved, membership_fee_type})
socket =
socket
|> put_flash(:info, gettext("Membership fee type saved successfully"))
|> push_navigate(to: return_path(socket.assigns.return_to, membership_fee_type))
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
end
@spec notify_parent(any()) :: any()
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
defp assign_form(%{assigns: %{membership_fee_type: membership_fee_type}} = socket) do
form =
if membership_fee_type do
AshPhoenix.Form.for_update(
membership_fee_type,
:update,
domain: MembershipFees,
as: "membership_fee_type"
)
else
AshPhoenix.Form.for_create(
MembershipFeeType,
:create,
domain: MembershipFees,
as: "membership_fee_type"
)
end
assign(socket, form: to_form(form))
end
# Helper to extract existing form values to preserve them when only one field changes
defp get_existing_form_values(form) do
# Extract values directly from form fields to get current state
# This ensures we get the actual current values, not just initial params
%{}
|> extract_form_value(form, :name, &to_string/1)
|> extract_form_value(form, :amount, &format_amount_value/1)
|> extract_form_value(form, :interval, &format_interval_value/1)
|> extract_form_value(form, :description, &to_string/1)
end
# Helper to extract a single form field value
defp extract_form_value(acc, form, field, formatter) do
if form[field] && form[field].value do
Map.put(acc, to_string(field), formatter.(form[field].value))
else
acc
end
end
# Formats amount value (Decimal or string) to string
defp format_amount_value(%Decimal{} = amount), do: Decimal.to_string(amount, :normal)
defp format_amount_value(value) when is_binary(value), do: value
defp format_amount_value(value), do: to_string(value)
# Formats interval value (atom or string) to string
defp format_interval_value(value) when is_atom(value), do: Atom.to_string(value)
defp format_interval_value(value) when is_binary(value), do: value
defp format_interval_value(value), do: to_string(value)
@spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t()
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types"
@spec get_affected_member_count(String.t()) :: non_neg_integer()
# Checks if amount changed and updates socket assigns accordingly
defp check_amount_change(socket, params) do
if socket.assigns.membership_fee_type && Map.has_key?(params, "amount") do
# Get current amount from form and new amount from params
current_form_amount = get_existing_form_values(socket.assigns.form)["amount"]
new_amount_str = params["amount"]
# Only check amount change if amount field is actually being changed in this validation
# This prevents re-triggering the warning when other fields (name, description) are edited
if current_form_amount != new_amount_str do
handle_amount_change(socket, new_amount_str, socket.assigns.membership_fee_type.amount)
else
# Amount didn't change in this validation - keep current warning state
# If warning was already confirmed (pending_amount is nil and show_amount_warning is false), keep it hidden
# If warning is shown but not confirmed, keep it shown
socket
end
else
socket
end
end
# Handles amount change detection and warning assignment
defp handle_amount_change(socket, new_amount_str, old_amount) do
case Decimal.parse(new_amount_str) do
{new_amount, _} when is_struct(new_amount, Decimal) ->
if Decimal.compare(new_amount, old_amount) != :eq do
show_amount_warning(socket, old_amount, new_amount, new_amount_str)
else
hide_amount_warning(socket)
end
:error ->
hide_amount_warning(socket)
end
end
# Shows amount change warning with affected member count
# Only calculates count if warning is being shown for the first time (false -> true)
defp show_amount_warning(socket, old_amount, new_amount, new_amount_str) do
# Only calculate count if warning is not already shown (optimization)
affected_count =
if socket.assigns.show_amount_warning do
# Warning already shown, reuse existing count
socket.assigns.affected_member_count
else
# Warning being shown for first time, calculate count
get_affected_member_count(socket.assigns.membership_fee_type.id)
end
socket
|> assign(:show_amount_warning, true)
|> assign(:old_amount, old_amount)
|> assign(:new_amount, new_amount)
|> assign(:affected_member_count, affected_count)
|> assign(:pending_amount, new_amount_str)
end
# Hides amount change warning
defp hide_amount_warning(socket) do
socket
|> assign(:show_amount_warning, false)
|> assign(:pending_amount, nil)
end
defp get_affected_member_count(fee_type_id) do
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id)) do
{:ok, count} -> count
_ -> 0
end
end
end

View file

@ -0,0 +1,224 @@
defmodule MvWeb.MembershipFeeTypeLive.Index do
@moduledoc """
LiveView for managing membership fee types (Admin).
## Features
- List all membership fee types
- Display: Name, Amount, Interval, Member count
- Create new membership fee types
- Edit existing membership fee types (name, amount, description - NOT interval)
- Delete membership fee types (if no members assigned)
## Permissions
- Admin only
"""
use MvWeb, :live_view
require Ash.Query
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership
alias Mv.Membership.Member
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
def mount(_params, _session, socket) do
fee_types = load_membership_fee_types()
member_counts = load_member_counts(fee_types)
{:ok,
socket
|> assign(:page_title, gettext("Membership Fee Types"))
|> assign(:membership_fee_types, fee_types)
|> assign(:member_counts, member_counts)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Membership Fee Types")}
<:subtitle>
{gettext("Manage membership fee types for membership fees.")}
</:subtitle>
<:actions>
<.button variant="primary" navigate={~p"/membership_fee_types/new"}>
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
</.button>
</:actions>
</.header>
<.table
id="membership_fee_types"
rows={@membership_fee_types}
row_id={fn mft -> "mft-#{mft.id}" end}
>
<:col :let={mft} label={gettext("Name")}>
<span class="font-medium">{mft.name}</span>
<p :if={mft.description} class="text-sm text-base-content/70">{mft.description}</p>
</:col>
<:col :let={mft} label={gettext("Amount")}>
<span class="font-mono">{MembershipFeeHelpers.format_currency(mft.amount)}</span>
</:col>
<:col :let={mft} label={gettext("Interval")}>
<span class="badge badge-outline">
{MembershipFeeHelpers.format_interval(mft.interval)}
</span>
</:col>
<:col :let={mft} label={gettext("Members")}>
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
</:col>
<:action :let={mft}>
<.link
navigate={~p"/membership_fee_types/#{mft.id}/edit"}
class="btn btn-ghost btn-xs"
aria-label={gettext("Edit membership fee type")}
>
<.icon name="hero-pencil" class="size-4" />
</.link>
</:action>
<:action :let={mft}>
<div
:if={get_member_count(mft, @member_counts) > 0}
class="tooltip tooltip-left"
data-tip={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
>
<button
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error opacity-50 cursor-not-allowed"
aria-label={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
disabled={true}
>
<.icon name="hero-trash" class="size-4" />
</button>
</div>
<button
:if={get_member_count(mft, @member_counts) == 0}
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete membership fee type")}
>
<.icon name="hero-trash" class="size-4" />
</button>
</:action>
</.table>
<.info_card />
</Layouts.app>
"""
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
fee_type = Ash.get!(MembershipFeeType, id, domain: MembershipFees)
case Ash.destroy(fee_type, domain: MembershipFees) do
:ok ->
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.member_counts, id)
{:noreply,
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
|> put_flash(:info, gettext("Membership fee type deleted"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
# Helper functions
defp load_membership_fee_types do
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: MembershipFees)
end
# Loads all member counts for fee types in a single query to avoid N+1 queries
defp load_member_counts(fee_types) do
fee_type_ids = Enum.map(fee_types, & &1.id)
# Load all members with membership_fee_type_id in a single query
members =
Member
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|> Ash.Query.select([:membership_fee_type_id])
|> Ash.read!(domain: Membership)
# Group by membership_fee_type_id and count
members
|> Enum.group_by(& &1.membership_fee_type_id)
|> Enum.map(fn {fee_type_id, members_list} -> {fee_type_id, length(members_list)} end)
|> Map.new()
end
# Gets member count from preloaded assigns map
defp get_member_count(fee_type, member_counts) do
Map.get(member_counts, fee_type.id, 0)
end
defp format_error(%Ash.Error.Invalid{} = error) do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred")
# Info card explaining the membership fee type concept
defp info_card(assigns) do
~H"""
<div class="card bg-base-200 mt-6">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-information-circle" class="size-5" />
{gettext("About Membership Fee Types")}
</h2>
<div class="prose prose-sm max-w-none">
<p>
{gettext(
"Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
)}
</p>
<ul>
<li>
<strong>{gettext("Name & Amount")}</strong>
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
</li>
<li>
<strong>{gettext("Interval")}</strong>
- {gettext(
"Fixed after creation. Members can only switch between types with the same interval."
)}
</li>
<li>
<strong>{gettext("Deletion")}</strong>
- {gettext("Only possible if no members are assigned to this type.")}
</li>
</ul>
</div>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,181 @@
defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
@moduledoc """
Helper module for membership fee status display in member list view.
Provides functions to efficiently load and determine cycle status for members
in the list view, avoiding N+1 queries.
"""
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.Member
alias MvWeb.Helpers.MembershipFeeHelpers
@doc """
Loads membership fee cycles for members efficiently.
Preloads cycles with membership_fee_type relationship to avoid N+1 queries.
Note: This loads all cycles for each member. The filtering to get the relevant
cycle (current or last completed) happens in `get_cycle_status_for_member/2`.
## Parameters
- `query` - Ash query for members
- `show_current` - If true, get current cycle status; if false, get last completed cycle status (currently unused, kept for API compatibility)
- `today` - Optional date to use as reference (currently unused, kept for API compatibility)
## Returns
Modified query with cycles loaded
## Performance
Uses Ash.Query.load to efficiently preload cycles in a single query.
All cycles are loaded; filtering happens in memory in `get_cycle_status_for_member/2`.
"""
@spec load_cycles_for_members(Ash.Query.t(), boolean(), Date.t() | nil) :: Ash.Query.t()
def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do
# Load membership_fee_type and cycles
query
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
end
@doc """
Gets the cycle status for a member.
Returns the status of either the last completed cycle or the current cycle,
depending on the `show_current` parameter.
## Parameters
- `member` - Member struct with loaded cycles and membership_fee_type
- `show_current` - If true, get current cycle status; if false, get last completed cycle status
## Returns
- `:paid`, `:unpaid`, or `:suspended` if cycle exists
- `nil` if no cycle exists
## Examples
# Get last completed cycle status
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, false)
:paid
# Get current cycle status
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, true)
:unpaid
"""
@spec get_cycle_status_for_member(Member.t(), boolean(), Date.t() | nil) ::
:paid | :unpaid | :suspended | nil
def get_cycle_status_for_member(member, show_current \\ false, today \\ nil) do
cycle =
if show_current do
MembershipFeeHelpers.get_current_cycle(member, today)
else
MembershipFeeHelpers.get_last_completed_cycle(member, today)
end
case cycle do
nil -> nil
cycle -> cycle.status
end
end
@doc """
Formats cycle status as a badge component.
Returns a map with badge information for rendering in templates.
## Parameters
- `status` - Cycle status (`:paid`, `:unpaid`, `:suspended`, or `nil`)
## Returns
Map with `:color`, `:icon`, and `:label` keys, or `nil` if status is nil
## Examples
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid)
%{color: "badge-success", icon: "hero-check-circle", label: "Paid"}
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil)
nil
"""
@spec format_cycle_status_badge(:paid | :unpaid | :suspended | nil) ::
%{color: String.t(), icon: String.t(), label: String.t()} | nil
def format_cycle_status_badge(nil), do: nil
def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
%{
color: MembershipFeeHelpers.status_color(status),
icon: MembershipFeeHelpers.status_icon(status),
label: format_status_label(status)
}
end
@doc """
Filters members by cycle status (paid or unpaid).
Returns members that have the specified status in either the last completed cycle
or the current cycle, depending on `show_current`.
## Parameters
- `members` - List of member structs with loaded cycles
- `status` - Cycle status to filter by (`:paid` or `:unpaid`)
- `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
## Returns
List of members with the specified cycle status
## Examples
# Filter unpaid members in last cycle
iex> filter_members_by_cycle_status(members, :unpaid, false)
[%Member{}, ...]
# Filter paid members in current cycle
iex> filter_members_by_cycle_status(members, :paid, true)
[%Member{}, ...]
"""
@spec filter_members_by_cycle_status([Member.t()], :paid | :unpaid, boolean()) :: [Member.t()]
def filter_members_by_cycle_status(members, status, show_current \\ false)
when status in [:paid, :unpaid] do
Enum.filter(members, fn member ->
member_status = get_cycle_status_for_member(member, show_current)
member_status == status
end)
end
@doc """
Filters members by unpaid cycle status.
Returns members that have unpaid cycles in either the last completed cycle
or the current cycle, depending on `show_current`.
## Parameters
- `members` - List of member structs with loaded cycles
- `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
## Returns
List of members with unpaid cycles
## Deprecated
This function is kept for backwards compatibility. Use `filter_members_by_cycle_status/3` instead.
"""
@spec filter_unpaid_members([Member.t()], boolean()) :: [Member.t()]
def filter_unpaid_members(members, show_current \\ false) do
filter_members_by_cycle_status(members, :unpaid, show_current)
end
# Private helper function to format status label
defp format_status_label(:paid), do: gettext("Paid")
defp format_status_label(:unpaid), do: gettext("Unpaid")
defp format_status_label(:suspended), do: gettext("Suspended")
end

View file

@ -69,9 +69,16 @@ defmodule MvWeb.Router do
live "/settings", GlobalSettingsLive live "/settings", GlobalSettingsLive
# Membership Fee Settings
live "/membership_fee_settings", MembershipFeeSettingsLive
# Membership Fee Types Management
live "/membership_fee_types", MembershipFeeTypeLive.Index, :index
live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit
# 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

@ -20,7 +20,6 @@ defmodule MvWeb.Translations.MemberFields do
def label(:first_name), do: gettext("First Name") def label(:first_name), do: gettext("First Name")
def label(:last_name), do: gettext("Last Name") def label(:last_name), do: gettext("Last Name")
def label(:email), do: gettext("Email") def label(:email), do: gettext("Email")
def label(:paid), do: gettext("Paid")
def label(:phone_number), do: gettext("Phone") def label(:phone_number), do: gettext("Phone")
def label(:join_date), do: gettext("Join Date") def label(:join_date), do: gettext("Join Date")
def label(:exit_date), do: gettext("Exit Date") def label(:exit_date), do: gettext("Exit Date")

View file

@ -38,7 +38,7 @@ defmodule Mv.MixProject do
[ [
{:tidewave, "~> 0.5", only: [:dev]}, {:tidewave, "~> 0.5", only: [:dev]},
{:sourceror, "~> 1.8", only: [:dev, :test]}, {:sourceror, "~> 1.8", only: [:dev, :test]},
{:live_debugger, "~> 0.4", only: [:dev]}, {:live_debugger, "~> 0.5", only: [:dev]},
{:ash_admin, "~> 0.13"}, {:ash_admin, "~> 0.13"},
{:ash_postgres, "~> 2.0"}, {:ash_postgres, "~> 2.0"},
{:ash_phoenix, "~> 2.0"}, {:ash_phoenix, "~> 2.0"},
@ -46,7 +46,7 @@ defmodule Mv.MixProject do
{:bcrypt_elixir, "~> 3.0"}, {:bcrypt_elixir, "~> 3.0"},
{:ash_authentication, "~> 4.9"}, {:ash_authentication, "~> 4.9"},
{:ash_authentication_phoenix, "~> 2.10"}, {:ash_authentication_phoenix, "~> 2.10"},
{:igniter, "~> 0.6", only: [:dev, :test]}, {:igniter, "~> 0.7", only: [:dev, :test]},
{:phoenix, "~> 1.8.0-rc.4", override: true}, {:phoenix, "~> 1.8.0-rc.4", override: true},
{:phoenix_ecto, "~> 4.5"}, {:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.10"}, {:ecto_sql, "~> 3.10"},
@ -69,7 +69,7 @@ defmodule Mv.MixProject do
{:req, "~> 0.5"}, {:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"}, {:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.26"}, {:gettext, "~> 1.0"},
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"}, {:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"}, {:bandit, "~> 1.5"},

View file

@ -1,48 +1,48 @@
%{ %{
"ash": {:hex, :ash, "3.7.1", "abb55dee19e0959e529e52fe0622468825ae05400f535484919713e492d9a9e7", [:mix], [{:crux, "~> 0.1.0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4474ce9befe9862d1ed73cadf8a755e836c45a14a7b3b952d02e1a12f2b2e529"}, "ash": {:hex, :ash, "3.12.0", "5b78000df650d86b446d88977ef8aa5c9d9f7ffa1193fa3c4b901c60bff2d130", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7cf45b4eb83aa0ab5e6707d6e4ea4a10c29ab20613c87f06344f7953b2ca5e18"},
"ash_admin": {:hex, :ash_admin, "0.13.19", "43227905381ea0b835039fb3f3d255a3664925619937869e605402bc2f95c5e5", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "41e6262c437164df6f052e43cc93be225a7e148b49a813fc451e70172338ee38"}, "ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
"ash_authentication": {:hex, :ash_authentication, "4.11.0", "4165ede37e179cb0a24b7bfc38d620fa93c05fb6272fbd353cafe27652b1e68b", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "8201d0169944c1df3db9b560494e50e1c3bc99c3b1a8a2ef1e61b0f77bc820df"}, "ash_authentication": {:hex, :ash_authentication, "4.13.6", "95b17f0bfc00bd6e229145b90c7026f784ae81639e832de4b5c96a738de5ed46", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "27ded84bdc61fd267794dee17a6cbe6e52d0f365d3e8ea0460d95977b82ac6f1"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.0", "75d7d77e3b626f3d8ea6ee44291d885950172ab399d997b2934f93d2e0a55a61", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "a423e22b40fdf3b1a7f2178e44ca68f48fdb5ba0d87e8d42a43de1a3b63ca704"}, "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.14.1", "60d127a73c2144b39fa3dab045cc3f7fce0c3ccd2dd3e8534288f5da65f0c1db", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3cd57aee855be3ccf2960ce0b005ad209c97fbfc81faa71212bcfbd6a4a90cae"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.17", "a074ae6d9d7135d99c4edc91ddebe4c035ca380b044592bf9c3d58471669cf52", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "94e4a6cc6ced31cddba930c45c1c3477aa59b956e7fc3cdc63095cf0e506bdf5"}, "ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"},
"ash_postgres": {:hex, :ash_postgres, "2.6.23", "5976a7e5e204b7bc627b1d17026bec9da4d880f2e09cd94bf4e8cee41fef32ce", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.7 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "61de4aedfe30f1ae14d8185cfc37a5b1940b45b60f2dfbdf9eb056f97dca41c5"}, "ash_postgres": {:hex, :ash_postgres, "2.6.27", "7aa119cc420909573a51802f414a49a9fb21a06ee78769efd7a4db040e748f5c", [:mix], [{:ash, ">= 3.11.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.16 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "f5e71dc3f77bc0c52374869df4b66493e13c0e27507c3d10ff13158ef7ea506f"},
"ash_sql": {:hex, :ash_sql, "0.3.7", "80affa5446075d71deb157c67290685a84b392d723be766bfb684f58fe0143de", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "ce4d974b8e784171c5a2a62593b3672b42dfd4888fa2239f01a6b32bad769038"}, "ash_sql": {:hex, :ash_sql, "0.3.16", "a4e62d2cf9b2f4a451067e5e3de28349a8d0e69cf50fc1861bad85f478ded046", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "f3d5a810b23e12e3e102799c68b1e934fa7f909ccaa4bd530f10c7317cfcfe56"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"}, "bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},
"crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"}, "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"}, "ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"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.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "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"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.6.30", "83a466369ebb8fe009e0823c7bf04314dc545122c2d48f896172fc79df33e99d", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "76a14d5b7f850bb03b5243088c3649d54a2e52e34a2aa1104dee23cf50a8bae0"}, "igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"},
"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.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "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.4.2", "775c3a570ef3c44d27d261b3c1aae23ef35cac949a57f67b3e7b1aa1fb2707bc", [: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", "5b24e37985f0424056a322a18dab4a5fb0f4e8ee4e55975985364e0b45d683b9"}, "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"},
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
@ -50,41 +50,41 @@
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
"phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.14", "cae84abc4cd00dde4bb200b8516db556704c585c267aff9cd4955ff83cceb86c", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b827980e2bc00fddd8674e3b567519a4e855b5de04bf8607140414f1101e2627"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.19", "c95e9acbc374fb796ee3e24bfecc8213123c74d9f9e45667ca40bb0a4d242953", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d5ad357d6b21562a5b431f0ad09dfe76db9ce5648c6949f1aac334c8c4455d32"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
"reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"}, "reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
"spark": {:hex, :spark, "2.3.5", "f30d30ecc3b4ab9b932d9aada66af7677fc1f297a2c349b0bcec3eafb9f996e8", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "0e9d339704d5d148f77f2b2fef3bcfc873a9e9bb4224fcf289c545d65827202f"}, "spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"},
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
"splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"}, "splode": {:hex, :splode, "0.2.10", "f755ebc8e5dc1556869c0513cf5f3450be602a41e01196249306483c4badbec0", [:mix], [], "hexpm", "906b6dc17b7ebc9b9fd9a31360bf0bd691d20e934fb28795c0ddb0c19d3198f1"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"}, "swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"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.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [: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", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"}, "tidewave": {:hex, :tidewave, "0.5.4", "b7b6db62779a6faf139e630eb54f218cf3091ec5d39600197008db8474cb6fb2", [:mix], [{:bandit, ">= 1.10.1", [hex: :bandit, repo: "hexpm", optional: true]}, {: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", "252c7cf4ffe81d4c5ad8ef709333e7124c5af554aa07dceab61135d0f205a898"},
"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"},
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"},
"ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"}, "ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},

View file

@ -1,58 +0,0 @@
# User-Member Association - Test Status
## Test Files Created/Modified
### 1. test/membership/member_available_for_linking_test.exs (NEU)
**Status**: Alle Tests sollten FEHLSCHLAGEN ❌
**Grund**: Die `:available_for_linking` Action existiert noch nicht
Tests:
- ✗ returns only unlinked members and limits to 10
- ✗ limits results to 10 members even when more exist
- ✗ email match: returns only member with matching email when exists
- ✗ email match: returns all unlinked members when no email match
- ✗ search query: filters by first_name, last_name, and email
- ✗ email match takes precedence over search query
### 2. test/accounts/user_member_linking_test.exs (NEU)
**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌
Tests:
- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert)
- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert)
- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert)
- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert)
### 3. test/mv_web/user_live/form_test.exs (ERWEITERT)
**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌
**Grund**: Member-Linking UI ist noch nicht implementiert
Neue Tests:
- ✗ shows linked member with unlink button when user has member
- ✗ shows member search field when user has no member
- ✗ selecting member and saving links member to user
- ✗ unlinking member and saving removes member from user
### 4. test/mv_web/user_live/index_test.exs (ERWEITERT)
**Status**: Neuer Test sollte FEHLSCHLAGEN ❌
**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt
Neuer Test:
- ✗ displays linked member name in user list
## Zusammenfassung
**Tests gesamt**: 13
**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden)
**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert)
## Nächste Schritte
1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex`
2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex`
3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex`
4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu
5. Füge Gettext-Übersetzungen hinzu
Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden.

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,7 @@ msgid "Actions"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
@ -38,6 +39,7 @@ msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#: 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/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
@ -143,10 +145,9 @@ msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex #: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
@ -173,6 +174,7 @@ msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Saving..." msgid "Saving..."
@ -204,6 +206,7 @@ msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yes" msgid "Yes"
msgstr "" msgstr ""
@ -262,6 +265,8 @@ msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex #: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_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/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cancel" msgid "Cancel"
@ -316,6 +321,7 @@ msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Members" msgid "Members"
msgstr "" msgstr ""
@ -627,16 +633,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
@ -704,8 +700,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 ""
@ -777,11 +773,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"
@ -808,6 +799,7 @@ msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This field cannot be empty" msgid "This field cannot be empty"
msgstr "" msgstr ""
@ -823,11 +815,6 @@ msgstr ""
msgid "Filter by payment status" msgid "Filter by payment status"
msgstr "" msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex #: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Payment filter" msgid "Payment filter"
@ -845,7 +832,6 @@ msgid "Back"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Coming soon" msgid "Coming soon"
msgstr "" msgstr ""
@ -856,40 +842,21 @@ msgstr ""
msgid "Contact Data" msgid "Contact Data"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contribution"
msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Nr." msgid "Nr."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payment Cycle"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Payment Data" msgid "Payment Data"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Payments" msgid "Payments"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Pending"
msgstr ""
#: 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
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -904,27 +871,11 @@ msgid "Phone"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Save" msgid "Save"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "This data is for demonstration purposes only (mockup)."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "monthly"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "yearly"
msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Create Member" msgid "Create Member"
@ -944,6 +895,9 @@ 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
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Amount" msgid "Amount"
msgstr "" msgstr ""
@ -954,6 +908,7 @@ msgid "Back to Settings"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only." msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr "" msgstr ""
@ -968,33 +923,16 @@ 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"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/navbar.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 "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"
@ -1020,50 +958,38 @@ 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
#: lib/mv_web/live/membership_fee_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"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
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/helpers/membership_fee_helpers.ex
#: 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
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Half-yearly" msgid "Half-yearly"
msgstr "" msgstr ""
@ -1079,18 +1005,16 @@ 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
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_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 ""
@ -1125,22 +1049,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 ""
@ -1155,24 +1079,22 @@ msgstr ""
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps." msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
msgstr "" msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: 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
#: lib/mv_web/live/membership_fee_type_live/form.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"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Name & Amount" msgid "Name & Amount"
msgstr "" msgstr ""
@ -1188,6 +1110,7 @@ msgid "No fee for honorary members"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type." msgid "Only possible if no members are assigned to this type."
msgstr "" msgstr ""
@ -1203,31 +1126,26 @@ 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/helpers/membership_fee_helpers.ex
#: 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
#: lib/mv_web/live/membership_fee_type_live/form.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"
@ -1239,7 +1157,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"
@ -1250,22 +1167,17 @@ 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"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
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"
@ -1282,17 +1194,14 @@ msgid "Suspend"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
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."
@ -1308,48 +1217,29 @@ msgstr ""
msgid "Total Contributions" msgid "Total Contributions"
msgstr "" msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
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/helpers/membership_fee_helpers.ex
#: 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
#: lib/mv_web/live/membership_fee_type_live/form.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"
@ -1366,6 +1256,7 @@ msgid "Last name"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "None" msgid "None"
msgstr "" msgstr ""
@ -1406,13 +1297,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

View file

@ -18,6 +18,7 @@ msgid "Actions"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
@ -38,6 +39,7 @@ msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#: 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/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
@ -143,10 +145,9 @@ msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex #: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
@ -173,6 +174,7 @@ msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Saving..." msgid "Saving..."
@ -187,7 +189,6 @@ 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_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -201,9 +202,9 @@ 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_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yes" msgid "Yes"
msgstr "" msgstr ""
@ -262,6 +263,8 @@ msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex #: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_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/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cancel" msgid "Cancel"
@ -276,6 +279,7 @@ 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_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Description" msgid "Description"
msgstr "" msgstr ""
@ -316,6 +320,7 @@ msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Members" msgid "Members"
msgstr "" msgstr ""
@ -325,6 +330,8 @@ 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_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Name" msgid "Name"
msgstr "" msgstr ""
@ -627,16 +634,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
@ -704,8 +701,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 ""
@ -777,11 +774,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"
@ -808,6 +800,7 @@ msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "This field cannot be empty" msgid "This field cannot be empty"
msgstr "" msgstr ""
@ -823,11 +816,6 @@ msgstr ""
msgid "Filter by payment status" msgid "Filter by payment status"
msgstr "" msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex #: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Payment filter" msgid "Payment filter"
@ -845,7 +833,6 @@ msgid "Back"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Coming soon" msgid "Coming soon"
msgstr "" msgstr ""
@ -856,40 +843,21 @@ msgstr ""
msgid "Contact Data" msgid "Contact Data"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contribution"
msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Nr." msgid "Nr."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Payment Cycle"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Payment Data" msgid "Payment Data"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Payments" msgid "Payments"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Pending"
msgstr ""
#: 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
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -904,27 +872,11 @@ msgid "Phone"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Save" msgid "Save"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "This data is for demonstration purposes only (mockup)."
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "monthly"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "yearly"
msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Create Member" msgid "Create Member"
@ -944,6 +896,9 @@ 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
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Amount" msgid "Amount"
msgstr "" msgstr ""
@ -954,6 +909,7 @@ msgid "Back to Settings"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only." msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr "" msgstr ""
@ -968,33 +924,16 @@ 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"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/navbar.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 "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"
@ -1020,50 +959,38 @@ 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
#: lib/mv_web/live/membership_fee_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"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
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/helpers/membership_fee_helpers.ex
#: 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
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Half-yearly" msgid "Half-yearly"
msgstr "" msgstr ""
@ -1079,18 +1006,16 @@ 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
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_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 ""
@ -1125,22 +1050,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 ""
@ -1155,24 +1080,22 @@ msgstr ""
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps." msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
msgstr "" msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: 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
#: lib/mv_web/live/membership_fee_type_live/form.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"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Name & Amount" msgid "Name & Amount"
msgstr "" msgstr ""
@ -1188,6 +1111,7 @@ msgid "No fee for honorary members"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type." msgid "Only possible if no members are assigned to this type."
msgstr "" msgstr ""
@ -1203,31 +1127,26 @@ 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/helpers/membership_fee_helpers.ex
#: 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
#: lib/mv_web/live/membership_fee_type_live/form.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"
@ -1239,7 +1158,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"
@ -1250,22 +1168,17 @@ 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"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
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"
@ -1282,17 +1195,14 @@ msgid "Suspend"
msgstr "" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
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."
@ -1308,48 +1218,29 @@ msgstr ""
msgid "Total Contributions" msgid "Total Contributions"
msgstr "" msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
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/helpers/membership_fee_helpers.ex
#: 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
#: lib/mv_web/live/membership_fee_type_live/form.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"
@ -1366,6 +1257,7 @@ msgid "Last name"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "None" msgid "None"
msgstr "" msgstr ""
@ -1406,13 +1298,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
@ -1433,6 +1321,7 @@ msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/translations/field_types.ex #: lib/mv_web/translations/field_types.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Date" msgid "Date"
@ -1521,16 +1410,15 @@ msgstr ""
#~ 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 "" #~ msgstr ""
#~ #: 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
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Custom Field Values" #~ msgid "Contribution"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_field_live/index_component.ex #~ #: lib/mv_web/live/member_field_live/index_component.ex
@ -1538,9 +1426,10 @@ msgstr ""
#~ msgid "Field Name" #~ msgid "Field Name"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/components/layouts/navbar.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 "Contribution Settings"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_field_live/index_component.ex #~ #: lib/mv_web/live/member_field_live/index_component.ex
@ -1553,25 +1442,56 @@ msgstr ""
#~ msgid "Hide %{field} in overview" #~ msgid "Hide %{field} in overview"
#~ 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 "Contribution start"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/show.ex #~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Id" #~ msgid "Copy emails"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/user_live/show.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Default Contribution Type"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Edit amount"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Example: Member Contribution View"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Not set" #~ msgid "Failed to delete some cycles: %{errors}"
#~ msgstr ""
#~ #: lib/mv_web/live/membership_fee_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to save settings. Please check the errors below."
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/user_live/index.html.heex #~ #: lib/mv_web/live/user_live/index.html.heex
#~ #: lib/mv_web/live/user_live/show.ex #~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "OIDC ID" #~ 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 "" #~ msgstr ""
#~ #: lib/mv_web/live/member_field_live/index_component.ex #~ #: lib/mv_web/live/member_field_live/index_component.ex
@ -1581,15 +1501,87 @@ 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 "Show in Overview" #~ msgid "New Custom field"
#~ msgstr ""
#~ #: lib/mv_web/live/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Not paid"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/show.ex #~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "This is a member record from your database." #~ msgid "Payment Cycle"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/custom_field_live/form.ex #~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Use this form to manage custom_field records in your database." #~ msgid "Pending"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Quarterly Interval - Joining Period Excluded"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Show Last/Current Cycle Payment Status"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Show current cycle"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Show last completed cycle"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Switch to current cycle"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Switch to last completed cycle"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "This data is for demonstration purposes only (mockup)."
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in current cycle"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in last cycle"
#~ 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 "Yearly Interval - Joining Period Included"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "monthly"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "yearly"
#~ msgstr "" #~ msgstr ""

View file

@ -0,0 +1,259 @@
defmodule Mv.Repo.Migrations.AddCustomFieldValuesToSearchVector do
@moduledoc """
Extends the search_vector in members table to include custom_field_values.
This migration:
1. Updates the members_search_vector_trigger() function to include custom field values
2. Creates a trigger function to update member search_vector when custom_field_values change
3. Creates a trigger on custom_field_values table
4. Updates existing search_vector values for all members
"""
use Ecto.Migration
def up do
# Update the main trigger function to include custom_field_values
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
DECLARE
custom_values_text text;
BEGIN
-- Aggregate all custom field values for this member
-- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy)
-- ->> operator always returns TEXT directly (no need for -> + ::text fallback)
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = NEW.id AND value IS NOT NULL;
-- Build search_vector with member fields and custom field values
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
# Create trigger function to update member search_vector when custom_field_values change
# Optimized:
# 1. Only fetch required fields instead of full member record to reduce overhead
# 2. Skip re-aggregation on UPDATE if value hasn't actually changed
execute("""
CREATE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$
DECLARE
member_id_val uuid;
member_first_name text;
member_last_name text;
member_email text;
member_phone_number text;
member_join_date date;
member_exit_date date;
member_notes text;
member_city text;
member_street text;
member_house_number text;
member_postal_code text;
custom_values_text text;
old_value_text text;
new_value_text text;
BEGIN
-- Get member ID from trigger context
member_id_val := COALESCE(NEW.member_id, OLD.member_id);
-- Optimization: For UPDATE operations, check if value actually changed
-- If value hasn't changed, we can skip the expensive re-aggregation
IF TG_OP = 'UPDATE' THEN
-- Extract OLD value for comparison (handle both JSONB formats)
-- ->> operator always returns TEXT directly
old_value_text := COALESCE(
NULLIF(OLD.value->>'_union_value', ''),
NULLIF(OLD.value->>'value', ''),
''
);
-- Extract NEW value for comparison (handle both JSONB formats)
new_value_text := COALESCE(
NULLIF(NEW.value->>'_union_value', ''),
NULLIF(NEW.value->>'value', ''),
''
);
-- Check if value, member_id, or custom_field_id actually changed
-- If nothing changed, skip expensive re-aggregation
IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND
(OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND
(OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN
RETURN COALESCE(NEW, OLD);
END IF;
END IF;
-- Fetch only required fields instead of full record (performance optimization)
SELECT
first_name,
last_name,
email,
phone_number,
join_date,
exit_date,
notes,
city,
street,
house_number,
postal_code
INTO
member_first_name,
member_last_name,
member_email,
member_phone_number,
member_join_date,
member_exit_date,
member_notes,
member_city,
member_street,
member_house_number,
member_postal_code
FROM members
WHERE id = member_id_val;
-- Aggregate all custom field values for this member
-- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy)
-- ->> operator always returns TEXT directly
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = member_id_val AND value IS NOT NULL;
-- Update the search_vector for the affected member
UPDATE members
SET search_vector =
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C')
WHERE id = member_id_val;
RETURN COALESCE(NEW, OLD);
END
$$ LANGUAGE plpgsql;
""")
# Create trigger on custom_field_values table
execute("""
CREATE TRIGGER update_member_search_vector_on_custom_field_value_change
AFTER INSERT OR UPDATE OR DELETE ON custom_field_values
FOR EACH ROW
EXECUTE FUNCTION update_member_search_vector_from_custom_field_value()
""")
# Update existing search_vector values for all members
execute("""
UPDATE members m
SET search_vector =
setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(
(SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
FROM custom_field_values
WHERE member_id = m.id AND value IS NOT NULL),
''
)), 'C')
""")
end
def down do
# Drop trigger on custom_field_values
execute(
"DROP TRIGGER IF EXISTS update_member_search_vector_on_custom_field_value_change ON custom_field_values"
)
# Drop trigger function
execute("DROP FUNCTION IF EXISTS update_member_search_vector_from_custom_field_value()")
# Restore original trigger function without custom_field_values
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
# Update existing search_vector values to remove custom_field_values
execute("""
UPDATE members m
SET search_vector =
setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C')
""")
end
end

View file

@ -0,0 +1,142 @@
defmodule Mv.Repo.Migrations.AddMembershipFeesTables do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:membership_fee_types, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
add :name, :text, null: false
# Precision: 10 digits total, 2 decimal places (max 99,999,999.99)
add :amount, :numeric, null: false, precision: 10, scale: 2
add :interval, :text, null: false
add :description, :text
end
create unique_index(:membership_fee_types, [:name],
name: "membership_fee_types_unique_name_index"
)
# CHECK constraint for interval values (enforced at DB level)
create constraint(:membership_fee_types, :membership_fee_types_interval_check,
check: "interval IN ('monthly', 'quarterly', 'half_yearly', 'yearly')"
)
# CHECK constraint for non-negative amount
create constraint(:membership_fee_types, :membership_fee_types_amount_check,
check: "amount >= 0"
)
create table(:membership_fee_cycles, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
add :cycle_start, :date, null: false
# Precision: 10 digits total, 2 decimal places (max 99,999,999.99)
add :amount, :numeric, null: false, precision: 10, scale: 2
add :status, :text, null: false, default: "unpaid"
add :notes, :text
# CASCADE: Delete cycles when member is deleted
add :member_id,
references(:members,
column: :id,
name: "membership_fee_cycles_member_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
),
null: false
# RESTRICT: Cannot delete fee type if cycles reference it
add :membership_fee_type_id,
references(:membership_fee_types,
column: :id,
name: "membership_fee_cycles_membership_fee_type_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :restrict
),
null: false
end
# CHECK constraint for status values (enforced at DB level)
create constraint(:membership_fee_cycles, :membership_fee_cycles_status_check,
check: "status IN ('unpaid', 'paid', 'suspended')"
)
# CHECK constraint for non-negative amount
create constraint(:membership_fee_cycles, :membership_fee_cycles_amount_check,
check: "amount >= 0"
)
# Indexes as specified in architecture document
create index(:membership_fee_cycles, [:member_id])
create index(:membership_fee_cycles, [:membership_fee_type_id])
create index(:membership_fee_cycles, [:status])
create index(:membership_fee_cycles, [:cycle_start])
# Composite unique index: one cycle per member per cycle_start
create unique_index(:membership_fee_cycles, [:member_id, :cycle_start],
name: "membership_fee_cycles_unique_cycle_per_member_index"
)
# Extend members table with membership fee fields
alter table(:members) do
add :membership_fee_start_date, :date
# RESTRICT: Cannot delete fee type if members are assigned to it
add :membership_fee_type_id,
references(:membership_fee_types,
column: :id,
name: "members_membership_fee_type_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :restrict
)
end
# Index for efficient lookup of members by fee type
create index(:members, [:membership_fee_type_id])
end
def down do
# First: Remove members extension (depends on membership_fee_types)
drop_if_exists index(:members, [:membership_fee_type_id])
drop constraint(:members, "members_membership_fee_type_id_fkey")
alter table(:members) do
remove :membership_fee_type_id
remove :membership_fee_start_date
end
# Second: Drop cycles table (depends on membership_fee_types)
drop_if_exists unique_index(:membership_fee_cycles, [:member_id, :cycle_start],
name: "membership_fee_cycles_unique_cycle_per_member_index"
)
drop_if_exists index(:membership_fee_cycles, [:cycle_start])
drop_if_exists index(:membership_fee_cycles, [:status])
drop_if_exists index(:membership_fee_cycles, [:membership_fee_type_id])
drop_if_exists index(:membership_fee_cycles, [:member_id])
drop constraint(:membership_fee_cycles, "membership_fee_cycles_member_id_fkey")
drop constraint(:membership_fee_cycles, "membership_fee_cycles_membership_fee_type_id_fkey")
drop_if_exists constraint(:membership_fee_cycles, :membership_fee_cycles_status_check)
drop_if_exists constraint(:membership_fee_cycles, :membership_fee_cycles_amount_check)
drop table(:membership_fee_cycles)
# Third: Drop fee types table
drop_if_exists unique_index(:membership_fee_types, [:name],
name: "membership_fee_types_unique_name_index"
)
drop_if_exists constraint(:membership_fee_types, :membership_fee_types_interval_check)
drop_if_exists constraint(:membership_fee_types, :membership_fee_types_amount_check)
drop table(:membership_fee_types)
end
end

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

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.RemovePaidFromMembers do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:members) do
remove :paid
end
end
def down do
alter table(:members) do
add :paid, :boolean
end
end
end

View file

@ -0,0 +1,80 @@
defmodule Mv.Repo.Migrations.AddAuthorizationDomain do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:users) do
add :role_id, :uuid
end
create table(:roles, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
end
alter table(:users) do
modify :role_id,
references(:roles,
column: :id,
name: "users_role_id_fkey",
type: :uuid,
on_delete: :restrict,
prefix: "public"
)
end
alter table(:roles) do
add :name, :text, null: false
add :description, :text
add :permission_set_name, :text, null: false
add :is_system_role, :boolean, null: false, default: false
add :inserted_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
add :updated_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
end
create unique_index(:roles, [:name], name: "roles_unique_name_index")
create index(:roles, [:permission_set_name], name: "roles_permission_set_name_index")
create index(:users, [:role_id], name: "users_role_id_index")
end
def down do
drop_if_exists index(:users, [:role_id], name: "users_role_id_index")
drop_if_exists index(:roles, [:permission_set_name], name: "roles_permission_set_name_index")
drop_if_exists unique_index(:roles, [:name], name: "roles_unique_name_index")
alter table(:roles) do
remove :updated_at
remove :inserted_at
remove :is_system_role
remove :permission_set_name
remove :description
remove :name
end
drop constraint(:users, "users_role_id_fkey")
alter table(:users) do
modify :role_id, :uuid
end
drop table(:roles)
alter table(:users) do
remove :role_id
end
end
end

View file

@ -5,6 +5,40 @@
alias Mv.Membership alias Mv.Membership
alias Mv.Accounts alias Mv.Accounts
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.CycleGenerator
# 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 +46,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 +71,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
@ -106,59 +128,153 @@ Accounts.create_user!(%{email: "admin@mv.local"}, upsert?: true, upsert_identity
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"}) |> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!() |> Ash.update!()
# Load all membership fee types for assignment
# Sort by name to ensure deterministic order
all_fee_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
|> Enum.to_list()
# Create sample members for testing - use upsert to prevent duplicates # Create sample members for testing - use upsert to prevent duplicates
for member_attrs <- [ # Member 1: Hans - All cycles paid
%{ # Member 2: Greta - All cycles unpaid
first_name: "Hans", # Member 3: Friedrich - Mixed cycles (paid, unpaid, suspended)
last_name: "Müller", # Member 4: Marianne - No membership fee type
email: "hans.mueller@example.de", member_attrs_list = [
join_date: ~D[2023-01-15], %{
paid: true, first_name: "Hans",
phone_number: "+49301234567", last_name: "Müller",
city: "München", email: "hans.mueller@example.de",
street: "Hauptstraße", join_date: ~D[2023-01-15],
house_number: "42", phone_number: "+49301234567",
postal_code: "80331" city: "München",
}, street: "Hauptstraße",
%{ house_number: "42",
first_name: "Greta", postal_code: "80331",
last_name: "Schmidt", membership_fee_type_id: Enum.at(all_fee_types, 0).id,
email: "greta.schmidt@example.de", cycle_status: :all_paid
join_date: ~D[2023-02-01], },
paid: false, %{
phone_number: "+49309876543", first_name: "Greta",
city: "Hamburg", last_name: "Schmidt",
street: "Lindenstraße", email: "greta.schmidt@example.de",
house_number: "17", join_date: ~D[2023-02-01],
postal_code: "20095", phone_number: "+49309876543",
notes: "Interessiert an Fortgeschrittenen-Kursen" city: "Hamburg",
}, street: "Lindenstraße",
%{ house_number: "17",
first_name: "Friedrich", postal_code: "20095",
last_name: "Wagner", notes: "Interessiert an Fortgeschrittenen-Kursen",
email: "friedrich.wagner@example.de", membership_fee_type_id: Enum.at(all_fee_types, 1).id,
join_date: ~D[2022-11-10], cycle_status: :all_unpaid
paid: true, },
phone_number: "+49301122334", %{
city: "Berlin", first_name: "Friedrich",
street: "Kastanienallee", last_name: "Wagner",
house_number: "8" email: "friedrich.wagner@example.de",
}, join_date: ~D[2022-11-10],
%{ phone_number: "+49301122334",
first_name: "Marianne", city: "Berlin",
last_name: "Wagner", street: "Kastanienallee",
email: "marianne.wagner@example.de", house_number: "8",
join_date: ~D[2022-11-10], membership_fee_type_id: Enum.at(all_fee_types, 2).id,
paid: true, cycle_status: :mixed
phone_number: "+49301122334", },
city: "Berlin", %{
street: "Kastanienallee", first_name: "Marianne",
house_number: "8" last_name: "Wagner",
} email: "marianne.wagner@example.de",
] do join_date: ~D[2022-11-10],
phone_number: "+49301122334",
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
# No membership_fee_type_id - member without fee type
}
]
# Create members and generate cycles
Enum.each(member_attrs_list, fn member_attrs ->
cycle_status = Map.get(member_attrs, :cycle_status)
member_attrs_without_status = Map.delete(member_attrs, :cycle_status)
# Use upsert to prevent duplicates based on email # Use upsert to prevent duplicates based on email
Membership.create_member!(member_attrs, upsert?: true, upsert_identity: :unique_email) # First create/update member without membership_fee_type_id to avoid overwriting existing assignments
end member_attrs_without_fee_type = Map.delete(member_attrs_without_status, :membership_fee_type_id)
member =
Membership.create_member!(member_attrs_without_fee_type,
upsert?: true,
upsert_identity: :unique_email
)
# Only set membership_fee_type_id if member doesn't have one yet (idempotent)
final_member =
if is_nil(member.membership_fee_type_id) and
Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
})
|> Ash.update!()
else
member
end
# Generate cycles if member has a fee type
if final_member.membership_fee_type_id do
# Load member with cycles to check if they already exist
member_with_cycles =
final_member
|> Ash.load!(:membership_fee_cycles)
# Only generate if no cycles exist yet (to avoid duplicates on re-run)
cycles =
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
# Generate cycles
{:ok, new_cycles, _notifications} =
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
new_cycles
else
# Use existing cycles
member_with_cycles.membership_fee_cycles
end
# Set cycle statuses based on member type
if cycle_status do
cycles
|> Enum.sort_by(& &1.cycle_start, Date)
|> Enum.with_index()
|> Enum.each(fn {cycle, index} ->
status =
case cycle_status do
:all_paid ->
:paid
:all_unpaid ->
:unpaid
:mixed ->
# Mix: first paid, second unpaid, third suspended, then repeat
case rem(index, 3) do
0 -> :paid
1 -> :unpaid
2 -> :suspended
end
end
# Only update if status is different
if cycle.status != status do
cycle
|> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!()
end
end)
end
end
end)
# Create additional users for user-member linking examples # Create additional users for user-member linking examples
additional_users = [ additional_users = [
@ -183,7 +299,6 @@ linked_members = [
last_name: "Weber", last_name: "Weber",
email: "maria.weber@example.de", email: "maria.weber@example.de",
join_date: ~D[2023-03-15], join_date: ~D[2023-03-15],
paid: true,
phone_number: "+49301357924", phone_number: "+49301357924",
city: "Frankfurt", city: "Frankfurt",
street: "Goetheplatz", street: "Goetheplatz",
@ -198,7 +313,6 @@ linked_members = [
last_name: "Klein", last_name: "Klein",
email: "thomas.klein@example.de", email: "thomas.klein@example.de",
join_date: ~D[2023-04-01], join_date: ~D[2023-04-01],
paid: false,
phone_number: "+49302468135", phone_number: "+49302468135",
city: "Köln", city: "Köln",
street: "Rheinstraße", street: "Rheinstraße",
@ -211,24 +325,84 @@ linked_members = [
] ]
# Create the linked members - use upsert to prevent duplicates # Create the linked members - use upsert to prevent duplicates
Enum.each(linked_members, fn member_attrs -> # Assign fee types to linked members using round-robin
# Continue from where we left off with the previous members
Enum.with_index(linked_members)
|> Enum.each(fn {member_attrs, index} ->
user = member_attrs.user user = member_attrs.user
member_attrs_without_user = Map.delete(member_attrs, :user) member_attrs_without_user = Map.delete(member_attrs, :user)
# Use upsert to prevent duplicates based on email
# First create/update member without membership_fee_type_id to avoid overwriting existing assignments
member_attrs_without_fee_type = Map.delete(member_attrs_without_user, :membership_fee_type_id)
# Check if user already has a member # Check if user already has a member
if user.member_id == nil do member =
# User is free, create member and link - use upsert to prevent duplicates if user.member_id == nil do
Membership.create_member!( # User is free, create member and link - use upsert to prevent duplicates
Map.put(member_attrs_without_user, :user, %{id: user.id}), Membership.create_member!(
upsert?: true, Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
upsert_identity: :unique_email upsert?: true,
) upsert_identity: :unique_email
else )
# User already has a member, just create the member without linking - use upsert to prevent duplicates else
Membership.create_member!(member_attrs_without_user, # User already has a member, just create the member without linking - use upsert to prevent duplicates
upsert?: true, Membership.create_member!(member_attrs_without_fee_type,
upsert_identity: :unique_email upsert?: true,
) upsert_identity: :unique_email
)
end
# Only set membership_fee_type_id if member doesn't have one yet (idempotent)
final_member =
if is_nil(member.membership_fee_type_id) do
# Assign deterministically using round-robin
# Start from where previous members ended (3 members before this)
fee_type_index = rem(3 + index, length(all_fee_types))
fee_type = Enum.at(all_fee_types, fee_type_index)
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
else
member
end
# Generate cycles for linked members
if final_member.membership_fee_type_id do
# Load member with cycles to check if they already exist
member_with_cycles =
final_member
|> Ash.load!(:membership_fee_cycles)
# Only generate if no cycles exist yet (to avoid duplicates on re-run)
cycles =
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
# Generate cycles
{:ok, new_cycles, _notifications} =
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
new_cycles
else
# Use existing cycles
member_with_cycles.membership_fee_cycles
end
# Set some cycles to paid for linked members (mixed status)
cycles
|> Enum.sort_by(& &1.cycle_start, Date)
|> Enum.with_index()
|> Enum.each(fn {cycle, index} ->
# Every other cycle is paid, rest unpaid
status = if rem(index, 2) == 0, do: :paid, else: :unpaid
# Only update if status is different
if cycle.status != status do
cycle
|> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!()
end
end)
end end
end) end)
@ -332,6 +506,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,132 @@
{
"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": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "slug",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value_type",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "required",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "true",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "show_in_overview",
"type": "boolean"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "6FEA699A67D34CFBA261DA8316AB711F6853C4F953D42C5D7940B22D17699B2E",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_name_index",
"keys": [
{
"type": "atom",
"value": "name"
}
],
"name": "unique_name",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_slug_index",
"keys": [
{
"type": "atom",
"value": "slug"
}
],
"name": "unique_slug",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "custom_fields"
}

View file

@ -0,0 +1,202 @@
{
"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"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "247CACFA5C8FD24BDD553252E9BBF489E8FE54F60704383B6BE66C616D203A65",
"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,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,233 @@
{
"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": "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": "E18E4B404581EFF050F85E895FAE986B79DB62C9E1611164C92B46B954C371C1",
"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": null,
"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": "43EA9EA365C09D423249AC4B6757A9AC07788C6C1E4BC7C50F8EF2CE01DE5684",
"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,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": null,
"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": "E93A7A1EE90E5CEAC98CEA57C99C6330465716248642D5E2949EF578DE514E99",
"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,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,118 @@
{
"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?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "permission_set_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "is_system_role",
"type": "boolean"
},
{
"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": "8822483B2830DB45988E3B673F36EAE43311B336EE34FBDA1FA24BF9867D7494",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "roles_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": "roles"
}

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,172 @@
{
"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": "email",
"type": "citext"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "hashed_password",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "oidc_id",
"type": "text"
},
{
"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": "users_member_id_fkey",
"on_delete": "nilify",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
},
{
"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": "users_role_id_fkey",
"on_delete": "restrict",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "roles"
},
"scale": null,
"size": null,
"source": "role_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "E381FA10CFC1D8D4CCD09AC1AD4B0CC9F8931436F22139CCF3A4558E84C422D3",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
}
],
"name": "unique_member",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_oidc_id_index",
"keys": [
{
"type": "atom",
"value": "oidc_id"
}
],
"name": "unique_oidc_id",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "users"
}

View file

@ -69,7 +69,7 @@ defmodule Mv.Membership.FuzzySearchTest do
ids = Enum.map(result, & &1.id) ids = Enum.map(result, & &1.id)
assert thomas.id in ids assert thomas.id in ids
refute jane.id in ids refute jane.id in ids
assert length(ids) >= 1 assert not Enum.empty?(ids)
end end
test "empty query returns all members" do test "empty query returns all members" do

View file

@ -0,0 +1,360 @@
defmodule Mv.Membership.MemberCycleCalculationsTest do
@moduledoc """
Tests for Member cycle status calculations.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CalendarCycles
# 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
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{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 cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "current_cycle_status" do
test "returns status of current cycle for member with active cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create a cycle that is active today (2024-01-01 to 2024-12-31)
# Assuming today is in 2024
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :paid
})
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == :paid
end
test "returns nil for member without current cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create a cycle in the past (not current)
create_cycle(member, fee_type, %{
cycle_start: ~D[2020-01-01],
status: :paid
})
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == nil
end
test "returns nil for member without cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == nil
end
test "returns status of current cycle for monthly interval" do
fee_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create a cycle that is active today (current month)
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :unpaid
})
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == :unpaid
end
end
describe "last_cycle_status" do
test "returns status of last completed cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create cycles: 2022 (completed), 2023 (completed), 2024 (current)
today = Date.utc_today()
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :paid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :unpaid
})
# Current cycle
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :paid
})
member = Ash.load!(member, :last_cycle_status)
# Should return status of 2023 (last completed)
assert member.last_cycle_status == :unpaid
end
test "returns nil for member without completed cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Only create current cycle (not completed yet)
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :paid
})
member = Ash.load!(member, :last_cycle_status)
assert member.last_cycle_status == nil
end
test "returns nil for member without cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
member = Ash.load!(member, :last_cycle_status)
assert member.last_cycle_status == nil
end
test "returns status of last completed cycle for monthly interval" do
fee_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
# Create cycles: last month (completed), current month (not completed)
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
create_cycle(member, fee_type, %{
cycle_start: last_month_start,
status: :paid
})
create_cycle(member, fee_type, %{
cycle_start: current_month_start,
status: :unpaid
})
member = Ash.load!(member, :last_cycle_status)
# Should return status of last month (last completed)
assert member.last_cycle_status == :paid
end
end
describe "overdue_count" do
test "counts only unpaid cycles that have ended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
# Create cycles:
# 2022: unpaid, ended (overdue)
# 2023: paid, ended (not overdue)
# 2024: unpaid, current (not overdue)
# 2025: unpaid, future (not overdue)
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid
})
# Current cycle
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :unpaid
})
# Future cycle (if we're not at the end of the year)
next_year = today.year + 1
if today.month < 12 or today.day < 31 do
next_year_start = Date.new!(next_year, 1, 1)
create_cycle(member, fee_type, %{
cycle_start: next_year_start,
status: :unpaid
})
end
member = Ash.load!(member, :overdue_count)
# Should only count 2022 (unpaid and ended)
assert member.overdue_count == 1
end
test "returns 0 when no overdue cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create only paid cycles
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :paid
})
member = Ash.load!(member, :overdue_count)
assert member.overdue_count == 0
end
test "returns 0 for member without cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
member = Ash.load!(member, :overdue_count)
assert member.overdue_count == 0
end
test "counts overdue cycles for monthly interval" do
fee_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
# Create cycles: two months ago (unpaid, ended), last month (paid, ended), current month (unpaid, not ended)
two_months_ago_start =
Date.add(today, -65) |> CalendarCycles.calculate_cycle_start(:monthly)
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
create_cycle(member, fee_type, %{
cycle_start: two_months_ago_start,
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: last_month_start,
status: :paid
})
create_cycle(member, fee_type, %{
cycle_start: current_month_start,
status: :unpaid
})
member = Ash.load!(member, :overdue_count)
# Should only count two_months_ago (unpaid and ended)
assert member.overdue_count == 1
end
test "counts multiple overdue cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create multiple unpaid, ended cycles
create_cycle(member, fee_type, %{
cycle_start: ~D[2020-01-01],
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2021-01-01],
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :unpaid
})
member = Ash.load!(member, :overdue_count)
assert member.overdue_count == 3
end
end
describe "calculations with multiple cycles" do
test "all calculations work correctly with multiple cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
# Create cycles: 2022 (unpaid, ended), 2023 (paid, ended), 2024 (unpaid, current)
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid
})
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :unpaid
})
member =
Ash.load!(member, [:current_cycle_status, :last_cycle_status, :overdue_count])
assert member.current_cycle_status == :unpaid
assert member.last_cycle_status == :paid
assert member.overdue_count == 1
end
end
end

View file

@ -0,0 +1,635 @@
defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
@moduledoc """
Tests for required custom fields validation.
Tests cover:
- Member creation without required custom field error
- Member creation with empty required custom field (nil/empty string) error
- Member creation with valid required custom field success
- Member update: removing a required custom field value error
- Boolean required custom field: false is valid, nil is invalid
"""
use Mv.DataCase, async: true
alias Mv.Membership
setup do
# Create required custom fields for different types
{:ok, required_string_field} =
Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "required_string",
value_type: :string,
required: true
})
|> Ash.create()
{:ok, required_integer_field} =
Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "required_integer",
value_type: :integer,
required: true
})
|> Ash.create()
{:ok, required_boolean_field} =
Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "required_boolean",
value_type: :boolean,
required: true
})
|> Ash.create()
{:ok, required_date_field} =
Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "required_date",
value_type: :date,
required: true
})
|> Ash.create()
{:ok, required_email_field} =
Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "required_email",
value_type: :email,
required: true
})
|> Ash.create()
{:ok, optional_field} =
Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "optional_string",
value_type: :string,
required: false
})
|> Ash.create()
%{
required_string_field: required_string_field,
required_integer_field: required_integer_field,
required_boolean_field: required_boolean_field,
required_date_field: required_date_field,
required_email_field: required_email_field,
optional_field: optional_field
}
end
# Helper function to create all required custom fields with valid default values
defp all_required_custom_fields_with_defaults(%{
required_string_field: string_field,
required_integer_field: integer_field,
required_boolean_field: boolean_field,
required_date_field: date_field,
required_email_field: email_field
}) do
[
%{
"custom_field_id" => string_field.id,
"value" => %{"_union_type" => "string", "_union_value" => "default"}
},
%{
"custom_field_id" => integer_field.id,
"value" => %{"_union_type" => "integer", "_union_value" => 0}
},
%{
"custom_field_id" => boolean_field.id,
"value" => %{"_union_type" => "boolean", "_union_value" => false}
},
%{
"custom_field_id" => date_field.id,
"value" => %{"_union_type" => "date", "_union_value" => ~D[2020-01-01]}
},
%{
"custom_field_id" => email_field.id,
"value" => %{"_union_type" => "email", "_union_value" => "test@example.com"}
}
]
end
describe "create_member with required custom fields" do
@valid_attrs %{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
test "fails when required custom field is missing", %{required_string_field: field} do
attrs = Map.put(@valid_attrs, :custom_field_values, [])
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required string custom field has nil value",
%{
required_string_field: field
} = context do
# Start with all required fields having valid values
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => nil}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required string custom field has empty string value",
%{
required_string_field: field
} = context do
# Start with all required fields having valid values
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => ""}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required string custom field has whitespace-only value",
%{
required_string_field: field
} = context do
# Start with all required fields having valid values
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => " "}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required string custom field has valid value",
%{
required_string_field: field
} = context do
# Start with all required fields having valid values, then update the string field
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test value"}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "fails when required integer custom field has nil value",
%{
required_integer_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "integer", "_union_value" => nil}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required integer custom field has empty string value",
%{
required_integer_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "integer", "_union_value" => ""}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required integer custom field has zero value",
%{
required_integer_field: _field
} = context do
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "succeeds when required integer custom field has positive value",
%{
required_integer_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "integer", "_union_value" => 42}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "fails when required boolean custom field has nil value",
%{
required_boolean_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => nil}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required boolean custom field has false value",
%{
required_boolean_field: _field
} = context do
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "succeeds when required boolean custom field has true value",
%{
required_boolean_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => true}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "fails when required date custom field has nil value",
%{
required_date_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "date", "_union_value" => nil}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required date custom field has empty string value",
%{
required_date_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "date", "_union_value" => ""}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required date custom field has valid date value",
%{
required_date_field: _field
} = context do
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "fails when required email custom field has nil value",
%{
required_email_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "email", "_union_value" => nil}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required email custom field has empty string value",
%{
required_email_field: field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "email", "_union_value" => ""}}
else
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required email custom field has valid email value",
%{
required_email_field: _field
} = context do
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "succeeds when multiple required custom fields are provided",
%{
required_string_field: string_field,
required_integer_field: integer_field,
required_boolean_field: boolean_field
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
cond do
cfv["custom_field_id"] == string_field.id ->
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test"}}
cfv["custom_field_id"] == integer_field.id ->
%{cfv | "value" => %{"_union_type" => "integer", "_union_value" => 42}}
cfv["custom_field_id"] == boolean_field.id ->
%{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => true}}
true ->
cfv
end
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "fails when one of multiple required custom fields is missing",
%{
required_string_field: string_field,
required_integer_field: integer_field
} = context do
# Provide only string field, missing integer, boolean, and date
custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.filter(fn cfv ->
cfv["custom_field_id"] == string_field.id
end)
|> Enum.map(fn cfv ->
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test"}}
end)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ integer_field.name
end
test "succeeds when optional custom field is missing", %{} = context do
# Provide all required fields, but no optional field
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
test "succeeds when optional custom field has nil value",
%{optional_field: field} = context do
# Provide all required fields plus optional field with nil
custom_field_values =
all_required_custom_fields_with_defaults(context) ++
[
%{
"custom_field_id" => field.id,
"value" => %{"_union_type" => "string", "_union_value" => nil}
}
]
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
end
end
describe "update_member with required custom fields" do
test "fails when removing a required custom field value",
%{
required_string_field: field
} = context do
# Create member with all required custom fields
custom_field_values = all_required_custom_fields_with_defaults(context)
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john@example.com",
custom_field_values: custom_field_values
})
# Try to update without the required custom field
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.update_member(member, %{custom_field_values: []})
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when setting required custom field value to empty",
%{
required_string_field: field
} = context do
# Create member with all required custom fields
custom_field_values = all_required_custom_fields_with_defaults(context)
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john@example.com",
custom_field_values: custom_field_values
})
# Try to update with empty value for the string field
updated_custom_field_values =
all_required_custom_fields_with_defaults(context)
|> Enum.map(fn cfv ->
if cfv["custom_field_id"] == field.id do
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => ""}}
else
cfv
end
end)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.update_member(member, %{
custom_field_values: updated_custom_field_values
})
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when updating required custom field to valid value",
%{
required_string_field: field
} = context do
# Create member with all required custom fields
custom_field_values = all_required_custom_fields_with_defaults(context)
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john@example.com",
custom_field_values: custom_field_values
})
# Load existing custom field values to get their IDs
{:ok, member_with_cfvs} = Ash.load(member, :custom_field_values)
# Update with new valid value for the string field, using existing IDs
updated_custom_field_values =
member_with_cfvs.custom_field_values
|> Enum.map(fn cfv ->
if cfv.custom_field_id == field.id do
%{
"id" => cfv.id,
"custom_field_id" => cfv.custom_field_id,
"value" => %{"_union_type" => "string", "_union_value" => "new value"}
}
else
# Keep other fields as they are
value_type = Atom.to_string(cfv.value.type)
actual_value = cfv.value.value
%{
"id" => cfv.id,
"custom_field_id" => cfv.custom_field_id,
"value" => %{"_union_type" => value_type, "_union_value" => actual_value}
}
end
end)
assert {:ok, _updated_member} =
Membership.update_member(member, %{
custom_field_values: updated_custom_field_values
})
end
end
# Helper function for error evaluation
defp error_message(errors, field) do
errors
|> Enum.filter(fn err -> Map.get(err, :field) == field end)
|> Enum.map_join(" ", &Map.get(&1, :message, ""))
end
end

View file

@ -0,0 +1,702 @@
defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
@moduledoc """
Tests for full-text search including custom_field_values.
Tests verify that custom field values are included in the search_vector
and can be found through the fuzzy_search functionality.
"""
use Mv.DataCase, async: false
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
# Create test members
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
{:ok, member3} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Charlie",
last_name: "Clark",
email: "charlie@example.com"
})
|> Ash.create()
# Create custom fields for different types
{:ok, string_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "membership_number",
value_type: :string
})
|> Ash.create()
{:ok, integer_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "member_id_number",
value_type: :integer
})
|> Ash.create()
{:ok, email_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "secondary_email",
value_type: :email
})
|> Ash.create()
{:ok, date_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "birthday",
value_type: :date
})
|> Ash.create()
{:ok, boolean_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "newsletter",
value_type: :boolean
})
|> Ash.create()
%{
member1: member1,
member2: member2,
member3: member3,
string_field: string_field,
integer_field: integer_field,
email_field: email_field,
date_field: date_field,
boolean_field: boolean_field
}
end
describe "search with custom field values" do
test "finds member by string custom field value", %{
member1: member1,
string_field: string_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "MEMBER12345"}
})
|> Ash.create()
# Force search_vector update by reloading member
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for the custom field value
results =
Member
|> Member.fuzzy_search(%{query: "MEMBER12345"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "finds member by integer custom field value", %{
member1: member1,
integer_field: integer_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => 42_424}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for the custom field value
results =
Member
|> Member.fuzzy_search(%{query: "42424"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "finds member by email custom field value", %{
member1: member1,
email_field: email_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => "alice.secondary@example.com"}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for partial custom field value (should work via FTS or custom field filter)
results =
Member
|> Member.fuzzy_search(%{query: "alice.secondary"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
# Search for full email address (should work via custom field filter LIKE)
results_full =
Member
|> Member.fuzzy_search(%{query: "alice.secondary@example.com"})
|> Ash.read!()
assert length(results_full) == 1
assert List.first(results_full).id == member1.id
# Search for domain part (should work via FTS or custom field filter)
# Note: May return multiple results if other members have same domain
results_domain =
Member
|> Member.fuzzy_search(%{query: "example.com"})
|> Ash.read!()
# Verify that member1 is in the results (may have other members too)
ids = Enum.map(results_domain, & &1.id)
assert member1.id in ids
end
test "finds member by date custom field value", %{
member1: member1,
date_field: date_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: date_field.id,
value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for the custom field value (date is stored as text in search_vector)
results =
Member
|> Member.fuzzy_search(%{query: "1990-05-15"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "finds member by boolean custom field value", %{
member1: member1,
boolean_field: boolean_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: boolean_field.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for the custom field value (boolean is stored as "true" or "false" text)
results =
Member
|> Member.fuzzy_search(%{query: "true"})
|> Ash.read!()
# Note: "true" might match other things, so we check that member1 is in results
assert Enum.any?(results, fn m -> m.id == member1.id end)
end
test "custom field value update triggers search_vector update", %{
member1: member1,
string_field: string_field
} do
# Create initial custom field value
{:ok, cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "OLDVALUE"}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Update custom field value
{:ok, _updated_cfv} =
cfv
|> Ash.Changeset.for_update(:update, %{
value: %{"_union_type" => "string", "_union_value" => "NEWVALUE123"}
})
|> Ash.update()
# Search for the new value
results =
Member
|> Member.fuzzy_search(%{query: "NEWVALUE123"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
# Old value should not be found
old_results =
Member
|> Member.fuzzy_search(%{query: "OLDVALUE"})
|> Ash.read!()
refute Enum.any?(old_results, fn m -> m.id == member1.id end)
end
test "custom field value delete triggers search_vector update", %{
member1: member1,
string_field: string_field
} do
# Create custom field value
{:ok, cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "TOBEDELETED"}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Verify it's searchable
results =
Member
|> Member.fuzzy_search(%{query: "TOBEDELETED"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
# Delete custom field value
assert :ok = Ash.destroy(cfv)
# Value should no longer be found
deleted_results =
Member
|> Member.fuzzy_search(%{query: "TOBEDELETED"})
|> Ash.read!()
refute Enum.any?(deleted_results, fn m -> m.id == member1.id end)
end
test "custom field value create triggers search_vector update", %{
member1: member1,
string_field: string_field
} do
# Create custom field value (trigger should update search_vector automatically)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "AUTOUPDATE"}
})
|> Ash.create()
# Search should find it immediately (trigger should have updated search_vector)
results =
Member
|> Member.fuzzy_search(%{query: "AUTOUPDATE"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "member update includes custom field values in search_vector", %{
member1: member1,
string_field: string_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "MEMBERUPDATE"}
})
|> Ash.create()
# Update member (should trigger search_vector update including custom fields)
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{notes: "Updated notes"})
|> Ash.update()
# Search should find the custom field value
results =
Member
|> Member.fuzzy_search(%{query: "MEMBERUPDATE"})
|> Ash.read!()
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "multiple custom field values are all searchable", %{
member1: member1,
string_field: string_field,
integer_field: integer_field,
email_field: email_field
} do
# Create multiple custom field values
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "MULTI1"}
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => 99_999}
})
|> Ash.create()
{:ok, _cfv3} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => "multi@test.com"}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# All values should be searchable
results1 =
Member
|> Member.fuzzy_search(%{query: "MULTI1"})
|> Ash.read!()
assert Enum.any?(results1, fn m -> m.id == member1.id end)
results2 =
Member
|> Member.fuzzy_search(%{query: "99999"})
|> Ash.read!()
assert Enum.any?(results2, fn m -> m.id == member1.id end)
results3 =
Member
|> Member.fuzzy_search(%{query: "multi@test.com"})
|> Ash.read!()
assert Enum.any?(results3, fn m -> m.id == member1.id end)
end
test "finds member by custom field value with numbers in text field (e.g. phone number)", %{
member1: member1,
string_field: string_field
} do
# Create custom field value with numbers and text (like phone number or ID)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "M-123-456"}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for full value (should work via search_vector)
results_full =
Member
|> Member.fuzzy_search(%{query: "M-123-456"})
|> Ash.read!()
assert Enum.any?(results_full, fn m -> m.id == member1.id end),
"Full value search should find member via search_vector"
# Note: Partial substring search may require additional implementation
# For now, we test that the full value is searchable, which is the primary use case
# Substring matching for custom fields may need to be implemented separately
end
test "finds member by phone number in Emergency Contact custom field", %{
member1: member1
} do
# Create Emergency Contact custom field
{:ok, emergency_contact_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Emergency Contact",
value_type: :string
})
|> Ash.create()
# Create custom field value with phone number
phone_number = "+49 123 456789"
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: emergency_contact_field.id,
value: %{"_union_type" => "string", "_union_value" => phone_number}
})
|> Ash.create()
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
# Search for full phone number (should work via search_vector)
results_full =
Member
|> Member.fuzzy_search(%{query: phone_number})
|> Ash.read!()
assert Enum.any?(results_full, fn m -> m.id == member1.id end),
"Full phone number search should find member via search_vector"
# Note: Partial substring search may require additional implementation
# For now, we test that the full phone number is searchable, which is the primary use case
# Substring matching for custom fields may need to be implemented separately
end
end
describe "custom field substring search (ILIKE)" do
test "finds member by prefix of custom field value", %{
member1: member1,
string_field: string_field
} do
# Create custom field value with a distinct word
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "Premium"}
})
|> Ash.create()
# Test prefix searches - should all find the member
for prefix <- ["Premium", "Premiu", "Premi", "Prem", "Pre"] do
results =
Member
|> Member.fuzzy_search(%{query: prefix})
|> Ash.read!()
assert Enum.any?(results, fn m -> m.id == member1.id end),
"Prefix '#{prefix}' should find member with custom field 'Premium'"
end
end
test "custom field search is case-insensitive", %{
member1: member1,
string_field: string_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "GoldMember"}
})
|> Ash.create()
# Test case variations - should all find the member
for variant <- [
"GoldMember",
"goldmember",
"GOLDMEMBER",
"GoLdMeMbEr",
"gold",
"GOLD",
"Gold"
] do
results =
Member
|> Member.fuzzy_search(%{query: variant})
|> Ash.read!()
assert Enum.any?(results, fn m -> m.id == member1.id end),
"Case variant '#{variant}' should find member with custom field 'GoldMember'"
end
end
test "finds member by suffix/middle of custom field value", %{
member1: member1,
string_field: string_field
} do
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "ActiveMember"}
})
|> Ash.create()
# Test suffix and middle substring searches
for substring <- ["Member", "ember", "tiveMem", "ctive"] do
results =
Member
|> Member.fuzzy_search(%{query: substring})
|> Ash.read!()
assert Enum.any?(results, fn m -> m.id == member1.id end),
"Substring '#{substring}' should find member with custom field 'ActiveMember'"
end
end
test "finds correct member among multiple with different custom field values", %{
member1: member1,
member2: member2,
member3: member3,
string_field: string_field
} do
# Create different custom field values for each member
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "Beginner"}
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member2.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "Advanced"}
})
|> Ash.create()
{:ok, _cfv3} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member3.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "Expert"}
})
|> Ash.create()
# Search for "Begin" - should only find member1
results_begin =
Member
|> Member.fuzzy_search(%{query: "Begin"})
|> Ash.read!()
assert length(results_begin) == 1
assert List.first(results_begin).id == member1.id
# Search for "Advan" - should only find member2
results_advan =
Member
|> Member.fuzzy_search(%{query: "Advan"})
|> Ash.read!()
assert length(results_advan) == 1
assert List.first(results_advan).id == member2.id
# Search for "Exper" - should only find member3
results_exper =
Member
|> Member.fuzzy_search(%{query: "Exper"})
|> Ash.read!()
assert length(results_exper) == 1
assert List.first(results_exper).id == member3.id
end
# Note: Legacy format (type/value) is supported via the SQL ILIKE query on value->>'value'
# This is tested implicitly by the migration trigger which handles both formats.
# The Ash union type only accepts the new format (_union_type/_union_value) for creation,
# but the search works on existing legacy data.
end
end

View file

@ -6,7 +6,6 @@ defmodule Mv.Membership.MemberTest do
@valid_attrs %{ @valid_attrs %{
first_name: "John", first_name: "John",
last_name: "Doe", last_name: "Doe",
paid: true,
email: "john@example.com", email: "john@example.com",
phone_number: "+49123456789", phone_number: "+49123456789",
join_date: ~D[2020-01-01], join_date: ~D[2020-01-01],
@ -42,14 +41,6 @@ defmodule Mv.Membership.MemberTest do
assert error_message(errors, :email) =~ "is not a valid email" assert error_message(errors, :email) =~ "is not a valid email"
end end
test "Paid is optional but must be boolean if specified" do
attrs = Map.put(@valid_attrs, :paid, nil)
attrs2 = Map.put(@valid_attrs, :paid, "yes")
assert {:ok, _member} = Membership.create_member(Map.delete(attrs, :paid))
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs2)
assert error_message(errors, :paid) =~ "is invalid"
end
test "Phone number is optional but must have a valid format if specified" do test "Phone number is optional but must have a valid format if specified" do
attrs = Map.put(@valid_attrs, :phone_number, "abc") attrs = Map.put(@valid_attrs, :phone_number, "abc")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
@ -58,12 +49,12 @@ defmodule Mv.Membership.MemberTest do
assert {:ok, _member} = Membership.create_member(attrs2) assert {:ok, _member} = Membership.create_member(attrs2)
end end
test "Join date is optional but must not be in the future" do test "Join date cannot be in the future" do
attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1)) attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1))
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :join_date) =~ "cannot be in the future" assert {:error,
attrs2 = Map.delete(@valid_attrs, :join_date) %Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidAttribute{field: :join_date}]}} =
assert {:ok, _member} = Membership.create_member(attrs2) Membership.create_member(attrs)
end end
test "Exit date is optional but must not be before join date if both are specified" do test "Exit date is optional but must not be before join date if both are specified" do

View file

@ -0,0 +1,453 @@
defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
@moduledoc """
Integration tests for membership fee type changes and cycle regeneration.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CalendarCycles
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
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-01-15]
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "type change cycle regeneration" do
test "future unpaid cycles are regenerated with new amount" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Create cycles: one in the past (paid), one current (unpaid)
# Note: Future cycles are not automatically generated by CycleGenerator,
# so we only test with current cycle
past_cycle_start = CalendarCycles.calculate_cycle_start(~D[2023-01-01], :yearly)
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Past cycle (paid) - should remain unchanged
# Check if it already exists (from auto-generation), if not create it
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
# Update to paid
existing_cycle
|> Ash.Changeset.for_update(:update, %{status: :paid})
|> Ash.update!()
_ ->
create_cycle(member, yearly_type1, %{
cycle_start: past_cycle_start,
status: :paid,
amount: Decimal.new("100.00")
})
end
# Current cycle (unpaid) - should be regenerated
# Delete if exists (from auto-generation), then create with old amount
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
Ash.destroy!(existing_cycle)
_ ->
:ok
end
_current_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
# Change membership fee type (same interval, different amount)
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify past cycle is unchanged
past_cycle_after =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one!()
assert past_cycle_after.status == :paid
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
# Verify current cycle was deleted and regenerated
# Check that cycle with new type exists (regenerated)
new_current_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
# Verify it has the new type and amount
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
assert new_current_cycle.status == :unpaid
# Verify old cycle with old type doesn't exist anymore
old_current_cycles =
MembershipFeeCycle
|> Ash.Query.filter(
member_id == ^member.id and cycle_start == ^current_cycle_start and
membership_fee_type_id == ^yearly_type1.id
)
|> Ash.read!()
assert Enum.empty?(old_current_cycles)
end
test "paid cycles remain unchanged" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Get the current cycle and mark it as paid
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Find current cycle and mark as paid
paid_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
|> Ash.Changeset.for_update(:mark_as_paid)
|> Ash.update!()
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify paid cycle is unchanged (not deleted and regenerated)
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, paid_cycle.id)
assert cycle_after.status == :paid
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
assert cycle_after.membership_fee_type_id == yearly_type1.id
end
test "suspended cycles remain unchanged" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Get the current cycle and mark it as suspended
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Find current cycle and mark as suspended
suspended_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
|> Ash.Changeset.for_update(:mark_as_suspended)
|> Ash.update!()
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify suspended cycle is unchanged (not deleted and regenerated)
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, suspended_cycle.id)
assert cycle_after.status == :suspended
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
assert cycle_after.membership_fee_type_id == yearly_type1.id
end
test "only cycles that haven't ended yet are deleted" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Create cycles: one in the past (unpaid, ended), one current (unpaid, not ended)
past_cycle_start =
CalendarCycles.calculate_cycle_start(
Date.add(today, -365),
:yearly
)
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Past cycle (unpaid) - should remain unchanged (cycle_start < today)
# Delete existing cycle if it exists (from auto-generation)
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
Ash.destroy!(existing_cycle)
_ ->
:ok
end
past_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: past_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
# Current cycle (unpaid) - should be regenerated (cycle_start >= today)
# Delete existing cycle if it exists (from auto-generation)
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
Ash.destroy!(existing_cycle)
_ ->
:ok
end
_current_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify past cycle is unchanged
{:ok, past_cycle_after} = Ash.get(MembershipFeeCycle, past_cycle.id)
assert past_cycle_after.status == :unpaid
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
# Verify current cycle was regenerated
# Check that cycle with new type exists
new_current_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
# Verify old cycle with old type doesn't exist anymore
old_current_cycles =
MembershipFeeCycle
|> Ash.Query.filter(
member_id == ^member.id and cycle_start == ^current_cycle_start and
membership_fee_type_id == ^yearly_type1.id
)
|> Ash.read!()
assert Enum.empty?(old_current_cycles)
end
test "member calculations update after type change" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member with join_date = today to avoid past cycles
# This ensures no overdue cycles exist
member = create_member(%{join_date: today})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Get current cycle start
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Ensure only one cycle exists (the current one)
# Delete all cycles except the current one
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(existing_cycles, fn cycle ->
if cycle.cycle_start != current_cycle_start do
Ash.destroy!(cycle)
end
end)
# Ensure current cycle exists and is unpaid
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
# Update to unpaid if it's not
if existing_cycle.status != :unpaid do
existing_cycle
|> Ash.Changeset.for_update(:mark_as_unpaid)
|> Ash.update!()
end
_ ->
# Create if it doesn't exist
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
end
# Load calculations before change
member = Ash.load!(member, [:current_cycle_status, :overdue_count])
assert member.current_cycle_status == :unpaid
assert member.overdue_count == 0
# Change membership fee type
assert {:ok, updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Reload member with calculations
updated_member = Ash.load!(updated_member, [:current_cycle_status, :overdue_count])
# Calculations should still work (cycle was regenerated)
assert updated_member.current_cycle_status == :unpaid
assert updated_member.overdue_count == 0
end
end
end

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,227 @@
defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
@moduledoc """
Tests for ValidateSameInterval change module.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.Changes.ValidateSameInterval
# 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
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
describe "validate_interval_match/1" do
test "allows change to type with same interval" do
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"})
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"})
member = create_member(%{membership_fee_type_id: yearly_type1.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> ValidateSameInterval.change(%{}, %{})
assert changeset.valid?
end
test "prevents change to type with different interval" do
yearly_type = create_fee_type(%{interval: :yearly})
monthly_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: monthly_type.id
})
|> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?
assert %{errors: errors} = changeset
assert Enum.any?(errors, fn error ->
error.field == :membership_fee_type_id and
error.message =~ "yearly" and
error.message =~ "monthly"
end)
end
test "allows first assignment of membership fee type" do
yearly_type = create_fee_type(%{interval: :yearly})
# No fee type assigned
member = create_member(%{})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type.id
})
|> ValidateSameInterval.change(%{}, %{})
assert changeset.valid?
end
test "prevents removal of membership fee type" do
yearly_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: nil
})
|> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?
assert %{errors: errors} = changeset
assert Enum.any?(errors, fn error ->
error.field == :membership_fee_type_id and
error.message =~ "Cannot remove membership fee type"
end)
end
test "does nothing when membership_fee_type_id is not changed" do
yearly_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
first_name: "New Name"
})
|> ValidateSameInterval.change(%{}, %{})
assert changeset.valid?
end
test "error message is clear and helpful" do
yearly_type = create_fee_type(%{interval: :yearly})
quarterly_type = create_fee_type(%{interval: :quarterly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: quarterly_type.id
})
|> ValidateSameInterval.change(%{}, %{})
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
assert error.message =~ "yearly"
assert error.message =~ "quarterly"
assert error.message =~ "same-interval"
end
test "handles all interval types correctly" do
intervals = [:monthly, :quarterly, :half_yearly, :yearly]
for interval1 <- intervals,
interval2 <- intervals,
interval1 != interval2 do
type1 =
create_fee_type(%{
interval: interval1,
name: "Type #{interval1} #{System.unique_integer([:positive])}"
})
type2 =
create_fee_type(%{
interval: interval2,
name: "Type #{interval2} #{System.unique_integer([:positive])}"
})
member = create_member(%{membership_fee_type_id: type1.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: type2.id
})
|> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?,
"Should prevent change from #{interval1} to #{interval2}"
end
end
end
describe "integration with update_member action" do
test "validation works when updating member via update_member action" do
yearly_type = create_fee_type(%{interval: :yearly})
monthly_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
# Try to update member with different interval type
assert {:error, %Ash.Error.Invalid{} = error} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: monthly_type.id
})
|> Ash.update()
# Check that error is about interval mismatch
error_message = extract_error_message(error)
assert error_message =~ "yearly"
assert error_message =~ "monthly"
assert error_message =~ "same-interval"
end
test "allows update when interval matches" do
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"})
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"})
member = create_member(%{membership_fee_type_id: yearly_type1.id})
# Update member with same-interval type
assert {:ok, updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
assert updated_member.membership_fee_type_id == yearly_type2.id
end
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) do
errors
|> Enum.filter(&(&1.field == :membership_fee_type_id))
|> Enum.map_join(" ", & &1.message)
end
end
end

View file

@ -0,0 +1,220 @@
defmodule Mv.MembershipFees.ForeignKeyTest do
@moduledoc """
Tests for foreign key behaviors (CASCADE and RESTRICT).
"""
use Mv.DataCase, async: true
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
describe "CASCADE behavior" do
test "deleting member deletes associated membership_fee_cycles" do
# Create member
{:ok, member} =
Ash.create(Member, %{
first_name: "Cascade",
last_name: "Test",
email: "cascade.test.#{System.unique_integer([:positive])}@example.com"
})
# Create fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Cascade Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
# Create multiple cycles for this member
{:ok, cycle1} =
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
})
{:ok, cycle2} =
Ash.create(MembershipFeeCycle, %{
cycle_start: ~D[2025-02-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
# Verify cycles exist
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle1.id)
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle2.id)
# Delete member
assert :ok = Ash.destroy(member)
# Verify cycles are also deleted (CASCADE)
# NotFound is wrapped in Ash.Error.Invalid
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle1.id)
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle2.id)
end
end
describe "RESTRICT behavior" do
test "cannot delete membership_fee_type if cycles reference it" do
# Create member
{:ok, member} =
Ash.create(Member, %{
first_name: "Restrict",
last_name: "Test",
email: "restrict.test.#{System.unique_integer([:positive])}@example.com"
})
# Create fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Restrict Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
# Create a cycle referencing 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
})
# Try to delete fee type - should fail due to RESTRICT
assert {:error, error} = Ash.destroy(fee_type)
# Check that it's a foreign key violation error
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
end
test "can delete membership_fee_type if no cycles reference it" do
# Create fee type without any cycles
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Deletable Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
# Should be able to delete
assert :ok = Ash.destroy(fee_type)
# Verify it's gone (NotFound is wrapped in Ash.Error.Invalid)
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeType, fee_type.id)
end
test "cannot delete membership_fee_type if members reference it" do
# Create fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Member Ref Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
# Create member with this fee type
{:ok, _member} =
Ash.create(Member, %{
first_name: "FeeType",
last_name: "Reference",
email: "feetype.ref.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
# Try to delete fee type - should fail due to RESTRICT
assert {:error, error} = Ash.destroy(fee_type)
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
end
end
describe "member extensions" do
test "member can be created with membership_fee_type_id" do
# Create fee type first
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Create Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
})
# Create member with fee type
{:ok, member} =
Ash.create(Member, %{
first_name: "With",
last_name: "FeeType",
email: "with.feetype.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
assert member.membership_fee_type_id == fee_type.id
end
test "member can be created with membership_fee_start_date" do
{:ok, member} =
Ash.create(Member, %{
first_name: "With",
last_name: "StartDate",
email: "with.startdate.#{System.unique_integer([:positive])}@example.com",
membership_fee_start_date: ~D[2025-01-01]
})
assert member.membership_fee_start_date == ~D[2025-01-01]
end
test "member can be created without membership fee fields" do
{:ok, member} =
Ash.create(Member, %{
first_name: "No",
last_name: "FeeFields",
email: "no.feefields.#{System.unique_integer([:positive])}@example.com"
})
assert member.membership_fee_type_id == nil
assert member.membership_fee_start_date == nil
end
test "member can be updated with membership_fee_type_id" do
# Create fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Update Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
})
# Create member without fee type
{:ok, member} =
Ash.create(Member, %{
first_name: "Update",
last_name: "Test",
email: "update.test.#{System.unique_integer([:positive])}@example.com"
})
assert member.membership_fee_type_id == nil
# Update member with fee type
{:ok, updated_member} = Ash.update(member, %{membership_fee_type_id: fee_type.id})
assert updated_member.membership_fee_type_id == fee_type.id
end
test "member can be updated with membership_fee_start_date" do
{:ok, member} =
Ash.create(Member, %{
first_name: "Start",
last_name: "Date",
email: "start.date.#{System.unique_integer([:positive])}@example.com"
})
assert member.membership_fee_start_date == nil
{:ok, updated_member} = Ash.update(member, %{membership_fee_start_date: ~D[2025-06-01]})
assert updated_member.membership_fee_start_date == ~D[2025-06-01]
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,207 @@
defmodule Mv.MembershipFees.MembershipFeeCycleTest do
@moduledoc """
Tests for MembershipFeeCycle resource, focusing on status management actions.
"""
use Mv.DataCase, async: true
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
# 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
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{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 cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "status defaults" do
test "status defaults to :unpaid when creating a cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
assert cycle.status == :unpaid
end
end
describe "mark_as_paid" do
test "sets status to :paid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid)
assert updated.status == :paid
end
test "can set notes when marking as paid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Payment received via bank transfer"},
action: :mark_as_paid
)
assert updated.status == :paid
assert updated.notes == "Payment received via bank transfer"
end
test "can change from suspended to paid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :suspended})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid)
assert updated.status == :paid
end
end
describe "mark_as_suspended" do
test "sets status to :suspended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_suspended)
assert updated.status == :suspended
end
test "can set notes when marking as suspended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Waived due to special circumstances"},
action: :mark_as_suspended
)
assert updated.status == :suspended
assert updated.notes == "Waived due to special circumstances"
end
test "can change from paid to suspended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_suspended)
assert updated.status == :suspended
end
end
describe "mark_as_unpaid" do
test "sets status to :unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert updated.status == :unpaid
end
test "can set notes when marking as unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Payment was reversed"}, action: :mark_as_unpaid)
assert updated.status == :unpaid
assert updated.notes == "Payment was reversed"
end
test "can change from suspended to unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :suspended})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert updated.status == :unpaid
end
end
describe "status transitions" do
test "all status transitions are allowed" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# unpaid -> paid
cycle1 = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, c1} = Ash.update(cycle1, %{}, action: :mark_as_paid)
assert c1.status == :paid
# paid -> suspended
assert {:ok, c2} = Ash.update(c1, %{}, action: :mark_as_suspended)
assert c2.status == :suspended
# suspended -> unpaid
assert {:ok, c3} = Ash.update(c2, %{}, action: :mark_as_unpaid)
assert c3.status == :unpaid
# unpaid -> suspended
assert {:ok, c4} = Ash.update(c3, %{}, action: :mark_as_suspended)
assert c4.status == :suspended
# suspended -> paid
assert {:ok, c5} = Ash.update(c4, %{}, action: :mark_as_paid)
assert c5.status == :paid
# paid -> unpaid
assert {:ok, c6} = Ash.update(c5, %{}, action: :mark_as_unpaid)
assert c6.status == :unpaid
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

Some files were not shown because too many files have changed in this diff Show more