diff --git a/metrics.go b/metrics.go index 1586168..c417971 100644 --- a/metrics.go +++ b/metrics.go @@ -138,36 +138,50 @@ func GetCurrentMetricPeriodStartForCalendarMetricPeriod(metricPeriod MetricPerio return nil } +// 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() + 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 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 - 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 @@ -219,41 +233,39 @@ 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 } - // 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..eaf5d0c --- /dev/null +++ b/metrics_internal_test.go @@ -0,0 +1,185 @@ +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) + } + 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)}, + {"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)}, + } + 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") + }) + } +} + +// 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)) + }) +} diff --git a/metrics_test.go b/metrics_test.go index e43abf2..81e0d95 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -651,3 +651,35 @@ func TestGetNextMetricPeriodStartFromCondition(t *testing.T) { assert.Equal(t, expected.Unix(), result.Unix()) }) } + +// 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 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) + + 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()) + }) +}