Merge branch 'main' into feature/286_export_pdf
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing

This commit is contained in:
carla 2026-02-13 17:40:05 +01:00
commit 22458cd52b
34 changed files with 4931 additions and 76 deletions

View file

@ -178,7 +178,9 @@ defmodule Mv.Authorization.PermissionSets do
# Groups overview
"/groups",
# Group detail
"/groups/:slug"
"/groups/:slug",
# Statistics
"/statistics"
]
}
end
@ -243,7 +245,9 @@ defmodule Mv.Authorization.PermissionSets do
# Group detail
"/groups/:slug",
# Edit group
"/groups/:slug/edit"
"/groups/:slug/edit",
# Statistics
"/statistics"
]
}
end

View file

@ -87,7 +87,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
def generate_cycles_for_member(member_or_id, opts \\ [])
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
case load_member(member_id) do
case load_member(member_id, opts) do
{:ok, member} -> generate_cycles_for_member(member, opts)
{:error, reason} -> {:error, reason}
end
@ -97,25 +97,25 @@ defmodule Mv.MembershipFees.CycleGenerator do
today = Keyword.get(opts, :today, Date.utc_today())
skip_lock? = Keyword.get(opts, :skip_lock?, false)
do_generate_cycles_with_lock(member, today, skip_lock?)
do_generate_cycles_with_lock(member, today, skip_lock?, opts)
end
# Generate cycles with lock handling
# Returns {:ok, cycles, notifications} - notifications are never sent here,
# they should be returned to the caller (e.g., via after_action hook)
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change or seeds)
# Just generate cycles without additional locking
do_generate_cycles(member, today)
do_generate_cycles(member, today, opts)
end
defp do_generate_cycles_with_lock(member, today, false) do
defp do_generate_cycles_with_lock(member, today, false, opts) do
lock_key = :erlang.phash2(member.id)
Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today) do
case do_generate_cycles(member, today, opts) do
{:ok, cycles, notifications} ->
# Return cycles and notifications - do NOT send notifications here
# They will be sent by the caller (e.g., via after_action hook)
@ -235,25 +235,33 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Private functions
defp load_member(member_id) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
# Use actor from opts when provided (e.g. seeds pass admin); otherwise system actor
defp get_actor(opts) do
case Keyword.get(opts, :actor) do
nil -> SystemActor.get_system_actor()
actor -> actor
end
end
defp load_member(member_id, opts) do
actor = get_actor(opts)
read_opts = Helpers.ash_actor_opts(actor)
query =
Member
|> Ash.Query.filter(id == ^member_id)
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
case Ash.read_one(query, opts) do
case Ash.read_one(query, read_opts) do
{:ok, nil} -> {:error, :member_not_found}
{:ok, member} -> {:ok, member}
{:error, reason} -> {:error, reason}
end
end
defp do_generate_cycles(member, today) do
defp do_generate_cycles(member, today, opts) do
# Reload member with relationships to ensure fresh data
case load_member(member.id) do
case load_member(member.id, opts) do
{:ok, member} ->
cond do
is_nil(member.membership_fee_type_id) ->
@ -263,7 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
{:error, :no_join_date}
true ->
generate_missing_cycles(member, today)
generate_missing_cycles(member, today, opts)
end
{:error, reason} ->
@ -271,7 +279,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp generate_missing_cycles(member, today) do
defp generate_missing_cycles(member, today, opts) do
fee_type = member.membership_fee_type
interval = fee_type.interval
amount = fee_type.amount
@ -287,7 +295,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
create_cycles(cycle_starts, member.id, fee_type.id, amount)
create_cycles(cycle_starts, member.id, fee_type.id, amount, opts)
else
{:ok, [], []}
end
@ -382,9 +390,9 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
actor = get_actor(opts)
create_opts = Helpers.ash_actor_opts(actor)
# Always use return_notifications?: true to collect notifications
# Notifications will be returned to the caller, who is responsible for
@ -400,7 +408,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
}
handle_cycle_creation_result(
Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ opts),
Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ create_opts),
cycle_start
)
end)

237
lib/mv/statistics.ex Normal file
View file

@ -0,0 +1,237 @@
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