diff --git a/.gitignore b/.gitignore index 058543c..9517a21 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,3 @@ 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 deleted file mode 100644 index 63445fb..0000000 --- a/docs/test-status-membership-fee-ui.md +++ /dev/null @@ -1,137 +0,0 @@ -# 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 0c90f4d..787b1d1 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -102,9 +102,6 @@ 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 @@ -242,63 +239,6 @@ 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 @@ -452,6 +392,11 @@ 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])], @@ -509,6 +454,10 @@ 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 deleted file mode 100644 index 55f28e6..0000000 --- a/lib/membership/member/changes/set_default_membership_fee_type.ex +++ /dev/null @@ -1,41 +0,0 @@ -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 4d9c8b7..b437ead 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, :amount] + accept [:status, :notes] end update :mark_as_paid do diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index c81dbd6..843ad2b 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -7,6 +7,7 @@ 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 9bc3afa..8a4ef24 100644 --- a/lib/mv/membership_fees/calendar_cycles.ex +++ b/lib/mv/membership_fees/calendar_cycles.ex @@ -299,15 +299,11 @@ defmodule Mv.MembershipFees.CalendarCycles do 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) + case cycle_start.month do + 1 -> Date.new!(cycle_start.year, 3, 31) + 4 -> Date.new!(cycle_start.year, 6, 30) + 7 -> Date.new!(cycle_start.year, 9, 30) + 10 -> Date.new!(cycle_start.year, 12, 31) end end @@ -317,13 +313,9 @@ defmodule Mv.MembershipFees.CalendarCycles do 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) + case cycle_start.month do + 1 -> Date.new!(cycle_start.year, 6, 30) + 7 -> Date.new!(cycle_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 23889fb..feb7b53 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -386,44 +386,18 @@ 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, 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) + {successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1)) all_notifications = Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end) if Enum.empty?(errors) do successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end) - - 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 adc3444..c2e28d6 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -32,9 +32,7 @@ defmodule MvWeb.Layouts.Navbar do
{gettext("Contributions")}