feat(overview): add period-scoped payment filter and field-picker descriptor

This commit is contained in:
Simon 2026-07-10 16:27:15 +02:00
parent 95ef3177ac
commit 7d1a71b1fd
12 changed files with 654 additions and 0 deletions

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)