From 7d1a71b1fd043b07c46e1457f6df9b7c238183d6 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 10 Jul 2026 16:27:15 +0200 Subject: [PATCH] feat(overview): add period-scoped payment filter and field-picker descriptor --- .../live/member_live/index/date_filter.ex | 39 +++++ .../member_live/index/filter_descriptor.ex | 140 ++++++++++++++++++ .../live/member_live/index/overview_query.ex | 35 +++++ .../live/member_live/index/payment_aging.ex | 36 +++++ priv/gettext/de/LC_MESSAGES/default.po | 18 +++ priv/gettext/default.pot | 18 +++ priv/gettext/en/LC_MESSAGES/default.po | 24 +++ .../member_live/date_filter_property_test.exs | 68 +++++++++ .../member_live/index_active_former_test.exs | 64 ++++++++ .../member_live/index_field_picker_test.exs | 82 ++++++++++ .../member_live/index_payment_filter_test.exs | 77 ++++++++++ .../member_live/index_payment_period_test.exs | 53 +++++++ 12 files changed, 654 insertions(+) create mode 100644 lib/mv_web/live/member_live/index/filter_descriptor.ex create mode 100644 test/mv_web/member_live/index_active_former_test.exs create mode 100644 test/mv_web/member_live/index_field_picker_test.exs create mode 100644 test/mv_web/member_live/index_payment_filter_test.exs diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex index b24c2f49..a26abae2 100644 --- a/lib/mv_web/live/member_live/index/date_filter.ex +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -67,6 +67,45 @@ defmodule MvWeb.MemberLive.Index.DateFilter do } end + @typedoc """ + The three-state active/former quick filter. It is not a stored field but a + view onto `exit_date.mode`: `:active` ↔ `:active_only`, `:former` ↔ + `:inactive_only`, `:all` ↔ `:all`. A detailed `:custom` exit-date selection + has no quick equivalent and surfaces as `:custom` (no quick chip selected). + """ + @type quick_state :: :active | :former | :all | :custom + + @quick_to_mode %{active: :active_only, former: :inactive_only, all: :all} + + @doc """ + Derives the active/former quick-filter state from the single `exit_date` + source. Total function; an absent or malformed `exit_date` reads as the + default `:active`. This is the only reader — there is no second state to keep + in sync (§2.4). + """ + @spec quick_state(map()) :: quick_state() + def quick_state(filters) when is_map(filters) do + case Map.get(filters, :exit_date, %{}) do + %{mode: :active_only} -> :active + %{mode: :inactive_only} -> :former + %{mode: :all} -> :all + %{mode: :custom} -> :custom + _ -> :active + end + end + + @doc """ + Applies an active/former quick-filter choice onto the date filter state by + writing the corresponding `exit_date` mode and clearing its bounds. Only the + `exit_date` slice is touched; `join_date` and custom-date entries are left + intact, so the quick filter and the detailed control share one source. + """ + @spec set_quick_state(map(), :active | :former | :all) :: map() + def set_quick_state(filters, state) + when is_map(filters) and state in [:active, :former, :all] do + Map.put(filters, :exit_date, %{mode: Map.fetch!(@quick_to_mode, state), from: nil, to: nil}) + end + @doc """ Decodes URL params into a date filter state map. diff --git a/lib/mv_web/live/member_live/index/filter_descriptor.ex b/lib/mv_web/live/member_live/index/filter_descriptor.ex new file mode 100644 index 00000000..96ecd801 --- /dev/null +++ b/lib/mv_web/live/member_live/index/filter_descriptor.ex @@ -0,0 +1,140 @@ +defmodule MvWeb.MemberLive.Index.FilterDescriptor do + @moduledoc """ + Data-driven catalog of the filter fields offered by the member-overview + add-filter builder. + + Each descriptor is a plain, serializable map describing one pickable field: + + %{ + key: atom() | String.t(), # stable identifier (custom fields use their UUID string) + group: :quick | :membership | :custom_fields, + label: String.t(), # human-readable, gettext-translated + control: atom() # which type-aware value control the builder renders + } + + The catalog is the persistence basis the saved-views work (#549) docks onto: + it is derived purely from the field context (groups, fee types, custom fields) + and carries no UI or query state. + + Grouping (confirmed taxonomy): + + * `:quick` — Payment, Active/former (common shortcuts) + * `:membership` — Group, Fee type, Join date, Exit date + * `:custom_fields` — one entry per filterable club-defined custom field + (boolean and date fields; other value types are not filtered on the + overview) + + A group with no descriptors is omitted entirely (no header) — see + `visible_groups/1`. + """ + + use Gettext, backend: MvWeb.Gettext + + @type group :: :quick | :membership | :custom_fields + + @type t :: %{ + key: atom() | String.t(), + group: group(), + label: String.t(), + control: atom() + } + + # Fixed display order of the groups in the picker. + @group_order [:quick, :membership, :custom_fields] + + # Custom-field value types the overview can filter on. + @filterable_custom_field_types [:boolean, :date] + + @doc """ + Returns the ordered list of all descriptors available for the given field + context. Recognised context keys (all optional, default empty): + + * `:groups` — list of `%{id: _, name: _}` group structs + * `:fee_types` — list of fee-type structs + * `:custom_fields` — list of custom-field structs (`:value_type`, `:name`, `:id`) + """ + @spec all(map()) :: [t()] + def all(context) when is_map(context) do + quick() ++ + membership(Map.get(context, :groups, []), Map.get(context, :fee_types, [])) ++ + custom_fields(Map.get(context, :custom_fields, [])) + end + + @doc "The group an individual descriptor belongs to." + @spec group_for(t()) :: group() + def group_for(%{group: group}), do: group + + @doc "The fixed display order of the picker groups." + @spec group_order() :: [group()] + def group_order, do: @group_order + + @doc """ + Returns `[{group, [descriptor]}]` in display order, including only groups that + have at least one descriptor. Groups with no available fields are omitted so + the picker renders no empty header (§1.10). + """ + @spec visible_groups([t()]) :: [{group(), [t()]}] + def visible_groups(descriptors) when is_list(descriptors) do + grouped = Enum.group_by(descriptors, & &1.group) + + @group_order + |> Enum.map(fn group -> {group, Map.get(grouped, group, [])} end) + |> Enum.reject(fn {_group, ds} -> ds == [] end) + end + + # --- static groups -------------------------------------------------------- + + defp quick do + [ + %{key: :payment, group: :quick, label: gettext("Payment"), control: :payment_count}, + %{ + key: :active_former, + group: :quick, + label: gettext("Active / former"), + control: :active_former + } + ] + end + + defp membership(groups, fee_types) do + maybe_group(groups) ++ + maybe_fee_type(fee_types) ++ + [ + %{key: :join_date, group: :membership, label: gettext("Join date"), control: :date_range}, + %{key: :exit_date, group: :membership, label: gettext("Exit date"), control: :exit_date} + ] + end + + defp maybe_group([]), do: [] + + defp maybe_group(_groups), + do: [%{key: :group, group: :membership, label: gettext("Group"), control: :group_membership}] + + defp maybe_fee_type([]), do: [] + + defp maybe_fee_type(_fee_types), + do: [ + %{ + key: :fee_type, + group: :membership, + label: gettext("Fee type"), + control: :fee_type_membership + } + ] + + defp custom_fields(custom_fields) do + custom_fields + |> Enum.filter(&(Map.get(&1, :value_type) in @filterable_custom_field_types)) + |> Enum.map(fn cf -> + %{ + key: to_string(cf.id), + group: :custom_fields, + label: cf.name, + control: control_for_custom_field(cf.value_type) + } + end) + end + + defp control_for_custom_field(:boolean), do: :boolean + defp control_for_custom_field(:date), do: :date_range +end diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex index ac3051f0..1afe499b 100644 --- a/lib/mv_web/live/member_live/index/overview_query.ex +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -52,6 +52,7 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do opts[:show_current_cycle], today(opts) ) + |> apply_payment_filter(opts[:payment_filter], payment_period(opts)) |> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || []) end @@ -232,6 +233,40 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do ) end + # --------------------------------------------------------------------------- + # Payment aging filter (period-scoped unpaid-cycle count) + # + # `fully_paid` keeps members with zero unpaid cycles whose cycle_end lies in + # the active period; `{:has_unpaid, n}` keeps members with at least n such + # cycles. Both push to SQL via the `unpaid_cycle_count` calculation (a + # correlated aggregate over `membership_fee_cycles`), so no member set is + # loaded into memory. Suspended/paid cycles are excluded by the calculation. + # --------------------------------------------------------------------------- + + defp payment_period(opts) do + case opts[:payment_period] do + %{from: _, to: _} = period -> period + _ -> %{from: nil, to: nil} + end + end + + defp apply_payment_filter(query, :fully_paid, %{from: from, to: to}) do + Ash.Query.filter( + query, + expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) == 0) + ) + end + + defp apply_payment_filter(query, {:has_unpaid, n}, %{from: from, to: to}) + when is_integer(n) and n > 0 do + Ash.Query.filter( + query, + expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^n) + ) + end + + defp apply_payment_filter(query, _filter, _period), do: query + # --------------------------------------------------------------------------- # Built-in date filters (join/exit) # --------------------------------------------------------------------------- diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex index 18d3163b..9dbe3e0c 100644 --- a/lib/mv_web/live/member_live/index/payment_aging.ex +++ b/lib/mv_web/live/member_live/index/payment_aging.ex @@ -28,8 +28,11 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do @type period :: %{from: Date.t() | nil, to: Date.t() | nil} + @type filter :: nil | :fully_paid | {:has_unpaid, 1..3} + @payment_period_from_param Constants.payment_period_from_param() @payment_period_to_param Constants.payment_period_to_param() + @payment_filter_param Constants.payment_filter_param() @doc """ The default period: all outstanding cycles, all time (both bounds nil). @@ -62,6 +65,39 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do def to_params(_), do: %{} + @doc """ + Decodes the payment-count filter param. + + * `"fully_paid"` → `:fully_paid` (exactly 0 unpaid cycles in the period) + * `"unpaid_1"` / `"unpaid_2"` / `"unpaid_3"` → `{:has_unpaid, N}` + (at least N unpaid cycles in the period) + * anything else → `nil` (no payment-count filter) + """ + @spec parse_filter(term()) :: filter() + def parse_filter("fully_paid"), do: :fully_paid + def parse_filter("unpaid_1"), do: {:has_unpaid, 1} + def parse_filter("unpaid_2"), do: {:has_unpaid, 2} + def parse_filter("unpaid_3"), do: {:has_unpaid, 3} + def parse_filter(_), do: nil + + @doc """ + Encodes a payment-count filter into URL params. `nil` yields the empty map. + """ + @spec filter_to_params(filter()) :: %{optional(String.t()) => String.t()} + def filter_to_params(:fully_paid), do: %{@payment_filter_param => "fully_paid"} + + def filter_to_params({:has_unpaid, n}) when n in 1..3, + do: %{@payment_filter_param => "unpaid_#{n}"} + + def filter_to_params(_), do: %{} + + @doc """ + Decodes the payment-count filter from a full params map. + """ + @spec parse_filter_params(map()) :: filter() + def parse_filter_params(params) when is_map(params), + do: parse_filter(Map.get(params, @payment_filter_param)) + @doc """ Badge descriptor for an unpaid-cycle count. A count of 0 reads "Paid" (success); a positive count reads "N unpaid" (error). diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index cc8dae08..8749a6a5 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -1297,6 +1297,7 @@ msgid "Exit Date" msgstr "Austrittsdatum" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Exit date" @@ -1470,6 +1471,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden." #: lib/mv_web/live/import_live/components.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" @@ -1906,6 +1908,7 @@ msgid "Join confirmation" msgstr "Beitrittsbestätigung" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Join date" msgstr "Beitrittsdatum" @@ -4219,3 +4222,18 @@ msgstr "Keine Adresse" #, elixir-autogen, elixir-format msgid "%{count} unpaid" msgstr "%{count} unbezahlt" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format +msgid "Active / former" +msgstr "Aktiv / ehemalig" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Group" +msgstr "Gruppen" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Payment" +msgstr "Zahlungen" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 34772344..2e1df9f5 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -1298,6 +1298,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Exit date" @@ -1471,6 +1472,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag msgstr "" #: lib/mv_web/live/import_live/components.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" @@ -1907,6 +1909,7 @@ msgid "Join confirmation" msgstr "" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Join date" msgstr "" @@ -4219,3 +4222,18 @@ msgstr "" #, elixir-autogen, elixir-format msgid "%{count} unpaid" msgstr "" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format +msgid "Active / former" +msgstr "" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format +msgid "Group" +msgstr "" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format +msgid "Payment" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index fe871774..faec9b03 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -1298,6 +1298,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Exit date" @@ -1471,6 +1472,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag msgstr "" #: lib/mv_web/live/import_live/components.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Fee type" @@ -1907,6 +1909,7 @@ msgid "Join confirmation" msgstr "" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format, fuzzy msgid "Join date" msgstr "" @@ -2697,6 +2700,7 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex +#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex @@ -4213,3 +4217,23 @@ msgstr "" #, elixir-autogen, elixir-format msgid "No address" msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "%{count} unpaid" +msgstr "" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format +msgid "Active / former" +msgstr "" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Group" +msgstr "" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Payment" +msgstr "" diff --git a/test/mv_web/live/member_live/date_filter_property_test.exs b/test/mv_web/live/member_live/date_filter_property_test.exs index 8844f911..7160262f 100644 --- a/test/mv_web/live/member_live/date_filter_property_test.exs +++ b/test/mv_web/live/member_live/date_filter_property_test.exs @@ -113,6 +113,74 @@ defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do end end + # §2.4 — single-source exit-date state (quick ↔ detail) ---------------- + # + # The active/former quick filter is a shortcut onto the very same + # `exit_date` state the detailed control writes: there is no separate + # quick-filter field. This property drives interleaved sequences of quick + # and detailed changes and asserts that `quick_state/1` is always a pure + # function of the single `exit_date` source — i.e. the two views can never + # diverge. + + defp quick_choice_gen, do: member_of([:active, :former, :all]) + + defp command_gen do + one_of([ + gen(all(s <- quick_choice_gen()), do: {:quick, s}), + gen all( + mode <- exit_date_mode_gen(), + from <- optional_date_gen(), + to <- optional_date_gen() + ) do + {:detail, %{mode: mode, from: from, to: to}} + end + ]) + end + + defp apply_command(filters, {:quick, s}), do: DateFilter.set_quick_state(filters, s) + defp apply_command(filters, {:detail, exit_date}), do: Map.put(filters, :exit_date, exit_date) + + defp expected_quick_for(:active_only), do: :active + defp expected_quick_for(:inactive_only), do: :former + defp expected_quick_for(:all), do: :all + defp expected_quick_for(:custom), do: :custom + + property "quick filter is a single-source shortcut onto exit_date, never divergent" do + check all(commands <- list_of(command_gen(), max_length: 12)) do + final = + Enum.reduce(commands, DateFilter.default(), fn command, filters -> + next = apply_command(filters, command) + + # Invariant at every step: quick_state is derived solely from the + # exit_date mode — one consistent value, no second source. + assert DateFilter.quick_state(next) == expected_quick_for(next.exit_date.mode) + + # A quick command resolves to exactly the chosen state and clears bounds. + case command do + {:quick, s} -> + assert DateFilter.quick_state(next) == s + assert next.exit_date.from == nil + assert next.exit_date.to == nil + + _ -> + :ok + end + + next + end) + + # join_date is untouched by any exit_date command (no cross-contamination). + assert final.join_date == DateFilter.default().join_date + end + end + + property "set_quick_state ∘ quick_state round-trips for the three quick states" do + check all(s <- quick_choice_gen(), exit_date <- exit_date_state_gen()) do + filters = Map.put(DateFilter.default(), :exit_date, exit_date) + assert filters |> DateFilter.set_quick_state(s) |> DateFilter.quick_state() == s + end + end + property "encoding then decoding built-in date filter state is identity" do check all( join_date <- join_date_state_gen(), diff --git a/test/mv_web/member_live/index_active_former_test.exs b/test/mv_web/member_live/index_active_former_test.exs new file mode 100644 index 00000000..55093117 --- /dev/null +++ b/test/mv_web/member_live/index_active_former_test.exs @@ -0,0 +1,64 @@ +defmodule MvWeb.MemberLive.IndexActiveFormerTest do + @moduledoc """ + §1.7 — the active/former quick filter is a three-state shortcut onto the + shared `exit_date` state: + + * "Former" → members with a past exit date (`ed_mode=inactive_only`) + * "Active" → members with no or a future exit date (default / `active_only`) + * "All" → every member (`ed_mode=all`) + + These assert the *result semantics* through the live overview and its URL + contract, which the quick-filter UI drives; they are independent of the + particular chip control and survive the builder rework. + """ + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + + alias Mv.Fixtures + + setup %{conn: conn} do + conn = conn_with_oidc_user(conn) + + active = Fixtures.member_fixture(%{first_name: "Ava", last_name: "Active", exit_date: nil}) + + future = + Fixtures.member_fixture(%{ + first_name: "Finn", + last_name: "Future", + exit_date: Date.add(Date.utc_today(), 30) + }) + + former = + Fixtures.member_fixture(%{ + first_name: " Former", + last_name: "Past", + exit_date: Date.add(Date.utc_today(), -30) + }) + + %{conn: conn, active: active, future: future, former: former} + end + + test "Former shows only members with a past exit date", ctx do + {:ok, view, _html} = live(ctx.conn, "/members?ed_mode=inactive_only") + + assert has_element?(view, "#row-#{ctx.former.id}") + refute has_element?(view, "#row-#{ctx.active.id}") + refute has_element?(view, "#row-#{ctx.future.id}") + end + + test "Active shows members with no or a future exit date", ctx do + {:ok, view, _html} = live(ctx.conn, "/members") + + assert has_element?(view, "#row-#{ctx.active.id}") + assert has_element?(view, "#row-#{ctx.future.id}") + refute has_element?(view, "#row-#{ctx.former.id}") + end + + test "All shows every member regardless of exit date", ctx do + {:ok, view, _html} = live(ctx.conn, "/members?ed_mode=all") + + assert has_element?(view, "#row-#{ctx.active.id}") + assert has_element?(view, "#row-#{ctx.future.id}") + assert has_element?(view, "#row-#{ctx.former.id}") + end +end diff --git a/test/mv_web/member_live/index_field_picker_test.exs b/test/mv_web/member_live/index_field_picker_test.exs new file mode 100644 index 00000000..6f208d8c --- /dev/null +++ b/test/mv_web/member_live/index_field_picker_test.exs @@ -0,0 +1,82 @@ +defmodule MvWeb.MemberLive.IndexFieldPickerTest do + @moduledoc """ + §1.10 / §3.5 — the FilterDescriptor catalog behind the field picker. + + Fields are grouped Quick / Membership / Custom fields; a group with no + available fields is omitted (renders no header). The catalog is derived + purely from the field context and is serializable (the persistence basis for + saved views, #549). + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.FilterDescriptor + + defp group_keys(descriptors, group) do + descriptors + |> Enum.filter(&(&1.group == group)) + |> Enum.map(& &1.key) + end + + test "groups are ordered Quick / Membership / Custom fields" do + context = %{ + groups: [%{id: Ecto.UUID.generate(), name: "Board"}], + fee_types: [%{id: Ecto.UUID.generate(), name: "Standard"}], + custom_fields: [%{id: Ecto.UUID.generate(), name: "Newsletter", value_type: :boolean}] + } + + descriptors = FilterDescriptor.all(context) + + assert Enum.map(FilterDescriptor.visible_groups(descriptors), &elem(&1, 0)) == + [:quick, :membership, :custom_fields] + end + + test "quick and membership fields are present with type-aware controls" do + descriptors = FilterDescriptor.all(%{groups: [%{id: Ecto.UUID.generate(), name: "G"}]}) + + assert group_keys(descriptors, :quick) == [:payment, :active_former] + assert :group in group_keys(descriptors, :membership) + assert :join_date in group_keys(descriptors, :membership) + assert :exit_date in group_keys(descriptors, :membership) + + payment = Enum.find(descriptors, &(&1.key == :payment)) + assert payment.control == :payment_count + end + + test "empty custom-fields group is omitted (no header)" do + descriptors = FilterDescriptor.all(%{custom_fields: []}) + + groups = Enum.map(FilterDescriptor.visible_groups(descriptors), &elem(&1, 0)) + refute :custom_fields in groups + end + + test "membership omits Group/Fee type when the club has none" do + descriptors = FilterDescriptor.all(%{groups: [], fee_types: []}) + + membership_keys = group_keys(descriptors, :membership) + refute :group in membership_keys + refute :fee_type in membership_keys + # Join/exit date are always available. + assert :join_date in membership_keys + assert :exit_date in membership_keys + end + + test "one custom-fields descriptor per filterable field; non-filterable types dropped" do + date_id = Ecto.UUID.generate() + bool_id = Ecto.UUID.generate() + + context = %{ + custom_fields: [ + %{id: date_id, name: "Birthday", value_type: :date}, + %{id: bool_id, name: "Consent", value_type: :boolean}, + %{id: Ecto.UUID.generate(), name: "Phone", value_type: :string} + ] + } + + custom = Enum.filter(FilterDescriptor.all(context), &(&1.group == :custom_fields)) + + assert Enum.map(custom, & &1.key) == [to_string(date_id), to_string(bool_id)] + # Keys are plain strings — serializable for #549. + assert Enum.all?(custom, &is_binary(&1.key)) + assert Enum.find(custom, &(&1.key == to_string(date_id))).control == :date_range + end +end diff --git a/test/mv_web/member_live/index_payment_filter_test.exs b/test/mv_web/member_live/index_payment_filter_test.exs new file mode 100644 index 00000000..f1b09fea --- /dev/null +++ b/test/mv_web/member_live/index_payment_filter_test.exs @@ -0,0 +1,77 @@ +defmodule MvWeb.MemberLive.IndexPaymentFilterTest do + @moduledoc """ + §1.16 / §2.2 / §3.3 — the payment-count filter in OverviewQuery. + + `fully_paid` selects members with 0 unpaid cycles in the active period; + `{:has_unpaid, N}` selects members with at least N — consistent with the + aging count (§2.5). All filtering resolves DB-side via the + `unpaid_cycle_count` calculation. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias MvWeb.MemberLive.Index.OverviewQuery + + @period %{from: ~D[2024-01-01], to: ~D[2024-12-31]} + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + ft = create_fee_type(%{interval: :monthly}, actor) + + # Wipe the seeded/other members so id sets are deterministic. + Mv.Membership.Member + |> Ash.read!(actor: actor) + |> Enum.each(&Ash.destroy!(&1, actor: actor)) + + %{actor: actor, ft: ft} + end + + defp member_with_unpaid(n, actor, ft) do + member = member_fixture_with_actor(%{}, actor) + + Enum.each(1..max(n, 0)//1, fn i -> + create_cycle(member, ft, %{cycle_start: Date.new!(2024, i, 1), status: :unpaid}, actor) + end) + + member + end + + defp ids(filter, actor) do + %{payment_filter: filter, payment_period: @period} + |> OverviewQuery.build() + |> Ash.read!(actor: actor) + |> MapSet.new(& &1.id) + end + + test "fully_paid selects members with zero in-period unpaid cycles", %{actor: actor, ft: ft} do + paid = member_with_unpaid(0, actor, ft) + create_cycle(paid, ft, %{cycle_start: ~D[2024-05-01], status: :paid}, actor) + one = member_with_unpaid(1, actor, ft) + + result = ids(:fully_paid, actor) + assert MapSet.member?(result, paid.id) + refute MapSet.member?(result, one.id) + end + + test "has-unpaid thresholds select members with count >= N", %{actor: actor, ft: ft} do + zero = member_with_unpaid(0, actor, ft) + one = member_with_unpaid(1, actor, ft) + two = member_with_unpaid(2, actor, ft) + three = member_with_unpaid(3, actor, ft) + + assert ids({:has_unpaid, 1}, actor) == MapSet.new([one.id, two.id, three.id]) + assert ids({:has_unpaid, 2}, actor) == MapSet.new([two.id, three.id]) + assert ids({:has_unpaid, 3}, actor) == MapSet.new([three.id]) + refute MapSet.member?(ids({:has_unpaid, 1}, actor), zero.id) + end + + test "suspended cycles do not count toward has-unpaid", %{actor: actor, ft: ft} do + member = member_fixture_with_actor(%{}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :suspended}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :suspended}, actor) + + refute MapSet.member?(ids({:has_unpaid, 1}, actor), member.id) + assert MapSet.member?(ids(:fully_paid, actor), member.id) + end +end diff --git a/test/mv_web/member_live/index_payment_period_test.exs b/test/mv_web/member_live/index_payment_period_test.exs index 7b56240e..1cf7c0ce 100644 --- a/test/mv_web/member_live/index_payment_period_test.exs +++ b/test/mv_web/member_live/index_payment_period_test.exs @@ -5,6 +5,7 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do backs the badge tooltip (unpaid cycles for the period; suspended shown apart). """ use Mv.DataCase, async: false + use ExUnitProperties import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] @@ -39,6 +40,58 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do end end + describe "payment-count filter codec" do + test "round-trips fully_paid and has-unpaid thresholds" do + for state <- [:fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}] do + params = PaymentAging.filter_to_params(state) + assert PaymentAging.parse_filter_params(params) == state + end + end + + test "nil filter serializes to no params and unknown values parse to nil" do + assert PaymentAging.filter_to_params(nil) == %{} + assert PaymentAging.parse_filter_params(%{}) == nil + assert PaymentAging.parse_filter_params(%{"pay_filter" => "bogus"}) == nil + end + + test "serializes to stable string tokens" do + assert PaymentAging.filter_to_params(:fully_paid) == %{"pay_filter" => "fully_paid"} + assert PaymentAging.filter_to_params({:has_unpaid, 2}) == %{"pay_filter" => "unpaid_2"} + end + end + + describe "payment URL round-trip (§2.1, payment keys)" do + @dates [nil, ~D[2020-01-01], ~D[2024-06-30], ~D[2025-12-31]] + @filters [nil, :fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}] + + property "decode∘encode is canonical and idempotent for period + payment filter" do + check all( + from <- StreamData.member_of(@dates), + to <- StreamData.member_of(@dates), + filter <- StreamData.member_of(@filters) + ) do + period = %{from: from, to: to} + + params = + period + |> PaymentAging.to_params() + |> Map.merge(PaymentAging.filter_to_params(filter)) + + assert PaymentAging.parse_period(params) == period + assert PaymentAging.parse_filter_params(params) == filter + + # Idempotence: re-encoding the decoded state yields the same params. + reencoded = + params + |> PaymentAging.parse_period() + |> PaymentAging.to_params() + |> Map.merge(PaymentAging.filter_to_params(PaymentAging.parse_filter_params(params))) + + assert reencoded == params + end + end + end + describe "badge/1" do test "0 renders as Paid (success)" do badge = PaymentAging.badge(0)