Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/hirosassa/zerodriver v0.1.4
github.com/jaswdr/faker/v2 v2.9.1
github.com/jinzhu/now v1.1.5
github.com/joho/godotenv v1.5.1
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/jszwec/csvutil v1.10.0
Expand Down Expand Up @@ -141,6 +140,7 @@ require (
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
Expand Down
40 changes: 17 additions & 23 deletions api/pkg/di/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ import (

"github.com/NdoleStudio/go-otelroundtripper"

"github.com/jinzhu/now"

"github.com/uptrace/uptrace-go/uptrace"

"github.com/NdoleStudio/httpsms/pkg/emails"
Expand Down Expand Up @@ -101,23 +99,13 @@ type Container struct {

// NewLiteContainer creates a Container without any routes or listeners
func NewLiteContainer() (container *Container) {
// Set location to UTC
now.DefaultConfig = &now.Config{
TimeLocation: time.UTC,
}

return &Container{
logger: logger(3).WithService(fmt.Sprintf("%T", container)),
}
}

// NewContainer creates a new dependency injection container
func NewContainer(projectID string, version string) (container *Container) {
// Set location to UTC
now.DefaultConfig = &now.Config{
TimeLocation: time.UTC,
}

container = &Container{
projectID: projectID,
version: version,
Expand Down Expand Up @@ -200,14 +188,16 @@ func (container *Container) App() (app *fiber.App) {
}

app.Use(otelfiber.Middleware())
app.Use(cors.New(
cors.Config{
AllowOrigins: getEnvWithDefault("CORS_ALLOW_ORIGINS", "*"),
AllowHeaders: getEnvWithDefault("CORS_ALLOW_HEADERS", "*"),
AllowMethods: getEnvWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,DELETE,OPTIONS"),
AllowCredentials: false,
ExposeHeaders: getEnvWithDefault("CORS_EXPOSE_HEADERS", "*"),
}),
app.Use(
cors.New(
cors.Config{
AllowOrigins: getEnvWithDefault("CORS_ALLOW_ORIGINS", "*"),
AllowHeaders: getEnvWithDefault("CORS_ALLOW_HEADERS", "*"),
AllowMethods: getEnvWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,DELETE,OPTIONS"),
AllowCredentials: false,
ExposeHeaders: getEnvWithDefault("CORS_EXPOSE_HEADERS", "*"),
},
),
)
app.Use(middlewares.HTTPRequestLogger(container.Tracer(), container.Logger()))
app.Use(middlewares.BearerAuth(container.Logger(), container.Tracer(), container.FirebaseAuthClient()))
Expand Down Expand Up @@ -853,6 +843,7 @@ func (container *Container) BillingUsageRepository() (repository repositories.Bi
container.Logger(),
container.Tracer(),
container.DB(),
container.UserRepository(),
)
}

Expand Down Expand Up @@ -1853,7 +1844,8 @@ func (container *Container) initializeAxiomTraceProvider(version string, namespa
"X-Axiom-Dataset": os.Getenv("AXIOM_DATASET_EVENTS"),
}

traceExporter, err := otlptracehttp.New(context.Background(),
traceExporter, err := otlptracehttp.New(
context.Background(),
otlptracehttp.WithEndpoint("us-east-1.aws.edge.axiom.co"),
otlptracehttp.WithHeaders(traceHeaders),
)
Expand All @@ -1878,7 +1870,8 @@ func (container *Container) initializeAxiomTraceProvider(version string, namespa
"X-Axiom-Dataset": os.Getenv("AXIOM_DATASET_METRICS"),
}

metricExporter, err := otlpmetrichttp.New(context.Background(),
metricExporter, err := otlpmetrichttp.New(
context.Background(),
otlpmetrichttp.WithEndpoint("us-east-1.aws.edge.axiom.co"),
otlpmetrichttp.WithHeaders(metricHeaders),
)
Expand Down Expand Up @@ -1993,7 +1986,8 @@ func consoleLogger(skipFrameCount int) *zerodriver.Logger {
l := zerolog.New(
zerolog.ConsoleWriter{
Out: os.Stderr,
}).With().Timestamp().CallerWithSkipFrameCount(skipFrameCount).Logger()
},
).With().Timestamp().CallerWithSkipFrameCount(skipFrameCount).Logger()
return &zerodriver.Logger{
Logger: &l,
}
Expand Down
41 changes: 41 additions & 0 deletions api/pkg/entities/billing_cycle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package entities

import "time"

// ComputeBillingCycle returns the start and end timestamps of the billing cycle
// that contains `now`, given the user's anchor day (1–31). The anchor day is
// dynamically clamped to the number of days in the relevant month.
func ComputeBillingCycle(now time.Time, anchorDay int) (start, end time.Time) {
clampedDay := min(anchorDay, daysInMonth(now.Year(), now.Month()))

if now.Day() >= clampedDay {
// Cycle started this month
start = time.Date(now.Year(), now.Month(), clampedDay, 0, 0, 0, 0, time.UTC)
} else {
// Cycle started last month
prev := now.AddDate(0, -1, 0)
prevClamped := min(anchorDay, daysInMonth(prev.Year(), prev.Month()))
start = time.Date(prev.Year(), prev.Month(), prevClamped, 0, 0, 0, 0, time.UTC)
}

// Compute next cycle start by moving to next month and clamping the day
nextMonth := start.Month() + 1
nextYear := start.Year()
if nextMonth > 12 {
nextMonth = 1
nextYear++
}

nextClamped := min(anchorDay, daysInMonth(nextYear, nextMonth))
nextCycleStart := time.Date(nextYear, nextMonth, nextClamped, 0, 0, 0, 0, time.UTC)

// End = one second before the next cycle start
end = nextCycleStart.Add(-time.Second)

return start, end
}

// daysInMonth returns the number of days in the given month/year.
func daysInMonth(year int, month time.Month) int {
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
}
98 changes: 98 additions & 0 deletions api/pkg/entities/billing_cycle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package entities

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestComputeBillingCycle(t *testing.T) {
tests := []struct {
name string
now time.Time
anchorDay int
wantStart time.Time
wantEnd time.Time
}{
{
name: "anchor day 1 (same as calendar month)",
now: time.Date(2026, 5, 15, 10, 0, 0, 0, time.UTC),
anchorDay: 1,
wantStart: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
wantEnd: time.Date(2026, 5, 31, 23, 59, 59, 0, time.UTC),
},
{
name: "anchor day 15, now is after anchor",
now: time.Date(2026, 5, 20, 10, 0, 0, 0, time.UTC),
anchorDay: 15,
wantStart: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC),
wantEnd: time.Date(2026, 6, 14, 23, 59, 59, 0, time.UTC),
},
{
name: "anchor day 15, now is before anchor",
now: time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC),
anchorDay: 15,
wantStart: time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC),
wantEnd: time.Date(2026, 5, 14, 23, 59, 59, 0, time.UTC),
},
{
name: "anchor day 15, now is exactly on anchor",
now: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC),
anchorDay: 15,
wantStart: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC),
wantEnd: time.Date(2026, 6, 14, 23, 59, 59, 0, time.UTC),
},
{
name: "anchor day 31 in February (clamped to 28)",
now: time.Date(2026, 2, 15, 10, 0, 0, 0, time.UTC),
anchorDay: 31,
wantStart: time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC),
wantEnd: time.Date(2026, 2, 27, 23, 59, 59, 0, time.UTC),
},
{
name: "anchor day 31 in March (not clamped)",
now: time.Date(2026, 3, 31, 10, 0, 0, 0, time.UTC),
anchorDay: 31,
wantStart: time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC),
wantEnd: time.Date(2026, 4, 29, 23, 59, 59, 0, time.UTC),
},
{
name: "anchor day 29 in February leap year",
now: time.Date(2024, 2, 29, 10, 0, 0, 0, time.UTC),
anchorDay: 29,
wantStart: time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC),
wantEnd: time.Date(2024, 3, 28, 23, 59, 59, 0, time.UTC),
},
{
name: "anchor day 29 in February non-leap year (clamped to 28)",
now: time.Date(2026, 2, 28, 10, 0, 0, 0, time.UTC),
anchorDay: 29,
wantStart: time.Date(2026, 2, 28, 0, 0, 0, 0, time.UTC),
wantEnd: time.Date(2026, 3, 28, 23, 59, 59, 0, time.UTC),
},
{
name: "year boundary: anchor day 20, now is Jan 5",
now: time.Date(2026, 1, 5, 10, 0, 0, 0, time.UTC),
anchorDay: 20,
wantStart: time.Date(2025, 12, 20, 0, 0, 0, 0, time.UTC),
wantEnd: time.Date(2026, 1, 19, 23, 59, 59, 0, time.UTC),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, end := ComputeBillingCycle(tt.now, tt.anchorDay)
assert.Equal(t, tt.wantStart, start)
assert.Equal(t, tt.wantEnd, end)
})
}
}

func TestDaysInMonth(t *testing.T) {
assert.Equal(t, 31, daysInMonth(2026, time.January))
assert.Equal(t, 28, daysInMonth(2026, time.February))
assert.Equal(t, 29, daysInMonth(2024, time.February))
assert.Equal(t, 30, daysInMonth(2026, time.April))
assert.Equal(t, 31, daysInMonth(2026, time.December))
}
10 changes: 10 additions & 0 deletions api/pkg/entities/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,13 @@ func (user User) Location() *time.Location {
}
return location
}

// GetBillingAnchorDay returns the day-of-month that anchors this user's billing cycle.
// For paid users with an active subscription, it uses the renewal date.
// For free users or when SubscriptionRenewsAt is nil, it falls back to the account creation date.
func (user User) GetBillingAnchorDay() int {
if user.SubscriptionRenewsAt != nil && !user.IsOnFreePlan() {
return user.SubscriptionRenewsAt.Day()
}
return user.CreatedAt.Day()
}
53 changes: 53 additions & 0 deletions api/pkg/entities/user_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package entities

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestUser_GetBillingAnchorDay_FreeUser(t *testing.T) {
user := User{
SubscriptionName: SubscriptionNameFree,
CreatedAt: time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC),
}
assert.Equal(t, 20, user.GetBillingAnchorDay())
}

func TestUser_GetBillingAnchorDay_EmptySubscription(t *testing.T) {
user := User{
SubscriptionName: "",
CreatedAt: time.Date(2026, 1, 5, 10, 0, 0, 0, time.UTC),
}
assert.Equal(t, 5, user.GetBillingAnchorDay())
}

func TestUser_GetBillingAnchorDay_PaidUser(t *testing.T) {
renewsAt := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
user := User{
SubscriptionName: SubscriptionNameProMonthly,
SubscriptionRenewsAt: &renewsAt,
CreatedAt: time.Date(2026, 1, 5, 10, 0, 0, 0, time.UTC),
}
assert.Equal(t, 15, user.GetBillingAnchorDay())
}

func TestUser_GetBillingAnchorDay_PaidUserNilRenewsAt(t *testing.T) {
user := User{
SubscriptionName: SubscriptionNameProMonthly,
SubscriptionRenewsAt: nil,
CreatedAt: time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC),
}
assert.Equal(t, 28, user.GetBillingAnchorDay())
}

func TestUser_GetBillingAnchorDay_PaidUserDay31(t *testing.T) {
renewsAt := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)
user := User{
SubscriptionName: SubscriptionNameUltraMonthly,
SubscriptionRenewsAt: &renewsAt,
CreatedAt: time.Date(2025, 12, 1, 10, 0, 0, 0, time.UTC),
}
assert.Equal(t, 31, user.GetBillingAnchorDay())
}
Loading
Loading