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} @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() @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()) :: filter() 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. """ @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(_), do: %{} @doc """ Decodes the payment-count filter from a full params map. """ @spec parse_filter_params(map()) :: filter() def parse_filter_params(params) when is_map(params), do: parse_filter(Map.get(params, @payment_filter_param)) @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 """ 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