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 "All paid" (success/green); a positive count reads "N open" (error/red). The color class is derived from the shared `MembershipFeeHelpers.status_variant/1` mapping, so the overview badge reads consistently with the member show page. """ @spec badge(non_neg_integer()) :: %{ color_class: String.t(), icon: String.t(), label: String.t(), count: non_neg_integer() } def badge(0) do %{ color_class: status_badge_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 %{ color_class: status_badge_class(:unpaid), icon: MembershipFeeHelpers.status_icon(:unpaid), label: gettext("%{count} open", count: count), count: count } end @doc """ DaisyUI badge color class for a cycle status, derived from the single shared `MembershipFeeHelpers.status_variant/1` mapping so the overview badges, the tooltip badges and the member show page all stay in lock-step. daisyUI emits all `badge-*` component colors, so the interpolated class is always bundled. """ @spec status_badge_class(:paid | :unpaid | :suspended) :: String.t() def status_badge_class(status), do: "badge-#{MembershipFeeHelpers.status_variant(status)}" @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 @doc """ Partitions a member's open cycles for the period into the unpaid cycles (which drive the count and the badge) and the suspended cycles, which the tooltip surfaces in their own labeled section rather than by color alone (§1.14). Both lists stay in chronological order. """ @spec partition_open_cycles(map(), period()) :: %{unpaid: [map()], suspended: [map()]} def partition_open_cycles(member, period) do {unpaid, suspended} = member |> open_cycles(period) |> Enum.split_with(&(&1.status == :unpaid)) %{unpaid: unpaid, suspended: suspended} end @tooltip_slots 5 @doc """ Partitions the tooltip's unpaid sub-list into a fixed five-slot budget: at most five badges for the unpaid cycles. Five or fewer unpaid 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 unpaid sub-list renders four cycle badges and one grey overflow badge — five items. Suspended cycles are listed separately (in their own labeled section) and are not capped by this budget, so the tooltip's overall badge count can exceed five. """ @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 """ 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 → `"March 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