Calendar Cycle Calculation Logic closes #276 #284
2 changed files with 510 additions and 0 deletions
329
lib/mv/membership_fees/calendar_cycles.ex
Normal file
329
lib/mv/membership_fees/calendar_cycles.ex
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
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` - Ignored in this 3-argument version (kept for API consistency)
|
||||||
|
moritz marked this conversation as resolved
carla
commented
why for API consistency? why for API consistency?
moritz
commented
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.
|
|||||||
|
- `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`)
|
||||||
|
- `reference_date` - The date used to determine which cycle to calculate
|
||||||
|
|
||||||
|
## 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-05-20])
|
||||||
|
~D[2024-05-01]
|
||||||
|
|
||||||
|
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :quarterly, ~D[2024-05-20])
|
||||||
|
~D[2024-04-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
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Calculates the start date of the cycle that contains the given date.
|
||||||
|
|
||||||
|
This is a convenience function that calls `calculate_cycle_start/3` with `date` as both
|
||||||
|
the input and reference date.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
- `date` - The date used to determine which cycle to calculate
|
||||||
|
- `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`)
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
The start date of the cycle containing the given 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]
|
||||||
|
"""
|
||||||
|
@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 is the last completed cycle.
|
||||||
|
|
||||||
|
A cycle is considered the last completed cycle if:
|
||||||
|
- The cycle has ended (cycle_end < today)
|
||||||
|
- The next cycle has not ended yet (today <= next_end)
|
||||||
|
|
||||||
|
In other words: `cycle_end < today <= next_end`
|
||||||
|
|
||||||
|
## 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 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 (cycle_end < today)
|
||||||
|
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 (today <= next_end)
|
||||||
|
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
|
||||||
181
test/mv/membership_fees/calendar_cycles_test.exs
Normal file
181
test/mv/membership_fees/calendar_cycles_test.exs
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
defmodule Mv.MembershipFees.CalendarCyclesTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for CalendarCycles module.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
|
||||||
|
doctest Mv.MembershipFees.CalendarCycles
|
||||||
|
|
||||||
|
describe "calculate_cycle_start/3" do
|
||||||
|
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 "current_cycle?/3" do
|
||||||
|
# Basic examples are covered by doctests
|
||||||
|
|
||||||
|
test "works for all interval types" do
|
||||||
|
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, today)
|
||||||
|
|
||||||
|
assert result == true, "Expected current cycle for #{interval} with start #{cycle_start}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
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
|
||||||
|
# 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, today) == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "works correctly for quarterly intervals" do
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# Result depends on actual today, so we just verify it's a boolean
|
||||||
|
assert is_boolean(result)
|
||||||
|
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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue
If our meaning of “today” is Europe/Berlin (or any non-UTC TZ), we can be wrong for a window around midnight.
We could explicitly define the business clock as UTC (and document it), or centralize “today” behind a project helper (e.g. Mv.Time.today/0) that uses a configured timezone.
I think this could be a part of a new issues, to set a timezone in the global settings