feat: implement calendar-based cycle calculation functions
All checks were successful
continuous-integration/drone/push Build is passing
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:
parent
ebbf347e42
commit
822d06ed54
2 changed files with 594 additions and 0 deletions
267
lib/mv/membership_fees/calendar_cycles.ex
Normal file
267
lib/mv/membership_fees/calendar_cycles.ex
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue