Rework Filter using a filter builder #556

Open
simon wants to merge 11 commits from issue/mitgliederverwaltung-548 into issue/mitgliederverwaltung-547
12 changed files with 654 additions and 0 deletions
Showing only changes of commit 7d1a71b1fd - Show all commits

View file

@ -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.

View file

@ -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

View file

@ -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)
# ---------------------------------------------------------------------------

View file

@ -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).

View file

@ -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"

View file

@ -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 ""

View file

@ -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 ""

View file

@ -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(),

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)