All checks were successful
continuous-integration/drone/push Build is passing
Add CalendarCycles module with functions for all interval types. Includes comprehensive tests for edge cases.
267 lines
7.3 KiB
Elixir
267 lines
7.3 KiB
Elixir
defmodule Mv.MembershipFees.CalendarCycles do
|
|
@moduledoc """
|
|
Calendar-based cycle calculation functions for membership fees.
|
|
|
|
This module provides pure functions for calculating cycle boundaries
|
|
based on interval types (monthly, quarterly, half-yearly, yearly).
|
|
|
|
All functions are pure (no side effects) and work with Elixir's `Date` type.
|
|
|
|
## 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]
|
|
"""
|
|
|
|
@type interval :: :monthly | :quarterly | :half_yearly | :yearly
|
|
|
|
@doc """
|
|
Calculates the start date of the cycle that contains the given date.
|
|
|
|
## Parameters
|
|
|
|
- `date` - The date for which to find the cycle start
|
|
- `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`)
|
|
- `reference_date` - Optional reference date (defaults to `date`)
|
|
|
|
## Returns
|
|
|
|
The start date of the cycle containing the given date.
|
|
|
|
## Examples
|
|
|
|
iex> calculate_cycle_start(~D[2024-03-15], :monthly)
|
|
~D[2024-03-01]
|
|
|
|
iex> calculate_cycle_start(~D[2024-05-15], :quarterly)
|
|
~D[2024-04-01]
|
|
|
|
iex> calculate_cycle_start(~D[2024-09-15], :half_yearly)
|
|
~D[2024-07-01]
|
|
|
|
iex> calculate_cycle_start(~D[2024-12-15], :yearly)
|
|
~D[2024-01-01]
|
|
"""
|
|
@spec calculate_cycle_start(Date.t(), interval(), Date.t() | nil) :: Date.t()
|
|
def calculate_cycle_start(date, interval, reference_date \\ nil) do
|
|
reference = reference_date || date
|
|
|
|
case interval do
|
|
:monthly -> monthly_cycle_start(reference)
|
|
:quarterly -> quarterly_cycle_start(reference)
|
|
:half_yearly -> half_yearly_cycle_start(reference)
|
|
:yearly -> yearly_cycle_start(reference)
|
|
end
|
|
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> calculate_cycle_end(~D[2024-03-01], :monthly)
|
|
~D[2024-03-31]
|
|
|
|
iex> calculate_cycle_end(~D[2024-02-01], :monthly)
|
|
~D[2024-02-29]
|
|
|
|
iex> calculate_cycle_end(~D[2024-01-01], :quarterly)
|
|
~D[2024-03-31]
|
|
|
|
iex> calculate_cycle_end(~D[2024-01-01], :half_yearly)
|
|
~D[2024-06-30]
|
|
|
|
iex> 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> next_cycle_start(~D[2024-01-01], :monthly)
|
|
~D[2024-02-01]
|
|
|
|
iex> next_cycle_start(~D[2024-01-01], :quarterly)
|
|
~D[2024-04-01]
|
|
|
|
iex> next_cycle_start(~D[2024-01-01], :half_yearly)
|
|
~D[2024-07-01]
|
|
|
|
iex> 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 today's date.
|
|
|
|
## Parameters
|
|
|
|
- `cycle_start` - The start date of the cycle
|
|
- `interval` - The interval type
|
|
|
|
## Returns
|
|
|
|
`true` if today is within the cycle, `false` otherwise.
|
|
|
|
## Examples
|
|
|
|
# Assuming today is 2024-03-15
|
|
iex> current_cycle?(~D[2024-03-01], :monthly)
|
|
true
|
|
|
|
iex> current_cycle?(~D[2024-02-01], :monthly)
|
|
false
|
|
"""
|
|
@spec current_cycle?(Date.t(), interval()) :: boolean()
|
|
def current_cycle?(cycle_start, interval) do
|
|
today = Date.utc_today()
|
|
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
|
|
|
|
@doc """
|
|
Checks if the cycle was just completed (ended yesterday or earlier, but is the most recent completed cycle).
|
|
|
|
## Parameters
|
|
|
|
- `cycle_start` - The start date of the cycle
|
|
- `interval` - The interval type
|
|
|
|
## Returns
|
|
|
|
`true` if the cycle ended yesterday or earlier and is the last completed cycle, `false` otherwise.
|
|
|
|
## Examples
|
|
|
|
# Assuming today is 2024-04-01 (cycle ended yesterday)
|
|
iex> last_completed_cycle?(~D[2024-03-01], :monthly)
|
|
true
|
|
|
|
# Assuming today is 2024-03-15 (cycle is still current)
|
|
iex> last_completed_cycle?(~D[2024-03-01], :monthly)
|
|
false
|
|
"""
|
|
@spec last_completed_cycle?(Date.t(), interval()) :: boolean()
|
|
def last_completed_cycle?(cycle_start, interval) do
|
|
today = Date.utc_today()
|
|
cycle_end = calculate_cycle_end(cycle_start, interval)
|
|
|
|
# Cycle must have ended (yesterday or earlier)
|
|
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
|
|
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
|
|
|
|
# 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
|