From 3c05079e121bcfd856cb400e0b7cb3393f467d36 Mon Sep 17 00:00:00 2001 From: "ryan.weiss" Date: Mon, 16 Mar 2026 21:45:12 -0700 Subject: [PATCH 1/2] Fix EndOf cycle final_date off-by-one EndOf#final_date subtracted 1 period before adding period_count, causing V1E12M to expire at end of month 11 instead of month 12. Remove the offset so the notation matches user expectations: V1E12M from Dec 1 now correctly expires Dec 31 (not Nov 30). Fixed: EndOf cycle expiration was 1 month early (QUAL-6189) --- CHANGELOG.md | 4 ++++ lib/sof/cycles/end_of.rb | 10 +++++----- spec/sof/cycles/dormant_spec.rb | 2 +- spec/sof/cycles/end_of_spec.rb | 28 ++++++++++++++-------------- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c602b14..e3cbb57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.13] - Unreleased +### Fixed + +- EndOf cycle `final_date` was off by one period — `V1E12M` now correctly expires at the end of the 12th month, not the 11th + ## [0.1.12] - 2025-09-05 ### Added diff --git a/lib/sof/cycles/end_of.rb b/lib/sof/cycles/end_of.rb index b86c572..0534d30 100644 --- a/lib/sof/cycles/end_of.rb +++ b/lib/sof/cycles/end_of.rb @@ -2,7 +2,7 @@ # Captures the logic for enforcing the EndOf cycle variant # E.g. "V1E18MF2020-01-05" means: -# You're good until the end of the 17th subsequent month from 2020-01-05. +# You're good until the end of the 18th month from 2020-01-05. # Complete 1 by that date to reset the cycle. # # Some of the calculations are quite different from other cycles. @@ -62,15 +62,15 @@ def satisfied_by?(_ = nil, anchor: Date.current) # # @param [nil] _ Unused parameter, maintained for compatibility # @return [Date] The final date of the cycle calculated as the end of the - # nth subsequent period after the FROM date, where n = (period count - 1) + # nth period after the FROM date # # @example # Cycle.for("V1E18MF2020-01-09").final_date - # # => # + # # => # def final_date(_ = nil) return nil if parser.dormant? || from_date.nil? time_span - .end_date(start_date - 1.send(period)) + .end_date(start_date) .end_of_month end @@ -86,7 +86,7 @@ def dormant_to_s end def subsequent_ordinal - ActiveSupport::Inflector.ordinalize(period_count - 1) + ActiveSupport::Inflector.ordinalize(period_count) end end end diff --git a/spec/sof/cycles/dormant_spec.rb b/spec/sof/cycles/dormant_spec.rb index 180d09a..27fd266 100644 --- a/spec/sof/cycles/dormant_spec.rb +++ b/spec/sof/cycles/dormant_spec.rb @@ -58,7 +58,7 @@ module SOF context "with a dormant EndOf cycle" do it "returns the cycle string representation with (dormant) suffix" do - expect(end_of_cycle.to_s).to eq "2x by the last day of the 17th subsequent month (dormant)" + expect(end_of_cycle.to_s).to eq "2x by the last day of the 18th subsequent month (dormant)" end end end diff --git a/spec/sof/cycles/end_of_spec.rb b/spec/sof/cycles/end_of_spec.rb index 7007fe6..e6e1520 100644 --- a/spec/sof/cycles/end_of_spec.rb +++ b/spec/sof/cycles/end_of_spec.rb @@ -10,7 +10,7 @@ module SOF let(:notation) { "V2E18MF#{from_date}" } let(:anchor) { nil } - let(:end_date) { (from_date + 17.months).end_of_month } + let(:end_date) { (from_date + 18.months).end_of_month } let(:from_date) { "2020-01-01".to_date } let(:completed_dates) { [] } @@ -24,7 +24,7 @@ module SOF end end - @end_date = ("2020-01-01".to_date + 17.months).end_of_month + @end_date = ("2020-01-01".to_date + 18.months).end_of_month it_behaves_like "#to_s returns", "2x by #{@end_date.to_fs(:american)}" @@ -32,13 +32,13 @@ module SOF before { allow(cycle.parser).to receive(:dormant?).and_return(true) } it_behaves_like "#to_s returns", - "2x by the last day of the 17th subsequent month" + "2x by the last day of the 18th subsequent month" end it_behaves_like "#volume returns the volume" it_behaves_like "#notation returns the notation" it_behaves_like "#as_json returns the notation" it_behaves_like "it computes #final_date(given)", - given: nil, returns: ("2020-01-01".to_date + 17.months).end_of_month + given: nil, returns: ("2020-01-01".to_date + 18.months).end_of_month it_behaves_like "it cannot be extended" describe "#last_completed" do @@ -66,7 +66,7 @@ module SOF too_late_date ] end - let(:recent_date) { from_date + 17.months } + let(:recent_date) { from_date + 18.months } let(:middle_date) { from_date + 2.months } let(:early_date) { from_date + 1.month } let(:too_early_date) { from_date - 1.day } @@ -85,7 +85,7 @@ module SOF describe "#satisfied_by?(anchor:)" do context "when the anchor date is < the final date" do - let(:anchor) { "2021-06-29".to_date } + let(:anchor) { "2021-07-30".to_date } it "returns true" do expect(cycle).to be_satisfied_by(anchor:) @@ -93,7 +93,7 @@ module SOF end context "when the anchor date is = the final date" do - let(:anchor) { "2021-06-30".to_date } + let(:anchor) { "2021-07-31".to_date } it "returns true" do expect(cycle).to be_satisfied_by(anchor:) @@ -101,7 +101,7 @@ module SOF end context "when the anchor date is > the final date" do - let(:anchor) { "2021-07-01".to_date } + let(:anchor) { "2021-08-01".to_date } it "returns false" do expect(cycle).not_to be_satisfied_by(completed_dates, anchor:) @@ -111,26 +111,26 @@ module SOF describe "#expiration_of(completion_dates)" do context "when the anchor date is < the final date" do - let(:anchor) { "2021-06-29".to_date } + let(:anchor) { "2021-07-30".to_date } it "returns the final date" do - expect(cycle.expiration_of(anchor:)).to eq "2021-06-30".to_date + expect(cycle.expiration_of(anchor:)).to eq "2021-07-31".to_date end end context "when the anchor date = the final date" do - let(:anchor) { "2021-06-30".to_date } + let(:anchor) { "2021-07-31".to_date } it "returns the final date" do - expect(cycle.expiration_of(anchor:)).to eq "2021-06-30".to_date + expect(cycle.expiration_of(anchor:)).to eq "2021-07-31".to_date end end context "when the anchor date > the final date" do - let(:anchor) { "2021-07-31".to_date } + let(:anchor) { "2021-08-31".to_date } it "returns the final date" do - expect(cycle.expiration_of(anchor:)).to eq "2021-06-30".to_date + expect(cycle.expiration_of(anchor:)).to eq "2021-07-31".to_date end end end From 113e7004a69dbb7f8a04ea799899ca651947e218 Mon Sep 17 00:00:00 2001 From: "ryan.weiss" Date: Mon, 16 Mar 2026 21:54:40 -0700 Subject: [PATCH 2/2] Add Recurring cycle kind (R notation) New cycle type for recurring windows anchored to a from_date. V1R24MF2026-03-31 = complete 1 within 24 months from March 31, 2026. After completion, next window starts from the completion date. Unlike EndOf: no end-of-month rounding. Unlike Lookback: anchored to from_date, not sliding from today. Added: Recurring cycle kind with R notation (QUAL-6317) --- CHANGELOG.md | 4 + lib/sof/cycle.rb | 4 +- lib/sof/cycles/recurring.rb | 80 +++++++++++++++ lib/sof/parser.rb | 4 +- spec/sof/cycles/recurring_spec.rb | 160 ++++++++++++++++++++++++++++++ 5 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 lib/sof/cycles/recurring.rb create mode 100644 spec/sof/cycles/recurring_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index e3cbb57..73f8727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.13] - Unreleased +### Added + +- Recurring cycle kind (`R` notation) — recurring windows anchored to a from_date (e.g., `V1R24MF2026-03-31`) + ### Fixed - EndOf cycle `final_date` was off by one period — `V1E12M` now correctly expires at the end of the 12th month, not the 11th diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index 9adf732..b2f627b 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -219,8 +219,8 @@ def initialize(notation, parser: Parser.new(notation)) :humanized_period, :period_key, :active?] => :@parser delegate [:kind, :recurring?, :volume_only?, :valid_periods] => "self.class" delegate [:period_count, :duration] => :time_span - delegate [:calendar?, :dormant?, :end_of?, :lookback?, :volume_only?, - :within?] => :kind_inquiry + delegate [:calendar?, :dormant?, :end_of?, :lookback?, + :volume_only?, :within?] => :kind_inquiry def kind_inquiry = ActiveSupport::StringInquirer.new(kind.to_s) diff --git a/lib/sof/cycles/recurring.rb b/lib/sof/cycles/recurring.rb new file mode 100644 index 0000000..353454f --- /dev/null +++ b/lib/sof/cycles/recurring.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Captures the logic for enforcing the Recurring cycle variant +# E.g. "V1R24MF2026-03-31" means: +# Complete 1 within 24 months starting from 2026-03-31. +# After completion, the next window starts from the completion date. +# +# Unlike EndOf, there is no end-of-month rounding. +# Unlike Lookback, the window is anchored to a from_date, not sliding from today. +module SOF + module Cycles + class Recurring < Cycle + @volume_only = false + @notation_id = "R" + @kind = :recurring + @valid_periods = %w[D W M Y] + + def self.recurring? = true + + def self.description + "Recurring - occurrences within a recurring time period anchored to a from date" + end + + def self.examples + ["V1R24MF2026-03-31 - once within 24 months from March 31, 2026"] + end + + def to_s + return dormant_to_s if parser.dormant? || from_date.nil? + + "#{volume}x within #{date_range}" + end + + # Returns the expiration date for the cycle + # + # @return [Date, nil] The final date of the current window + def expiration_of(_ = nil, anchor: nil) + return nil if parser.dormant? || from_date.nil? + final_date + end + + # Is the supplied anchor date within the current window? + # + # @return [Boolean] true if the anchor is before or on the final date + def satisfied_by?(_ = nil, anchor: Date.current) + return false if parser.dormant? || from_date.nil? + anchor <= final_date + end + + # Always returns the from_date + def last_completed(_ = nil) = from_date&.to_date + + # Calculates the final date of the current window + # + # @return [Date] from_date + period (no end-of-month rounding) + # + # @example + # Cycle.for("V1R24MF2026-03-31").final_date + # # => # + def final_date(_ = nil) + return nil if parser.dormant? || from_date.nil? + time_span.end_date(start_date) + end + + def start_date(_ = nil) = from_date&.to_date + + private + + def dormant_to_s + <<~DESC.squish + #{volume}x every #{humanized_span} + DESC + end + + def date_range + [start_date, final_date].map { it.to_fs(:american) }.join(" - ") + end + end + end +end diff --git a/lib/sof/parser.rb b/lib/sof/parser.rb index 967fd45..400c88f 100644 --- a/lib/sof/parser.rb +++ b/lib/sof/parser.rb @@ -14,13 +14,13 @@ class Parser extend Forwardable PARTS_REGEX = / ^(?V(?\d*))? # optional volume - (?(?L|C|W|E) # kind + (?(?L|C|W|E|R) # kind (?\d+) # period count (?D|W|M|Q|Y)?)? # period_key (?F(?\d{4}-\d{2}-\d{2}))?$ # optional from /ix - def self.dormant_capable_kinds = %w[E W] + def self.dormant_capable_kinds = %w[E W R] def self.for(notation_or_parser) return notation_or_parser if notation_or_parser.is_a? self diff --git a/spec/sof/cycles/recurring_spec.rb b/spec/sof/cycles/recurring_spec.rb new file mode 100644 index 0000000..2cd1c17 --- /dev/null +++ b/spec/sof/cycles/recurring_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "shared_examples" + +module SOF + RSpec.describe Cycles::Recurring, type: :value do + subject(:cycle) { Cycle.for(notation) } + + let(:notation) { "V1R24MF#{from_date}" } + let(:anchor) { nil } + + let(:end_date) { from_date + 24.months } + let(:from_date) { "2026-03-31".to_date } + + let(:completed_dates) { [] } + + it_behaves_like "#kind returns", :recurring + it_behaves_like "#valid_periods are", %w[D W M Y] + + describe "#recurring?" do + it "repeats" do + expect(cycle).to be_recurring + end + end + + @range = ["2026-03-31".to_date, "2026-03-31".to_date + 24.months] + .map { it.to_fs(:american) }.join(" - ") + it_behaves_like "#to_s returns", + "1x within #{@range}" + + context "when the cycle is dormant" do + before { allow(cycle.parser).to receive(:dormant?).and_return(true) } + + it_behaves_like "#to_s returns", + "1x every 24 months" + end + + it_behaves_like "#volume returns the volume" + it_behaves_like "#notation returns the notation" + it_behaves_like "#as_json returns the notation" + it_behaves_like "it computes #final_date(given)", + given: nil, returns: "2026-03-31".to_date + 24.months + it_behaves_like "it cannot be extended" + + describe "#last_completed" do + context "with an activated cycle" do + it_behaves_like "last_completed is", :from_date + end + + context "with a dormant cycle" do + let(:notation) { "V1R24M" } + + it "returns nil" do + expect(cycle.last_completed).to be_nil + end + end + end + + describe "#final_date" do + it "returns from_date + period without end-of-month rounding" do + expect(cycle.final_date).to eq "2028-03-31".to_date + end + + context "with a mid-month from_date" do + let(:from_date) { "2026-06-15".to_date } + + it "preserves the exact day" do + expect(cycle.final_date).to eq "2028-06-15".to_date + end + end + end + + describe "#covered_dates" do + let(:completed_dates) do + [ + within_window, + just_before_end, + too_early_date, + too_late_date + ] + end + let(:within_window) { from_date + 6.months } + let(:just_before_end) { end_date - 1.day } + let(:too_early_date) { from_date - 1.day } + let(:too_late_date) { end_date + 1.day } + + let(:anchor) { from_date + 1.year } + + it "returns dates that fall within the window" do + expect(cycle.covered_dates(completed_dates, anchor:)).to eq([ + within_window, + just_before_end + ]) + end + end + + describe "#satisfied_by?(anchor:)" do + context "when the anchor date is < the final date" do + let(:anchor) { "2028-03-30".to_date } + + it "returns true" do + expect(cycle).to be_satisfied_by(anchor:) + end + end + + context "when the anchor date is = the final date" do + let(:anchor) { "2028-03-31".to_date } + + it "returns true" do + expect(cycle).to be_satisfied_by(anchor:) + end + end + + context "when the anchor date is > the final date" do + let(:anchor) { "2028-04-01".to_date } + + it "returns false" do + expect(cycle).not_to be_satisfied_by(completed_dates, anchor:) + end + end + end + + describe "#expiration_of" do + it "returns the final date" do + expect(cycle.expiration_of).to eq "2028-03-31".to_date + end + end + + describe "dormant behavior" do + let(:notation) { "V1R24M" } + + it "is dormant without a from_date" do + expect(cycle).to be_dormant + end + + it "returns nil for final_date" do + expect(cycle.final_date).to be_nil + end + + it "returns nil for expiration_of" do + expect(cycle.expiration_of).to be_nil + end + + it "returns false for satisfied_by?" do + expect(cycle).not_to be_satisfied_by(anchor: Date.current) + end + end + + describe "activation" do + let(:notation) { "V1R24M" } + + it "can be activated with a date" do + activated = Cycle.for(cycle.parser.activated_notation("2026-03-31".to_date)) + expect(activated.notation).to eq "V1R24MF2026-03-31" + expect(activated).not_to be_dormant + end + end + end +end