refactor: improve CalendarCycles API and tests based on code review
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Moritz 2025-12-11 20:08:19 +01:00
parent 822d06ed54
commit b257c9897f
Signed by: moritz
GPG key ID: 1020A035E5DD0824
2 changed files with 127 additions and 237 deletions

View file

@ -2,10 +2,16 @@ defmodule Mv.MembershipFees.CalendarCycles do
@moduledoc """
Calendar-based cycle calculation functions for membership fees.
This module provides pure functions for calculating cycle boundaries
This module provides 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.
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
@ -29,47 +35,61 @@ defmodule Mv.MembershipFees.CalendarCycles do
~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 given date.
Calculates the start date of the cycle that contains the reference date.
## Parameters
- `date` - The date for which to find the cycle start
- `date` - The date for which to find the cycle start (used as default if `reference_date` not provided)
- `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`)
- `reference_date` - Optional reference date (defaults to `date`)
- `reference_date` - The reference date to use for calculation (defaults to `date`)
## Returns
The start date of the cycle containing the given date.
The start date of the cycle containing the reference date.
## Examples
iex> calculate_cycle_start(~D[2024-03-15], :monthly)
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly)
~D[2024-03-01]
iex> calculate_cycle_start(~D[2024-05-15], :quarterly)
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly)
~D[2024-04-01]
iex> calculate_cycle_start(~D[2024-09-15], :half_yearly)
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly)
~D[2024-07-01]
iex> calculate_cycle_start(~D[2024-12-15], :yearly)
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() | nil) :: Date.t()
def calculate_cycle_start(date, interval, reference_date \\ nil) do
reference = reference_date || date
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly, ~D[2024-05-20])
~D[2024-05-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)
:quarterly -> quarterly_cycle_start(reference)
:half_yearly -> half_yearly_cycle_start(reference)
:yearly -> yearly_cycle_start(reference)
: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
@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.
@ -84,19 +104,19 @@ defmodule Mv.MembershipFees.CalendarCycles do
## Examples
iex> calculate_cycle_end(~D[2024-03-01], :monthly)
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly)
~D[2024-03-31]
iex> calculate_cycle_end(~D[2024-02-01], :monthly)
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly)
~D[2024-02-29]
iex> calculate_cycle_end(~D[2024-01-01], :quarterly)
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly)
~D[2024-03-31]
iex> calculate_cycle_end(~D[2024-01-01], :half_yearly)
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly)
~D[2024-06-30]
iex> calculate_cycle_end(~D[2024-01-01], :yearly)
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()
@ -123,16 +143,16 @@ defmodule Mv.MembershipFees.CalendarCycles do
## Examples
iex> next_cycle_start(~D[2024-01-01], :monthly)
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly)
~D[2024-02-01]
iex> next_cycle_start(~D[2024-01-01], :quarterly)
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly)
~D[2024-04-01]
iex> next_cycle_start(~D[2024-01-01], :half_yearly)
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly)
~D[2024-07-01]
iex> next_cycle_start(~D[2024-01-01], :yearly)
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()
@ -143,63 +163,74 @@ defmodule Mv.MembershipFees.CalendarCycles do
end
@doc """
Checks if the cycle contains today's date.
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 today is within the cycle, `false` otherwise.
`true` if the given date is within the cycle, `false` otherwise.
## Examples
# Assuming today is 2024-03-15
iex> current_cycle?(~D[2024-03-01], :monthly)
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15])
true
iex> current_cycle?(~D[2024-02-01], :monthly)
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()) :: boolean()
def current_cycle?(cycle_start, interval) do
today = Date.utc_today()
@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 was just completed (ended yesterday or earlier, but is the most recent completed cycle).
Checks if the cycle was just completed (ended before or on the given date, but is the most recent completed cycle).
## 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 ended yesterday or earlier and is the last completed cycle, `false` otherwise.
`true` if the cycle ended before or on the given date 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)
iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-04-01])
true
# Assuming today is 2024-03-15 (cycle is still current)
iex> last_completed_cycle?(~D[2024-03-01], :monthly)
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()) :: boolean()
def last_completed_cycle?(cycle_start, interval) do
today = Date.utc_today()
@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 (yesterday or earlier)
# Cycle must have ended (before or on the given date)
case Date.compare(today, cycle_end) do
:gt ->
# Check if this is the most recent completed cycle
@ -214,6 +245,11 @@ defmodule Mv.MembershipFees.CalendarCycles do
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