mitgliederverwaltung/lib/mv/membership_fees/calendar_cycles.ex
Moritz b257c9897f
All checks were successful
continuous-integration/drone/push Build is passing
refactor: improve CalendarCycles API and tests based on code review
2025-12-11 20:08:19 +01:00

303 lines
9.5 KiB
Elixir

defmodule Mv.MembershipFees.CalendarCycles do
@moduledoc """
Calendar-based cycle calculation functions for membership fees.
This module provides functions for calculating cycle boundaries
based on interval types (monthly, quarterly, half-yearly, yearly).
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
- `: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]
"""
@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 reference date.
## Parameters
- `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` - The reference date to use for calculation (defaults to `date`)
## Returns
The start date of the cycle containing the reference date.
## Examples
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly)
~D[2024-03-01]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly)
~D[2024-04-01]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly)
~D[2024-07-01]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-12-15], :yearly)
~D[2024-01-01]
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_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.
## Parameters
- `cycle_start` - The start date of the cycle
- `interval` - The interval type
## Returns
The end date of the cycle.
## Examples
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly)
~D[2024-03-31]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly)
~D[2024-02-29]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly)
~D[2024-03-31]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly)
~D[2024-06-30]
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()
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> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly)
~D[2024-02-01]
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly)
~D[2024-04-01]
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly)
~D[2024-07-01]
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()
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 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 the given date is within the cycle, `false` otherwise.
## Examples
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15])
true
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(), 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 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 before or on the given date and is the last completed cycle, `false` otherwise.
## Examples
iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-04-01])
true
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(), Date.t()) :: boolean()
def last_completed_cycle?(cycle_start, interval, today) do
cycle_end = calculate_cycle_end(cycle_start, interval)
# 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
# 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
@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
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