Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ SECURITY_AUDIT_REPORT.md

docs/
.output
.agents/
skills-lock.json
5 changes: 5 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
"type": "stdio",
"command": "npx",
"args": ["@upstash/context7-mcp@latest"]
},
"axiom": {
"type": "stdio",
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.axiom.co/mcp"]
}
}
}
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
54 changes: 23 additions & 31 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 @@ -323,9 +313,9 @@ func (container *Container) DBWithoutMigration() (db *gorm.DB) {

container.logger.Debug(fmt.Sprintf("creating %T", db))

config := &gorm.Config{TranslateError: true}
if isLocal() {
config.Logger = container.GormLogger()
config := &gorm.Config{
TranslateError: true,
Logger: container.GormLogger(),
}

db, err := gorm.Open(postgres.Open(os.Getenv("DATABASE_URL")), config)
Expand All @@ -348,9 +338,9 @@ func (container *Container) DB() (db *gorm.DB) {

container.logger.Debug(fmt.Sprintf("creating %T", db))

config := &gorm.Config{TranslateError: true}
if isLocal() {
config.Logger = container.GormLogger()
config := &gorm.Config{
TranslateError: true,
Logger: container.GormLogger(),
}

db, err := gorm.Open(postgres.Open(os.Getenv("DATABASE_URL")), config)
Expand Down Expand Up @@ -1007,7 +997,6 @@ func (container *Container) HTTPRoundTripper(name string) http.RoundTripper {
otelroundtripper.WithName(name),
otelroundtripper.WithParent(container.RetryHTTPRoundTripper()),
otelroundtripper.WithMeter(otel.GetMeterProvider().Meter(container.projectID)),
otelroundtripper.WithAttributes(container.OtelResources(container.version, container.projectID).Attributes()...),
)
}

Expand All @@ -1017,7 +1006,6 @@ func (container *Container) HTTPRoundTripperWithoutRetry(name string) http.Round
return otelroundtripper.New(
otelroundtripper.WithName(name),
otelroundtripper.WithMeter(otel.GetMeterProvider().Meter(container.projectID)),
otelroundtripper.WithAttributes(container.OtelResources(container.version, container.projectID).Attributes()...),
)
}

Expand All @@ -1028,6 +1016,7 @@ func (container *Container) OtelResources(version string, namespace string) *res
semconv.ServiceNameKey.String(namespace),
semconv.ServiceVersionKey.String(version),
semconv.ServiceInstanceIDKey.String(hostName()),
semconv.HostNameKey.String(hostName()),
semconv.DeploymentEnvironmentKey.String(os.Getenv("ENV")),
)
}
Expand Down Expand Up @@ -1853,7 +1842,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 +1868,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 +1984,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
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())
}
4 changes: 1 addition & 3 deletions api/pkg/handlers/events_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,9 @@ func (h *EventsHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber
// Dispatch a cloud event
// This is an internal API so no documentation provided
func (h *EventsHandler) Dispatch(c *fiber.Ctx) error {
ctx, span := h.tracer.StartFromFiberCtx(c)
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
defer span.End()

ctxLogger := h.tracer.CtxLogger(h.logger, span)

var request cloudevents.Event
if err := c.BodyParser(&request); err != nil {
msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request)
Expand Down
2 changes: 1 addition & 1 deletion api/pkg/repositories/billing_usage_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ type BillingUsageRepository interface {
// GetHistory returns past billing usage by entities.UserID
GetHistory(ctx context.Context, userID entities.UserID, params IndexParams) (*[]entities.BillingUsage, error)

// DeleteForUser deletes all billing usage for an entities.UserID
// DeleteAllForUser deletes all billing usage for an entities.UserID
DeleteAllForUser(ctx context.Context, userID entities.UserID) error
}
98 changes: 98 additions & 0 deletions api/pkg/repositories/billing_usage_repository_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package repositories

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))
}
Loading
Loading