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 alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.DateFormatter @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 = DateFormatter.format_date(cycle_start) end_str = DateFormatter.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 """ Returns the Core Components badge variant for a cycle status (WCAG-compliant). Use with <.badge variant={MembershipFeeHelpers.status_variant(status)}>. Suspended uses :warning (yellow) to match the edit cycle-status button. """ @spec status_variant(:paid | :unpaid | :suspended) :: :success | :error | :warning def status_variant(:paid), do: :success def status_variant(:unpaid), do: :error def status_variant(:suspended), do: :warning @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" @doc """ Handles a membership-fee-type "delete" event for the fee-type list and the fee-settings LiveViews. Loads the fee type, attempts to destroy it, and returns the updated socket with the matching flash. On success the deleted type and its member count are dropped from the `:membership_fee_types` and `:member_counts` assigns. The NotFound, Forbidden (delete and access), and generic error branches preserve the exact messages both views used before they shared this block. """ @spec delete_fee_type(Phoenix.LiveView.Socket.t(), String.t(), term()) :: {:noreply, Phoenix.LiveView.Socket.t()} def delete_fee_type(socket, id, actor) do case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do {:ok, fee_type} -> destroy_fee_type(socket, fee_type, id, actor) {:error, %Ash.Error.Query.NotFound{}} -> {:noreply, Phoenix.LiveView.put_flash(socket, :error, gettext("Membership fee type not found"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, Phoenix.LiveView.put_flash( socket, :error, gettext("You do not have permission to access this membership fee type") )} {:error, error} -> {:noreply, Phoenix.LiveView.put_flash(socket, :error, fee_error_message(error))} end end defp destroy_fee_type(socket, fee_type, id, actor) do case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do :ok -> updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id)) updated_counts = Map.delete(socket.assigns.member_counts, id) {:noreply, socket |> Phoenix.Component.assign(:membership_fee_types, updated_types) |> Phoenix.Component.assign(:member_counts, updated_counts) |> Phoenix.LiveView.put_flash(:success, gettext("Membership fee type deleted"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, Phoenix.LiveView.put_flash( socket, :error, gettext("You do not have permission to delete this membership fee type") )} {:error, error} -> {:noreply, Phoenix.LiveView.put_flash(socket, :error, fee_error_message(error))} end end defp fee_error_message(%Ash.Error.Invalid{} = error) do Enum.map_join(error.errors, ", ", fn e -> e.message end) end defp fee_error_message(_error), do: gettext("An error occurred") end