feat(overview): add point-in-time, suspended, OR-group and count-range filters

This commit is contained in:
Simon 2026-07-10 16:27:15 +02:00
parent c547ca19af
commit c0d1550401
23 changed files with 1116 additions and 117 deletions

View file

@ -44,6 +44,14 @@ defmodule Mv.Constants do
@payment_filter_param "pay_filter" @payment_filter_param "pay_filter"
@payment_count_min_param "pay_min"
@payment_count_max_param "pay_max"
@suspended_param "suspended"
@stichtag_param "stichtag"
@max_boolean_filters 50 @max_boolean_filters 50
@max_mailto_bulk_recipients 50 @max_mailto_bulk_recipients 50
@ -200,6 +208,49 @@ defmodule Mv.Constants do
""" """
def payment_filter_param, do: @payment_filter_param def payment_filter_param, do: @payment_filter_param
@doc """
Returns the URL parameter name for the payment open-cycle count lower bound
(used with `pay_filter=has_unpaid` for the two-sided count range).
## Examples
iex> Mv.Constants.payment_count_min_param()
"pay_min"
"""
def payment_count_min_param, do: @payment_count_min_param
@doc """
Returns the URL parameter name for the payment open-cycle count upper bound.
## Examples
iex> Mv.Constants.payment_count_max_param()
"pay_max"
"""
def payment_count_max_param, do: @payment_count_max_param
@doc """
Returns the URL parameter name for the suspended-status filter flag.
## Examples
iex> Mv.Constants.suspended_param()
"suspended"
"""
def suspended_param, do: @suspended_param
@doc """
Returns the URL parameter name for the point-in-time membership ("Stichtag")
filter: keeps members who were active on the given ISO-8601 date X, i.e.
`join_date <= X and (exit_date is nil or exit_date > X)`.
## Examples
iex> Mv.Constants.stichtag_param()
"stichtag"
"""
def stichtag_param, do: @stichtag_param
@doc """ @doc """
Returns the maximum number of boolean custom field filters allowed per request. Returns the maximum number of boolean custom field filters allowed per request.

View file

@ -35,6 +35,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
alias Mv.Constants alias Mv.Constants
alias MvWeb.MemberLive.Index.DateFilter alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.DatePresets
alias MvWeb.MemberLive.Index.FilterDescriptor alias MvWeb.MemberLive.Index.FilterDescriptor
alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.FilterParams
alias MvWeb.MemberLive.Index.PaymentAging alias MvWeb.MemberLive.Index.PaymentAging
@ -968,32 +969,30 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
# --- date presets --------------------------------------------------------- # --- date presets ---------------------------------------------------------
defp date_presets do defp date_presets do
[ Enum.map(DatePresets.all(), fn preset -> {Atom.to_string(preset), preset_label(preset)} end)
{"this_year", gettext("This year")},
{"last_year", gettext("Last year")},
{"this_month", gettext("This month")},
{"last_30_days", gettext("Last 30 days")}
]
end end
defp preset_range("this_year") do defp preset_label(:last_7_days), do: gettext("Last 7 days")
y = Date.utc_today().year defp preset_label(:last_30_days), do: gettext("Last 30 days")
{Date.new!(y, 1, 1), Date.new!(y, 12, 31)} defp preset_label(:last_3_months), do: gettext("Last 3 months")
end defp preset_label(:last_12_months), do: gettext("Last 12 months")
defp preset_label(:this_month), do: gettext("This month")
defp preset_label(:this_quarter), do: gettext("This quarter")
defp preset_label(:this_year), do: gettext("This year")
defp preset_range("last_year") do # Resolves a preset string (from `phx-value-preset`) to a concrete `{from, to}`
y = Date.utc_today().year - 1 # tuple via the shared, clock-free `DatePresets` resolver, or nil for an
{Date.new!(y, 1, 1), Date.new!(y, 12, 31)} # unknown preset. The resolver defines "this year" as period-to-date
end # ([Jan 1, today]) per §2.8, superseding the prior full-year range.
defp preset_range(preset) when is_binary(preset) do
case Enum.find(DatePresets.all(), &(Atom.to_string(&1) == preset)) do
nil ->
nil
defp preset_range("this_month") do key ->
today = Date.utc_today() %{from: from, to: to} = DatePresets.resolve(key)
{Date.beginning_of_month(today), Date.end_of_month(today)} {from, to}
end end
defp preset_range("last_30_days") do
today = Date.utc_today()
{Date.add(today, -30), today}
end end
defp preset_range(_), do: nil defp preset_range(_), do: nil

View file

@ -59,6 +59,7 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery alias MvWeb.MemberLive.Index.OverviewQuery
alias MvWeb.MemberLive.Index.PaymentAging alias MvWeb.MemberLive.Index.PaymentAging
alias MvWeb.MemberLive.Index.Stichtag
alias MvWeb.MemberLive.Index.ViewSettings alias MvWeb.MemberLive.Index.ViewSettings
require Ash.Query require Ash.Query
@ -177,6 +178,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_order, fn -> :asc end) |> assign_new(:sort_order, fn -> :asc end)
|> assign(:payment_filter, nil) |> assign(:payment_filter, nil)
|> assign(:payment_period, PaymentAging.default_period()) |> assign(:payment_period, PaymentAging.default_period())
|> assign(:suspended, false)
|> assign(:stichtag, nil)
|> assign(:group_filters, %{}) |> assign(:group_filters, %{})
|> assign(:groups, groups) |> assign(:groups, groups)
|> assign(:fee_type_filters, %{}) |> assign(:fee_type_filters, %{})
@ -679,6 +682,8 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_sort(params) |> maybe_update_sort(params)
|> maybe_update_payment_filter(params) |> maybe_update_payment_filter(params)
|> maybe_update_payment_period(params) |> maybe_update_payment_period(params)
|> maybe_update_suspended(params)
|> maybe_update_stichtag(params)
|> maybe_update_group_filters(params) |> maybe_update_group_filters(params)
|> maybe_update_fee_type_filters(params) |> maybe_update_fee_type_filters(params)
|> maybe_update_boolean_filters(params) |> maybe_update_boolean_filters(params)
@ -728,6 +733,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_order, socket.assigns.sort_order,
socket.assigns.payment_filter, socket.assigns.payment_filter,
socket.assigns.payment_period, socket.assigns.payment_period,
socket.assigns[:suspended],
socket.assigns[:stichtag],
socket.assigns[:group_filters], socket.assigns[:group_filters],
socket.assigns[:fee_type_filters], socket.assigns[:fee_type_filters],
socket.assigns.boolean_custom_field_filters, socket.assigns.boolean_custom_field_filters,
@ -883,10 +890,19 @@ defmodule MvWeb.MemberLive.Index do
base_params = add_group_filters(base_params, opts.group_filters || %{}) base_params = add_group_filters(base_params, opts.group_filters || %{})
base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{}) base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{})
base_params = add_payment_params(base_params, opts.payment_filter, opts.payment_period) base_params = add_payment_params(base_params, opts.payment_filter, opts.payment_period)
base_params = add_status_params(base_params, opts[:suspended], opts[:stichtag])
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{}) base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
add_date_filters(base_params, opts.date_filters) add_date_filters(base_params, opts.date_filters)
end end
# Suspended-status flag and Stichtag date serialize additively into the flat
# URL contract (suspended=1 / stichtag=<iso>), omitted when inactive (§2.1).
defp add_status_params(params, suspended, stichtag) do
params
|> Map.merge(PaymentAging.suspended_to_params(suspended))
|> Map.merge(Stichtag.to_params(stichtag))
end
# Period-scoped payment model (§3.3): the payment-count filter and the active # Period-scoped payment model (§3.3): the payment-count filter and the active
# period both serialize into the flat URL contract (pay_filter/pay_from/pay_to). # period both serialize into the flat URL contract (pay_filter/pay_from/pay_to).
defp add_payment_params(params, payment_filter, payment_period) do defp add_payment_params(params, payment_filter, payment_period) do
@ -906,6 +922,8 @@ defmodule MvWeb.MemberLive.Index do
sort_order: socket.assigns.sort_order, sort_order: socket.assigns.sort_order,
payment_filter: socket.assigns.payment_filter, payment_filter: socket.assigns.payment_filter,
payment_period: socket.assigns.payment_period, payment_period: socket.assigns.payment_period,
suspended: socket.assigns[:suspended] || false,
stichtag: socket.assigns[:stichtag],
group_filters: socket.assigns[:group_filters] || %{}, group_filters: socket.assigns[:group_filters] || %{},
boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
fee_type_filters: socket.assigns[:fee_type_filters] || %{}, fee_type_filters: socket.assigns[:fee_type_filters] || %{},
@ -1366,6 +1384,8 @@ defmodule MvWeb.MemberLive.Index do
date_custom_fields: socket.assigns[:date_custom_fields], date_custom_fields: socket.assigns[:date_custom_fields],
payment_filter: socket.assigns.payment_filter, payment_filter: socket.assigns.payment_filter,
payment_period: socket.assigns.payment_period, payment_period: socket.assigns.payment_period,
suspended: socket.assigns[:suspended] || false,
stichtag: socket.assigns[:stichtag],
sort_field: socket.assigns.sort_field, sort_field: socket.assigns.sort_field,
sort_order: socket.assigns.sort_order, sort_order: socket.assigns.sort_order,
custom_fields: socket.assigns.all_custom_fields custom_fields: socket.assigns.all_custom_fields
@ -1448,7 +1468,7 @@ defmodule MvWeb.MemberLive.Index do
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field) or field in valid_fields or custom_field_sort?(field) or
field in [:groups, :membership_fee_type, :name, :address] field in [:groups, :membership_fee_type, :name, :address, :payment]
end end
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
@ -1458,6 +1478,7 @@ defmodule MvWeb.MemberLive.Index do
field == "membership_fee_type" -> :membership_fee_type field == "membership_fee_type" -> :membership_fee_type
field == "name" -> :name field == "name" -> :name
field == "address" -> :address field == "address" -> :address
field == "payment" -> :payment
true -> safe_member_field_atom_only(field) true -> safe_member_field_atom_only(field)
end end
@ -1507,9 +1528,10 @@ defmodule MvWeb.MemberLive.Index do
defp determine_field(default, nil), do: default defp determine_field(default, nil), do: default
# Computed/pseudo fields that are nonetheless sortable (they resolve to DB # Computed/pseudo fields that are nonetheless sortable (they resolve to DB
# sort keys in OverviewQuery): groups aggregate and the composite Name/Address # sort keys in OverviewQuery): groups aggregate, the composite Name/Address
# columns. Other computed fields (e.g. membership_fee_status) are not sortable. # columns, and the period-scoped payment (unpaid-cycle-count) column. Other
@sortable_computed_fields [:groups, :name, :address] # computed fields (e.g. membership_fee_status) are not sortable.
@sortable_computed_fields [:groups, :name, :address, :payment]
defp determine_field(default, sf) when is_binary(sf) do defp determine_field(default, sf) when is_binary(sf) do
sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1) sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)
@ -1578,6 +1600,15 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_payment_period(socket, params), defp maybe_update_payment_period(socket, params),
do: assign(socket, :payment_period, PaymentAging.parse_period(params)) do: assign(socket, :payment_period, PaymentAging.parse_period(params))
# Suspended-status filter (§1.23) and point-in-time Stichtag (§1.24/§2.6):
# both decoded straight from the URL so `handle_params` remains the single
# source of truth. Absent params fall back to no filter.
defp maybe_update_suspended(socket, params),
do: assign(socket, :suspended, PaymentAging.parse_suspended(params))
defp maybe_update_stichtag(socket, params),
do: assign(socket, :stichtag, Stichtag.parse(params))
defp maybe_update_group_filters(socket, params) when is_map(params) do defp maybe_update_group_filters(socket, params) when is_map(params) do
prefix = @group_filter_prefix prefix = @group_filter_prefix
prefix_len = String.length(prefix) prefix_len = String.length(prefix)
@ -1836,17 +1867,16 @@ defmodule MvWeb.MemberLive.Index do
all-outstanding default (both bounds nil) reads just "Payment"; a bounded all-outstanding default (both bounds nil) reads just "Payment"; a bounded
period appends the range so the scope is always legible. period appends the range so the scope is always legible.
""" """
def payment_column_label(%{from: nil, to: nil}), do: gettext("Fees") def payment_column_label(_period), do: gettext("Fees")
def payment_column_label(%{from: from, to: to}) do @doc """
range = "#{payment_bound(from)}#{payment_bound(to)}" Whether a payment period is actively scoped (at least one bound set), used to
gettext("Fees · %{range}", range: range) decide whether the compact period indicator badge renders (§1.28). The
end all-outstanding default (both bounds nil) shows no badge.
"""
def payment_column_label(_), do: gettext("Fees") def payment_period_active?(%{from: nil, to: nil}), do: false
def payment_period_active?(%{from: _, to: _}), do: true
defp payment_bound(%Date{} = d), do: Date.to_iso8601(d) def payment_period_active?(_), do: false
defp payment_bound(_), do: ""
defp update_selection_assigns(socket) do defp update_selection_assigns(socket) do
selected_members = socket.assigns.selected_members selected_members = socket.assigns.selected_members

View file

@ -456,7 +456,31 @@
<:col <:col
:let={member} :let={member}
:if={:membership_fee_status in @member_fields_visible} :if={:membership_fee_status in @member_fields_visible}
label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)} sort_field={:payment}
label={
~H"""
<div class="flex items-center gap-1.5">
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_payment}
field={:payment}
label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)}
sort_hint={gettext("Click to sort by open payment cycles")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
<span
:if={MvWeb.MemberLive.Index.payment_period_active?(@payment_period)}
class="badge badge-soft badge-info badge-sm shrink-0"
title={PaymentAging.period_tooltip(@payment_period)}
aria-label={PaymentAging.period_tooltip(@payment_period)}
data-testid="payment-period-badge"
>
{PaymentAging.period_short_code(@payment_period) || gettext("filtered")}
</span>
</div>
"""
}
> >
<% count = member.unpaid_cycle_count || 0 %> <% count = member.unpaid_cycle_count || 0 %>
<% badge = PaymentAging.badge(count) %> <% badge = PaymentAging.badge(count) %>

View file

@ -0,0 +1,70 @@
defmodule MvWeb.MemberLive.Index.DatePresets do
@moduledoc """
Relative date-range presets for the date value controls (§1.25/§2.8).
Each preset resolves against a reference date "today" into a concrete
`%{from: Date.t(), to: Date.t()}` range. Rolling "last N days/months" presets
end on today; the "this month/quarter/year" presets run period-to-date from
the respective period start up to today. Resolving is pure and clock-free
the caller passes `today` (defaulting to `Date.utc_today/0`), so the resolved
bounds are testable and reproducible.
The resolved bounds feed the same `jd_*` / `ed_*` / `cdf_*` URL params as an
absolute custom range; presets are purely an input convenience and add no new
persistence dimension.
"""
@type preset ::
:last_7_days
| :last_30_days
| :last_3_months
| :last_12_months
| :this_month
| :this_quarter
| :this_year
@type range :: %{from: Date.t(), to: Date.t()}
@presets [
:last_7_days,
:last_30_days,
:last_3_months,
:last_12_months,
:this_month,
:this_quarter,
:this_year
]
@doc """
Returns every preset key in display order (rolling windows first, then the
period-to-date presets), for rendering the preset radio list.
"""
@spec all() :: [preset()]
def all, do: @presets
@doc """
Whether `key` names a known preset.
"""
@spec preset?(term()) :: boolean()
def preset?(key), do: key in @presets
@doc """
Resolves a preset to a concrete `{from, to}` range relative to `today`
(default `Date.utc_today/0`).
"""
@spec resolve(preset(), Date.t()) :: range()
def resolve(preset, today \\ Date.utc_today())
def resolve(:last_7_days, today), do: %{from: Date.add(today, -6), to: today}
def resolve(:last_30_days, today), do: %{from: Date.add(today, -29), to: today}
def resolve(:last_3_months, today), do: %{from: Date.shift(today, month: -3), to: today}
def resolve(:last_12_months, today), do: %{from: Date.shift(today, month: -12), to: today}
def resolve(:this_month, today), do: %{from: %{today | day: 1}, to: today}
def resolve(:this_quarter, today) do
first_month = div(today.month - 1, 3) * 3 + 1
%{from: Date.new!(today.year, first_month, 1), to: today}
end
def resolve(:this_year, today), do: %{from: Date.new!(today.year, 1, 1), to: today}
end

View file

@ -46,9 +46,16 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
opts[:boolean_custom_fields] opts[:boolean_custom_fields]
) )
|> apply_date_filters(opts[:date_filters]) |> apply_date_filters(opts[:date_filters])
|> apply_stichtag_filter(opts[:stichtag])
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields]) |> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|> apply_payment_filter(opts[:payment_filter], payment_period(opts)) |> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || []) |> apply_suspended_filter(opts[:suspended], payment_period(opts))
|> apply_sort(
opts[:sort_field],
opts[:sort_order],
opts[:custom_fields] || [],
payment_period(opts)
)
end end
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -61,7 +68,12 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_search(query, _), do: query defp apply_search(query, _), do: query
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Group filters (AND across selected groups) # Group filters (:in OR across selected groups; :not_in AND-of-not-exists)
#
# Multiple "is" selections bundle into a single membership `exists` over the
# union of group ids, so a member matching *any* selected group is kept (OR,
# §1.29/§2.7). Each "is not" selection stays an independent not-exists, so a
# member is excluded when it belongs to *any* of them.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
defp apply_group_filters(query, group_filters, _groups) defp apply_group_filters(query, group_filters, _groups)
@ -71,31 +83,32 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_group_filters(query, group_filters, groups) do defp apply_group_filters(query, group_filters, groups) do
valid_ids = valid_id_set(groups) valid_ids = valid_id_set(groups)
Enum.reduce(group_filters, query, fn {group_id_str, value}, q -> {in_filters, not_in_filters} =
if MapSet.member?(valid_ids, group_id_str) do group_filters
apply_one_group_filter(q, group_id_str, value) |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
else |> Enum.split_with(fn {_, value} -> value == :in end)
q
query
|> apply_group_in_filter(Enum.map(in_filters, fn {id_str, _} -> id_str end))
|> apply_group_not_in_filters(Enum.map(not_in_filters, fn {id_str, _} -> id_str end))
end
defp apply_group_in_filter(query, id_strs) do
case cast_uuids(id_strs) do
[] -> query
uuids -> Ash.Query.filter(query, expr(exists(member_groups, group_id in ^uuids)))
end
end
defp apply_group_not_in_filters(query, id_strs) do
Enum.reduce(id_strs, query, fn id_str, q ->
case Ecto.UUID.cast(id_str) do
{:ok, uuid} -> Ash.Query.filter(q, expr(not exists(member_groups, group_id == ^uuid)))
_ -> q
end end
end) end)
end end
defp apply_one_group_filter(query, group_id_str, :in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, uuid} -> Ash.Query.filter(query, expr(exists(member_groups, group_id == ^uuid)))
_ -> query
end
end
defp apply_one_group_filter(query, group_id_str, :not_in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, uuid} -> Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^uuid)))
_ -> query
end
end
defp apply_one_group_filter(query, _id, _value), do: query
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fee-type filters (:in OR; :not_in AND) # Fee-type filters (:in OR; :not_in AND)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -209,8 +222,53 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
) )
end end
# Two-sided open-cycle range (§1.22/§3.14): keeps members whose in-period
# unpaid count is within [min, max]. `max` nil means unbounded above, so
# `{:unpaid_range, 1, nil}` is the default "has open" (>= 1). Both bounds push
# to SQL through the same `unpaid_cycle_count` calculation.
defp apply_payment_filter(query, {:unpaid_range, min, max}, %{from: from, to: to})
when is_integer(min) and min > 0 do
query
|> Ash.Query.filter(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^min)
|> apply_unpaid_upper_bound(max, from, to)
end
defp apply_payment_filter(query, _filter, _period), do: query defp apply_payment_filter(query, _filter, _period), do: query
defp apply_unpaid_upper_bound(query, max, from, to) when is_integer(max) and max > 0 do
Ash.Query.filter(
query,
expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) <= ^max)
)
end
defp apply_unpaid_upper_bound(query, _max, _from, _to), do: query
# Suspended-status filter (§1.23): keeps members with at least one suspended
# cycle whose cycle_end falls in the active period. Independent of the unpaid
# count, which excludes suspended (§2.5). A correlated `exists` pushes to SQL;
# the period bounds are composed conditionally so an unbounded (nil) side adds
# no `cycle_end` comparison rather than comparing against a literal nil.
defp apply_suspended_filter(query, true, %{from: from, to: to}) do
Ash.Query.filter(
query,
expr(exists(membership_fee_cycles, ^suspended_period_predicate(from, to)))
)
end
defp apply_suspended_filter(query, _suspended, _period), do: query
defp suspended_period_predicate(nil, nil), do: expr(status == :suspended)
defp suspended_period_predicate(from, nil),
do: expr(status == :suspended and cycle_end >= ^from)
defp suspended_period_predicate(nil, to),
do: expr(status == :suspended and cycle_end <= ^to)
defp suspended_period_predicate(from, to),
do: expr(status == :suspended and cycle_end >= ^from and cycle_end <= ^to)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Built-in date filters (join/exit) # Built-in date filters (join/exit)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -226,21 +284,41 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_custom_date_filters(query, _filters, _custom_fields), do: query defp apply_custom_date_filters(query, _filters, _custom_fields), do: query
# ---------------------------------------------------------------------------
# Point-in-time membership ("Stichtag"): a member counts as a member on the
# reference date X iff it had joined by X and had not yet left as of X
# (§1.24/§2.6). "Left as of X" means a non-nil exit_date at or before X, so an
# exit_date strictly after X (or nil) still counts as a member on X.
# ---------------------------------------------------------------------------
defp apply_stichtag_filter(query, %Date{} = x) do
Ash.Query.filter(
query,
expr(join_date <= ^x and (is_nil(exit_date) or exit_date > ^x))
)
end
defp apply_stichtag_filter(query, _), do: query
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sort (always append the unique id tie-breaker for keyset stability) # Sort (always append the unique id tie-breaker for keyset stability)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc) defp apply_sort(query, nil, _order, _custom_fields, _period),
defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc) do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, _field, nil, _custom_fields, _period),
do: Ash.Query.sort(query, id: :asc)
# Composite "Member" column: sort by last name then first name. The email that # Composite "Member" column: sort by last name then first name. The email that
# shares the cell is display-only and does not affect the sort key (§1.21). # shares the cell is display-only and does not affect the sort key (§1.21).
defp apply_sort(query, field, order, _custom_fields) when field in [:name, "name"] do defp apply_sort(query, field, order, _custom_fields, _period) when field in [:name, "name"] do
Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}]) Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}])
end end
# Composite "Address" column: sort by city, then postal code, then street. # Composite "Address" column: sort by city, then postal code, then street.
defp apply_sort(query, field, order, _custom_fields) when field in [:address, "address"] do defp apply_sort(query, field, order, _custom_fields, _period)
when field in [:address, "address"] do
Ash.Query.sort(query, [ Ash.Query.sort(query, [
{:city, order}, {:city, order},
{:postal_code, order}, {:postal_code, order},
@ -249,7 +327,20 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
]) ])
end end
defp apply_sort(query, field, order, custom_fields) do # "Beiträge" (payment) column: sort DB-side by the period-scoped unpaid-cycle
# count (§1.27). The count calculation takes the active period as arguments so
# the sort key matches the displayed value; the id tie-breaker keeps keyset
# pages stable across equal counts.
defp apply_sort(query, field, order, _custom_fields, %{from: from, to: to})
when field in [:payment, "payment"] do
Ash.Query.sort(query, [
{Ash.Sort.expr_sort(unpaid_cycle_count(period_from: ^from, period_to: ^to), :integer),
order},
{:id, :asc}
])
end
defp apply_sort(query, field, order, custom_fields, _period) do
case sort_key(field) do case sort_key(field) do
nil -> apply_custom_field_sort(query, field, order, custom_fields) nil -> apply_custom_field_sort(query, field, order, custom_fields)
key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}]) key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}])

View file

@ -29,11 +29,18 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
@type period :: %{from: Date.t() | nil, to: Date.t() | nil} @type period :: %{from: Date.t() | nil, to: Date.t() | nil}
@type filter :: nil | :fully_paid | {:has_unpaid, 1..3} @type filter ::
nil
| :fully_paid
| {:has_unpaid, 1..3}
| {:unpaid_range, pos_integer(), pos_integer() | nil}
@payment_period_from_param Constants.payment_period_from_param() @payment_period_from_param Constants.payment_period_from_param()
@payment_period_to_param Constants.payment_period_to_param() @payment_period_to_param Constants.payment_period_to_param()
@payment_filter_param Constants.payment_filter_param() @payment_filter_param Constants.payment_filter_param()
@payment_count_min_param Constants.payment_count_min_param()
@payment_count_max_param Constants.payment_count_max_param()
@suspended_param Constants.suspended_param()
@doc """ @doc """
The default period: all outstanding cycles, all time (both bounds nil). The default period: all outstanding cycles, all time (both bounds nil).
@ -83,6 +90,10 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
@doc """ @doc """
Encodes a payment-count filter into URL params. `nil` yields the empty map. Encodes a payment-count filter into URL params. `nil` yields the empty map.
The two-sided `{:unpaid_range, min, max}` form encodes as
`pay_filter=has_unpaid` plus `pay_min` (and `pay_max` when bounded above),
so a fresh "has open" default (`min=1`, `max=nil`) round-trips canonically.
""" """
@spec filter_to_params(filter()) :: %{optional(String.t()) => String.t()} @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(:fully_paid), do: %{@payment_filter_param => "fully_paid"}
@ -90,14 +101,61 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
def filter_to_params({:has_unpaid, n}) when n in 1..3, def filter_to_params({:has_unpaid, n}) when n in 1..3,
do: %{@payment_filter_param => "unpaid_#{n}"} do: %{@payment_filter_param => "unpaid_#{n}"}
def filter_to_params({:unpaid_range, min, max}) when is_integer(min) and min > 0 do
%{@payment_filter_param => "has_unpaid", @payment_count_min_param => Integer.to_string(min)}
|> maybe_put_max(max)
end
def filter_to_params(_), do: %{} def filter_to_params(_), do: %{}
defp maybe_put_max(params, max) when is_integer(max) and max > 0,
do: Map.put(params, @payment_count_max_param, Integer.to_string(max))
defp maybe_put_max(params, _max), do: params
@doc """ @doc """
Decodes the payment-count filter from a full params map. Decodes the payment-count filter from a full params map.
A `pay_filter=has_unpaid` value reads the `pay_min`/`pay_max` bounds into a
`{:unpaid_range, min, max}`; an absent/invalid `pay_min` defaults to 1 and an
absent/invalid `pay_max` to nil (unbounded). Other `pay_filter` values fall
back to the single-token decode (`fully_paid` / `unpaid_N`).
""" """
@spec parse_filter_params(map()) :: filter() @spec parse_filter_params(map()) :: filter()
def parse_filter_params(params) when is_map(params), def parse_filter_params(params) when is_map(params) do
do: parse_filter(Map.get(params, @payment_filter_param)) case Map.get(params, @payment_filter_param) do
"has_unpaid" ->
{:unpaid_range, parse_count(Map.get(params, @payment_count_min_param), 1),
parse_count(Map.get(params, @payment_count_max_param), nil)}
other ->
parse_filter(other)
end
end
defp parse_count(value, default) when is_binary(value) do
case Integer.parse(String.trim(value)) do
{n, ""} when n > 0 -> n
_ -> default
end
end
defp parse_count(_value, default), do: default
@doc """
Decodes the suspended-status filter flag (§1.23) from a params map. The flag
is present (`suspended=1`) only when active; any other value reads as false.
"""
@spec parse_suspended(map()) :: boolean()
def parse_suspended(params) when is_map(params), do: Map.get(params, @suspended_param) == "1"
@doc """
Encodes the suspended-status filter flag into URL params. Only `true` emits a
param (`suspended=1`); false/nil yield the empty map so a fresh URL is canonical.
"""
@spec suspended_to_params(boolean() | nil) :: %{optional(String.t()) => String.t()}
def suspended_to_params(true), do: %{@suspended_param => "1"}
def suspended_to_params(_), do: %{}
@doc """ @doc """
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Alle bezahlt" Badge descriptor for an unpaid-cycle count. A count of 0 reads "Alle bezahlt"
@ -132,6 +190,53 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
} }
end end
@doc """
Compact short code for a payment period (§1.28), or nil when the period is not
compactly codeable (the header then falls back to a plain "filtered" badge).
* `{nil, nil}` (all-outstanding default) nil (no badge)
* a full calendar year (Jan 1 Dec 31) `"2026"`
* a full calendar quarter `"Q1 2026"`
* anything else (arbitrary or open-ended range) nil
"""
@spec period_short_code(period()) :: String.t() | nil
def period_short_code(%{from: %Date{} = from, to: %Date{} = to}) do
cond do
full_year?(from, to) -> Integer.to_string(from.year)
full_quarter?(from, to) -> "Q#{quarter(from.month)} #{from.year}"
true -> nil
end
end
def period_short_code(_), do: nil
@doc """
Human-readable tooltip naming the active payment period with locally formatted
(`dd.MM.yyyy`) bounds (§1.28). The all-outstanding default names all
outstanding cycles; open-ended periods render an open bound as an ellipsis.
"""
@spec period_tooltip(period()) :: String.t()
def period_tooltip(%{from: nil, to: nil}), do: gettext("Fees across all outstanding cycles")
def period_tooltip(%{from: from, to: to}) do
gettext("Fees in period %{from}%{to}", from: period_bound(from), to: period_bound(to))
end
def period_tooltip(_), do: gettext("Fees across all outstanding cycles")
defp period_bound(%Date{} = d), do: DateFormatter.format_date(d)
defp period_bound(_), do: ""
defp full_year?(from, to),
do:
from.month == 1 and from.day == 1 and to.year == from.year and to.month == 12 and
to.day == 31
defp full_quarter?(from, to) do
from.day == 1 and from.month in [1, 4, 7, 10] and to.year == from.year and
to == Date.end_of_month(Date.new!(from.year, from.month + 2, 1))
end
@doc """ @doc """
A member's open cycles for the badge tooltip: the unpaid and suspended cycles A member's open cycles for the badge tooltip: the unpaid and suspended cycles
whose `cycle_end` falls inside `period`, merged into a single list sorted whose `cycle_end` falls inside `period`, merged into a single list sorted

View file

@ -0,0 +1,36 @@
defmodule MvWeb.MemberLive.Index.Stichtag do
@moduledoc """
URL codec for the point-in-time membership ("Stichtag") filter (§1.24/§2.6).
The Stichtag is a single ISO-8601 date X carried in the flat URL contract
under the `stichtag` key. When set, the overview keeps members who were active
on X (`join_date <= X and (exit_date is nil or exit_date > X)`); the predicate
itself lives in `OverviewQuery`. Absent or malformed values decode to nil (no
filter), and nil encodes to the empty map so a fresh URL stays canonical.
"""
@stichtag_param Mv.Constants.stichtag_param()
@doc """
Decodes the Stichtag date from a params map. Missing or malformed ISO-8601
values yield nil.
"""
@spec parse(map()) :: Date.t() | nil
def parse(params) when is_map(params), do: parse_date(Map.get(params, @stichtag_param))
@doc """
Encodes a Stichtag date into URL params. nil yields the empty map.
"""
@spec to_params(Date.t() | nil) :: %{optional(String.t()) => String.t()}
def to_params(%Date{} = date), do: %{@stichtag_param => Date.to_iso8601(date)}
def to_params(_), do: %{}
defp parse_date(value) when is_binary(value) do
case Date.from_iso8601(String.trim(value)) do
{:ok, date} -> date
_ -> nil
end
end
defp parse_date(_), do: nil
end

View file

@ -3956,6 +3956,7 @@ msgid "email %{email} has already been taken"
msgstr "E-Mail %{email} wurde bereits verwendet" msgstr "E-Mail %{email} wurde bereits verwendet"
#: lib/mv_web/components/bulk_actions_dropdown.ex #: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "filtered" msgid "filtered"
msgstr "gefiltert" msgstr "gefiltert"
@ -4212,11 +4213,6 @@ msgstr "Beigetreten"
msgid "Last 30 days" msgid "Last 30 days"
msgstr "Letzte 30 Tage" msgstr "Letzte 30 Tage"
#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "Last year"
msgstr "Letztes Jahr"
#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Membership" msgid "Membership"
@ -4307,11 +4303,6 @@ msgstr "Februar"
msgid "Fees" msgid "Fees"
msgstr "Beiträge" msgstr "Beiträge"
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fees · %{range}"
msgstr "Beiträge · %{range}"
#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/index/payment_aging.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "January" msgid "January"
@ -4347,27 +4338,37 @@ msgstr "Oktober"
msgid "September" msgid "September"
msgstr "September" msgstr "September"
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Click for member details" msgid "Click to sort by open payment cycles"
#~ msgstr "Klicke für Mitglieds-Details" msgstr "Klicke, um nach offenen Beitragszyklen zu sortieren"
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/components/add_filter_builder_component.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "No open cycles for this period" msgid "Last 12 months"
#~ msgstr "Keine offenen Zyklen in diesem Zeitraum" msgstr "Letzte 12 Monate"
#~ #: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/components/add_filter_builder_component.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Payment · %{range}" msgid "Last 3 months"
#~ msgstr "Zahlung · %{range}" msgstr "Letzte 3 Monate"
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/components/add_filter_builder_component.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Suspended: %{date}" msgid "Last 7 days"
#~ msgstr "Ausgesetzt: %{date}" msgstr "Letzte 7 Tage"
#~ #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/components/add_filter_builder_component.ex
#~ #, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
#~ msgid "Unpaid until %{date}" msgid "This quarter"
#~ msgstr "Offen bis %{date}" msgstr "Dieses Quartal"
#: lib/mv_web/live/member_live/index/payment_aging.ex
#, elixir-autogen, elixir-format
msgid "Fees across all outstanding cycles"
msgstr "Beiträge über alle offenen Zyklen"
#: lib/mv_web/live/member_live/index/payment_aging.ex
#, elixir-autogen, elixir-format
msgid "Fees in period %{from}%{to}"
msgstr "Beiträge im Zeitraum %{from}%{to}"

View file

@ -3956,6 +3956,7 @@ msgid "email %{email} has already been taken"
msgstr "" msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex #: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "filtered" msgid "filtered"
msgstr "" msgstr ""
@ -4212,11 +4213,6 @@ msgstr ""
msgid "Last 30 days" msgid "Last 30 days"
msgstr "" msgstr ""
#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "Last year"
msgstr ""
#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Membership" msgid "Membership"
@ -4307,11 +4303,6 @@ msgstr ""
msgid "Fees" msgid "Fees"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fees · %{range}"
msgstr ""
#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/index/payment_aging.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "January" msgid "January"
@ -4346,3 +4337,38 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "September" msgid "September"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click to sort by open payment cycles"
msgstr ""
#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "Last 12 months"
msgstr ""
#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "Last 3 months"
msgstr ""
#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "Last 7 days"
msgstr ""
#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "This quarter"
msgstr ""
#: lib/mv_web/live/member_live/index/payment_aging.ex
#, elixir-autogen, elixir-format
msgid "Fees across all outstanding cycles"
msgstr ""
#: lib/mv_web/live/member_live/index/payment_aging.ex
#, elixir-autogen, elixir-format
msgid "Fees in period %{from}%{to}"
msgstr ""

View file

@ -0,0 +1,66 @@
defmodule MvWeb.MemberLive.IndexDatePresetsPropertyTest do
@moduledoc """
§2.8 Relative date-range preset resolution.
For any preset evaluated against any reference date "today", the resolved
`{from, to}` matches the preset's definition: rolling "last N days" windows end
on today, and the "this month/quarter/year" presets run period-to-date from the
respective period start to today.
"""
use ExUnit.Case, async: true
use ExUnitProperties
alias MvWeb.MemberLive.Index.DatePresets
defp date_gen do
gen all(
year <- StreamData.integer(2000..2100),
month <- StreamData.integer(1..12),
day <- StreamData.integer(1..28)
) do
Date.new!(year, month, day)
end
end
property "last-N-days presets end on today and span N days inclusive" do
check all(today <- date_gen()) do
assert DatePresets.resolve(:last_7_days, today) == %{from: Date.add(today, -6), to: today}
assert DatePresets.resolve(:last_30_days, today) == %{from: Date.add(today, -29), to: today}
end
end
property "last-N-months presets shift the start back N months and end on today" do
check all(today <- date_gen()) do
assert DatePresets.resolve(:last_3_months, today) ==
%{from: Date.shift(today, month: -3), to: today}
assert DatePresets.resolve(:last_12_months, today) ==
%{from: Date.shift(today, month: -12), to: today}
end
end
property "this-period presets run from the period start to today" do
check all(today <- date_gen()) do
month_start = %{today | day: 1}
q_first_month = div(today.month - 1, 3) * 3 + 1
quarter_start = Date.new!(today.year, q_first_month, 1)
year_start = Date.new!(today.year, 1, 1)
assert DatePresets.resolve(:this_month, today) == %{from: month_start, to: today}
assert DatePresets.resolve(:this_quarter, today) == %{from: quarter_start, to: today}
assert DatePresets.resolve(:this_year, today) == %{from: year_start, to: today}
end
end
test "all/0 lists every resolvable preset key in display order" do
assert DatePresets.all() == [
:last_7_days,
:last_30_days,
:last_3_months,
:last_12_months,
:this_month,
:this_quarter,
:this_year
]
end
end

View file

@ -1,8 +1,8 @@
defmodule MvWeb.MemberLive.IndexDatePresetsTest do defmodule MvWeb.MemberLive.IndexDatePresetsTest do
@moduledoc """ @moduledoc """
§1.8 a date field's value control offers presets; picking "This year" §1.25 (supersedes §1.8) a date field's value control offers relative
applies the Jan 1 Dec 31 range of the current year and writes the equivalent presets; picking "This year" applies the period-to-date range (Jan 1 today)
range params to the URL. of the current year and writes the equivalent range params to the URL.
""" """
use MvWeb.ConnCase, async: false use MvWeb.ConnCase, async: false
@ -20,7 +20,7 @@ defmodule MvWeb.MemberLive.IndexDatePresetsTest do
%{conn: conn_with_oidc_user(conn)} %{conn: conn_with_oidc_user(conn)}
end end
test "the 'This year' preset applies the current-year range and writes URL params", %{ test "the 'This year' preset applies the year-to-date range and writes URL params", %{
conn: conn conn: conn
} do } do
{:ok, view, _html} = live(conn, ~p"/members") {:ok, view, _html} = live(conn, ~p"/members")
@ -32,8 +32,20 @@ defmodule MvWeb.MemberLive.IndexDatePresetsTest do
view |> element("[data-testid='date-preset-this_year']") |> render_click() view |> element("[data-testid='date-preset-this_year']") |> render_click()
path = assert_patch(view) path = assert_patch(view)
year = Date.utc_today().year today = Date.utc_today()
assert path =~ "jd_from=#{year}-01-01" assert path =~ "jd_from=#{today.year}-01-01"
assert path =~ "jd_to=#{year}-12-31" assert path =~ "jd_to=#{Date.to_iso8601(today)}"
end
test "the 'Last 30 days' preset applies a today-anchored 30-day window", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "join_date")
view |> element("[data-testid='date-preset-last_30_days']") |> render_click()
path = assert_patch(view)
today = Date.utc_today()
assert path =~ "jd_from=#{Date.to_iso8601(Date.add(today, -29))}"
assert path =~ "jd_to=#{Date.to_iso8601(today)}"
end end
end end

View file

@ -0,0 +1,38 @@
defmodule MvWeb.MemberLive.IndexFilterUrlRoundtripTest do
@moduledoc """
§2.1 (extended) the v2 filter keys survive the LiveView decodeencode round
trip. Loading `/members` with a Stichtag date, the suspended flag, and a
two-sided payment count range, then triggering an unrelated re-patch (a sort
click), preserves every one of those keys in the pushed URL.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
setup %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, _member} =
Mv.Membership.create_member(
%{first_name: "Round", last_name: "Trip", email: "roundtrip@example.com"},
actor: actor
)
%{conn: conn_with_oidc_user(conn)}
end
test "stichtag, suspended and payment count range persist through a re-patch", %{conn: conn} do
url = "/members?stichtag=2026-03-15&suspended=1&pay_filter=has_unpaid&pay_min=2&pay_max=4"
{:ok, view, _html} = live(conn, url)
# Trigger an unrelated URL re-patch by sorting a stable column.
view |> element("[data-testid='payment']") |> render_click()
path = assert_patch(view)
assert path =~ "stichtag=2026-03-15"
assert path =~ "suspended=1"
assert path =~ "pay_filter=has_unpaid"
assert path =~ "pay_min=2"
assert path =~ "pay_max=4"
end
end

View file

@ -0,0 +1,72 @@
defmodule MvWeb.MemberLive.IndexGroupOrTest do
@moduledoc """
§1.29 / §2.7 Multi-select group semantics is OR.
Two "is" group selections match members in *either* group (union). Two
"is not" selections exclude members in *any* of them. Verified at the
`OverviewQuery` layer where the semantics live.
"""
use Mv.DataCase, async: false
import Mv.Fixtures, only: [member_fixture_with_actor: 2, group_fixture: 1]
alias Mv.Membership.Member
alias Mv.Membership.MemberGroup
alias MvWeb.MemberLive.Index.OverviewQuery
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
g1 = group_fixture(%{name: "G1-#{System.unique_integer([:positive])}"})
g2 = group_fixture(%{name: "G2-#{System.unique_integer([:positive])}"})
m1 = member_fixture_with_actor(%{}, actor)
m2 = member_fixture_with_actor(%{}, actor)
m3 = member_fixture_with_actor(%{}, actor)
add_to_group(m1, g1, actor)
add_to_group(m2, g2, actor)
%{actor: actor, g1: g1, g2: g2, m1: m1, m2: m2, m3: m3}
end
defp add_to_group(member, group, actor) do
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|> Ash.create!(actor: actor)
end
defp ids(opts, actor) do
opts
|> OverviewQuery.build()
|> Ash.read!(actor: actor)
|> MapSet.new(& &1.id)
end
test "two 'is' group selections match members in either group (OR)", ctx do
result =
ids(
%{
group_filters: %{to_string(ctx.g1.id) => :in, to_string(ctx.g2.id) => :in},
groups: [ctx.g1, ctx.g2]
},
ctx.actor
)
assert MapSet.equal?(result, MapSet.new([ctx.m1.id, ctx.m2.id]))
end
test "two 'is not' group selections exclude members in any of them", ctx do
result =
ids(
%{
group_filters: %{to_string(ctx.g1.id) => :not_in, to_string(ctx.g2.id) => :not_in},
groups: [ctx.g1, ctx.g2]
},
ctx.actor
)
assert MapSet.equal?(result, MapSet.new([ctx.m3.id]))
end
end

View file

@ -3,8 +3,8 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
Tests for filtering members by group in the member overview. Tests for filtering members by group in the member overview.
Uses the filter dropdown (MemberFilterComponent) with one row per group: Uses the filter dropdown (MemberFilterComponent) with one row per group:
All / Yes / No (per group). Multiple active group filters combine with AND All / Yes / No (per group). Multiple "is" selections combine with OR (union),
(member must match all selected group conditions). while each "is not" selection excludes members in that group (§1.29/§2.7).
""" """
# Kept async: false as a deferred scope decision. The deferrable-FK migration # Kept async: false as a deferred scope decision. The deferrable-FK migration
# removed the concurrent-create_member deadlock that previously forced this, so # removed the concurrent-create_member deadlock that previously forced this, so

View file

@ -0,0 +1,41 @@
defmodule MvWeb.MemberLive.IndexPaymentCountCodecTest do
@moduledoc """
§1.22 / §3.14 URL codec for the two-sided payment open-cycle count range.
The `{:unpaid_range, min, max}` filter round-trips through the flat URL
contract: encoding to params then decoding yields the canonical filter, with
the "has open" default (`min=1`, `max=nil`) and both-bounded ranges covered.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.PaymentAging
defp roundtrip(filter),
do: filter |> PaymentAging.filter_to_params() |> PaymentAging.parse_filter_params()
test "default has-open range (min 1, unbounded above) round-trips" do
assert roundtrip({:unpaid_range, 1, nil}) == {:unpaid_range, 1, nil}
end
test "two-sided range round-trips" do
assert roundtrip({:unpaid_range, 2, 5}) == {:unpaid_range, 2, 5}
end
test "exact single-value range round-trips" do
assert roundtrip({:unpaid_range, 3, 3}) == {:unpaid_range, 3, 3}
end
test "has_unpaid params decode into an unpaid_range" do
params = %{"pay_filter" => "has_unpaid", "pay_min" => "2", "pay_max" => "4"}
assert PaymentAging.parse_filter_params(params) == {:unpaid_range, 2, 4}
end
test "has_unpaid with no bounds defaults to min 1, max nil" do
assert PaymentAging.parse_filter_params(%{"pay_filter" => "has_unpaid"}) ==
{:unpaid_range, 1, nil}
end
test "fully_paid still round-trips" do
assert roundtrip(:fully_paid) == :fully_paid
end
end

View file

@ -66,6 +66,24 @@ defmodule MvWeb.MemberLive.IndexPaymentFilterTest do
refute MapSet.member?(ids({:has_unpaid, 1}, actor), zero.id) refute MapSet.member?(ids({:has_unpaid, 1}, actor), zero.id)
end end
test "unpaid-range selects members whose in-period count is within [min, max]", %{
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)
# two-sided: 2..3
assert ids({:unpaid_range, 2, 3}, actor) == MapSet.new([two.id, three.id])
# lower-bounded only: >= 1 (max nil)
assert ids({:unpaid_range, 1, nil}, actor) == MapSet.new([one.id, two.id, three.id])
# exact single value: 1..1
assert ids({:unpaid_range, 1, 1}, actor) == MapSet.new([one.id])
refute MapSet.member?(ids({:unpaid_range, 1, nil}, actor), zero.id)
end
test "suspended cycles do not count toward has-unpaid", %{actor: actor, ft: ft} do test "suspended cycles do not count toward has-unpaid", %{actor: actor, ft: ft} do
member = member_fixture_with_actor(%{}, actor) 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-01-01], status: :suspended}, actor)

View file

@ -40,6 +40,43 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
end end
end end
describe "period short code (§1.28)" do
test "the all-outstanding default has no short code" do
assert PaymentAging.period_short_code(%{from: nil, to: nil}) == nil
end
test "a full calendar year renders as the year" do
assert PaymentAging.period_short_code(%{from: ~D[2026-01-01], to: ~D[2026-12-31]}) == "2026"
end
test "a full calendar quarter renders as Q# YYYY" do
assert PaymentAging.period_short_code(%{from: ~D[2026-01-01], to: ~D[2026-03-31]}) ==
"Q1 2026"
assert PaymentAging.period_short_code(%{from: ~D[2026-10-01], to: ~D[2026-12-31]}) ==
"Q4 2026"
end
test "an arbitrary bounded period has no compact short code" do
assert PaymentAging.period_short_code(%{from: ~D[2026-02-03], to: ~D[2026-08-17]}) == nil
# An open-ended period is not compactly codeable either.
assert PaymentAging.period_short_code(%{from: ~D[2026-01-01], to: nil}) == nil
end
end
describe "period tooltip (§1.28)" do
test "names the period with locally formatted bounds" do
tip = PaymentAging.period_tooltip(%{from: ~D[2026-01-01], to: ~D[2026-12-31]})
assert tip =~ "01.01.2026"
assert tip =~ "31.12.2026"
end
test "the all-outstanding default reads as all outstanding" do
tip = PaymentAging.period_tooltip(%{from: nil, to: nil})
assert is_binary(tip) and tip =~ "outstanding"
end
end
describe "payment-count filter codec" do describe "payment-count filter codec" do
test "round-trips fully_paid and has-unpaid thresholds" 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 for state <- [:fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}] do

View file

@ -0,0 +1,85 @@
defmodule MvWeb.MemberLive.IndexPaymentSortTest do
@moduledoc """
§1.27 The Beiträge (payment) column sorts DB-side by the period-scoped
unpaid-cycle count, ascending and descending, with the unique id tie-breaker
preserving keyset stability. Verified at the `OverviewQuery` layer.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
alias Mv.Membership.Member
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)
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
%{actor: actor, ft: ft}
end
defp member_with_unpaid(n, ctx) do
member = member_fixture_with_actor(%{}, ctx.actor)
Enum.each(1..max(n, 0)//1, fn i ->
create_cycle(
member,
ctx.ft,
%{cycle_start: Date.new!(2024, i, 1), status: :unpaid},
ctx.actor
)
end)
member
end
defp sorted_ids(order, ctx) do
%{sort_field: :payment, sort_order: order, payment_period: @period}
|> OverviewQuery.build()
|> Ash.read!(actor: ctx.actor)
|> Enum.map(& &1.id)
end
test "sorts ascending by in-period unpaid count", ctx do
zero = member_with_unpaid(0, ctx)
one = member_with_unpaid(1, ctx)
two = member_with_unpaid(2, ctx)
assert sorted_ids(:asc, ctx) == [zero.id, one.id, two.id]
end
test "sorts descending by in-period unpaid count", ctx do
zero = member_with_unpaid(0, ctx)
one = member_with_unpaid(1, ctx)
two = member_with_unpaid(2, ctx)
assert sorted_ids(:desc, ctx) == [two.id, one.id, zero.id]
end
describe "payment sort header (LiveView)" do
setup %{conn: conn} do
%{conn: conn_with_oidc_user(conn)}
end
@tag :ui
test "clicking the payment header reflects the sort in the URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("[data-testid='payment']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=payment"
assert path =~ "sort_order=asc"
end
@tag :ui
test "the payment th exposes aria-sort when active", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members?sort_field=payment&sort_order=desc")
assert has_element?(view, "th[aria-sort='descending']")
end
end
end

View file

@ -69,4 +69,25 @@ defmodule MvWeb.MemberLive.IndexPaymentWiringTest do
{:ok, all_time, _} = live(ctx.conn, "/members?pay_filter=unpaid_1") {:ok, all_time, _} = live(ctx.conn, "/members?pay_filter=unpaid_1")
assert has_element?(all_time, "#row-#{member.id}") assert has_element?(all_time, "#row-#{member.id}")
end end
describe "period indicator badge (§1.28)" do
test "no badge in the default all-outstanding scope", ctx do
{:ok, view, _} = live(ctx.conn, "/members")
refute has_element?(view, "[data-testid='payment-period-badge']")
end
test "a full year renders the compact year short code", ctx do
{:ok, view, _} = live(ctx.conn, "/members?pay_from=2026-01-01&pay_to=2026-12-31")
assert has_element?(view, "[data-testid='payment-period-badge']", "2026")
end
test "an arbitrary period falls back to a 'filtered' badge with a period tooltip", ctx do
{:ok, view, _} = live(ctx.conn, "/members?pay_from=2026-02-03&pay_to=2026-08-17")
badge = view |> element("[data-testid='payment-period-badge']") |> render()
assert badge =~ "03.02.2026"
assert badge =~ "17.08.2026"
end
end
end end

View file

@ -0,0 +1,40 @@
defmodule MvWeb.MemberLive.IndexStatusParamsCodecTest do
@moduledoc """
§2.1 (extended) URL codec round-trip for the new v2 filter keys: the
point-in-time Stichtag date and the suspended-status flag. Encoding a state to
params then decoding yields the canonical state, and the default (no filter)
encodes to the empty map so a fresh URL is canonical.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.PaymentAging
alias MvWeb.MemberLive.Index.Stichtag
describe "Stichtag date" do
test "a set date round-trips" do
date = ~D[2026-03-15]
assert date |> Stichtag.to_params() |> Stichtag.parse() == date
end
test "nil encodes to the empty map and decodes to nil" do
assert Stichtag.to_params(nil) == %{}
assert Stichtag.parse(%{}) == nil
end
test "a malformed date param decodes to nil" do
assert Stichtag.parse(%{"stichtag" => "not-a-date"}) == nil
end
end
describe "suspended flag" do
test "true round-trips" do
assert true |> PaymentAging.suspended_to_params() |> PaymentAging.parse_suspended() == true
end
test "false/nil encodes to the empty map and decodes to false" do
assert PaymentAging.suspended_to_params(false) == %{}
assert PaymentAging.suspended_to_params(nil) == %{}
assert PaymentAging.parse_suspended(%{}) == false
end
end
end

View file

@ -0,0 +1,78 @@
defmodule MvWeb.MemberLive.IndexStichtagPropertyTest do
@moduledoc """
§2.6 Point-in-time membership ("member on the reference date X").
For arbitrary (join_date, exit_date) pairs and reference dates X, the Stichtag
filter keeps a member iff `join_date <= X and (exit_date is nil or exit_date > X)`.
Verified at the `OverviewQuery` layer against an in-memory oracle.
"""
use Mv.DataCase, async: false
use ExUnitProperties
import Mv.Fixtures, only: [member_fixture_with_actor: 2]
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index.OverviewQuery
@dates [
~D[2023-01-01],
~D[2024-06-15],
~D[2025-01-01],
~D[2025-06-15],
~D[2026-01-01]
]
defp clear(actor) do
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
end
defp on_date?(join, exit_date, x) do
Date.compare(join, x) != :gt and (is_nil(exit_date) or Date.compare(exit_date, x) == :gt)
end
# Generates a (join_date, exit_date) pair where exit_date is nil or on/after
# join_date, so it satisfies the Member "exit not before join" validation.
defp valid_pair_gen do
StreamData.bind(StreamData.member_of(@dates), fn join ->
later = [nil | Enum.filter(@dates, &(Date.compare(&1, join) == :gt))]
StreamData.bind(StreamData.member_of(later), fn exit_date ->
StreamData.constant({join, exit_date})
end)
end)
end
property "Stichtag filter keeps exactly the members active on X" do
actor = Mv.Helpers.SystemActor.get_system_actor()
check all(
specs <- StreamData.list_of(valid_pair_gen(), min_length: 1, max_length: 5),
x <- StreamData.member_of(@dates),
max_runs: 25
) do
clear(actor)
created =
Enum.map(specs, fn {join, exit_date} ->
member =
member_fixture_with_actor(%{join_date: join, exit_date: exit_date}, actor)
{member.id, join, exit_date}
end)
expected =
for {id, join, exit_date} <- created,
on_date?(join, exit_date, x),
into: MapSet.new(),
do: id
actual =
%{stichtag: x}
|> OverviewQuery.build()
|> Ash.read!(actor: actor)
|> MapSet.new(& &1.id)
assert MapSet.equal?(actual, expected)
end
end
end

View file

@ -0,0 +1,58 @@
defmodule MvWeb.MemberLive.IndexSuspendedFilterTest do
@moduledoc """
§1.23 Filter by `suspended` status.
With the suspended filter active, the overview keeps members that have at
least one `suspended` cycle whose `cycle_end` falls in the active period.
Suspended is filterable as its own status, independent of the unpaid count
(which excludes suspended, §2.5). Verified at the `OverviewQuery` layer.
"""
use Mv.DataCase, async: false
import Mv.Fixtures, only: [member_fixture_with_actor: 2, create_fee_type: 2, create_cycle: 4]
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index.OverviewQuery
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
ft = create_fee_type(%{interval: :yearly}, actor)
%{actor: actor, ft: ft}
end
defp member_with_cycle(ctx, status, cycle_start) do
m = member_fixture_with_actor(%{}, ctx.actor)
create_cycle(m, ctx.ft, %{cycle_start: cycle_start, status: status}, ctx.actor)
m
end
defp ids(opts, actor) do
opts |> OverviewQuery.build() |> Ash.read!(actor: actor) |> MapSet.new(& &1.id)
end
test "keeps members with a suspended cycle whose cycle_end is in the period", ctx do
suspended_in = member_with_cycle(ctx, :suspended, ~D[2025-01-01])
_unpaid = member_with_cycle(ctx, :unpaid, ~D[2025-01-01])
_paid = member_with_cycle(ctx, :paid, ~D[2025-01-01])
_suspended_out = member_with_cycle(ctx, :suspended, ~D[2023-01-01])
result =
ids(
%{suspended: true, payment_period: %{from: ~D[2025-01-01], to: ~D[2025-12-31]}},
ctx.actor
)
assert MapSet.equal?(result, MapSet.new([suspended_in.id]))
end
test "all-time period keeps every member with any suspended cycle", ctx do
s1 = member_with_cycle(ctx, :suspended, ~D[2025-01-01])
s2 = member_with_cycle(ctx, :suspended, ~D[2023-01-01])
_unpaid = member_with_cycle(ctx, :unpaid, ~D[2025-01-01])
result = ids(%{suspended: true, payment_period: %{from: nil, to: nil}}, ctx.actor)
assert MapSet.equal?(result, MapSet.new([s1.id, s2.id]))
end
end