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

View file

@ -6,53 +6,9 @@ defmodule Mv.MembershipFees.CalendarCyclesTest do
alias Mv.MembershipFees.CalendarCycles
doctest Mv.MembershipFees.CalendarCycles
describe "calculate_cycle_start/3" do
test "monthly: returns 1st of month for any date" do
assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly) == ~D[2024-03-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-03-31], :monthly) == ~D[2024-03-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-03-01], :monthly) == ~D[2024-03-01]
end
test "quarterly: returns 1st of quarter (Jan/Apr/Jul/Oct)" do
# Q1 (Jan-Mar)
assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :quarterly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-02-15], :quarterly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :quarterly) == ~D[2024-01-01]
# Q2 (Apr-Jun)
assert CalendarCycles.calculate_cycle_start(~D[2024-04-15], :quarterly) == ~D[2024-04-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) == ~D[2024-04-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :quarterly) == ~D[2024-04-01]
# Q3 (Jul-Sep)
assert CalendarCycles.calculate_cycle_start(~D[2024-07-15], :quarterly) == ~D[2024-07-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-08-15], :quarterly) == ~D[2024-07-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-09-15], :quarterly) == ~D[2024-07-01]
# Q4 (Oct-Dec)
assert CalendarCycles.calculate_cycle_start(~D[2024-10-15], :quarterly) == ~D[2024-10-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-11-15], :quarterly) == ~D[2024-10-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-12-15], :quarterly) == ~D[2024-10-01]
end
test "half_yearly: returns 1st of half (Jan/Jul)" do
# First half (Jan-Jun)
assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :half_yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :half_yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :half_yearly) == ~D[2024-01-01]
# Second half (Jul-Dec)
assert CalendarCycles.calculate_cycle_start(~D[2024-07-15], :half_yearly) == ~D[2024-07-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) == ~D[2024-07-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-12-15], :half_yearly) == ~D[2024-07-01]
end
test "yearly: returns 1st of January" do
assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_start(~D[2024-12-15], :yearly) == ~D[2024-01-01]
end
test "uses reference_date when provided" do
date = ~D[2024-03-15]
reference = ~D[2024-05-20]
@ -62,178 +18,76 @@ defmodule Mv.MembershipFees.CalendarCyclesTest do
end
end
describe "calculate_cycle_end/2" do
test "monthly: returns last day of month" do
# 31-day month
assert CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) == ~D[2024-03-31]
# 30-day month
assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :monthly) == ~D[2024-04-30]
# February in leap year
assert CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) == ~D[2024-02-29]
# February in non-leap year
assert CalendarCycles.calculate_cycle_end(~D[2023-02-01], :monthly) == ~D[2023-02-28]
end
test "quarterly: returns last day of quarter" do
# Q1: Jan-Mar -> Mar 31
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) == ~D[2024-03-31]
# Q2: Apr-Jun -> Jun 30
assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) == ~D[2024-06-30]
# Q3: Jul-Sep -> Sep 30
assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :quarterly) == ~D[2024-09-30]
# Q4: Oct-Dec -> Dec 31
assert CalendarCycles.calculate_cycle_end(~D[2024-10-01], :quarterly) == ~D[2024-12-31]
end
test "half_yearly: returns last day of half-year" do
# First half: Jan-Jun -> Jun 30
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) == ~D[2024-06-30]
# Second half: Jul-Dec -> Dec 31
assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :half_yearly) == ~D[2024-12-31]
end
test "yearly: returns Dec 31" do
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) == ~D[2024-12-31]
assert CalendarCycles.calculate_cycle_end(~D[2023-01-01], :yearly) == ~D[2023-12-31]
end
end
describe "next_cycle_start/2" do
test "monthly: adds one month" do
assert CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly) == ~D[2024-02-01]
assert CalendarCycles.next_cycle_start(~D[2024-02-01], :monthly) == ~D[2024-03-01]
assert CalendarCycles.next_cycle_start(~D[2024-12-01], :monthly) == ~D[2025-01-01]
end
test "quarterly: adds three months" do
assert CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly) == ~D[2024-04-01]
assert CalendarCycles.next_cycle_start(~D[2024-04-01], :quarterly) == ~D[2024-07-01]
assert CalendarCycles.next_cycle_start(~D[2024-07-01], :quarterly) == ~D[2024-10-01]
assert CalendarCycles.next_cycle_start(~D[2024-10-01], :quarterly) == ~D[2025-01-01]
end
test "half_yearly: adds six months" do
assert CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly) == ~D[2024-07-01]
assert CalendarCycles.next_cycle_start(~D[2024-07-01], :half_yearly) == ~D[2025-01-01]
end
test "yearly: adds one year" do
assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01]
assert CalendarCycles.next_cycle_start(~D[2023-01-01], :yearly) == ~D[2024-01-01]
end
end
describe "current_cycle?/2" do
test "returns true when today is within cycle" do
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
assert CalendarCycles.current_cycle?(cycle_start, :monthly) == true
end
test "returns true when today equals cycle start" do
today = Date.utc_today()
cycle_start = today
# For monthly, if today is the 1st, it's the cycle start
if today.day == 1 do
assert CalendarCycles.current_cycle?(cycle_start, :monthly) == true
end
end
test "returns true when today equals cycle end" do
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, :monthly)
# If today is the last day of the month, it's the cycle end
if today == cycle_end do
assert CalendarCycles.current_cycle?(cycle_start, :monthly) == true
end
end
test "returns false when today is before cycle start" do
future_date = Date.add(Date.utc_today(), 35)
cycle_start = CalendarCycles.calculate_cycle_start(future_date, :monthly)
assert CalendarCycles.current_cycle?(cycle_start, :monthly) == false
end
test "returns false when today is after cycle end" do
past_date = Date.add(Date.utc_today(), -35)
cycle_start = CalendarCycles.calculate_cycle_start(past_date, :monthly)
assert CalendarCycles.current_cycle?(cycle_start, :monthly) == false
end
describe "current_cycle?/3" do
# Basic examples are covered by doctests
test "works for all interval types" do
today = Date.utc_today()
today = ~D[2024-03-15]
for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do
cycle_start = CalendarCycles.calculate_cycle_start(today, interval)
result = CalendarCycles.current_cycle?(cycle_start, interval)
result = CalendarCycles.current_cycle?(cycle_start, interval, today)
assert result == true, "Expected current cycle for #{interval} with start #{cycle_start}"
end
end
end
describe "last_completed_cycle?/2" do
test "returns true when cycle ended yesterday" do
yesterday = Date.add(Date.utc_today(), -1)
cycle_start = CalendarCycles.calculate_cycle_start(yesterday, :monthly)
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, :monthly)
# Only test if yesterday was actually the cycle end
if yesterday == cycle_end do
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == true
end
end
test "returns false when cycle is still current" do
describe "current_cycle?/2 wrapper" do
test "calls current_cycle?/3 with Date.utc_today()" do
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == false
end
# This test verifies the wrapper works, but uses actual today
# The real testing happens in current_cycle?/3 tests above
result = CalendarCycles.current_cycle?(cycle_start, :monthly)
test "returns false when cycle is in the future" do
future_date = Date.add(Date.utc_today(), 35)
cycle_start = CalendarCycles.calculate_cycle_start(future_date, :monthly)
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == false
assert result == true
end
end
describe "last_completed_cycle?/3" do
# Basic examples are covered by doctests
test "returns false when next cycle has also ended" do
# Use a date from two cycles ago
past_date = Date.add(Date.utc_today(), -65)
cycle_start = CalendarCycles.calculate_cycle_start(past_date, :monthly)
# Two cycles ago: cycle ended, but next cycle also ended
today = ~D[2024-05-15]
cycle_start = ~D[2024-03-01]
# Cycle ended 2024-03-31, next cycle ended 2024-04-30, today is 2024-05-15
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == false
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false
end
test "works correctly for quarterly intervals" do
# Test with a known past quarter
# Q1 2024 ended on 2024-03-31
# Q2 2024 ends on 2024-06-30
# Today is 2024-04-15 (after Q1 ended, before Q2 ended)
today = ~D[2024-04-15]
past_quarter_start = ~D[2024-01-01]
assert CalendarCycles.last_completed_cycle?(past_quarter_start, :quarterly, today) == true
end
test "returns false when cycle ended on the given date" do
# Cycle ends on today, so it's still current, not completed
today = ~D[2024-03-31]
cycle_start = ~D[2024-03-01]
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false
end
end
describe "last_completed_cycle?/2 wrapper" do
test "calls last_completed_cycle?/3 with Date.utc_today()" do
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
if Date.compare(today, CalendarCycles.calculate_cycle_end(past_quarter_start, :quarterly)) ==
:gt do
# Check if next quarter hasn't ended yet
next_quarter_start = CalendarCycles.next_cycle_start(past_quarter_start, :quarterly)
next_quarter_end = CalendarCycles.calculate_cycle_end(next_quarter_start, :quarterly)
# This test verifies the wrapper works, but uses actual today
# The real testing happens in last_completed_cycle?/3 tests above
result = CalendarCycles.last_completed_cycle?(cycle_start, :monthly)
if Date.compare(today, next_quarter_end) in [:lt, :eq] do
assert CalendarCycles.last_completed_cycle?(past_quarter_start, :quarterly) == true
end
end
# Result depends on actual today, so we just verify it's a boolean
assert is_boolean(result)
end
end