From 95ef3177acd914ca6645285ad6eed3b61cf791f0 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 10 Jul 2026 16:27:14 +0200 Subject: [PATCH] feat(member): add period-scoped unpaid-cycle payment-aging model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counts each member's unpaid billing cycles that fall inside the selected period. Cycle boundaries are member-relative — derived from each member's fee-type interval — so "the current cycle" spans different calendar dates per member. --- lib/membership/member.ex | 19 +++ lib/mv/constants.ex | 38 +++++ .../live/member_live/index/payment_aging.ex | 131 ++++++++++++++++++ priv/gettext/de/LC_MESSAGES/default.po | 6 + priv/gettext/default.pot | 6 + .../index_payment_aging_property_test.exs | 82 +++++++++++ .../member_live/index_payment_aging_test.exs | 79 +++++++++++ .../member_live/index_payment_period_test.exs | 95 +++++++++++++ 8 files changed, 456 insertions(+) create mode 100644 lib/mv_web/live/member_live/index/payment_aging.ex create mode 100644 test/mv_web/member_live/index_payment_aging_property_test.exs create mode 100644 test/mv_web/member_live/index_payment_aging_test.exs create mode 100644 test/mv_web/member_live/index_payment_period_test.exs diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 9f7965bb..b0ff298f 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -717,6 +717,25 @@ defmodule Mv.Membership.Member do constraints one_of: [:unpaid, :paid, :suspended] end + calculate :unpaid_cycle_count, + :integer, + expr( + count(membership_fee_cycles, + query: [ + filter: + expr( + status == :unpaid and + (is_nil(^arg(:period_from)) or cycle_end >= ^arg(:period_from)) and + (is_nil(^arg(:period_to)) or cycle_end <= ^arg(:period_to)) + ) + ] + ) + ) do + description "Count of the member's unpaid cycles whose cycle_end is within the given period" + argument :period_from, :date, allow_nil?: true + argument :period_to, :date, allow_nil?: true + end + calculate :overdue_count, :integer do description "Count of unpaid cycles that have already ended (cycle_end < today)" # Automatically load cycles with all attributes and membership_fee_type diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 657aa9b7..449d1868 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -38,6 +38,12 @@ defmodule Mv.Constants do @custom_date_filter_prefix "cdf_" + @payment_period_from_param "pay_from" + + @payment_period_to_param "pay_to" + + @payment_filter_param "pay_filter" + @max_boolean_filters 50 @max_mailto_bulk_recipients 50 @@ -162,6 +168,38 @@ defmodule Mv.Constants do """ def custom_date_filter_prefix, do: @custom_date_filter_prefix + @doc """ + Returns the URL parameter name for the payment-aging period lower bound + (ISO-8601 date; scopes both the payment column and the payment filter). + + ## Examples + + iex> Mv.Constants.payment_period_from_param() + "pay_from" + """ + def payment_period_from_param, do: @payment_period_from_param + + @doc """ + Returns the URL parameter name for the payment-aging period upper bound. + + ## Examples + + iex> Mv.Constants.payment_period_to_param() + "pay_to" + """ + def payment_period_to_param, do: @payment_period_to_param + + @doc """ + Returns the URL parameter name for the payment-count filter + (`fully_paid` | `unpaid_1` | `unpaid_2` | `unpaid_3`). + + ## Examples + + iex> Mv.Constants.payment_filter_param() + "pay_filter" + """ + def payment_filter_param, do: @payment_filter_param + @doc """ Returns the maximum number of boolean custom field filters allowed per request. diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex new file mode 100644 index 00000000..18d3163b --- /dev/null +++ b/lib/mv_web/live/member_live/index/payment_aging.ex @@ -0,0 +1,131 @@ +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 diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index db07d05f..cc8dae08 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2696,6 +2696,7 @@ msgstr "Optionen" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex +#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex @@ -4213,3 +4214,8 @@ msgstr "Klicke, um nach Nachname zu sortieren" #, elixir-autogen, elixir-format msgid "No address" msgstr "Keine Adresse" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "%{count} unpaid" +msgstr "%{count} unbezahlt" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index c3b21727..34772344 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2697,6 +2697,7 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex +#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex @@ -4213,3 +4214,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "No address" msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "%{count} unpaid" +msgstr "" diff --git a/test/mv_web/member_live/index_payment_aging_property_test.exs b/test/mv_web/member_live/index_payment_aging_property_test.exs new file mode 100644 index 00000000..ae6d7223 --- /dev/null +++ b/test/mv_web/member_live/index_payment_aging_property_test.exs @@ -0,0 +1,82 @@ +defmodule MvWeb.MemberLive.IndexPaymentAgingPropertyTest do + @moduledoc """ + §2.5 — Unpaid-cycle count correctness (period-scoped, member-relative). + + For arbitrary per-member cycle sets (status × cycle_end) and arbitrary active + periods, the `unpaid_cycle_count` calculation equals the set-theoretic + definition `|{c : c.status == :unpaid and c.cycle_end ∈ period}|` — paid and + suspended cycles excluded, nil period bounds unbounded. + """ + use Mv.DataCase, async: false + use ExUnitProperties + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias Mv.MembershipFees.MembershipFeeCycle + + require Ash.Query + + # Distinct monthly cycle_starts so cycles never collide on the + # (member_id, cycle_start) identity; monthly keeps each cycle_end inside a + # single month for legible in/out-of-period placement. + @starts for y <- 2022..2026, m <- [1, 4, 7, 10], do: Date.new!(y, m, 1) + @bounds [nil, ~D[2023-01-01], ~D[2024-06-30], ~D[2025-12-31]] + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + fee_type = create_fee_type(%{interval: :monthly}, actor) + %{actor: actor, fee_type: fee_type} + end + + defp cycle_gen do + StreamData.tuple( + {StreamData.member_of(@starts), StreamData.member_of([:unpaid, :paid, :suspended])} + ) + end + + defp in_period?(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 + + property "unpaid_cycle_count equals the set-theoretic definition", %{ + actor: actor, + fee_type: ft + } do + check all( + cycles <- StreamData.list_of(cycle_gen(), max_length: 6), + from <- StreamData.member_of(@bounds), + to <- StreamData.member_of(@bounds), + max_runs: 40 + ) do + member = member_fixture_with_actor(%{}, actor) + + cycles + |> Enum.uniq_by(fn {start, _status} -> start end) + |> Enum.each(fn {start, status} -> + create_cycle(member, ft, %{cycle_start: start, status: status}, actor) + end) + + stored = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id) + |> Ash.read!(actor: actor) + + expected = + Enum.count(stored, fn c -> + c.status == :unpaid and in_period?(c.cycle_end, from, to) + end) + + actual = + member + |> Ash.load!([unpaid_cycle_count: %{period_from: from, period_to: to}], actor: actor) + |> Map.fetch!(:unpaid_cycle_count) + + assert actual == expected + + # Clean up so populations do not accumulate across runs. + Enum.each(stored, &Ash.destroy!(&1, actor: actor)) + Ash.destroy!(member, actor: actor) + end + end +end diff --git a/test/mv_web/member_live/index_payment_aging_test.exs b/test/mv_web/member_live/index_payment_aging_test.exs new file mode 100644 index 00000000..f80d1a42 --- /dev/null +++ b/test/mv_web/member_live/index_payment_aging_test.exs @@ -0,0 +1,79 @@ +defmodule MvWeb.MemberLive.IndexPaymentAgingTest do + @moduledoc """ + §1.13 / §3.3 — the period-scoped `unpaid_cycle_count` calculation on Member. + + Counts a member's cycles with `status == :unpaid` whose denormalized + `cycle_end` falls inside the active period; paid and suspended cycles are + never counted. A nil period bound means "unbounded on that side" (the + all-outstanding default counts every unpaid cycle). + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias Mv.Membership.Member + + require Ash.Query + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + # Monthly so several cycles can share one period without cycle_start collisions. + fee_type = create_fee_type(%{interval: :monthly}, actor) + %{actor: actor, fee_type: fee_type} + end + + defp count(member, from, to, actor) do + member + |> Ash.load!([unpaid_cycle_count: %{period_from: from, period_to: to}], actor: actor) + |> Map.fetch!(:unpaid_cycle_count) + end + + test "counts only unpaid cycles with cycle_end in the period; suspended and paid excluded", + %{actor: actor, fee_type: ft} do + # No membership_fee_type_id on the member itself → no auto-generated cycles. + member = member_fixture_with_actor(%{}, actor) + + # In-period unpaid (counted) + create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :unpaid}, actor) + # In-period but not unpaid (excluded) + create_cycle(member, ft, %{cycle_start: ~D[2024-03-01], status: :paid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-04-01], status: :suspended}, actor) + # Unpaid but out of period (excluded) + create_cycle(member, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor) + + assert count(member, ~D[2024-01-01], ~D[2024-12-31], actor) == 2 + + # Fully-paid member (only paid/suspended in period) → 0 + paid_member = member_fixture_with_actor(%{}, actor) + create_cycle(paid_member, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor) + create_cycle(paid_member, ft, %{cycle_start: ~D[2024-02-01], status: :suspended}, actor) + assert count(paid_member, ~D[2024-01-01], ~D[2024-12-31], actor) == 0 + end + + test "nil period bounds count every unpaid cycle (all-outstanding default)", + %{actor: actor, fee_type: ft} do + member = member_fixture_with_actor(%{}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2020-01-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-06-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2026-01-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2025-01-01], status: :paid}, actor) + + assert count(member, nil, nil, actor) == 3 + end + + test "loads for a whole read query (DB-pushable aggregate)", %{actor: actor, fee_type: ft} do + member = member_fixture_with_actor(%{}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor) + + [loaded] = + Member + |> Ash.Query.filter(id == ^member.id) + |> Ash.Query.load( + unpaid_cycle_count: %{period_from: ~D[2024-01-01], period_to: ~D[2024-12-31]} + ) + |> Ash.read!(actor: actor) + + assert loaded.unpaid_cycle_count == 1 + end +end diff --git a/test/mv_web/member_live/index_payment_period_test.exs b/test/mv_web/member_live/index_payment_period_test.exs new file mode 100644 index 00000000..7b56240e --- /dev/null +++ b/test/mv_web/member_live/index_payment_period_test.exs @@ -0,0 +1,95 @@ +defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do + @moduledoc """ + §1.13 / §1.14 / §3.3 — the PaymentAging module: period parse/serialize with + the all-outstanding default, badge formatting, and the open-cycles list that + backs the badge tooltip (unpaid cycles for the period; suspended shown apart). + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias MvWeb.MemberLive.Index.PaymentAging + + require Ash.Query + + describe "period parsing / serialization" do + test "default period is all-outstanding (both bounds nil)" do + assert PaymentAging.default_period() == %{from: nil, to: nil} + end + + test "no params parses to the default period" do + assert PaymentAging.parse_period(%{}) == %{from: nil, to: nil} + end + + test "parses ISO bounds and round-trips through to_params" do + period = %{from: ~D[2024-01-01], to: ~D[2024-12-31]} + params = PaymentAging.to_params(period) + + assert params["pay_from"] == "2024-01-01" + assert params["pay_to"] == "2024-12-31" + assert PaymentAging.parse_period(params) == period + end + + test "default period serializes to no params" do + assert PaymentAging.to_params(PaymentAging.default_period()) == %{} + end + + test "malformed bounds fall back to nil" do + assert PaymentAging.parse_period(%{"pay_from" => "not-a-date"}) == %{from: nil, to: nil} + end + end + + describe "badge/1" do + test "0 renders as Paid (success)" do + badge = PaymentAging.badge(0) + assert badge.variant == :success + assert badge.label == "Paid" + end + + test "N > 0 renders as N unpaid (error)" do + badge = PaymentAging.badge(2) + assert badge.variant == :error + assert badge.label =~ "2" + assert badge.label =~ "unpaid" + end + end + + describe "open_cycles/2" do + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + ft = create_fee_type(%{interval: :monthly}, actor) + %{actor: actor, ft: ft} + end + + test "lists in-period unpaid cycles and flags suspended separately; paid excluded", + %{actor: actor, ft: ft} do + member = member_fixture_with_actor(%{}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-03-01], status: :suspended}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-04-01], status: :paid}, actor) + # out of period + create_cycle(member, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor) + + member = Ash.load!(member, :membership_fee_cycles, actor: actor) + result = PaymentAging.open_cycles(member, %{from: ~D[2024-01-01], to: ~D[2024-12-31]}) + + assert length(result.unpaid) == 2 + assert length(result.suspended) == 1 + assert Enum.all?(result.unpaid, &(&1.status == :unpaid)) + assert Enum.all?(result.suspended, &(&1.status == :suspended)) + end + + test "nil bounds include every unpaid/suspended cycle", %{actor: actor, ft: ft} do + member = member_fixture_with_actor(%{}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2020-01-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2026-01-01], status: :suspended}, actor) + + member = Ash.load!(member, :membership_fee_cycles, actor: actor) + result = PaymentAging.open_cycles(member, PaymentAging.default_period()) + + assert length(result.unpaid) == 1 + assert length(result.suspended) == 1 + end + end +end