From 3d9f96eb440c804c41d46d346d13ae45b32fdbef 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 035b942..b13e77d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dormant capability is now declared on each cycle class (`def self.dormant_capable? = true`) instead of only in `Parser.dormant_capable_kinds` +### 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 310f257..3414d14 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. @@ -64,15 +64,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 @@ -88,7 +88,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 aa62e298b1521de6ac2852924d2408aedcfbe1d3 Mon Sep 17 00:00:00 2001 From: "ryan.weiss" Date: Thu, 19 Mar 2026 23:33:39 -0700 Subject: [PATCH 2/2] Drop ambiguous 'subsequent' from EndOf dormant to_s With the off-by-one fixed, "18th month" is literally correct. "18th subsequent month" was ambiguous about whether it meant "18 months after" or "the 18th month following the current one." Also simplifies dormant_to_s from heredoc+squish to a plain string, and renames subsequent_ordinal to ordinalized_period_count. --- lib/sof/cycles/end_of.rb | 7 ++----- spec/sof/cycles/dormant_spec.rb | 2 +- spec/sof/cycles/end_of_spec.rb | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/sof/cycles/end_of.rb b/lib/sof/cycles/end_of.rb index 3414d14..8cd64fb 100644 --- a/lib/sof/cycles/end_of.rb +++ b/lib/sof/cycles/end_of.rb @@ -81,13 +81,10 @@ def start_date(_ = nil) = from_date&.to_date private def dormant_to_s - <<~DESC.squish - #{volume}x by the last day of the #{subsequent_ordinal} - subsequent #{period} - DESC + "#{volume}x by the last day of the #{ordinalized_period_count} #{period}" end - def subsequent_ordinal + def ordinalized_period_count ActiveSupport::Inflector.ordinalize(period_count) end end diff --git a/spec/sof/cycles/dormant_spec.rb b/spec/sof/cycles/dormant_spec.rb index 27fd266..e3ecbb9 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 18th subsequent month (dormant)" + expect(end_of_cycle.to_s).to eq "2x by the last day of the 18th month (dormant)" end end end diff --git a/spec/sof/cycles/end_of_spec.rb b/spec/sof/cycles/end_of_spec.rb index e6e1520..5e65843 100644 --- a/spec/sof/cycles/end_of_spec.rb +++ b/spec/sof/cycles/end_of_spec.rb @@ -32,7 +32,7 @@ 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 18th subsequent month" + "2x by the last day of the 18th month" end it_behaves_like "#volume returns the volume" it_behaves_like "#notation returns the notation"