From b257c9897f4600f3091bdbeb68033d6520a62e8c Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 11 Dec 2025 20:08:19 +0100 Subject: [PATCH] refactor: improve CalendarCycles API and tests based on code review --- lib/mv/membership_fees/calendar_cycles.ex | 126 ++++++---- .../membership_fees/calendar_cycles_test.exs | 238 ++++-------------- 2 files changed, 127 insertions(+), 237 deletions(-) diff --git a/lib/mv/membership_fees/calendar_cycles.ex b/lib/mv/membership_fees/calendar_cycles.ex index 77e1479..5e25432 100644 --- a/lib/mv/membership_fees/calendar_cycles.ex +++ b/lib/mv/membership_fees/calendar_cycles.ex @@ -2,10 +2,16 @@ defmodule Mv.MembershipFees.CalendarCycles do @moduledoc """ Calendar-based cycle calculation functions for membership fees. - This module provides pure functions for calculating cycle boundaries + This module provides 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. + 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 @@ -29,47 +35,61 @@ defmodule Mv.MembershipFees.CalendarCycles do ~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 given date. + Calculates the start date of the cycle that contains the reference date. ## Parameters - - `date` - The date for which to find the cycle start + - `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` - Optional reference date (defaults to `date`) + - `reference_date` - The reference date to use for calculation (defaults to `date`) ## Returns - The start date of the cycle containing the given date. + The start date of the cycle containing the reference date. ## Examples - iex> calculate_cycle_start(~D[2024-03-15], :monthly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly) ~D[2024-03-01] - iex> calculate_cycle_start(~D[2024-05-15], :quarterly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) ~D[2024-04-01] - iex> calculate_cycle_start(~D[2024-09-15], :half_yearly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) ~D[2024-07-01] - iex> calculate_cycle_start(~D[2024-12-15], :yearly) + 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() | nil) :: Date.t() - def calculate_cycle_start(date, interval, reference_date \\ nil) do - reference = reference_date || date + 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) - :quarterly -> quarterly_cycle_start(reference) - :half_yearly -> half_yearly_cycle_start(reference) - :yearly -> yearly_cycle_start(reference) + :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. @@ -84,19 +104,19 @@ defmodule Mv.MembershipFees.CalendarCycles do ## Examples - iex> calculate_cycle_end(~D[2024-03-01], :monthly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) ~D[2024-03-31] - iex> calculate_cycle_end(~D[2024-02-01], :monthly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) ~D[2024-02-29] - iex> calculate_cycle_end(~D[2024-01-01], :quarterly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) ~D[2024-03-31] - iex> calculate_cycle_end(~D[2024-01-01], :half_yearly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) ~D[2024-06-30] - iex> calculate_cycle_end(~D[2024-01-01], :yearly) + 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() @@ -123,16 +143,16 @@ defmodule Mv.MembershipFees.CalendarCycles do ## Examples - iex> next_cycle_start(~D[2024-01-01], :monthly) + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly) ~D[2024-02-01] - iex> next_cycle_start(~D[2024-01-01], :quarterly) + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly) ~D[2024-04-01] - iex> next_cycle_start(~D[2024-01-01], :half_yearly) + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly) ~D[2024-07-01] - iex> next_cycle_start(~D[2024-01-01], :yearly) + 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() @@ -143,63 +163,74 @@ defmodule Mv.MembershipFees.CalendarCycles do end @doc """ - Checks if the cycle contains today's date. + 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 today is within the cycle, `false` otherwise. + `true` if the given date is within the cycle, `false` otherwise. ## Examples - # Assuming today is 2024-03-15 - iex> current_cycle?(~D[2024-03-01], :monthly) + iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15]) true - iex> current_cycle?(~D[2024-02-01], :monthly) + 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()) :: boolean() - def current_cycle?(cycle_start, interval) do - today = Date.utc_today() + @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 yesterday or earlier, but is the most recent completed cycle). + 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 yesterday or earlier and is the last completed cycle, `false` otherwise. + `true` if the cycle ended before or on the given date 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) + iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-04-01]) true - # Assuming today is 2024-03-15 (cycle is still current) - iex> last_completed_cycle?(~D[2024-03-01], :monthly) + 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()) :: boolean() - def last_completed_cycle?(cycle_start, interval) do - today = Date.utc_today() + @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 (yesterday or earlier) + # 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 @@ -214,6 +245,11 @@ defmodule Mv.MembershipFees.CalendarCycles do 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 diff --git a/test/mv/membership_fees/calendar_cycles_test.exs b/test/mv/membership_fees/calendar_cycles_test.exs index 7079e3f..29fec48 100644 --- a/test/mv/membership_fees/calendar_cycles_test.exs +++ b/test/mv/membership_fees/calendar_cycles_test.exs @@ -6,53 +6,9 @@ defmodule Mv.MembershipFees.CalendarCyclesTest do alias Mv.MembershipFees.CalendarCycles + doctest 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] @@ -62,178 +18,76 @@ defmodule Mv.MembershipFees.CalendarCyclesTest do 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 + describe "current_cycle?/3" do + # Basic examples are covered by doctests test "works for all interval types" do - today = Date.utc_today() + 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) + result = CalendarCycles.current_cycle?(cycle_start, interval, today) 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 + 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) - assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == false - end + # 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) - 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 + 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 - # Use a date from two cycles ago - past_date = Date.add(Date.utc_today(), -65) - cycle_start = CalendarCycles.calculate_cycle_start(past_date, :monthly) + # 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) == false + assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false end test "works correctly for quarterly intervals" do - # Test with a known past quarter + # 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) - 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) + # 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) - if Date.compare(today, next_quarter_end) in [:lt, :eq] do - assert CalendarCycles.last_completed_cycle?(past_quarter_start, :quarterly) == true - end - end + # Result depends on actual today, so we just verify it's a boolean + assert is_boolean(result) end end