From 822d06ed548361e837390554070e5644d7687a27 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 11 Dec 2025 19:45:01 +0100 Subject: [PATCH] feat: implement calendar-based cycle calculation functions Add CalendarCycles module with functions for all interval types. Includes comprehensive tests for edge cases. --- lib/mv/membership_fees/calendar_cycles.ex | 267 ++++++++++++++ .../membership_fees/calendar_cycles_test.exs | 327 ++++++++++++++++++ 2 files changed, 594 insertions(+) create mode 100644 lib/mv/membership_fees/calendar_cycles.ex create mode 100644 test/mv/membership_fees/calendar_cycles_test.exs diff --git a/lib/mv/membership_fees/calendar_cycles.ex b/lib/mv/membership_fees/calendar_cycles.ex new file mode 100644 index 0000000..77e1479 --- /dev/null +++ b/lib/mv/membership_fees/calendar_cycles.ex @@ -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 diff --git a/test/mv/membership_fees/calendar_cycles_test.exs b/test/mv/membership_fees/calendar_cycles_test.exs new file mode 100644 index 0000000..7079e3f --- /dev/null +++ b/test/mv/membership_fees/calendar_cycles_test.exs @@ -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