Calendar Cycle Calculation Logic closes #276 #284

Merged
moritz merged 3 commits from feature/276_cycle_calculation into main 2025-12-16 16:39:37 +01:00
2 changed files with 594 additions and 0 deletions
Showing only changes of commit 3fc4440bce - Show all commits

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]
moritz marked this conversation as resolved

why for API consistency?

why for API consistency?

Because all other cycle function use the current date as first argument for testing the functions. This is just kept to have consistency between the other functions.

Because all other cycle function use the current date as first argument for testing the functions. This is just kept to have consistency between the other functions.
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