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 require Logger 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, reason} -> Logger.warning("Statistics.first_join_year failed: #{inspect(reason)}") 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, reason} -> Logger.warning("Statistics.active_member_count failed: #{inspect(reason)}") 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, reason} -> Logger.warning("Statistics.inactive_member_count failed: #{inspect(reason)}") 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, reason} -> Logger.warning("Statistics.joins_by_year failed: #{inspect(reason)}") 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, reason} -> Logger.warning("Statistics.exits_by_year failed: #{inspect(reason)}") 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)) query = maybe_filter_by_fee_type(query, opts) # Only pass actor and domain to Ash.read; fee_type_id is only for our filter above opts_for_read = opts |> Keyword.drop([:fee_type_id]) |> Keyword.put(:domain, MembershipFees) case Ash.read(query, opts_for_read) do {:ok, cycles} -> cycle_totals_from_cycles(cycles) {:error, reason} -> Logger.warning("Statistics.cycle_totals_by_year failed: #{inspect(reason)}") 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 defp maybe_filter_by_fee_type(query, opts) do case Keyword.get(opts, :fee_type_id) do nil -> query id when is_binary(id) -> # Only apply filter for valid UUID strings (e.g. from form/URL) if Ecto.UUID.cast(id) != :error do Ash.Query.filter(query, expr(membership_fee_type_id == ^id)) else query end id -> Ash.Query.filter(query, expr(membership_fee_type_id == ^id)) end 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)) query = maybe_filter_by_fee_type(query, opts) opts_for_read = opts |> Keyword.drop([:fee_type_id]) |> Keyword.put(:domain, MembershipFees) case Ash.read(query, opts_for_read) do {:ok, cycles} -> Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end) {:error, reason} -> Logger.warning("Statistics.open_amount_total failed: #{inspect(reason)}") Decimal.new(0) end end end