Merge branch 'main' into feature/223_memberfields_settings
This commit is contained in:
commit
909d4af2a2
121 changed files with 20360 additions and 2522 deletions
|
|
@ -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
1
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
318
code_review_fixes_-_membership_fee_features_e886dc4b.plan.md
Normal file
318
code_review_fixes_-_membership_fee_features_e886dc4b.plan.md
Normal 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
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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**
|
|
||||||
|
|
||||||
666
docs/csv-member-import-v1.md
Normal file
666
docs/csv-member-import-v1.md
Normal 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**
|
||||||
243
docs/custom-fields-search-performance.md
Normal file
243
docs/custom-fields-search-performance.md
Normal 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)
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
723
docs/membership-fee-architecture.md
Normal file
723
docs/membership-fee-architecture.md
Normal 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**
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
137
docs/test-status-membership-fee-ui.md
Normal file
137
docs/test-status-membership-fee-ui.md
Normal 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
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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, ¤t_cycle?(&1, today))
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Checks if a cycle is the current cycle (active today)
|
||||||
|
defp current_cycle?(cycle, today) do
|
||||||
|
case Map.get(cycle, :membership_fee_type) do
|
||||||
|
%{interval: interval} ->
|
||||||
|
cycle_end =
|
||||||
|
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||||
|
|
||||||
|
Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
|
||||||
|
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
|
||||||
|
def get_last_completed_cycle(member) do
|
||||||
|
today = Date.utc_today()
|
||||||
|
|
||||||
|
# Check if cycles are already loaded
|
||||||
|
cycles = Map.get(member, :membership_fee_cycles)
|
||||||
|
|
||||||
|
if is_list(cycles) and cycles != [] do
|
||||||
|
cycles
|
||||||
|
|> filter_completed_cycles(today)
|
||||||
|
|> sort_cycles_by_end_date()
|
||||||
|
|> List.first()
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filters cycles that have ended (cycle_end < today)
|
||||||
|
defp filter_completed_cycles(cycles, today) do
|
||||||
|
Enum.filter(cycles, fn cycle ->
|
||||||
|
case Map.get(cycle, :membership_fee_type) do
|
||||||
|
%{interval: interval} ->
|
||||||
|
cycle_end =
|
||||||
|
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||||
|
|
||||||
|
Date.compare(today, cycle_end) == :gt
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sorts cycles by end date in descending order
|
||||||
|
defp sort_cycles_by_end_date(cycles) do
|
||||||
|
Enum.sort_by(
|
||||||
|
cycles,
|
||||||
|
fn cycle ->
|
||||||
|
interval = Map.get(cycle, :membership_fee_type).interval
|
||||||
|
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||||
|
end,
|
||||||
|
{:desc, Date}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
|
||||||
|
def get_overdue_cycles(member) do
|
||||||
|
today = Date.utc_today()
|
||||||
|
|
||||||
|
# Check if cycles are already loaded
|
||||||
|
cycles = Map.get(member, :membership_fee_cycles)
|
||||||
|
|
||||||
|
if is_list(cycles) and cycles != [] do
|
||||||
|
filter_overdue_cycles(cycles, today)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filters cycles that are unpaid and have ended (cycle_end < today)
|
||||||
|
defp filter_overdue_cycles(cycles, today) do
|
||||||
|
Enum.filter(cycles, fn cycle ->
|
||||||
|
case Map.get(cycle, :membership_fee_type) do
|
||||||
|
%{interval: interval} ->
|
||||||
|
cycle_end =
|
||||||
|
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||||
|
|
||||||
|
cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Regenerates cycles when membership fee type changes
|
||||||
|
# Deletes future unpaid cycles and regenerates them with the new type/amount
|
||||||
|
# Uses advisory lock to prevent concurrent modifications
|
||||||
|
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
|
||||||
|
# to be sent after transaction commits
|
||||||
|
@doc false
|
||||||
|
def regenerate_cycles_on_type_change(member) do
|
||||||
|
today = Date.utc_today()
|
||||||
|
lock_key = :erlang.phash2(member.id)
|
||||||
|
|
||||||
|
# Use advisory lock to prevent concurrent deletion and regeneration
|
||||||
|
# This ensures atomicity when multiple updates happen simultaneously
|
||||||
|
if Mv.Repo.in_transaction?() do
|
||||||
|
regenerate_cycles_in_transaction(member, today, lock_key)
|
||||||
|
else
|
||||||
|
regenerate_cycles_new_transaction(member, today, lock_key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Already in transaction: use advisory lock directly
|
||||||
|
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||||
|
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
||||||
|
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
|
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Not in transaction: start new transaction with advisory lock
|
||||||
|
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
||||||
|
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
||||||
|
Mv.Repo.transaction(fn ->
|
||||||
|
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
|
|
||||||
|
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
||||||
|
{:ok, notifications} ->
|
||||||
|
# Return notifications - they will be sent by the caller
|
||||||
|
notifications
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Mv.Repo.rollback(reason)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
{:ok, notifications} -> {:ok, notifications}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Performs the actual cycle deletion and regeneration
|
||||||
|
# Returns {:ok, notifications} or {:error, reason}
|
||||||
|
# notifications are collected to be sent after transaction commits
|
||||||
|
defp do_regenerate_cycles_on_type_change(member, today, opts) do
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
|
|
||||||
|
# Find all unpaid cycles for this member
|
||||||
|
# We need to check cycle_end for each cycle using its own interval
|
||||||
|
all_unpaid_cycles_query =
|
||||||
|
Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
|> Ash.Query.filter(member_id == ^member.id)
|
||||||
|
|> Ash.Query.filter(status == :unpaid)
|
||||||
|
|> Ash.Query.load([:membership_fee_type])
|
||||||
|
|
||||||
|
case Ash.read(all_unpaid_cycles_query) do
|
||||||
|
{:ok, all_unpaid_cycles} ->
|
||||||
|
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
|
||||||
|
delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filters cycles that haven't ended yet (cycle_end >= today)
|
||||||
|
# These are the "future" cycles that should be regenerated
|
||||||
|
defp filter_future_cycles(all_unpaid_cycles, today) do
|
||||||
|
Enum.filter(all_unpaid_cycles, fn cycle ->
|
||||||
|
case cycle.membership_fee_type do
|
||||||
|
%{interval: interval} ->
|
||||||
|
cycle_end =
|
||||||
|
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||||
|
|
||||||
|
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Deletes future cycles and regenerates them with the new type/amount
|
||||||
|
# Passes today to ensure consistent date across deletion and regeneration
|
||||||
|
# Returns {:ok, notifications} or {:error, reason}
|
||||||
|
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
|
||||||
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
|
|
||||||
|
if Enum.empty?(cycles_to_delete) do
|
||||||
|
# No cycles to delete, just regenerate
|
||||||
|
regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
||||||
|
else
|
||||||
|
case delete_cycles(cycles_to_delete) do
|
||||||
|
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
|
||||||
|
defp delete_cycles(cycles_to_delete) do
|
||||||
|
delete_results =
|
||||||
|
Enum.map(cycles_to_delete, fn cycle ->
|
||||||
|
Ash.destroy(cycle)
|
||||||
|
end)
|
||||||
|
|
||||||
|
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
|
||||||
|
{:error, :deletion_failed}
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Regenerates cycles with new type/amount
|
||||||
|
# Passes today to ensure consistent date across deletion and regeneration
|
||||||
|
# skip_lock?: true means advisory lock is already set by caller
|
||||||
|
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||||
|
defp regenerate_cycles(member_id, today, opts) do
|
||||||
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
|
|
||||||
|
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||||
|
member_id,
|
||||||
|
today: today,
|
||||||
|
skip_lock?: skip_lock?
|
||||||
|
) do
|
||||||
|
{:ok, _cycles, notifications} when is_list(notifications) ->
|
||||||
|
{:ok, notifications}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# 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,14 +1022,101 @@ 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
|
end
|
||||||
nil -> %{query: q}
|
|
||||||
fields -> %{query: q, fields: fields}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Ash.Query.for_read(query, :search, args)
|
# ============================================================================
|
||||||
|
# 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
|
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
|
end
|
||||||
|
|
||||||
# Private helper to apply filters for :available_for_linking action
|
# Private helper to apply filters for :available_for_linking action
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
174
lib/membership_fees/changes/set_membership_fee_start_date.ex
Normal file
174
lib/membership_fees/changes/set_membership_fee_start_date.ex
Normal 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
|
||||||
148
lib/membership_fees/changes/validate_same_interval.ex
Normal file
148
lib/membership_fees/changes/validate_same_interval.ex
Normal 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
|
||||||
132
lib/membership_fees/membership_fee_cycle.ex
Normal file
132
lib/membership_fees/membership_fee_cycle.ex
Normal 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
|
||||||
190
lib/membership_fees/membership_fee_type.ex
Normal file
190
lib/membership_fees/membership_fee_type.ex
Normal 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
|
||||||
42
lib/membership_fees/membership_fees.ex
Normal file
42
lib/membership_fees/membership_fees.ex
Normal 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
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
31
lib/mv/authorization/authorization.ex
Normal file
31
lib/mv/authorization/authorization.ex
Normal 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
|
||||||
294
lib/mv/authorization/permission_sets.ex
Normal file
294
lib/mv/authorization/permission_sets.ex
Normal 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
|
||||||
142
lib/mv/authorization/role.ex
Normal file
142
lib/mv/authorization/role.ex
Normal 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
24
lib/mv/config.ex
Normal 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
|
||||||
|
|
@ -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_"
|
||||||
|
|
|
||||||
337
lib/mv/membership_fees/calendar_cycles.ex
Normal file
337
lib/mv/membership_fees/calendar_cycles.ex
Normal 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
|
||||||
174
lib/mv/membership_fees/cycle_generation_job.ex
Normal file
174
lib/mv/membership_fees/cycle_generation_job.ex
Normal 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
|
||||||
460
lib/mv/membership_fees/cycle_generator.ex
Normal file
460
lib/mv/membership_fees/cycle_generator.ex
Normal 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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
241
lib/mv_web/helpers/membership_fee_helpers.ex
Normal file
241
lib/mv_web/helpers/membership_fee_helpers.ex
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
<%= for fee_type <- @available_fee_types do %>
|
||||||
|
<option
|
||||||
|
value={fee_type.id}
|
||||||
|
selected={fee_type.id == @form[:membership_fee_type_id].value}
|
||||||
|
>
|
||||||
|
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
|
||||||
|
fee_type.interval
|
||||||
|
)})
|
||||||
|
</option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<%= for {msg, _opts} <- @form.errors[:membership_fee_type_id] || [] do %>
|
||||||
|
<p class="text-error text-sm mt-1">{msg}</p>
|
||||||
|
<% end %>
|
||||||
|
<%= if @interval_warning do %>
|
||||||
|
<div class="alert alert-warning mt-2">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||||
|
<span>{@interval_warning}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-40">
|
<% end %>
|
||||||
<label class="label text-sm font-medium">{gettext("Payment Cycle")}</label>
|
<p class="text-sm text-base-content/60 mt-2">
|
||||||
<div class="flex gap-3 mt-2">
|
{gettext(
|
||||||
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
|
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||||
<input type="radio" name="mock_cycle" checked disabled class="radio radio-sm" />
|
)}
|
||||||
<span class="text-sm">{gettext("monthly")}</span>
|
</p>
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
|
|
||||||
<input type="radio" name="mock_cycle" disabled class="radio radio-sm" />
|
|
||||||
<span class="text-sm">{gettext("yearly")}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-24 flex items-end">
|
|
||||||
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
|
|
||||||
</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
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
case cycle_status_filter do
|
||||||
nil -> base_params
|
nil -> base_params
|
||||||
:paid -> Map.put(base_params, "paid_filter", "paid")
|
:paid -> Map.put(base_params, "cycle_status_filter", "paid")
|
||||||
:not_paid -> Map.put(base_params, "paid_filter", "not_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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
) do %>
|
||||||
|
<span class={["badge", badge.color]}>
|
||||||
|
<.icon name={badge.icon} class="size-4" />
|
||||||
|
{badge.label}
|
||||||
</span>
|
</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">
|
||||||
|
|
|
||||||
|
|
@ -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,16 +45,36 @@ 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>
|
||||||
|
|
||||||
|
<%= if @active_tab == :contact do %>
|
||||||
|
<%!-- Contact Data Tab Content --%>
|
||||||
<%!-- Personal Data and Custom Fields Row --%>
|
<%!-- Personal Data and Custom Fields Row --%>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
<%!-- Personal Data Section --%>
|
<%!-- Personal Data Section --%>
|
||||||
|
|
@ -131,15 +153,14 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Custom Fields Section --%>
|
<%!-- Custom Fields Section --%>
|
||||||
<%= if Enum.any?(@member.custom_field_values) do %>
|
<%= if Enum.any?(@custom_fields) do %>
|
||||||
<div>
|
<div>
|
||||||
<.section_box title={gettext("Custom Fields")}>
|
<.section_box title={gettext("Custom Fields")}>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<%= for cfv <- sort_custom_field_values(@member.custom_field_values) do %>
|
<%= for custom_field <- @custom_fields do %>
|
||||||
<% custom_field = cfv.custom_field %>
|
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
|
||||||
<% value_type = custom_field && custom_field.value_type %>
|
<.data_field label={custom_field.name}>
|
||||||
<.data_field label={custom_field && custom_field.name}>
|
{format_custom_field_value(cfv, custom_field.value_type)}
|
||||||
{format_custom_field_value(cfv.value, value_type)}
|
|
||||||
</.data_field>
|
</.data_field>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -148,51 +169,119 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Payment Data Section (Mockup) --%>
|
<%!-- Payment Data Section --%>
|
||||||
<div class="max-w-xl">
|
<div class="w-full">
|
||||||
<.section_box title={gettext("Payment Data")}>
|
<.section_box title={gettext("Payment Data")}>
|
||||||
<div role="alert" class="alert alert-info mb-4">
|
<%= if @member.membership_fee_type do %>
|
||||||
<.icon name="hero-information-circle" class="size-5" />
|
<div class="flex gap-6 flex-wrap">
|
||||||
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
|
<.data_field
|
||||||
</div>
|
label={gettext("Type")}
|
||||||
|
value={@member.membership_fee_type.name}
|
||||||
<div class="flex gap-6">
|
class="min-w-32"
|
||||||
<.data_field label={gettext("Contribution")} value="72 €" class="w-24" />
|
/>
|
||||||
<.data_field label={gettext("Payment Cycle")} value={gettext("monthly")} class="w-28" />
|
<.data_field
|
||||||
<.data_field label={gettext("Paid")} class="w-24">
|
label={gettext("Membership Fee")}
|
||||||
<%= if @member.paid do %>
|
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
|
||||||
<span class="badge badge-success">{gettext("Paid")}</span>
|
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 %>
|
<% else %>
|
||||||
<span class="badge badge-warning">{gettext("Pending")}</span>
|
<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 %>
|
<% end %>
|
||||||
</.data_field>
|
</.data_field>
|
||||||
</div>
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-base-content/70 italic">
|
||||||
|
{gettext("No membership fee type assigned")}
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</.section_box>
|
</.section_box>
|
||||||
</div>
|
</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,20 +440,38 @@ 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
|
||||||
|
if String.trim(value) == "" do
|
||||||
|
render_empty_value()
|
||||||
|
else
|
||||||
assigns = %{email: value}
|
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
|
||||||
Integer.to_string(value)
|
Integer.to_string(value)
|
||||||
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
|
||||||
|
|
|
||||||
927
lib/mv_web/live/member_live/show/membership_fees_component.ex
Normal file
927
lib/mv_web/live/member_live/show/membership_fees_component.ex
Normal 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
|
||||||
296
lib/mv_web/live/membership_fee_settings_live.ex
Normal file
296
lib/mv_web/live/membership_fee_settings_live.ex
Normal 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
|
||||||
455
lib/mv_web/live/membership_fee_type_live/form.ex
Normal file
455
lib/mv_web/live/membership_fee_type_live/form.ex
Normal 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
|
||||||
224
lib/mv_web/live/membership_fee_type_live/index.ex
Normal file
224
lib/mv_web/live/membership_fee_type_live/index.ex
Normal 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
|
||||||
181
lib/mv_web/member_live/index/membership_fee_status.ex
Normal file
181
lib/mv_web/member_live/index/membership_fee_status.ex
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
6
mix.exs
6
mix.exs
|
|
@ -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"},
|
||||||
|
|
|
||||||
62
mix.lock
62
mix.lock
|
|
@ -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"},
|
||||||
|
|
|
||||||
58
notes.md
58
notes.md
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,60 +128,154 @@ 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
|
||||||
|
# Member 3: Friedrich - Mixed cycles (paid, unpaid, suspended)
|
||||||
|
# Member 4: Marianne - No membership fee type
|
||||||
|
member_attrs_list = [
|
||||||
%{
|
%{
|
||||||
first_name: "Hans",
|
first_name: "Hans",
|
||||||
last_name: "Müller",
|
last_name: "Müller",
|
||||||
email: "hans.mueller@example.de",
|
email: "hans.mueller@example.de",
|
||||||
join_date: ~D[2023-01-15],
|
join_date: ~D[2023-01-15],
|
||||||
paid: true,
|
|
||||||
phone_number: "+49301234567",
|
phone_number: "+49301234567",
|
||||||
city: "München",
|
city: "München",
|
||||||
street: "Hauptstraße",
|
street: "Hauptstraße",
|
||||||
house_number: "42",
|
house_number: "42",
|
||||||
postal_code: "80331"
|
postal_code: "80331",
|
||||||
|
membership_fee_type_id: Enum.at(all_fee_types, 0).id,
|
||||||
|
cycle_status: :all_paid
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
first_name: "Greta",
|
first_name: "Greta",
|
||||||
last_name: "Schmidt",
|
last_name: "Schmidt",
|
||||||
email: "greta.schmidt@example.de",
|
email: "greta.schmidt@example.de",
|
||||||
join_date: ~D[2023-02-01],
|
join_date: ~D[2023-02-01],
|
||||||
paid: false,
|
|
||||||
phone_number: "+49309876543",
|
phone_number: "+49309876543",
|
||||||
city: "Hamburg",
|
city: "Hamburg",
|
||||||
street: "Lindenstraße",
|
street: "Lindenstraße",
|
||||||
house_number: "17",
|
house_number: "17",
|
||||||
postal_code: "20095",
|
postal_code: "20095",
|
||||||
notes: "Interessiert an Fortgeschrittenen-Kursen"
|
notes: "Interessiert an Fortgeschrittenen-Kursen",
|
||||||
|
membership_fee_type_id: Enum.at(all_fee_types, 1).id,
|
||||||
|
cycle_status: :all_unpaid
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
first_name: "Friedrich",
|
first_name: "Friedrich",
|
||||||
last_name: "Wagner",
|
last_name: "Wagner",
|
||||||
email: "friedrich.wagner@example.de",
|
email: "friedrich.wagner@example.de",
|
||||||
join_date: ~D[2022-11-10],
|
join_date: ~D[2022-11-10],
|
||||||
paid: true,
|
|
||||||
phone_number: "+49301122334",
|
phone_number: "+49301122334",
|
||||||
city: "Berlin",
|
city: "Berlin",
|
||||||
street: "Kastanienallee",
|
street: "Kastanienallee",
|
||||||
house_number: "8"
|
house_number: "8",
|
||||||
|
membership_fee_type_id: Enum.at(all_fee_types, 2).id,
|
||||||
|
cycle_status: :mixed
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
first_name: "Marianne",
|
first_name: "Marianne",
|
||||||
last_name: "Wagner",
|
last_name: "Wagner",
|
||||||
email: "marianne.wagner@example.de",
|
email: "marianne.wagner@example.de",
|
||||||
join_date: ~D[2022-11-10],
|
join_date: ~D[2022-11-10],
|
||||||
paid: true,
|
|
||||||
phone_number: "+49301122334",
|
phone_number: "+49301122334",
|
||||||
city: "Berlin",
|
city: "Berlin",
|
||||||
street: "Kastanienallee",
|
street: "Kastanienallee",
|
||||||
house_number: "8"
|
house_number: "8"
|
||||||
|
# No membership_fee_type_id - member without fee type
|
||||||
}
|
}
|
||||||
] do
|
]
|
||||||
|
|
||||||
|
# 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
|
||||||
|
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
|
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 = [
|
||||||
%{email: "hans.mueller@example.de"},
|
%{email: "hans.mueller@example.de"},
|
||||||
|
|
@ -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,25 +325,85 @@ 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
|
||||||
|
member =
|
||||||
if user.member_id == nil do
|
if user.member_id == nil do
|
||||||
# User is free, create member and link - use upsert to prevent duplicates
|
# User is free, create member and link - use upsert to prevent duplicates
|
||||||
Membership.create_member!(
|
Membership.create_member!(
|
||||||
Map.put(member_attrs_without_user, :user, %{id: user.id}),
|
Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
|
||||||
upsert?: true,
|
upsert?: true,
|
||||||
upsert_identity: :unique_email
|
upsert_identity: :unique_email
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
# User already has a member, just create the member without linking - use upsert to prevent duplicates
|
# User already has a member, just create the member without linking - use upsert to prevent duplicates
|
||||||
Membership.create_member!(member_attrs_without_user,
|
Membership.create_member!(member_attrs_without_fee_type,
|
||||||
upsert?: true,
|
upsert?: true,
|
||||||
upsert_identity: :unique_email
|
upsert_identity: :unique_email
|
||||||
)
|
)
|
||||||
end
|
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)
|
||||||
|
|
||||||
# Create sample custom field values for some members
|
# Create sample custom field values for some members
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
132
priv/resource_snapshots/repo/custom_fields/20251218113900.json
Normal file
132
priv/resource_snapshots/repo/custom_fields/20251218113900.json
Normal 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"
|
||||||
|
}
|
||||||
202
priv/resource_snapshots/repo/members/20251204123714.json
Normal file
202
priv/resource_snapshots/repo/members/20251204123714.json
Normal 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"
|
||||||
|
}
|
||||||
245
priv/resource_snapshots/repo/members/20251211195058.json
Normal file
245
priv/resource_snapshots/repo/members/20251211195058.json
Normal 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"
|
||||||
|
}
|
||||||
233
priv/resource_snapshots/repo/members/20251218113900.json
Normal file
233
priv/resource_snapshots/repo/members/20251218113900.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
118
priv/resource_snapshots/repo/roles/20260106165250.json
Normal file
118
priv/resource_snapshots/repo/roles/20260106165250.json
Normal 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"
|
||||||
|
}
|
||||||
103
priv/resource_snapshots/repo/settings/20251211195058.json
Normal file
103
priv/resource_snapshots/repo/settings/20251211195058.json
Normal 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"
|
||||||
|
}
|
||||||
172
priv/resource_snapshots/repo/users/20260106161215.json
Normal file
172
priv/resource_snapshots/repo/users/20260106161215.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
360
test/membership/member_cycle_calculations_test.exs
Normal file
360
test/membership/member_cycle_calculations_test.exs
Normal 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
|
||||||
635
test/membership/member_required_custom_fields_test.exs
Normal file
635
test/membership/member_required_custom_fields_test.exs
Normal 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
|
||||||
702
test/membership/member_search_with_custom_fields_test.exs
Normal file
702
test/membership/member_search_with_custom_fields_test.exs
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
453
test/membership/member_type_change_integration_test.exs
Normal file
453
test/membership/member_type_change_integration_test.exs
Normal 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
|
||||||
98
test/membership/membership_fee_settings_test.exs
Normal file
98
test/membership/membership_fee_settings_test.exs
Normal 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
|
||||||
|
|
@ -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
|
||||||
227
test/membership_fees/changes/validate_same_interval_test.exs
Normal file
227
test/membership_fees/changes/validate_same_interval_test.exs
Normal 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
|
||||||
220
test/membership_fees/foreign_key_test.exs
Normal file
220
test/membership_fees/foreign_key_test.exs
Normal 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
|
||||||
211
test/membership_fees/member_cycle_integration_test.exs
Normal file
211
test/membership_fees/member_cycle_integration_test.exs
Normal 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
|
||||||
207
test/membership_fees/membership_fee_cycle_test.exs
Normal file
207
test/membership_fees/membership_fee_cycle_test.exs
Normal 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
|
||||||
221
test/membership_fees/membership_fee_type_integration_test.exs
Normal file
221
test/membership_fees/membership_fee_type_integration_test.exs
Normal 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
Loading…
Add table
Add a link
Reference in a new issue