feat(member): add period-scoped unpaid-cycle payment-aging model

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.
This commit is contained in:
Simon 2026-07-10 16:27:14 +02:00
parent 4b647c21e7
commit 95ef3177ac
8 changed files with 456 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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