feat: implement calendar-based cycle calculation functions
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.
This commit is contained in:
Moritz 2025-12-11 19:45:01 +01:00
parent ebbf347e42
commit 822d06ed54
Signed by: moritz
GPG key ID: 1020A035E5DD0824
2 changed files with 594 additions and 0 deletions

View file

@ -0,0 +1,267 @@
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

View file

@ -0,0 +1,327 @@
defmodule Mv.MembershipFees.CalendarCyclesTest do
@moduledoc """
Tests for CalendarCycles module.
"""
use ExUnit.Case, async: true
alias 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]
assert CalendarCycles.calculate_cycle_start(date, :monthly, reference) == ~D[2024-05-01]
assert CalendarCycles.calculate_cycle_start(date, :quarterly, reference) == ~D[2024-04-01]
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
test "works for all interval types" do
today = Date.utc_today()
for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do
cycle_start = CalendarCycles.calculate_cycle_start(today, interval)
result = CalendarCycles.current_cycle?(cycle_start, interval)
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
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == false
end
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
end
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)
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == false
end
test "works correctly for quarterly intervals" do
# Test with a known past quarter
past_quarter_start = ~D[2024-01-01]
today = Date.utc_today()
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)
if Date.compare(today, next_quarter_end) in [:lt, :eq] do
assert CalendarCycles.last_completed_cycle?(past_quarter_start, :quarterly) == true
end
end
end
end
describe "edge cases" do
test "leap year: February has 29 days" do
# 2024 is a leap year
assert CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) == ~D[2024-02-29]
# 2023 is not a leap year
assert CalendarCycles.calculate_cycle_end(~D[2023-02-01], :monthly) == ~D[2023-02-28]
end
test "year boundary: December 31 to January 1" do
# Yearly cycle
assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01]
# Monthly cycle across year boundary
assert CalendarCycles.next_cycle_start(~D[2024-12-01], :monthly) == ~D[2025-01-01]
# Half-yearly cycle across year boundary
assert CalendarCycles.next_cycle_start(~D[2024-07-01], :half_yearly) == ~D[2025-01-01]
# Quarterly cycle across year boundary
assert CalendarCycles.next_cycle_start(~D[2024-10-01], :quarterly) == ~D[2025-01-01]
end
test "month boundary: different month lengths" do
# 31-day months
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :monthly) == ~D[2024-01-31]
assert CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) == ~D[2024-03-31]
assert CalendarCycles.calculate_cycle_end(~D[2024-05-01], :monthly) == ~D[2024-05-31]
# 30-day months
assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :monthly) == ~D[2024-04-30]
assert CalendarCycles.calculate_cycle_end(~D[2024-06-01], :monthly) == ~D[2024-06-30]
assert CalendarCycles.calculate_cycle_end(~D[2024-09-01], :monthly) == ~D[2024-09-30]
assert CalendarCycles.calculate_cycle_end(~D[2024-11-01], :monthly) == ~D[2024-11-30]
end
test "date in middle of cycle: all functions work correctly" do
middle_date = ~D[2024-03-15]
# calculate_cycle_start
assert CalendarCycles.calculate_cycle_start(middle_date, :monthly) == ~D[2024-03-01]
assert CalendarCycles.calculate_cycle_start(middle_date, :quarterly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_start(middle_date, :half_yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_start(middle_date, :yearly) == ~D[2024-01-01]
# calculate_cycle_end
monthly_start = CalendarCycles.calculate_cycle_start(middle_date, :monthly)
assert CalendarCycles.calculate_cycle_end(monthly_start, :monthly) == ~D[2024-03-31]
# next_cycle_start
assert CalendarCycles.next_cycle_start(monthly_start, :monthly) == ~D[2024-04-01]
end
test "quarterly: all quarter boundaries correct" do
# Q1 boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :quarterly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) == ~D[2024-03-31]
# Q2 boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) == ~D[2024-04-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) == ~D[2024-06-30]
# Q3 boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-08-15], :quarterly) == ~D[2024-07-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :quarterly) == ~D[2024-09-30]
# Q4 boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-11-15], :quarterly) == ~D[2024-10-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-10-01], :quarterly) == ~D[2024-12-31]
end
test "half_yearly: both half boundaries correct" do
# First half boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :half_yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) == ~D[2024-06-30]
# Second half boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) == ~D[2024-07-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :half_yearly) == ~D[2024-12-31]
end
test "yearly: full year boundaries" do
assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) == ~D[2024-12-31]
assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01]
end
end
end