feat: add membership fee helper modules

MembershipFeeHelpers: formatting functions for currency, intervals, cycles
MembershipFeeStatus: helper for loading and determining cycle status in member list
This commit is contained in:
Moritz 2025-12-16 10:57:44 +01:00
parent a728968dad
commit 09dfbe455b
Signed by: moritz
GPG key ID: 1020A035E5DD0824
2 changed files with 346 additions and 0 deletions

View file

@ -0,0 +1,203 @@
defmodule MvWeb.Helpers.MembershipFeeHelpers do
@moduledoc """
Helper functions for membership fee UI components.
Provides formatting and utility functions for displaying membership fee
information in LiveViews and templates.
"""
use Gettext, backend: MvWeb.Gettext
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Membership.Member
@doc """
Formats a decimal amount as currency string.
## Examples
iex> MvWeb.Helpers.MembershipFeeHelpers.format_currency(Decimal.new("60.00"))
"60,00 €"
iex> MvWeb.Helpers.MembershipFeeHelpers.format_currency(Decimal.new("5.5"))
"5,50 €"
"""
@spec format_currency(Decimal.t()) :: String.t()
def format_currency(%Decimal{} = amount) do
# Use German format: comma as decimal separator
amount_str = Decimal.to_string(amount, :normal)
amount_str = String.replace(amount_str, ".", ",")
"#{amount_str}"
end
@doc """
Formats an interval atom as a translated string.
## Examples
iex> MvWeb.Helpers.MembershipFeeHelpers.format_interval(:monthly)
"Monthly"
iex> MvWeb.Helpers.MembershipFeeHelpers.format_interval(:yearly)
"Yearly"
"""
@spec format_interval(:monthly | :quarterly | :half_yearly | :yearly) :: String.t()
def format_interval(:monthly), do: gettext("Monthly")
def format_interval(:quarterly), do: gettext("Quarterly")
def format_interval(:half_yearly), do: gettext("Half-yearly")
def format_interval(:yearly), do: gettext("Yearly")
@doc """
Formats a cycle date range as a string.
Calculates the cycle end date from cycle_start and interval, then formats
both dates in European format (dd.mm.yyyy).
## Examples
iex> cycle_start = ~D[2024-01-01]
iex> MvWeb.Helpers.MembershipFeeHelpers.format_cycle_range(cycle_start, :yearly)
"01.01.2024 - 31.12.2024"
iex> cycle_start = ~D[2024-03-01]
iex> MvWeb.Helpers.MembershipFeeHelpers.format_cycle_range(cycle_start, :monthly)
"01.03.2024 - 31.03.2024"
"""
@spec format_cycle_range(Date.t(), :monthly | :quarterly | :half_yearly | :yearly) :: String.t()
def format_cycle_range(cycle_start, interval) do
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
start_str = format_date(cycle_start)
end_str = format_date(cycle_end)
"#{start_str} - #{end_str}"
end
@doc """
Gets the last completed cycle for a member.
Returns the cycle that was most recently completed (ended before today).
Returns `nil` if no completed cycles exist.
## Parameters
- `member` - Member struct with loaded membership_fee_cycles and membership_fee_type
- `today` - Optional date to use as reference (defaults to today)
## Returns
- `%MembershipFeeCycle{}` if found
- `nil` if no completed cycle exists
## Examples
# Member with cycles from 2023 and 2024, today is 2025-01-15
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_last_completed_cycle(member)
# => %MembershipFeeCycle{cycle_start: ~D[2024-01-01], ...}
"""
@spec get_last_completed_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
def get_last_completed_cycle(member, today \\ nil)
def get_last_completed_cycle(%Member{} = member, today) do
today = today || Date.utc_today()
case member.membership_fee_type do
nil ->
nil
fee_type ->
cycles = member.membership_fee_cycles || []
cycles
|> Enum.filter(fn cycle ->
CalendarCycles.last_completed_cycle?(cycle.cycle_start, fee_type.interval, today)
end)
|> List.first()
end
end
@doc """
Gets the current cycle for a member.
Returns the cycle that contains today's date.
Returns `nil` if no current cycle exists.
## Parameters
- `member` - Member struct with loaded membership_fee_cycles and membership_fee_type
- `today` - Optional date to use as reference (defaults to today)
## Returns
- `%MembershipFeeCycle{}` if found
- `nil` if no current cycle exists
## Examples
# Member with cycles, today is 2024-06-15 (within Q2 2024)
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_current_cycle(member)
# => %MembershipFeeCycle{cycle_start: ~D[2024-04-01], ...}
"""
@spec get_current_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
def get_current_cycle(member, today \\ nil)
def get_current_cycle(%Member{} = member, today) do
today = today || Date.utc_today()
case member.membership_fee_type do
nil ->
nil
fee_type ->
cycles = member.membership_fee_cycles || []
cycles
|> Enum.filter(fn cycle ->
CalendarCycles.current_cycle?(cycle.cycle_start, fee_type.interval, today)
end)
|> List.first()
end
end
@doc """
Gets the CSS color class for a status badge.
## Examples
iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:paid)
"badge-success"
iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:unpaid)
"badge-error"
iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:suspended)
"badge-ghost"
"""
@spec status_color(:paid | :unpaid | :suspended) :: String.t()
def status_color(:paid), do: "badge-success"
def status_color(:unpaid), do: "badge-error"
def status_color(:suspended), do: "badge-ghost"
@doc """
Gets the icon name for a status.
## Examples
iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:paid)
"hero-check-circle"
iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:unpaid)
"hero-x-circle"
iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:suspended)
"hero-pause-circle"
"""
@spec status_icon(:paid | :unpaid | :suspended) :: String.t()
def status_icon(:paid), do: "hero-check-circle"
def status_icon(:unpaid), do: "hero-x-circle"
def status_icon(:suspended), do: "hero-pause-circle"
# Private helper function for date formatting
defp format_date(%Date{} = date) do
Calendar.strftime(date, "%d.%m.%Y")
end
end

View file

@ -0,0 +1,143 @@
defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
@moduledoc """
Helper module for membership fee status display in member list view.
Provides functions to efficiently load and determine cycle status for members
in the list view, avoiding N+1 queries.
"""
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.Member
alias MvWeb.Helpers.MembershipFeeHelpers
@doc """
Loads membership fee cycles for members efficiently.
Preloads cycles with membership_fee_type relationship to avoid N+1 queries.
Only loads the relevant cycle per member (last completed or current, depending on show_current).
## Parameters
- `query` - Ash query for members
- `show_current` - If true, load current cycle; if false, load last completed cycle
- `today` - Optional date to use as reference (defaults to today)
## Returns
Modified query with cycles loaded
## Performance
Uses Ash.Query.load to efficiently preload cycles in a single query.
Filters cycles at database level to only load the relevant cycle per member.
"""
@spec load_cycles_for_members(Ash.Query.t(), boolean(), Date.t() | nil) :: Ash.Query.t()
def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do
# Load membership_fee_type and cycles with efficient filtering
query
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
end
@doc """
Gets the cycle status for a member.
Returns the status of either the last completed cycle or the current cycle,
depending on the `show_current` parameter.
## Parameters
- `member` - Member struct with loaded cycles and membership_fee_type
- `show_current` - If true, get current cycle status; if false, get last completed cycle status
## Returns
- `:paid`, `:unpaid`, or `:suspended` if cycle exists
- `nil` if no cycle exists
## Examples
# Get last completed cycle status
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, false)
:paid
# Get current cycle status
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, true)
:unpaid
"""
@spec get_cycle_status_for_member(Member.t(), boolean()) :: :paid | :unpaid | :suspended | nil
def get_cycle_status_for_member(member, show_current \\ false) do
cycle =
if show_current do
MembershipFeeHelpers.get_current_cycle(member)
else
MembershipFeeHelpers.get_last_completed_cycle(member)
end
case cycle do
nil -> nil
cycle -> cycle.status
end
end
@doc """
Formats cycle status as a badge component.
Returns a map with badge information for rendering in templates.
## Parameters
- `status` - Cycle status (`:paid`, `:unpaid`, `:suspended`, or `nil`)
## Returns
Map with `:color`, `:icon`, and `:label` keys, or `nil` if status is nil
## Examples
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid)
%{color: "badge-success", icon: "hero-check-circle", label: "Paid"}
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil)
nil
"""
@spec format_cycle_status_badge(:paid | :unpaid | :suspended | nil) ::
%{color: String.t(), icon: String.t(), label: String.t()} | nil
def format_cycle_status_badge(nil), do: nil
def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
%{
color: MembershipFeeHelpers.status_color(status),
icon: MembershipFeeHelpers.status_icon(status),
label: format_status_label(status)
}
end
@doc """
Filters members by unpaid cycle status.
Returns members that have unpaid cycles in either the last completed cycle
or the current cycle, depending on `show_current`.
## Parameters
- `members` - List of member structs with loaded cycles
- `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
## Returns
List of members with unpaid cycles
"""
@spec filter_unpaid_members([Member.t()], boolean()) :: [Member.t()]
def filter_unpaid_members(members, show_current \\ false) do
Enum.filter(members, fn member ->
status = get_cycle_status_for_member(member, show_current)
status == :unpaid
end)
end
# Private helper function to format status label
defp format_status_label(:paid), do: gettext("Paid")
defp format_status_label(:unpaid), do: gettext("Unpaid")
defp format_status_label(:suspended), do: gettext("Suspended")
end