feat(member): add period-scoped unpaid-cycle payment-aging model

Counts each member's unpaid billing cycles that fall inside the selected
period. Cycle boundaries are member-relative — derived from each member's
fee-type interval — so "the current cycle" spans different calendar dates
per member.
This commit is contained in:
Simon 2026-07-10 16:27:14 +02:00
parent 4b647c21e7
commit 95ef3177ac
8 changed files with 456 additions and 0 deletions

View file

@ -717,6 +717,25 @@ defmodule Mv.Membership.Member do
constraints one_of: [:unpaid, :paid, :suspended]
end
calculate :unpaid_cycle_count,
:integer,
expr(
count(membership_fee_cycles,
query: [
filter:
expr(
status == :unpaid and
(is_nil(^arg(:period_from)) or cycle_end >= ^arg(:period_from)) and
(is_nil(^arg(:period_to)) or cycle_end <= ^arg(:period_to))
)
]
)
) do
description "Count of the member's unpaid cycles whose cycle_end is within the given period"
argument :period_from, :date, allow_nil?: true
argument :period_to, :date, allow_nil?: true
end
calculate :overdue_count, :integer do
description "Count of unpaid cycles that have already ended (cycle_end < today)"
# Automatically load cycles with all attributes and membership_fee_type

View file

@ -38,6 +38,12 @@ defmodule Mv.Constants do
@custom_date_filter_prefix "cdf_"
@payment_period_from_param "pay_from"
@payment_period_to_param "pay_to"
@payment_filter_param "pay_filter"
@max_boolean_filters 50
@max_mailto_bulk_recipients 50
@ -162,6 +168,38 @@ defmodule Mv.Constants do
"""
def custom_date_filter_prefix, do: @custom_date_filter_prefix
@doc """
Returns the URL parameter name for the payment-aging period lower bound
(ISO-8601 date; scopes both the payment column and the payment filter).
## Examples
iex> Mv.Constants.payment_period_from_param()
"pay_from"
"""
def payment_period_from_param, do: @payment_period_from_param
@doc """
Returns the URL parameter name for the payment-aging period upper bound.
## Examples
iex> Mv.Constants.payment_period_to_param()
"pay_to"
"""
def payment_period_to_param, do: @payment_period_to_param
@doc """
Returns the URL parameter name for the payment-count filter
(`fully_paid` | `unpaid_1` | `unpaid_2` | `unpaid_3`).
## Examples
iex> Mv.Constants.payment_filter_param()
"pay_filter"
"""
def payment_filter_param, do: @payment_filter_param
@doc """
Returns the maximum number of boolean custom field filters allowed per request.

View file

@ -0,0 +1,131 @@
defmodule MvWeb.MemberLive.Index.PaymentAging do
@moduledoc """
Period-scoped payment aging for the member overview.
The overview presents payment as an accounts-receivable aging count: per
member, the number of that member's cycles with `status == :unpaid` whose
denormalized `cycle_end` falls inside an active period. The period is a view
dimension that scopes the column and the payment filter together; its default
is **all outstanding** (both bounds nil all time). Suspended cycles are
excluded from the count but surfaced separately in the badge tooltip.
This module owns:
* the period value shape and its default,
* URL param encode/decode for the period,
* the badge descriptor derived from a count,
* the open-cycles list (unpaid + suspended, member-relative) that backs the
badge tooltip.
The count itself is computed DB-side by the `unpaid_cycle_count` calculation
on `Mv.Membership.Member`; `open_cycles/2` works over already-loaded cycles.
"""
use Gettext, backend: MvWeb.Gettext
alias Mv.Constants
alias MvWeb.Helpers.MembershipFeeHelpers
@type period :: %{from: Date.t() | nil, to: Date.t() | nil}
@payment_period_from_param Constants.payment_period_from_param()
@payment_period_to_param Constants.payment_period_to_param()
@doc """
The default period: all outstanding cycles, all time (both bounds nil).
"""
@spec default_period() :: period()
def default_period, do: %{from: nil, to: nil}
@doc """
Decodes URL params into a period. Absent or malformed ISO-8601 bounds fall
back to nil, so no params yields the all-outstanding default.
"""
@spec parse_period(map()) :: period()
def parse_period(params) when is_map(params) do
%{
from: parse_date(Map.get(params, @payment_period_from_param)),
to: parse_date(Map.get(params, @payment_period_to_param))
}
end
@doc """
Encodes a period into URL params. A nil bound is omitted; the default period
encodes to the empty map (a fresh URL is the canonical default).
"""
@spec to_params(period()) :: %{optional(String.t()) => String.t()}
def to_params(%{from: from, to: to}) do
%{}
|> maybe_put_date(@payment_period_from_param, from)
|> maybe_put_date(@payment_period_to_param, to)
end
def to_params(_), do: %{}
@doc """
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Paid"
(success); a positive count reads "N unpaid" (error).
"""
@spec badge(non_neg_integer()) :: %{
variant: atom(),
label: String.t(),
count: non_neg_integer()
}
def badge(0), do: %{variant: :success, label: gettext("Paid"), count: 0}
def badge(count) when is_integer(count) and count > 0 do
%{
variant: MembershipFeeHelpers.status_variant(:unpaid),
label: gettext("%{count} unpaid", count: count),
count: count
}
end
@doc """
Splits a member's loaded cycles into the open (unpaid) and suspended cycles
whose `cycle_end` falls inside `period`, both sorted by `cycle_end`. Paid
cycles and cycles outside the period are dropped. Used to render the badge
tooltip. Expects `membership_fee_cycles` to be loaded on the member.
"""
@spec open_cycles(map(), period()) :: %{unpaid: [map()], suspended: [map()]}
def open_cycles(member, %{from: from, to: to}) do
cycles = in_period_cycles(member, from, to)
%{
unpaid: cycles |> Enum.filter(&(&1.status == :unpaid)) |> sort_by_end(),
suspended: cycles |> Enum.filter(&(&1.status == :suspended)) |> sort_by_end()
}
end
defp in_period_cycles(member, from, to) do
case Map.get(member, :membership_fee_cycles) do
cycles when is_list(cycles) -> Enum.filter(cycles, &in_period?(&1.cycle_end, from, to))
_ -> []
end
end
defp in_period?(%Date{} = cycle_end, from, to) do
(is_nil(from) or Date.compare(cycle_end, from) != :lt) and
(is_nil(to) or Date.compare(cycle_end, to) != :gt)
end
defp in_period?(_, _, _), do: false
defp sort_by_end(cycles), do: Enum.sort_by(cycles, & &1.cycle_end, Date)
defp maybe_put_date(params, _key, nil), do: params
defp maybe_put_date(params, key, %Date{} = date),
do: Map.put(params, key, Date.to_iso8601(date))
defp parse_date(nil), do: nil
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