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 use ExUnitProperties 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 "payment-count filter codec" do test "round-trips fully_paid and has-unpaid thresholds" do for state <- [:fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}] do params = PaymentAging.filter_to_params(state) assert PaymentAging.parse_filter_params(params) == state end end test "nil filter serializes to no params and unknown values parse to nil" do assert PaymentAging.filter_to_params(nil) == %{} assert PaymentAging.parse_filter_params(%{}) == nil assert PaymentAging.parse_filter_params(%{"pay_filter" => "bogus"}) == nil end test "serializes to stable string tokens" do assert PaymentAging.filter_to_params(:fully_paid) == %{"pay_filter" => "fully_paid"} assert PaymentAging.filter_to_params({:has_unpaid, 2}) == %{"pay_filter" => "unpaid_2"} end end describe "payment URL round-trip (§2.1, payment keys)" do @dates [nil, ~D[2020-01-01], ~D[2024-06-30], ~D[2025-12-31]] @filters [nil, :fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}] property "decode∘encode is canonical and idempotent for period + payment filter" do check all( from <- StreamData.member_of(@dates), to <- StreamData.member_of(@dates), filter <- StreamData.member_of(@filters) ) do period = %{from: from, to: to} params = period |> PaymentAging.to_params() |> Map.merge(PaymentAging.filter_to_params(filter)) assert PaymentAging.parse_period(params) == period assert PaymentAging.parse_filter_params(params) == filter # Idempotence: re-encoding the decoded state yields the same params. reencoded = params |> PaymentAging.parse_period() |> PaymentAging.to_params() |> Map.merge(PaymentAging.filter_to_params(PaymentAging.parse_filter_params(params))) assert reencoded == params end 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