Add Statistics module for member and cycle aggregates
- first_join_year, active/inactive counts, joins/exits by year - cycle_totals_by_year, open_amount_total - Unit tests for Statistics
This commit is contained in:
parent
82e908a7e4
commit
fd10fe5cf6
2 changed files with 370 additions and 0 deletions
180
lib/mv/statistics.ex
Normal file
180
lib/mv/statistics.ex
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
defmodule Mv.Statistics do
|
||||||
|
@moduledoc """
|
||||||
|
Aggregated statistics for members and membership fee cycles.
|
||||||
|
|
||||||
|
Used by the statistics LiveView to display counts and sums. All functions
|
||||||
|
accept an `opts` keyword list and pass `:actor` (and `:domain` where needed)
|
||||||
|
to Ash reads so that policies are enforced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.MembershipFees
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the earliest year in which any member has a join_date.
|
||||||
|
|
||||||
|
Used to determine the start of the "relevant" year range for statistics
|
||||||
|
(from first membership to current year). Returns `nil` if no member has
|
||||||
|
a join_date.
|
||||||
|
"""
|
||||||
|
@spec first_join_year(keyword()) :: non_neg_integer() | nil
|
||||||
|
def first_join_year(opts) do
|
||||||
|
query =
|
||||||
|
Member
|
||||||
|
|> Ash.Query.filter(expr(not is_nil(join_date)))
|
||||||
|
|> Ash.Query.sort(join_date: :asc)
|
||||||
|
|> Ash.Query.limit(1)
|
||||||
|
|
||||||
|
case Ash.read_one(query, opts) do
|
||||||
|
{:ok, nil} -> nil
|
||||||
|
{:ok, member} -> member.join_date.year
|
||||||
|
{:error, _} -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the count of active members (exit_date is nil).
|
||||||
|
"""
|
||||||
|
@spec active_member_count(keyword()) :: non_neg_integer()
|
||||||
|
def active_member_count(opts) do
|
||||||
|
query =
|
||||||
|
Member
|
||||||
|
|> Ash.Query.filter(expr(is_nil(exit_date)))
|
||||||
|
|
||||||
|
case Ash.count(query, opts) do
|
||||||
|
{:ok, count} -> count
|
||||||
|
{:error, _} -> 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the count of inactive members (exit_date is not nil).
|
||||||
|
"""
|
||||||
|
@spec inactive_member_count(keyword()) :: non_neg_integer()
|
||||||
|
def inactive_member_count(opts) do
|
||||||
|
query =
|
||||||
|
Member
|
||||||
|
|> Ash.Query.filter(expr(not is_nil(exit_date)))
|
||||||
|
|
||||||
|
case Ash.count(query, opts) do
|
||||||
|
{:ok, count} -> count
|
||||||
|
{:error, _} -> 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the count of members who joined in the given year (join_date in that year).
|
||||||
|
"""
|
||||||
|
@spec joins_by_year(integer(), keyword()) :: non_neg_integer()
|
||||||
|
def joins_by_year(year, opts) do
|
||||||
|
first_day = Date.new!(year, 1, 1)
|
||||||
|
last_day = Date.new!(year, 12, 31)
|
||||||
|
|
||||||
|
query =
|
||||||
|
Member
|
||||||
|
|> Ash.Query.filter(expr(join_date >= ^first_day and join_date <= ^last_day))
|
||||||
|
|
||||||
|
case Ash.count(query, opts) do
|
||||||
|
{:ok, count} -> count
|
||||||
|
{:error, _} -> 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the count of members who exited in the given year (exit_date in that year).
|
||||||
|
"""
|
||||||
|
@spec exits_by_year(integer(), keyword()) :: non_neg_integer()
|
||||||
|
def exits_by_year(year, opts) do
|
||||||
|
first_day = Date.new!(year, 1, 1)
|
||||||
|
last_day = Date.new!(year, 12, 31)
|
||||||
|
|
||||||
|
query =
|
||||||
|
Member
|
||||||
|
|> Ash.Query.filter(expr(exit_date >= ^first_day and exit_date <= ^last_day))
|
||||||
|
|
||||||
|
case Ash.count(query, opts) do
|
||||||
|
{:ok, count} -> count
|
||||||
|
{:error, _} -> 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns totals for membership fee cycles whose cycle_start falls in the given year.
|
||||||
|
|
||||||
|
Returns a map with keys: `:total`, `:paid`, `:unpaid`, `:suspended` (each a Decimal sum).
|
||||||
|
"""
|
||||||
|
@spec cycle_totals_by_year(integer(), keyword()) :: %{
|
||||||
|
total: Decimal.t(),
|
||||||
|
paid: Decimal.t(),
|
||||||
|
unpaid: Decimal.t(),
|
||||||
|
suspended: Decimal.t()
|
||||||
|
}
|
||||||
|
def cycle_totals_by_year(year, opts) do
|
||||||
|
first_day = Date.new!(year, 1, 1)
|
||||||
|
last_day = Date.new!(year, 12, 31)
|
||||||
|
|
||||||
|
query =
|
||||||
|
MembershipFeeCycle
|
||||||
|
|> Ash.Query.filter(expr(cycle_start >= ^first_day and cycle_start <= ^last_day))
|
||||||
|
|
||||||
|
opts_with_domain = Keyword.put(opts, :domain, MembershipFees)
|
||||||
|
|
||||||
|
case Ash.read(query, opts_with_domain) do
|
||||||
|
{:ok, cycles} -> cycle_totals_from_cycles(cycles)
|
||||||
|
{:error, _} -> zero_cycle_totals()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cycle_totals_from_cycles(cycles) do
|
||||||
|
by_status = Enum.group_by(cycles, & &1.status)
|
||||||
|
sum = fn status -> sum_amounts(by_status[status] || []) end
|
||||||
|
|
||||||
|
total =
|
||||||
|
[:paid, :unpaid, :suspended]
|
||||||
|
|> Enum.map(&sum.(&1))
|
||||||
|
|> Enum.reduce(Decimal.new(0), &Decimal.add/2)
|
||||||
|
|
||||||
|
%{
|
||||||
|
total: total,
|
||||||
|
paid: sum.(:paid),
|
||||||
|
unpaid: sum.(:unpaid),
|
||||||
|
suspended: sum.(:suspended)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sum_amounts(cycles),
|
||||||
|
do: Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end)
|
||||||
|
|
||||||
|
defp zero_cycle_totals do
|
||||||
|
%{
|
||||||
|
total: Decimal.new(0),
|
||||||
|
paid: Decimal.new(0),
|
||||||
|
unpaid: Decimal.new(0),
|
||||||
|
suspended: Decimal.new(0)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the sum of amount for all cycles with status :unpaid.
|
||||||
|
"""
|
||||||
|
@spec open_amount_total(keyword()) :: Decimal.t()
|
||||||
|
def open_amount_total(opts) do
|
||||||
|
query =
|
||||||
|
MembershipFeeCycle
|
||||||
|
|> Ash.Query.filter(expr(status == :unpaid))
|
||||||
|
|
||||||
|
opts_with_domain = Keyword.put(opts, :domain, MembershipFees)
|
||||||
|
|
||||||
|
case Ash.read(query, opts_with_domain) do
|
||||||
|
{:ok, cycles} ->
|
||||||
|
Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
Decimal.new(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
190
test/mv/statistics_test.exs
Normal file
190
test/mv/statistics_test.exs
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
defmodule Mv.StatisticsTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for Mv.Statistics module (member and membership fee cycle statistics).
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
|
alias Mv.Statistics
|
||||||
|
alias Mv.MembershipFees
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: actor}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_fee_type(actor, attrs \\ %{}) do
|
||||||
|
MembershipFeeType
|
||||||
|
|> Ash.Changeset.for_create(
|
||||||
|
:create,
|
||||||
|
Map.merge(
|
||||||
|
%{
|
||||||
|
name: "Test Fee #{System.unique_integer([:positive])}",
|
||||||
|
amount: Decimal.new("50.00"),
|
||||||
|
interval: :yearly
|
||||||
|
},
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> Ash.create!(actor: actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "first_join_year/1" do
|
||||||
|
test "returns the year of the earliest join_date", %{actor: actor} do
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2019-03-15]})
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01]})
|
||||||
|
assert Statistics.first_join_year(actor: actor) == 2019
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns the only member's join year when one member exists", %{actor: actor} do
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2021-06-01]})
|
||||||
|
assert Statistics.first_join_year(actor: actor) == 2021
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil when no members exist", %{actor: actor} do
|
||||||
|
# Relies on empty member table for this test; may be nil if other tests created members
|
||||||
|
result = Statistics.first_join_year(actor: actor)
|
||||||
|
assert result == nil or is_integer(result)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "active_member_count/1" do
|
||||||
|
test "returns 0 when there are no members", %{actor: actor} do
|
||||||
|
assert Statistics.active_member_count(actor: actor) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 1 when one member has no exit_date", %{actor: actor} do
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-15]})
|
||||||
|
assert Statistics.active_member_count(actor: actor) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 0 for that member when exit_date is set", %{actor: actor} do
|
||||||
|
_member =
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-15], exit_date: ~D[2024-06-01]})
|
||||||
|
|
||||||
|
assert Statistics.active_member_count(actor: actor) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "counts only active members when mix of active and inactive", %{actor: actor} do
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01]})
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01], exit_date: ~D[2024-01-01]})
|
||||||
|
assert Statistics.active_member_count(actor: actor) == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "inactive_member_count/1" do
|
||||||
|
test "returns 0 when all members are active", %{actor: actor} do
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01]})
|
||||||
|
assert Statistics.inactive_member_count(actor: actor) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 1 when one member has exit_date set", %{actor: actor} do
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01], exit_date: ~D[2024-06-01]})
|
||||||
|
assert Statistics.inactive_member_count(actor: actor) == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "joins_by_year/2" do
|
||||||
|
test "returns 0 for year with no joins", %{actor: actor} do
|
||||||
|
assert Statistics.joins_by_year(1999, actor: actor) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 1 when one member has join_date in that year", %{actor: actor} do
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-06-15]})
|
||||||
|
assert Statistics.joins_by_year(2023, actor: actor) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 2 when two members joined in that year", %{actor: actor} do
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01]})
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-12-31]})
|
||||||
|
assert Statistics.joins_by_year(2023, actor: actor) == 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "exits_by_year/2" do
|
||||||
|
test "returns 0 for year with no exits", %{actor: actor} do
|
||||||
|
assert Statistics.exits_by_year(1999, actor: actor) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 1 when one member has exit_date in that year", %{actor: actor} do
|
||||||
|
Mv.Fixtures.member_fixture(%{join_date: ~D[2020-01-01], exit_date: ~D[2023-06-15]})
|
||||||
|
assert Statistics.exits_by_year(2023, actor: actor) == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "cycle_totals_by_year/2" do
|
||||||
|
test "returns zero totals for year with no cycles", %{actor: actor} do
|
||||||
|
result = Statistics.cycle_totals_by_year(1999, actor: actor)
|
||||||
|
assert result.total == Decimal.new(0)
|
||||||
|
assert result.paid == Decimal.new(0)
|
||||||
|
assert result.unpaid == Decimal.new(0)
|
||||||
|
assert result.suspended == Decimal.new(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns totals by status for cycles in that year", %{actor: actor} do
|
||||||
|
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")})
|
||||||
|
|
||||||
|
# Creating members with fee type triggers cycle generation (2020..today). We use 2024 cycles.
|
||||||
|
_member1 =
|
||||||
|
Mv.Fixtures.member_fixture(%{
|
||||||
|
join_date: ~D[2020-01-01],
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
})
|
||||||
|
|
||||||
|
_member2 =
|
||||||
|
Mv.Fixtures.member_fixture(%{
|
||||||
|
join_date: ~D[2020-01-01],
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get 2024 cycles and set status (each member has one 2024 yearly cycle from generator)
|
||||||
|
cycles_2024 =
|
||||||
|
MembershipFeeCycle
|
||||||
|
|> Ash.Query.filter(
|
||||||
|
expr(cycle_start >= ^~D[2024-01-01] and cycle_start < ^~D[2025-01-01])
|
||||||
|
)
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> Enum.sort_by(& &1.member_id)
|
||||||
|
|
||||||
|
[c1, c2] = cycles_2024
|
||||||
|
assert {:ok, _} = Ash.update(c1, %{status: :paid}, domain: MembershipFees, actor: actor)
|
||||||
|
|
||||||
|
assert {:ok, _} =
|
||||||
|
Ash.update(c2, %{status: :suspended}, domain: MembershipFees, actor: actor)
|
||||||
|
|
||||||
|
result = Statistics.cycle_totals_by_year(2024, actor: actor)
|
||||||
|
assert Decimal.equal?(result.total, Decimal.new("100.00"))
|
||||||
|
assert Decimal.equal?(result.paid, Decimal.new("50.00"))
|
||||||
|
assert Decimal.equal?(result.unpaid, Decimal.new(0))
|
||||||
|
assert Decimal.equal?(result.suspended, Decimal.new("50.00"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "open_amount_total/1" do
|
||||||
|
test "returns 0 when there are no unpaid cycles", %{actor: actor} do
|
||||||
|
assert Statistics.open_amount_total(actor: actor) == Decimal.new(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns sum of amount for all unpaid cycles", %{actor: actor} do
|
||||||
|
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")})
|
||||||
|
|
||||||
|
_member =
|
||||||
|
Mv.Fixtures.member_fixture(%{
|
||||||
|
join_date: ~D[2020-01-01],
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
})
|
||||||
|
|
||||||
|
# Cycle generator creates yearly cycles (2020..today), all unpaid by default
|
||||||
|
unpaid_sum = Statistics.open_amount_total(actor: actor)
|
||||||
|
assert Decimal.compare(unpaid_sum, Decimal.new(0)) == :gt
|
||||||
|
# Should be 50 * number of years from 2020 to current year
|
||||||
|
current_year = Date.utc_today().year
|
||||||
|
expected_count = current_year - 2020 + 1
|
||||||
|
assert Decimal.equal?(unpaid_sum, Decimal.new(50 * expected_count))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue