Skip to content

Commit 4b2fc31

Browse files
AchoArnoldCopilot
andcommitted
feat(billing): add ComputeBillingCycle function with dynamic clamping
Implement billing cycle computation that: - Accepts a timestamp and anchor day (1-31) - Dynamically clamps anchor day to month's actual days - Handles edge cases: February short months, leap years, year boundaries - Returns (start, end) tuple representing full billing cycle window Includes comprehensive test suite with 9 test cases covering: - Calendar month alignment (anchor=1) - Mid-month anchors (anchor=15) - Edge cases (leap years, month boundaries) - Year boundaries All tests pass. Implementation is tested and verified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a2e3e36 commit 4b2fc31

2 files changed

Lines changed: 139 additions & 0 deletions

File tree

api/pkg/entities/billing_cycle.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package entities
2+
3+
import "time"
4+
5+
// ComputeBillingCycle returns the start and end timestamps of the billing cycle
6+
// that contains `now`, given the user's anchor day (1–31). The anchor day is
7+
// dynamically clamped to the number of days in the relevant month.
8+
func ComputeBillingCycle(now time.Time, anchorDay int) (start, end time.Time) {
9+
clampedDay := min(anchorDay, daysInMonth(now.Year(), now.Month()))
10+
11+
if now.Day() >= clampedDay {
12+
// Cycle started this month
13+
start = time.Date(now.Year(), now.Month(), clampedDay, 0, 0, 0, 0, time.UTC)
14+
} else {
15+
// Cycle started last month
16+
prev := now.AddDate(0, -1, 0)
17+
prevClamped := min(anchorDay, daysInMonth(prev.Year(), prev.Month()))
18+
start = time.Date(prev.Year(), prev.Month(), prevClamped, 0, 0, 0, 0, time.UTC)
19+
}
20+
21+
// Compute next cycle start by moving to next month and clamping the day
22+
nextMonth := start.Month() + 1
23+
nextYear := start.Year()
24+
if nextMonth > 12 {
25+
nextMonth = 1
26+
nextYear++
27+
}
28+
29+
nextClamped := min(anchorDay, daysInMonth(nextYear, nextMonth))
30+
nextCycleStart := time.Date(nextYear, nextMonth, nextClamped, 0, 0, 0, 0, time.UTC)
31+
32+
// End = one second before the next cycle start
33+
end = nextCycleStart.Add(-time.Second)
34+
35+
return start, end
36+
}
37+
38+
// daysInMonth returns the number of days in the given month/year.
39+
func daysInMonth(year int, month time.Month) int {
40+
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
41+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package entities
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestComputeBillingCycle(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
now time.Time
14+
anchorDay int
15+
wantStart time.Time
16+
wantEnd time.Time
17+
}{
18+
{
19+
name: "anchor day 1 (same as calendar month)",
20+
now: time.Date(2026, 5, 15, 10, 0, 0, 0, time.UTC),
21+
anchorDay: 1,
22+
wantStart: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
23+
wantEnd: time.Date(2026, 5, 31, 23, 59, 59, 0, time.UTC),
24+
},
25+
{
26+
name: "anchor day 15, now is after anchor",
27+
now: time.Date(2026, 5, 20, 10, 0, 0, 0, time.UTC),
28+
anchorDay: 15,
29+
wantStart: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC),
30+
wantEnd: time.Date(2026, 6, 14, 23, 59, 59, 0, time.UTC),
31+
},
32+
{
33+
name: "anchor day 15, now is before anchor",
34+
now: time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC),
35+
anchorDay: 15,
36+
wantStart: time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC),
37+
wantEnd: time.Date(2026, 5, 14, 23, 59, 59, 0, time.UTC),
38+
},
39+
{
40+
name: "anchor day 15, now is exactly on anchor",
41+
now: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC),
42+
anchorDay: 15,
43+
wantStart: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC),
44+
wantEnd: time.Date(2026, 6, 14, 23, 59, 59, 0, time.UTC),
45+
},
46+
{
47+
name: "anchor day 31 in February (clamped to 28)",
48+
now: time.Date(2026, 2, 15, 10, 0, 0, 0, time.UTC),
49+
anchorDay: 31,
50+
wantStart: time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC),
51+
wantEnd: time.Date(2026, 2, 27, 23, 59, 59, 0, time.UTC),
52+
},
53+
{
54+
name: "anchor day 31 in March (not clamped)",
55+
now: time.Date(2026, 3, 31, 10, 0, 0, 0, time.UTC),
56+
anchorDay: 31,
57+
wantStart: time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC),
58+
wantEnd: time.Date(2026, 4, 29, 23, 59, 59, 0, time.UTC),
59+
},
60+
{
61+
name: "anchor day 29 in February leap year",
62+
now: time.Date(2024, 2, 29, 10, 0, 0, 0, time.UTC),
63+
anchorDay: 29,
64+
wantStart: time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC),
65+
wantEnd: time.Date(2024, 3, 28, 23, 59, 59, 0, time.UTC),
66+
},
67+
{
68+
name: "anchor day 29 in February non-leap year (clamped to 28)",
69+
now: time.Date(2026, 2, 28, 10, 0, 0, 0, time.UTC),
70+
anchorDay: 29,
71+
wantStart: time.Date(2026, 2, 28, 0, 0, 0, 0, time.UTC),
72+
wantEnd: time.Date(2026, 3, 28, 23, 59, 59, 0, time.UTC),
73+
},
74+
{
75+
name: "year boundary: anchor day 20, now is Jan 5",
76+
now: time.Date(2026, 1, 5, 10, 0, 0, 0, time.UTC),
77+
anchorDay: 20,
78+
wantStart: time.Date(2025, 12, 20, 0, 0, 0, 0, time.UTC),
79+
wantEnd: time.Date(2026, 1, 19, 23, 59, 59, 0, time.UTC),
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
start, end := ComputeBillingCycle(tt.now, tt.anchorDay)
86+
assert.Equal(t, tt.wantStart, start)
87+
assert.Equal(t, tt.wantEnd, end)
88+
})
89+
}
90+
}
91+
92+
func TestDaysInMonth(t *testing.T) {
93+
assert.Equal(t, 31, daysInMonth(2026, time.January))
94+
assert.Equal(t, 28, daysInMonth(2026, time.February))
95+
assert.Equal(t, 29, daysInMonth(2024, time.February))
96+
assert.Equal(t, 30, daysInMonth(2026, time.April))
97+
assert.Equal(t, 31, daysInMonth(2026, time.December))
98+
}

0 commit comments

Comments
 (0)