From 5899c70746b3621647515c7043acbf029de9fb2a Mon Sep 17 00:00:00 2001 From: Coleman McFarland Date: Wed, 9 Sep 2026 20:33:25 -0400 Subject: [PATCH 1/3] clamp billing-cycle resets to the month's last day A subscription anchored on the 29th, 30th, or 31st got the wrong metric period in shorter months: time.Date normalized "September 31" into October 1, which was in the future, so the current reset stepped back to September 1 instead of August 31. The next reset was misplaced the same way. Both functions now place the anniversary within the target month, clamped to its last day, and step to the adjacent month by recomputing the anniversary there rather than by AddDate. --- metrics.go | 44 +++++++++++++++++++--------------------- metrics_internal_test.go | 37 +++++++++++++++++++++++++++++++++ metrics_test.go | 33 ++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 metrics_internal_test.go diff --git a/metrics.go b/metrics.go index 1586168..f9fa5a1 100644 --- a/metrics.go +++ b/metrics.go @@ -138,6 +138,20 @@ func GetCurrentMetricPeriodStartForCalendarMetricPeriod(metricPeriod MetricPerio return nil } +// billingAnniversaryInMonth returns the subscription's monthly reset moment within +// the given month: the anchor's day of month, or the month's last day when the +// anchor day does not exist in it (a subscription started on the 31st resets on +// the 30th in a 30-day month and on the 28th or 29th in February). Building the +// date directly would let Go normalize "September 31" into October 1, placing the +// reset in the wrong month. +func billingAnniversaryInMonth(year int, month time.Month, anchor time.Time) time.Time { + day := anchor.Day() + if lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day(); day > lastDay { + day = lastDay + } + return time.Date(year, month, day, anchor.Hour(), anchor.Minute(), anchor.Second(), anchor.Nanosecond(), time.UTC) +} + // Given a company, determine the beginning of the current metric period based on the company's billing subscription func GetCurrentMetricPeriodStartForCompanyBillingSubscription(company *Company) *time.Time { // if no subscription exists, we use calendar month reset @@ -154,20 +168,12 @@ func GetCurrentMetricPeriodStartForCompanyBillingSubscription(company *Company) } // find the most recent reset date based on subscription start date - currentReset := time.Date( - now.Year(), - now.Month(), - periodStart.Day(), - periodStart.Hour(), - periodStart.Minute(), - periodStart.Second(), - periodStart.Nanosecond(), - time.UTC, - ) + currentReset := billingAnniversaryInMonth(now.Year(), now.Month(), periodStart) // if the reset date for current month is in the future, use previous month's reset date if currentReset.After(now) { - currentReset = currentReset.AddDate(0, -1, 0) + previousMonth := time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, time.UTC) + currentReset = billingAnniversaryInMonth(previousMonth.Year(), previousMonth.Month(), periodStart) } // if the current reset is before the subscription period start, use the period start instead @@ -239,21 +245,13 @@ func GetNextMetricPeriodStartForSubscription(subscription *Subscription) *time.T return &periodStart } - // month metric period will reset on the same day/hour/minute/second as the susbcription started every month; get that timestamp for the current month - nextReset := time.Date( - now.Year(), - now.Month(), - periodStart.Day(), - periodStart.Hour(), - periodStart.Minute(), - periodStart.Second(), - periodStart.Nanosecond(), - time.UTC, - ) + // month metric period will reset on the same day/hour/minute/second as the subscription started every month; get that timestamp for the current month + nextReset := billingAnniversaryInMonth(now.Year(), now.Month(), periodStart) // if we've already passed this month's reset date, move to next month if !nextReset.After(now) { - nextReset = nextReset.AddDate(0, 1, 0) + nextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC) + nextReset = billingAnniversaryInMonth(nextMonth.Year(), nextMonth.Month(), periodStart) } // if the next reset is after the end of the billing period, use the end of the billing period instead diff --git a/metrics_internal_test.go b/metrics_internal_test.go new file mode 100644 index 0000000..16ee3b0 --- /dev/null +++ b/metrics_internal_test.go @@ -0,0 +1,37 @@ +package rulesengine + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestBillingAnniversaryInMonth(t *testing.T) { + anchor := func(year int, month time.Month, day int) time.Time { + return time.Date(year, month, day, 9, 30, 15, 250, time.UTC) + } + cases := []struct { + name string + year int + month time.Month + start time.Time + want time.Time + }{ + {"day exists: 15th in September", 2026, time.September, anchor(2026, time.January, 15), anchor(2026, time.September, 15)}, + {"31st clamps to the 30th in September", 2026, time.September, anchor(2026, time.August, 31), anchor(2026, time.September, 30)}, + {"31st clamps to the 30th in April", 2026, time.April, anchor(2026, time.March, 31), anchor(2026, time.April, 30)}, + {"31st clamps to the 28th in a non-leap February", 2026, time.February, anchor(2026, time.January, 31), anchor(2026, time.February, 28)}, + {"30th clamps to the 29th in a leap February", 2028, time.February, anchor(2028, time.January, 30), anchor(2028, time.February, 29)}, + {"29th fits in a leap February", 2028, time.February, anchor(2028, time.January, 29), anchor(2028, time.February, 29)}, + {"31st fits in a 31-day month", 2026, time.October, anchor(2026, time.August, 31), anchor(2026, time.October, 31)}, + {"time of day is preserved", 2026, time.June, anchor(2026, time.May, 31), anchor(2026, time.June, 30)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := billingAnniversaryInMonth(tc.year, tc.month, tc.start) + assert.Equal(t, tc.want, got) + assert.Equal(t, tc.month, got.Month(), "the reset must stay in the requested month") + }) + } +} diff --git a/metrics_test.go b/metrics_test.go index e43abf2..df44dce 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -651,3 +651,36 @@ func TestGetNextMetricPeriodStartFromCondition(t *testing.T) { assert.Equal(t, expected.Unix(), result.Unix()) }) } + +// A subscription anchored on the 31st must reset on the last day of shorter +// months, never spill into the following month. These run against the real clock, +// so they assert the invariants that hold on every date rather than a fixed day. +func TestBillingCycleAnchoredOnThe31st(t *testing.T) { + now := time.Now().UTC() + lastDayOf := func(ts time.Time) int { + return time.Date(ts.Year(), ts.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day() + } + company := createTestCompany() + // Started on the most recent 31st at least a year ago, so the anchor day is + // always 31 and the subscription is always in progress. + company.Subscription.PeriodStart = time.Date(now.Year()-1, time.January, 31, 12, 0, 0, 0, time.UTC) + company.Subscription.PeriodEnd = now.AddDate(0, 2, 0) + + t.Run("current period start", func(t *testing.T) { + result := rulesengine.GetCurrentMetricPeriodStartForCompanyBillingSubscription(company) + assert.NotNil(t, result) + assert.False(t, result.After(now), "the current period must already have started") + assert.True(t, now.Sub(*result) < 32*24*time.Hour, "the current period started within the last month, got %s", result) + assert.Equal(t, min(31, lastDayOf(*result)), result.Day(), "reset on the 31st, or the last day of a shorter month") + assert.Equal(t, 12, result.Hour()) + }) + + t.Run("next period start", func(t *testing.T) { + result := rulesengine.GetNextMetricPeriodStartForCompanyBillingSubscription(company) + assert.NotNil(t, result) + assert.True(t, result.After(now), "the next reset must be in the future") + assert.True(t, result.Sub(now) <= 31*24*time.Hour, "the next reset is within a month, got %s", result) + assert.Equal(t, min(31, lastDayOf(*result)), result.Day(), "reset on the 31st, or the last day of a shorter month") + assert.Equal(t, 12, result.Hour()) + }) +} From ecd6ffbf3666205a6b8d778f13d7406d5bae8d97 Mon Sep 17 00:00:00 2001 From: Coleman McFarland Date: Thu, 10 Sep 2026 10:47:45 -0400 Subject: [PATCH 2/3] chore: de-slop the billing-anniversary comments --- metrics.go | 8 +++----- metrics_test.go | 9 ++++----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/metrics.go b/metrics.go index f9fa5a1..064aad9 100644 --- a/metrics.go +++ b/metrics.go @@ -138,11 +138,9 @@ func GetCurrentMetricPeriodStartForCalendarMetricPeriod(metricPeriod MetricPerio return nil } -// billingAnniversaryInMonth returns the subscription's monthly reset moment within -// the given month: the anchor's day of month, or the month's last day when the -// anchor day does not exist in it (a subscription started on the 31st resets on -// the 30th in a 30-day month and on the 28th or 29th in February). Building the -// date directly would let Go normalize "September 31" into October 1, placing the +// billingAnniversaryInMonth returns the subscription's monthly reset within the +// given month, with the anchor day clamped to the month's last day. Building the +// date directly lets Go normalize "September 31" into October 1, which puts the // reset in the wrong month. func billingAnniversaryInMonth(year int, month time.Month, anchor time.Time) time.Time { day := anchor.Day() diff --git a/metrics_test.go b/metrics_test.go index df44dce..81e0d95 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -652,17 +652,16 @@ func TestGetNextMetricPeriodStartFromCondition(t *testing.T) { }) } -// A subscription anchored on the 31st must reset on the last day of shorter -// months, never spill into the following month. These run against the real clock, -// so they assert the invariants that hold on every date rather than a fixed day. +// A 31st anchor resets on the last day of shorter months, never the next month. +// These run against the real clock, so they assert what holds on every date. func TestBillingCycleAnchoredOnThe31st(t *testing.T) { now := time.Now().UTC() lastDayOf := func(ts time.Time) int { return time.Date(ts.Year(), ts.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day() } company := createTestCompany() - // Started on the most recent 31st at least a year ago, so the anchor day is - // always 31 and the subscription is always in progress. + // Started on January 31 of last year, so the anchor day is 31 and the + // subscription is in progress on every date this test can run. company.Subscription.PeriodStart = time.Date(now.Year()-1, time.January, 31, 12, 0, 0, 0, time.UTC) company.Subscription.PeriodEnd = now.AddDate(0, 2, 0) From 8cdf4eaa742630a6f91e9f22c15296ce6e1b00f2 Mon Sep 17 00:00:00 2001 From: Coleman McFarland Date: Thu, 10 Sep 2026 10:55:38 -0400 Subject: [PATCH 3/3] chore(tests): sweep the billing-cycle resets against an oracle The two period functions now take the evaluation instant through unexported variants so their month arithmetic can be tested on any date; the public functions pass time.Now. Tests sweep every anchor day across two years against an independent oracle, check every anchor day in every month of leap, non-leap, and century years, pin the reset instant and the nanosecond before it, and cover the period-end cap, future starts, and the no-subscription fallback. --- metrics.go | 36 +++++++--- metrics_internal_test.go | 148 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 10 deletions(-) diff --git a/metrics.go b/metrics.go index 064aad9..c417971 100644 --- a/metrics.go +++ b/metrics.go @@ -152,17 +152,27 @@ func billingAnniversaryInMonth(year int, month time.Month, anchor time.Time) tim // Given a company, determine the beginning of the current metric period based on the company's billing subscription func GetCurrentMetricPeriodStartForCompanyBillingSubscription(company *Company) *time.Time { + if company == nil { + return currentBillingPeriodStartAt(nil, time.Now().UTC()) + } + return currentBillingPeriodStartAt(company.Subscription, time.Now().UTC()) +} + +// currentBillingPeriodStartAt is GetCurrentMetricPeriodStartForCompanyBillingSubscription +// evaluated at a given instant, so the month arithmetic can be tested on any date. +func currentBillingPeriodStartAt(subscription *Subscription, now time.Time) *time.Time { // if no subscription exists, we use calendar month reset - if company == nil || company.Subscription == nil { - return GetCurrentMetricPeriodStartForCalendarMetricPeriod(MetricPeriodCurrentMonth) + if subscription == nil { + firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + return &firstOfMonth } - now := time.Now().UTC() - periodStart := company.Subscription.PeriodStart + periodStart := subscription.PeriodStart // if the start period is in the future, the metric period is from the start of the current calendar month if periodStart.After(now) { - return GetCurrentMetricPeriodStartForCalendarMetricPeriod(MetricPeriodCurrentMonth) + firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + return &firstOfMonth } // find the most recent reset date based on subscription start date @@ -223,21 +233,27 @@ func GetNextMetricPeriodStartForCompanyBillingSubscription(company *Company) *ti // GetNextMetricPeriodStartForSubscription determines the next metric period start based on the // subscription's billing cycle. If subscription is nil, returns the start of the next calendar month. func GetNextMetricPeriodStartForSubscription(subscription *Subscription) *time.Time { + return nextBillingPeriodStartAt(subscription, time.Now().UTC()) +} + +// nextBillingPeriodStartAt is GetNextMetricPeriodStartForSubscription evaluated at a +// given instant, so the month arithmetic can be tested on any date. +func nextBillingPeriodStartAt(subscription *Subscription, now time.Time) *time.Time { + startOfNextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC) + // if no subscription exists, we use calendar month reset if subscription == nil { - return GetNextMetricPeriodStartForCalendarMetricPeriod(MetricPeriodCurrentMonth) + return &startOfNextMonth } - now := time.Now().UTC() periodEnd := subscription.PeriodEnd periodStart := subscription.PeriodStart // if the start period is in the future, the metric period is from the start of the current calendar month until either // the end of the current calendar month or the start of the billing period, whichever comes first if periodStart.After(now) { - startOfNextMonth := GetNextMetricPeriodStartForCalendarMetricPeriod(MetricPeriodCurrentMonth) - if periodStart.After(*startOfNextMonth) { - return startOfNextMonth + if periodStart.After(startOfNextMonth) { + return &startOfNextMonth } return &periodStart diff --git a/metrics_internal_test.go b/metrics_internal_test.go index 16ee3b0..eaf5d0c 100644 --- a/metrics_internal_test.go +++ b/metrics_internal_test.go @@ -1,12 +1,38 @@ package rulesengine import ( + "fmt" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// oracleAnniversary is an independent formulation of the clamp: walk down from the +// anchor day until the date stays inside the requested month. +func oracleAnniversary(year int, month time.Month, anchor time.Time) time.Time { + for day := anchor.Day(); day >= 1; day-- { + candidate := time.Date(year, month, day, anchor.Hour(), anchor.Minute(), anchor.Second(), anchor.Nanosecond(), time.UTC) + if candidate.Month() == month { + return candidate + } + } + panic("unreachable: day 1 exists in every month") +} + +// oracleResets lists every reset instant of a subscription from its period start +// through the horizon, computed independently of the code under test. +func oracleResets(periodStart, horizon time.Time) []time.Time { + resets := []time.Time{periodStart} + cursor := time.Date(periodStart.Year(), periodStart.Month()+1, 1, 0, 0, 0, 0, time.UTC) + for cursor.Before(horizon) { + resets = append(resets, oracleAnniversary(cursor.Year(), cursor.Month(), periodStart)) + cursor = cursor.AddDate(0, 1, 0) + } + return resets +} + func TestBillingAnniversaryInMonth(t *testing.T) { anchor := func(year int, month time.Month, day int) time.Time { return time.Date(year, month, day, 9, 30, 15, 250, time.UTC) @@ -24,6 +50,7 @@ func TestBillingAnniversaryInMonth(t *testing.T) { {"31st clamps to the 28th in a non-leap February", 2026, time.February, anchor(2026, time.January, 31), anchor(2026, time.February, 28)}, {"30th clamps to the 29th in a leap February", 2028, time.February, anchor(2028, time.January, 30), anchor(2028, time.February, 29)}, {"29th fits in a leap February", 2028, time.February, anchor(2028, time.January, 29), anchor(2028, time.February, 29)}, + {"29th clamps to the 28th in a century non-leap February", 2100, time.February, anchor(2100, time.January, 29), anchor(2100, time.February, 28)}, {"31st fits in a 31-day month", 2026, time.October, anchor(2026, time.August, 31), anchor(2026, time.October, 31)}, {"time of day is preserved", 2026, time.June, anchor(2026, time.May, 31), anchor(2026, time.June, 30)}, } @@ -35,3 +62,124 @@ func TestBillingAnniversaryInMonth(t *testing.T) { }) } } + +// Every anchor day in every month of leap, non-leap, and century years agrees with +// the independent oracle and never leaves its month. +func TestBillingAnniversaryInMonth_Exhaustive(t *testing.T) { + for _, year := range []int{2023, 2024, 2026, 2028, 2100, 2400} { + for month := time.January; month <= time.December; month++ { + for day := 1; day <= 31; day++ { + anchor := time.Date(2020, time.January, day, 23, 59, 59, 999_999_999, time.UTC) + got := billingAnniversaryInMonth(year, month, anchor) + want := oracleAnniversary(year, month, anchor) + if !assert.Equal(t, want, got, "%d-%s anchor day %d", year, month, day) { + return + } + assert.Equal(t, year, got.Year()) + assert.Equal(t, month, got.Month()) + } + } + } +} + +// Sweep the clock day by day across two years for a subscription anchored on each +// day of the month and check both period functions against the oracle: the current +// reset is the latest one at or before now, the next is the earliest one after now, +// and the two bracket now with a gap of one calendar month. +func TestBillingPeriodStarts_SweepAgainstOracle(t *testing.T) { + horizon := time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC) + for day := 1; day <= 31; day++ { + periodStart := time.Date(2025, time.January, day, 13, 45, 7, 500, time.UTC) + periodEnd := time.Date(2030, time.January, 1, 0, 0, 0, 0, time.UTC) + sub := &Subscription{ID: fmt.Sprintf("sub_%d", day), PeriodStart: periodStart, PeriodEnd: periodEnd} + resets := oracleResets(periodStart, horizon.AddDate(0, 2, 0)) + + checks := 0 + for now := periodStart; now.Before(horizon); now = now.Add(24 * time.Hour) { + var wantCurrent, wantNext time.Time + for _, r := range resets { + if !r.After(now) { + wantCurrent = r + } else { + wantNext = r + break + } + } + + current := currentBillingPeriodStartAt(sub, now) + next := nextBillingPeriodStartAt(sub, now) + require.NotNil(t, current) + require.NotNil(t, next) + if !assert.Equal(t, wantCurrent, *current, "anchor %d, now %s: current", day, now) { + return + } + if !assert.Equal(t, wantNext, *next, "anchor %d, now %s: next", day, now) { + return + } + assert.False(t, current.After(now)) + assert.True(t, next.After(now)) + gap := next.Sub(*current) + assert.True(t, gap >= 28*24*time.Hour && gap <= 31*24*time.Hour, "anchor %d, now %s: gap %s", day, now, gap) + assert.Equal(t, min(day, daysIn(current.Year(), current.Month())), current.Day(), "anchor %d, now %s", day, now) + assert.Equal(t, min(day, daysIn(next.Year(), next.Month())), next.Day(), "anchor %d, now %s", day, now) + checks++ + } + require.GreaterOrEqual(t, checks, 700) + } +} + +func daysIn(year int, month time.Month) int { + return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day() +} + +// The reset instant itself belongs to the new period; a nanosecond earlier still +// belongs to the old one. +func TestBillingPeriodStarts_Boundaries(t *testing.T) { + periodStart := time.Date(2026, time.January, 31, 8, 0, 0, 0, time.UTC) + sub := &Subscription{ID: "sub", PeriodStart: periodStart, PeriodEnd: time.Date(2027, time.January, 31, 8, 0, 0, 0, time.UTC)} + + feb := time.Date(2026, time.February, 28, 8, 0, 0, 0, time.UTC) + mar := time.Date(2026, time.March, 31, 8, 0, 0, 0, time.UTC) + apr := time.Date(2026, time.April, 30, 8, 0, 0, 0, time.UTC) + + assert.Equal(t, feb, *currentBillingPeriodStartAt(sub, feb), "at the reset instant the new period has begun") + assert.Equal(t, mar, *nextBillingPeriodStartAt(sub, feb)) + + assert.Equal(t, periodStart, *currentBillingPeriodStartAt(sub, feb.Add(-time.Nanosecond)), "a nanosecond before the reset is still the previous period") + assert.Equal(t, feb, *nextBillingPeriodStartAt(sub, feb.Add(-time.Nanosecond))) + + assert.Equal(t, mar, *currentBillingPeriodStartAt(sub, apr.Add(-time.Nanosecond))) + assert.Equal(t, apr, *nextBillingPeriodStartAt(sub, apr.Add(-time.Nanosecond))) + + // Never before the subscription started. + assert.Equal(t, periodStart, *currentBillingPeriodStartAt(sub, periodStart)) + assert.Equal(t, periodStart, *currentBillingPeriodStartAt(sub, periodStart.Add(time.Hour))) +} + +// The next reset is capped at the recorded period end, and a subscription that has +// not started yet falls back to calendar months bounded by its start. +func TestBillingPeriodStarts_EdgeCases(t *testing.T) { + t.Run("next reset never passes the period end", func(t *testing.T) { + periodStart := time.Date(2026, time.March, 31, 0, 0, 0, 0, time.UTC) + periodEnd := time.Date(2026, time.April, 15, 0, 0, 0, 0, time.UTC) + sub := &Subscription{ID: "sub", PeriodStart: periodStart, PeriodEnd: periodEnd} + got := nextBillingPeriodStartAt(sub, time.Date(2026, time.April, 10, 0, 0, 0, 0, time.UTC)) + assert.Equal(t, periodEnd, *got) + }) + + t.Run("future period start uses calendar months until it begins", func(t *testing.T) { + now := time.Date(2026, time.September, 10, 12, 0, 0, 0, time.UTC) + soon := &Subscription{ID: "sub", PeriodStart: now.AddDate(0, 0, 5), PeriodEnd: now.AddDate(0, 1, 5)} + assert.Equal(t, time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC), *currentBillingPeriodStartAt(soon, now)) + assert.Equal(t, soon.PeriodStart, *nextBillingPeriodStartAt(soon, now), "the start arrives before the next calendar month") + + later := &Subscription{ID: "sub", PeriodStart: now.AddDate(0, 2, 0), PeriodEnd: now.AddDate(0, 3, 0)} + assert.Equal(t, time.Date(2026, time.October, 1, 0, 0, 0, 0, time.UTC), *nextBillingPeriodStartAt(later, now), "the calendar month ends first") + }) + + t.Run("no subscription uses calendar months", func(t *testing.T) { + now := time.Date(2026, time.September, 10, 12, 0, 0, 0, time.UTC) + assert.Equal(t, time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC), *currentBillingPeriodStartAt(nil, now)) + assert.Equal(t, time.Date(2026, time.October, 1, 0, 0, 0, 0, time.UTC), *nextBillingPeriodStartAt(nil, now)) + }) +}