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