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:
parent
4b647c21e7
commit
95ef3177ac
8 changed files with 456 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
131
lib/mv_web/live/member_live/index/payment_aging.ex
Normal file
131
lib/mv_web/live/member_live/index/payment_aging.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
79
test/mv_web/member_live/index_payment_aging_test.exs
Normal file
79
test/mv_web/member_live/index_payment_aging_test.exs
Normal 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
|
||||
95
test/mv_web/member_live/index_payment_period_test.exs
Normal file
95
test/mv_web/member_live/index_payment_period_test.exs
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue