167 lines
5.7 KiB
Elixir
167 lines
5.7 KiB
Elixir
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}
|
|
|
|
@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 "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
|