defmodule Mv.MembershipFees.CalendarCycles do @moduledoc """ Calendar-based cycle calculation functions for membership fees. This module provides functions for calculating cycle boundaries based on interval types (monthly, quarterly, half-yearly, yearly). The calculation functions (`calculate_cycle_start/3`, `calculate_cycle_end/2`, `next_cycle_start/2`) are pure functions with no side effects. The time-dependent functions (`current_cycle?/3`, `last_completed_cycle?/3`) depend on a date parameter for testability. Their 2-argument variants (`current_cycle?/2`, `last_completed_cycle?/2`) use `Date.utc_today()` and are not referentially transparent. ## Interval Types - `:monthly` - Cycles from 1st to last day of each month - `:quarterly` - Cycles from 1st of Jan/Apr/Jul/Oct to last day of quarter - `:half_yearly` - Cycles from 1st of Jan/Jul to last day of half-year - `:yearly` - Cycles from Jan 1st to Dec 31st ## Examples iex> date = ~D[2024-03-15] iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(date, :monthly) ~D[2024-03-01] iex> cycle_start = ~D[2024-01-01] iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle_start, :yearly) ~D[2024-12-31] iex> cycle_start = ~D[2024-01-01] iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(cycle_start, :yearly) ~D[2025-01-01] """ @typedoc """ Interval type for membership fee cycles. - `:monthly` - Monthly cycles (1st to last day of month) - `:quarterly` - Quarterly cycles (1st of quarter to last day of quarter) - `:half_yearly` - Half-yearly cycles (1st of half-year to last day of half-year) - `:yearly` - Yearly cycles (Jan 1st to Dec 31st) """ @type interval :: :monthly | :quarterly | :half_yearly | :yearly @doc """ Calculates the start date of the cycle that contains the reference date. ## Parameters - `date` - Ignored in this 3-argument version (kept for API consistency) - `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`) - `reference_date` - The date used to determine which cycle to calculate ## Returns The start date of the cycle containing the reference date. ## Examples iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly, ~D[2024-05-20]) ~D[2024-05-01] iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :quarterly, ~D[2024-05-20]) ~D[2024-04-01] """ @spec calculate_cycle_start(Date.t(), interval(), Date.t()) :: Date.t() def calculate_cycle_start(_date, interval, reference_date) do case interval do :monthly -> monthly_cycle_start(reference_date) :quarterly -> quarterly_cycle_start(reference_date) :half_yearly -> half_yearly_cycle_start(reference_date) :yearly -> yearly_cycle_start(reference_date) end end @doc """ Calculates the start date of the cycle that contains the given date. This is a convenience function that calls `calculate_cycle_start/3` with `date` as both the input and reference date. ## Parameters - `date` - The date used to determine which cycle to calculate - `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`) ## Returns The start date of the cycle containing the given date. ## Examples iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly) ~D[2024-03-01] iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) ~D[2024-04-01] iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) ~D[2024-07-01] iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-12-15], :yearly) ~D[2024-01-01] """ @spec calculate_cycle_start(Date.t(), interval()) :: Date.t() def calculate_cycle_start(date, interval) do calculate_cycle_start(date, interval, date) end @doc """ Calculates the end date of a cycle based on its start date and interval. ## Parameters - `cycle_start` - The start date of the cycle - `interval` - The interval type ## Returns The end date of the cycle. ## Examples iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) ~D[2024-03-31] iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) ~D[2024-02-29] iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) ~D[2024-03-31] iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) ~D[2024-06-30] iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) ~D[2024-12-31] """ @spec calculate_cycle_end(Date.t(), interval()) :: Date.t() def calculate_cycle_end(cycle_start, interval) do case interval do :monthly -> monthly_cycle_end(cycle_start) :quarterly -> quarterly_cycle_end(cycle_start) :half_yearly -> half_yearly_cycle_end(cycle_start) :yearly -> yearly_cycle_end(cycle_start) end end @doc """ Calculates the start date of the next cycle. ## Parameters - `cycle_start` - The start date of the current cycle - `interval` - The interval type ## Returns The start date of the next cycle. ## Examples iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly) ~D[2024-02-01] iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly) ~D[2024-04-01] iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly) ~D[2024-07-01] iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) ~D[2025-01-01] """ @spec next_cycle_start(Date.t(), interval()) :: Date.t() def next_cycle_start(cycle_start, interval) do cycle_end = calculate_cycle_end(cycle_start, interval) next_date = Date.add(cycle_end, 1) calculate_cycle_start(next_date, interval) end @doc """ Checks if the cycle contains the given date. ## Parameters - `cycle_start` - The start date of the cycle - `interval` - The interval type - `today` - The date to check (defaults to today's date) ## Returns `true` if the given date is within the cycle, `false` otherwise. ## Examples iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15]) true iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-02-01], :monthly, ~D[2024-03-15]) false iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-01]) true iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-31]) true """ @spec current_cycle?(Date.t(), interval(), Date.t()) :: boolean() def current_cycle?(cycle_start, interval, today) do cycle_end = calculate_cycle_end(cycle_start, interval) Date.compare(cycle_start, today) in [:lt, :eq] and Date.compare(today, cycle_end) in [:lt, :eq] end @spec current_cycle?(Date.t(), interval()) :: boolean() def current_cycle?(cycle_start, interval) do current_cycle?(cycle_start, interval, Date.utc_today()) end @doc """ Checks if the cycle is the last completed cycle. A cycle is considered the last completed cycle if: - The cycle has ended (cycle_end < today) - The next cycle has not ended yet (today <= next_end) In other words: `cycle_end < today <= next_end` ## Parameters - `cycle_start` - The start date of the cycle - `interval` - The interval type - `today` - The date to check against (defaults to today's date) ## Returns `true` if the cycle is the last completed cycle, `false` otherwise. ## Examples iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-04-01]) true iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15]) false iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-02-01], :monthly, ~D[2024-04-15]) false """ @spec last_completed_cycle?(Date.t(), interval(), Date.t()) :: boolean() def last_completed_cycle?(cycle_start, interval, today) do cycle_end = calculate_cycle_end(cycle_start, interval) # Cycle must have ended (cycle_end < today) case Date.compare(today, cycle_end) do :gt -> # Check if this is the most recent completed cycle # by verifying that the next cycle hasn't ended yet (today <= next_end) next_start = next_cycle_start(cycle_start, interval) next_end = calculate_cycle_end(next_start, interval) Date.compare(today, next_end) in [:lt, :eq] _ -> false end end @spec last_completed_cycle?(Date.t(), interval()) :: boolean() def last_completed_cycle?(cycle_start, interval) do last_completed_cycle?(cycle_start, interval, Date.utc_today()) end # Private helper functions defp monthly_cycle_start(date) do Date.new!(date.year, date.month, 1) end defp monthly_cycle_end(cycle_start) do Date.end_of_month(cycle_start) end defp quarterly_cycle_start(date) do quarter_start_month = case date.month do m when m in [1, 2, 3] -> 1 m when m in [4, 5, 6] -> 4 m when m in [7, 8, 9] -> 7 m when m in [10, 11, 12] -> 10 end Date.new!(date.year, quarter_start_month, 1) end defp quarterly_cycle_end(cycle_start) do case cycle_start.month do 1 -> Date.new!(cycle_start.year, 3, 31) 4 -> Date.new!(cycle_start.year, 6, 30) 7 -> Date.new!(cycle_start.year, 9, 30) 10 -> Date.new!(cycle_start.year, 12, 31) end end defp half_yearly_cycle_start(date) do half_start_month = if date.month in 1..6, do: 1, else: 7 Date.new!(date.year, half_start_month, 1) end defp half_yearly_cycle_end(cycle_start) do case cycle_start.month do 1 -> Date.new!(cycle_start.year, 6, 30) 7 -> Date.new!(cycle_start.year, 12, 31) end end defp yearly_cycle_start(date) do Date.new!(date.year, 1, 1) end defp yearly_cycle_end(cycle_start) do Date.new!(cycle_start.year, 12, 31) end end