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.DateFormatter alias MvWeb.Helpers.MembershipFeeHelpers @type period :: %{from: Date.t() | nil, to: Date.t() | nil} @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_to_param Constants.payment_period_to_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 """ 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 """ 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()) :: nil | :fully_paid | {:has_unpaid, 1..3} 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. 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()} 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({: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: %{} 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 """ 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() def parse_filter_params(params) when is_map(params) do 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 """ Badge descriptor for an unpaid-cycle count. A count of 0 reads "Alle bezahlt" (success/green); a positive count reads "N offen" (error/red). Reuses the established cycle-status color and hero-icon codes so the overview badge reads consistently with the member show page. """ @spec badge(non_neg_integer()) :: %{ variant: atom(), color_class: String.t(), icon: String.t(), label: String.t(), count: non_neg_integer() } def badge(0) do %{ variant: :success, color_class: cycle_color_class(:paid), icon: MembershipFeeHelpers.status_icon(:paid), label: gettext("All paid"), count: 0 } end def badge(count) when is_integer(count) and count > 0 do %{ variant: MembershipFeeHelpers.status_variant(:unpaid), color_class: cycle_color_class(:unpaid), icon: MembershipFeeHelpers.status_icon(:unpaid), label: gettext("%{count} open", count: count), count: count } 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). Explains that the shown figures refer to the filtered contribution period, then names it: 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( "The values refer to the filtered contribution period. Fees across all outstanding cycles" ) def period_tooltip(%{from: from, to: to}) do gettext( "The values refer to the filtered contribution period. Fees in period %{from}–%{to}", from: period_bound(from), to: period_bound(to) ) end def period_tooltip(_), do: gettext( "The values refer to the filtered contribution period. 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 """ 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 chronologically by `cycle_start`. Paid cycles and cycles outside the period are dropped. Expects `membership_fee_cycles` to be loaded on the member. """ @spec open_cycles(map(), period()) :: [map()] def open_cycles(member, %{from: from, to: to}) do member |> in_period_cycles(from, to) |> Enum.filter(&(&1.status in [:unpaid, :suspended])) |> Enum.sort_by(& &1.cycle_start, Date) end @tooltip_slots 5 @doc """ Partitions open cycles into the tooltip's fixed five-slot budget: at most five badges total. Five or fewer cycles show in full with no overflow. More than five collapse to the first four chronological cycles plus a single overflow count (total − 4), so the tooltip renders four cycle badges and one grey overflow badge — five items. """ @spec tooltip_cycles([map()]) :: {[map()], non_neg_integer()} def tooltip_cycles(cycles) when is_list(cycles) do if length(cycles) > @tooltip_slots do {Enum.take(cycles, @tooltip_slots - 1), length(cycles) - (@tooltip_slots - 1)} else {cycles, 0} end end @doc """ Static daisyUI badge color token for a cycle status, matching the codes used on the member show page. Literal strings so Tailwind's content scanner keeps them in the bundle (a runtime `badge-\#{status}` would be tree-shaken). """ @spec cycle_color_class(:paid | :unpaid | :suspended) :: String.t() def cycle_color_class(:paid), do: "badge-success" def cycle_color_class(:unpaid), do: "badge-error" def cycle_color_class(:suspended), do: "badge-warning" @doc """ Hero-icon name for a cycle status (reuses the established status icons). """ @spec cycle_icon(:paid | :unpaid | :suspended) :: String.t() def cycle_icon(status), do: MembershipFeeHelpers.status_icon(status) @doc """ Short, human-readable period label for a cycle, derived from its `cycle_start` and the fee-type interval: * yearly → `"2025"` * quarterly → `"Q1 2026"` * half-yearly → `"H1 2026"` * monthly → `"März 2026"` Falls back to a `dd.MM.yyyy–dd.MM.yyyy` date range when the interval is not loaded/known. """ @spec short_period(map()) :: String.t() def short_period(%{cycle_start: %Date{} = start} = cycle) do case cycle_interval(cycle) do :yearly -> Integer.to_string(start.year) :quarterly -> "Q#{quarter(start.month)} #{start.year}" :half_yearly -> "H#{half(start.month)} #{start.year}" :monthly -> "#{month_name(start.month)} #{start.year}" _ -> cycle_date_range(cycle) end end def short_period(cycle), do: cycle_date_range(cycle) 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 cycle_interval(%{membership_fee_type: %{interval: interval}}) when interval in [:monthly, :quarterly, :half_yearly, :yearly], do: interval defp cycle_interval(_), do: nil defp cycle_date_range(%{cycle_start: %Date{} = s, cycle_end: %Date{} = e}), do: "#{DateFormatter.format_date(s)}–#{DateFormatter.format_date(e)}" defp cycle_date_range(%{cycle_start: %Date{} = s}), do: DateFormatter.format_date(s) defp cycle_date_range(_), do: "" defp quarter(month), do: div(month - 1, 3) + 1 defp half(month) when month <= 6, do: 1 defp half(_month), do: 2 defp month_name(1), do: gettext("January") defp month_name(2), do: gettext("February") defp month_name(3), do: gettext("March") defp month_name(4), do: gettext("April") defp month_name(5), do: gettext("May") defp month_name(6), do: gettext("June") defp month_name(7), do: gettext("July") defp month_name(8), do: gettext("August") defp month_name(9), do: gettext("September") defp month_name(10), do: gettext("October") defp month_name(11), do: gettext("November") defp month_name(12), do: gettext("December") 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