diff --git a/.gitignore b/.gitignore index 9517a21..058543c 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ npm-debug.log # Docker secrets directory (generated by `just init-secrets`) /secrets/ +notes.md diff --git a/docs/test-status-membership-fee-ui.md b/docs/test-status-membership-fee-ui.md new file mode 100644 index 0000000..63445fb --- /dev/null +++ b/docs/test-status-membership-fee-ui.md @@ -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 + diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 787b1d1..0c90f4d 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -102,6 +102,9 @@ defmodule Mv.Membership.Member do where [changing(:user)] 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 @@ -239,6 +242,63 @@ defmodule Mv.Membership.Member do {: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 + change after_action(fn changeset, member, _context -> + 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 + if Application.get_env(:mv, :sql_sandbox, false) do + # Run synchronously in test environment for DB sandbox compatibility + # Use skip_lock?: true to avoid nested transactions (after_action runs within action transaction) + # Return notifications to Ash so they are sent after commit + case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( + member.id, + today: Date.utc_today(), + skip_lock?: true + ) do + {:ok, _cycles, notifications} -> + {:ok, member, notifications} + + {:error, reason} -> + require Logger + + Logger.warning( + "Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}" + ) + + {:ok, member} + end + else + # Run asynchronously in other environments + # Send notifications explicitly since they cannot be returned via after_action + Task.start(fn -> + case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do + {:ok, _cycles, notifications} -> + # Send notifications manually for async case + if Enum.any?(notifications) do + Ash.Notifier.notify(notifications) + end + + {:error, reason} -> + require Logger + + Logger.warning( + "Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}" + ) + end + end) + + {:ok, member} + end + else + {:ok, member} + end + end) end # Action to handle fuzzy search on specific fields @@ -392,11 +452,6 @@ defmodule Mv.Membership.Member do end end - # Join date not in the future - validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), - where: [present(:join_date)], - message: "cannot be in the future" - # Exit date not before join date validate compare(:exit_date, greater_than: :join_date), where: [present([:join_date, :exit_date])], @@ -454,10 +509,6 @@ defmodule Mv.Membership.Member do constraints min_length: 5, max_length: 254 end - attribute :paid, :boolean do - allow_nil? true - end - attribute :phone_number, :string do allow_nil? true end diff --git a/lib/membership/member/changes/set_default_membership_fee_type.ex b/lib/membership/member/changes/set_default_membership_fee_type.ex new file mode 100644 index 0000000..55f28e6 --- /dev/null +++ b/lib/membership/member/changes/set_default_membership_fee_type.ex @@ -0,0 +1,41 @@ +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 + + 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 diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex index b437ead..4d9c8b7 100644 --- a/lib/membership_fees/membership_fee_cycle.ex +++ b/lib/membership_fees/membership_fee_cycle.ex @@ -49,7 +49,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do update :update do primary? true - accept [:status, :notes] + accept [:status, :notes, :amount] end update :mark_as_paid do diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 843ad2b..c81dbd6 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -7,7 +7,6 @@ defmodule Mv.Constants do :first_name, :last_name, :email, - :paid, :phone_number, :join_date, :exit_date, diff --git a/lib/mv/membership_fees/calendar_cycles.ex b/lib/mv/membership_fees/calendar_cycles.ex index 8a4ef24..9bc3afa 100644 --- a/lib/mv/membership_fees/calendar_cycles.ex +++ b/lib/mv/membership_fees/calendar_cycles.ex @@ -299,11 +299,15 @@ defmodule Mv.MembershipFees.CalendarCycles do end defp quarterly_cycle_end(cycle_start) do - case cycle_start.month do - 1 -> Date.new!(cycle_start.year, 3, 31) - 4 -> Date.new!(cycle_start.year, 6, 30) - 7 -> Date.new!(cycle_start.year, 9, 30) - 10 -> Date.new!(cycle_start.year, 12, 31) + # 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 @@ -313,9 +317,13 @@ defmodule Mv.MembershipFees.CalendarCycles do end defp half_yearly_cycle_end(cycle_start) do - case cycle_start.month do - 1 -> Date.new!(cycle_start.year, 6, 30) - 7 -> Date.new!(cycle_start.year, 12, 31) + # 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 diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index feb7b53..23889fb 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -386,18 +386,44 @@ defmodule Mv.MembershipFees.CycleGenerator do {:ok, cycle} -> {:ok, cycle, []} + {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidAttribute{private_vars: %{constraint: constraint, constraint_type: :unique}}]}} = error -> + # Cycle already exists (unique constraint violation) - skip it silently + # This makes the function idempotent and prevents errors on server restart + if constraint == "membership_fee_cycles_unique_cycle_per_member_index" do + {:skip, cycle_start} + else + {:error, {cycle_start, error}} + end + {:error, reason} -> {:error, {cycle_start, reason}} end end) - {successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1)) + {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)}") diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex index c2e28d6..adc3444 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -32,7 +32,9 @@ defmodule MvWeb.Layouts.Navbar do
{gettext("Contributions")}