- get_last_completed_cycle/2 and get_current_cycle/2 return nil when member is nil. - Avoids FunctionClauseError when MemberLive.Show receives no member (e.g. after redirect or policy filter). Add unit tests for nil member.
245 lines
7.3 KiB
Elixir
245 lines
7.3 KiB
Elixir
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.Membership.Member
|
|
alias Mv.MembershipFees.CalendarCycles
|
|
alias Mv.MembershipFees.MembershipFeeCycle
|
|
|
|
@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, always 2 decimal places
|
|
normalized = Decimal.round(amount, 2)
|
|
normalized_str = Decimal.to_string(normalized, :normal)
|
|
|
|
format_currency_parts(normalized_str)
|
|
end
|
|
|
|
# Formats currency string with comma as decimal separator
|
|
defp format_currency_parts(normalized_str) do
|
|
case String.split(normalized_str, ".") do
|
|
[int_part, dec_part] ->
|
|
format_with_decimal_part(int_part, dec_part)
|
|
|
|
[int_part] ->
|
|
"#{int_part},00 €"
|
|
|
|
_ ->
|
|
# Fallback for unexpected split results
|
|
"#{String.replace(normalized_str, ".", ",")} €"
|
|
end
|
|
end
|
|
|
|
# Formats currency with decimal part, ensuring exactly 2 decimal places
|
|
defp format_with_decimal_part(int_part, dec_part) do
|
|
dec_size = byte_size(dec_part)
|
|
|
|
formatted_dec =
|
|
cond do
|
|
dec_size == 1 -> "#{dec_part}0"
|
|
dec_size == 2 -> dec_part
|
|
dec_size > 2 -> String.slice(dec_part, 0, 2)
|
|
true -> "00"
|
|
end
|
|
|
|
"#{int_part},#{formatted_dec} €"
|
|
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() | nil, Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
|
def get_last_completed_cycle(member, today \\ nil)
|
|
|
|
def get_last_completed_cycle(nil, _today), do: 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 || []
|
|
|
|
# Get all completed cycles (cycle_end < today)
|
|
completed_cycles =
|
|
cycles
|
|
|> Enum.filter(fn cycle ->
|
|
cycle_end = CalendarCycles.calculate_cycle_end(cycle.cycle_start, fee_type.interval)
|
|
Date.compare(today, cycle_end) == :gt
|
|
end)
|
|
|
|
# Return the most recent completed cycle (highest cycle_start)
|
|
completed_cycles
|
|
|> Enum.max_by(& &1.cycle_start, Date, fn -> nil end)
|
|
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() | nil, Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
|
def get_current_cycle(member, today \\ nil)
|
|
|
|
def get_current_cycle(nil, _today), do: 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)
|
|
|> Enum.sort_by(& &1.cycle_start, {:desc, Date})
|
|
|> 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
|