From b26d66aa9375f826aa4608c2962875a9101dff54 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 10 Feb 2026 22:29:26 +0100 Subject: [PATCH] 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 --- lib/mv/statistics.ex | 180 ++++++++++++++++++++++++++++++++++ test/mv/statistics_test.exs | 190 ++++++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 lib/mv/statistics.ex create mode 100644 test/mv/statistics_test.exs diff --git a/lib/mv/statistics.ex b/lib/mv/statistics.ex new file mode 100644 index 0000000..e11606e --- /dev/null +++ b/lib/mv/statistics.ex @@ -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 diff --git a/test/mv/statistics_test.exs b/test/mv/statistics_test.exs new file mode 100644 index 0000000..167b608 --- /dev/null +++ b/test/mv/statistics_test.exs @@ -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