From 4b2fc31f4381752716efa00bbb9db034df4da85f Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Fri, 29 May 2026 22:26:10 +0300
Subject: [PATCH 01/14] 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>
---
api/pkg/entities/billing_cycle.go | 41 +++++++++++
api/pkg/entities/billing_cycle_test.go | 98 ++++++++++++++++++++++++++
2 files changed, 139 insertions(+)
create mode 100644 api/pkg/entities/billing_cycle.go
create mode 100644 api/pkg/entities/billing_cycle_test.go
diff --git a/api/pkg/entities/billing_cycle.go b/api/pkg/entities/billing_cycle.go
new file mode 100644
index 00000000..65e4422b
--- /dev/null
+++ b/api/pkg/entities/billing_cycle.go
@@ -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()
+}
diff --git a/api/pkg/entities/billing_cycle_test.go b/api/pkg/entities/billing_cycle_test.go
new file mode 100644
index 00000000..5d173264
--- /dev/null
+++ b/api/pkg/entities/billing_cycle_test.go
@@ -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))
+}
From 5494bd8e34cc379deaa86d7a7ca1b632e11e6296 Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Fri, 29 May 2026 22:28:04 +0300
Subject: [PATCH 02/14] feat(billing): add User.GetBillingAnchorDay() method
This method returns the day-of-month that anchors a user's billing cycle.
For paid users with SubscriptionRenewsAt set, it uses the renewal date day.
For free users or when SubscriptionRenewsAt is nil, it falls back to CreatedAt day.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
api/pkg/entities/user.go | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/api/pkg/entities/user.go b/api/pkg/entities/user.go
index f26a8135..6cf2eba9 100644
--- a/api/pkg/entities/user.go
+++ b/api/pkg/entities/user.go
@@ -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()
+}
From 2b99b0515c6eea0596e0ba43e15ba3dbea627ab1 Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Fri, 29 May 2026 22:28:57 +0300
Subject: [PATCH 03/14] test(billing): add tests for User.GetBillingAnchorDay()
method
Tests cover free users, empty subscriptions, paid users with/without renewal dates, and day 31 edge cases.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
api/pkg/entities/user_test.go | 53 +++++++++++++++++++++++++++++++++++
1 file changed, 53 insertions(+)
create mode 100644 api/pkg/entities/user_test.go
diff --git a/api/pkg/entities/user_test.go b/api/pkg/entities/user_test.go
new file mode 100644
index 00000000..0417e63f
--- /dev/null
+++ b/api/pkg/entities/user_test.go
@@ -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())
+}
From 4292241703704cf4b46aad56e5a8fe9c16978149 Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Fri, 29 May 2026 22:34:15 +0300
Subject: [PATCH 04/14] feat(billing): replace calendar-month queries with
personalized range queries
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
api/go.mod | 2 +-
api/pkg/di/container.go | 40 ++++------
.../gorm_billing_usage_repository.go | 78 +++++++++++++------
3 files changed, 71 insertions(+), 49 deletions(-)
diff --git a/api/go.mod b/api/go.mod
index 657a3f86..dad47f40 100644
--- a/api/go.mod
+++ b/api/go.mod
@@ -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
@@ -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
diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go
index 45cad0cc..1bbf889f 100644
--- a/api/pkg/di/container.go
+++ b/api/pkg/di/container.go
@@ -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"
@@ -101,11 +99,6 @@ 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)),
}
@@ -113,11 +106,6 @@ func NewLiteContainer() (container *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,
@@ -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()))
@@ -853,6 +843,7 @@ func (container *Container) BillingUsageRepository() (repository repositories.Bi
container.Logger(),
container.Tracer(),
container.DB(),
+ container.UserRepository(),
)
}
@@ -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),
)
@@ -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),
)
@@ -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,
}
diff --git a/api/pkg/repositories/gorm_billing_usage_repository.go b/api/pkg/repositories/gorm_billing_usage_repository.go
index 4df8e65d..b0ae248e 100644
--- a/api/pkg/repositories/gorm_billing_usage_repository.go
+++ b/api/pkg/repositories/gorm_billing_usage_repository.go
@@ -10,16 +10,16 @@ import (
"github.com/NdoleStudio/httpsms/pkg/telemetry"
"github.com/cockroachdb/cockroach-go/v2/crdb/crdbgorm"
"github.com/google/uuid"
- "github.com/jinzhu/now"
"github.com/palantir/stacktrace"
"gorm.io/gorm"
)
// gormBillingUsageRepository is responsible for persisting entities.BillingUsage
type gormBillingUsageRepository struct {
- logger telemetry.Logger
- tracer telemetry.Tracer
- db *gorm.DB
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ db *gorm.DB
+ userRepository UserRepository
}
// NewGormBillingUsageRepository creates the GORM version of the BillingUsageRepository
@@ -27,11 +27,13 @@ func NewGormBillingUsageRepository(
logger telemetry.Logger,
tracer telemetry.Tracer,
db *gorm.DB,
+ userRepository UserRepository,
) BillingUsageRepository {
return &gormBillingUsageRepository{
- logger: logger.WithService(fmt.Sprintf("%T", &gormBillingUsageRepository{})),
- tracer: tracer,
- db: db,
+ logger: logger.WithService(fmt.Sprintf("%T", &gormBillingUsageRepository{})),
+ tracer: tracer,
+ db: db,
+ userRepository: userRepository,
}
}
@@ -57,12 +59,17 @@ func (repository *gormBillingUsageRepository) RegisterSentMessage(ctx context.Co
func(tx *gorm.DB) error {
result := tx.WithContext(ctx).
Model(&entities.BillingUsage{}).
- Where("start_timestamp = ?", now.New(timestamp).BeginningOfMonth()).
Where("user_id = ?", userID).
+ Where("start_timestamp <= ?", timestamp).
+ Where("end_timestamp >= ?", timestamp).
UpdateColumn("sent_messages", gorm.Expr("sent_messages + ?", 1))
if result.Error == nil && result.RowsAffected == 0 {
- return tx.Create(repository.createBillingUsage(userID, timestamp, 1, 0)).Error
+ usage, err := repository.createBillingUsageForUser(ctx, userID, timestamp, 1, 0)
+ if err != nil {
+ return err
+ }
+ return tx.Create(usage).Error
}
return result.Error
},
@@ -78,12 +85,17 @@ func (repository *gormBillingUsageRepository) RegisterReceivedMessage(ctx contex
func(tx *gorm.DB) error {
result := tx.WithContext(ctx).
Model(&entities.BillingUsage{}).
- Where("start_timestamp = ?", now.New(timestamp).BeginningOfMonth()).
Where("user_id = ?", userID).
+ Where("start_timestamp <= ?", timestamp).
+ Where("end_timestamp >= ?", timestamp).
UpdateColumn("received_messages", gorm.Expr("received_messages + ?", 1))
if result.Error == nil && result.RowsAffected == 0 {
- return tx.Create(repository.createBillingUsage(userID, timestamp, 0, 1)).Error
+ usage, err := repository.createBillingUsageForUser(ctx, userID, timestamp, 0, 1)
+ if err != nil {
+ return err
+ }
+ return tx.Create(usage).Error
}
return result.Error
},
@@ -96,29 +108,36 @@ func (repository *gormBillingUsageRepository) GetCurrent(ctx context.Context, us
defer span.End()
timestamp := time.Now().UTC()
- usage := repository.createBillingUsage(userID, timestamp, 0, 0)
+ var usage entities.BillingUsage
err := crdbgorm.ExecuteTx(ctx, repository.db, nil,
func(tx *gorm.DB) error {
- loadedUsage := &entities.BillingUsage{}
result := tx.WithContext(ctx).
Where("user_id = ?", userID).
- Where("start_timestamp = ?", now.New(timestamp).BeginningOfMonth()).
- First(&loadedUsage)
+ Where("start_timestamp <= ?", timestamp).
+ Where("end_timestamp >= ?", timestamp).
+ First(&usage)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
- return tx.WithContext(ctx).Create(usage).Error
+ newUsage, createErr := repository.createBillingUsageForUser(ctx, userID, timestamp, 0, 0)
+ if createErr != nil {
+ return createErr
+ }
+ if err := tx.WithContext(ctx).Create(newUsage).Error; err != nil {
+ return err
+ }
+ usage = *newUsage
+ return nil
}
- *usage = *loadedUsage
return result.Error
},
)
if err != nil {
- return usage, stacktrace.Propagate(err, fmt.Sprintf("cannot load billing usage for user [%s]", userID))
+ return &usage, stacktrace.Propagate(err, fmt.Sprintf("cannot load billing usage for user [%s]", userID))
}
- return usage, err
+ return &usage, nil
}
// GetHistory returns past billing usage by entities.UserID
@@ -126,11 +145,12 @@ func (repository *gormBillingUsageRepository) GetHistory(ctx context.Context, us
ctx, span := repository.tracer.Start(ctx)
defer span.End()
+ timestamp := time.Now().UTC()
usages := new([]entities.BillingUsage)
err := repository.db.WithContext(ctx).
Where("user_id = ?", userID).
- Where("start_timestamp != ?", now.BeginningOfMonth()).
+ Where("end_timestamp < ?", timestamp).
Order("start_timestamp DESC").
Limit(params.Limit).
Offset(params.Skip).
@@ -141,16 +161,24 @@ func (repository *gormBillingUsageRepository) GetHistory(ctx context.Context, us
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
}
- return usages, err
+ return usages, nil
}
-func (repository *gormBillingUsageRepository) createBillingUsage(userID entities.UserID, timestamp time.Time, sent uint, received uint) *entities.BillingUsage {
+// createBillingUsageForUser loads the user to determine anchor day and computes cycle boundaries.
+func (repository *gormBillingUsageRepository) createBillingUsageForUser(ctx context.Context, userID entities.UserID, timestamp time.Time, sent uint, received uint) (*entities.BillingUsage, error) {
+ user, err := repository.userRepository.Load(ctx, userID)
+ if err != nil {
+ return nil, stacktrace.Propagate(err, fmt.Sprintf("cannot load user [%s] to compute billing cycle", userID))
+ }
+
+ start, end := entities.ComputeBillingCycle(timestamp, user.GetBillingAnchorDay())
+
return &entities.BillingUsage{
ID: uuid.New(),
UserID: userID,
SentMessages: sent,
ReceivedMessages: received,
- StartTimestamp: now.New(timestamp).BeginningOfMonth(),
- EndTimestamp: now.New(timestamp).EndOfMonth(),
- }
+ StartTimestamp: start,
+ EndTimestamp: end,
+ }, nil
}
From 29cbac7e459e8eb59bff55a027ea3686a19e687e Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Fri, 29 May 2026 22:37:24 +0300
Subject: [PATCH 05/14] feat(web): show full billing period range and remove
Total Cost column
- Usage history now shows 'May 12, 2026 - June 13, 2026' format
- Removed Total Cost column from usage history table
- Added billingPeriodDate filter for long date format
- Updated billingPeriod filter to show date range
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
web/pages/billing/index.vue | 14 +++++++-------
web/plugins/filters.ts | 26 +++++++++++++++++++++++---
2 files changed, 30 insertions(+), 10 deletions(-)
diff --git a/web/pages/billing/index.vue b/web/pages/billing/index.vue
index 039f129f..436499db 100644
--- a/web/pages/billing/index.vue
+++ b/web/pages/billing/index.vue
@@ -386,9 +386,6 @@
Received
Messages
- |
- Total Cost
- |
@@ -398,7 +395,13 @@
:key="billingUsage.id"
>
- {{ billingUsage.start_timestamp | billingPeriod }}
+ {{
+ billingUsage.start_timestamp | billingPeriodDate
+ }}
+ –
+ {{
+ billingUsage.end_timestamp | billingPeriodDate
+ }}
|
{{ billingUsage.sent_messages | decimal }}
@@ -406,9 +409,6 @@
|
{{ billingUsage.received_messages }}
|
-
- {{ billingUsage.total_cost | money }}
- |
diff --git a/web/plugins/filters.ts b/web/plugins/filters.ts
index 2a7c1a99..64894a4f 100644
--- a/web/plugins/filters.ts
+++ b/web/plugins/filters.ts
@@ -45,12 +45,32 @@ Vue.filter('decimal', (value: string): string => {
})
Vue.filter('billingPeriod', (value: string): string => {
- const options = {
+ const startDate = new Date(value)
+ const options: Intl.DateTimeFormatOptions = {
+ month: 'short',
+ day: 'numeric',
+ }
+ const optionsWithYear: Intl.DateTimeFormatOptions = {
+ month: 'short',
+ day: 'numeric',
year: 'numeric',
+ }
+ const start = startDate.toLocaleDateString('en-US', options)
+ const endDate = new Date(startDate)
+ endDate.setMonth(endDate.getMonth() + 1)
+ endDate.setDate(endDate.getDate() - 1)
+ const end = endDate.toLocaleDateString('en-US', optionsWithYear)
+ return `${start} – ${end}`
+})
+
+Vue.filter('billingPeriodDate', (value: string): string => {
+ const date = new Date(value)
+ const options: Intl.DateTimeFormatOptions = {
+ day: 'numeric',
month: 'long',
+ year: 'numeric',
}
- // @ts-ignore
- return new Date(value).toLocaleDateString('en-US', options)
+ return date.toLocaleDateString('en-US', options)
})
Vue.filter('humanizeTime', (value: string): string => {
From 9538336b0692a22f7e668efbd212c0f8b70e5db8 Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Fri, 29 May 2026 22:49:44 +0300
Subject: [PATCH 06/14] refactor(billing): move computeBillingCycle to
repository as private method
The function is only used within gormBillingUsageRepository, so it
belongs there as an unexported function rather than in the entities
package.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
api/pkg/entities/billing_cycle.go | 41 -------------------
.../billing_usage_repository_test.go} | 4 +-
.../gorm_billing_usage_repository.go | 36 +++++++++++++++-
3 files changed, 37 insertions(+), 44 deletions(-)
delete mode 100644 api/pkg/entities/billing_cycle.go
rename api/pkg/{entities/billing_cycle_test.go => repositories/billing_usage_repository_test.go} (97%)
diff --git a/api/pkg/entities/billing_cycle.go b/api/pkg/entities/billing_cycle.go
deleted file mode 100644
index 65e4422b..00000000
--- a/api/pkg/entities/billing_cycle.go
+++ /dev/null
@@ -1,41 +0,0 @@
-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()
-}
diff --git a/api/pkg/entities/billing_cycle_test.go b/api/pkg/repositories/billing_usage_repository_test.go
similarity index 97%
rename from api/pkg/entities/billing_cycle_test.go
rename to api/pkg/repositories/billing_usage_repository_test.go
index 5d173264..93a39d6f 100644
--- a/api/pkg/entities/billing_cycle_test.go
+++ b/api/pkg/repositories/billing_usage_repository_test.go
@@ -1,4 +1,4 @@
-package entities
+package repositories
import (
"testing"
@@ -82,7 +82,7 @@ func TestComputeBillingCycle(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- start, end := ComputeBillingCycle(tt.now, tt.anchorDay)
+ start, end := computeBillingCycle(tt.now, tt.anchorDay)
assert.Equal(t, tt.wantStart, start)
assert.Equal(t, tt.wantEnd, end)
})
diff --git a/api/pkg/repositories/gorm_billing_usage_repository.go b/api/pkg/repositories/gorm_billing_usage_repository.go
index b0ae248e..e6b34110 100644
--- a/api/pkg/repositories/gorm_billing_usage_repository.go
+++ b/api/pkg/repositories/gorm_billing_usage_repository.go
@@ -171,7 +171,7 @@ func (repository *gormBillingUsageRepository) createBillingUsageForUser(ctx cont
return nil, stacktrace.Propagate(err, fmt.Sprintf("cannot load user [%s] to compute billing cycle", userID))
}
- start, end := entities.ComputeBillingCycle(timestamp, user.GetBillingAnchorDay())
+ start, end := computeBillingCycle(timestamp, user.GetBillingAnchorDay())
return &entities.BillingUsage{
ID: uuid.New(),
@@ -182,3 +182,37 @@ func (repository *gormBillingUsageRepository) createBillingUsageForUser(ctx cont
EndTimestamp: end,
}, nil
}
+
+// 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 {
+ start = time.Date(now.Year(), now.Month(), clampedDay, 0, 0, 0, 0, time.UTC)
+ } else {
+ 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)
+ }
+
+ 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 = 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()
+}
From d17aba704d1fb8de5ff1cb677996847e189cd4f5 Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Fri, 29 May 2026 22:53:47 +0300
Subject: [PATCH 07/14] fix(billing): use server-computed end_timestamp in
Overview and load user within transaction
- Overview section now uses actual end_timestamp from store instead of
recomputing via JS arithmetic that diverges from server clamping
- Simplified billingPeriod filter to format a single date
- createBillingUsageForUser now accepts tx parameter to keep user read
within the same CockroachDB transaction snapshot
- Removed UserRepository dependency from BillingUsageRepository since
user is loaded directly via the transaction
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
api/pkg/di/container.go | 1 -
.../gorm_billing_usage_repository.go | 28 +++++++++----------
web/pages/billing/index.vue | 8 +++++-
web/plugins/filters.ts | 13 ++-------
4 files changed, 22 insertions(+), 28 deletions(-)
diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go
index 1bbf889f..85b566c7 100644
--- a/api/pkg/di/container.go
+++ b/api/pkg/di/container.go
@@ -843,7 +843,6 @@ func (container *Container) BillingUsageRepository() (repository repositories.Bi
container.Logger(),
container.Tracer(),
container.DB(),
- container.UserRepository(),
)
}
diff --git a/api/pkg/repositories/gorm_billing_usage_repository.go b/api/pkg/repositories/gorm_billing_usage_repository.go
index e6b34110..33db958d 100644
--- a/api/pkg/repositories/gorm_billing_usage_repository.go
+++ b/api/pkg/repositories/gorm_billing_usage_repository.go
@@ -16,10 +16,9 @@ import (
// gormBillingUsageRepository is responsible for persisting entities.BillingUsage
type gormBillingUsageRepository struct {
- logger telemetry.Logger
- tracer telemetry.Tracer
- db *gorm.DB
- userRepository UserRepository
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ db *gorm.DB
}
// NewGormBillingUsageRepository creates the GORM version of the BillingUsageRepository
@@ -27,13 +26,11 @@ func NewGormBillingUsageRepository(
logger telemetry.Logger,
tracer telemetry.Tracer,
db *gorm.DB,
- userRepository UserRepository,
) BillingUsageRepository {
return &gormBillingUsageRepository{
- logger: logger.WithService(fmt.Sprintf("%T", &gormBillingUsageRepository{})),
- tracer: tracer,
- db: db,
- userRepository: userRepository,
+ logger: logger.WithService(fmt.Sprintf("%T", &gormBillingUsageRepository{})),
+ tracer: tracer,
+ db: db,
}
}
@@ -65,7 +62,7 @@ func (repository *gormBillingUsageRepository) RegisterSentMessage(ctx context.Co
UpdateColumn("sent_messages", gorm.Expr("sent_messages + ?", 1))
if result.Error == nil && result.RowsAffected == 0 {
- usage, err := repository.createBillingUsageForUser(ctx, userID, timestamp, 1, 0)
+ usage, err := repository.createBillingUsageForUser(ctx, tx, userID, timestamp, 1, 0)
if err != nil {
return err
}
@@ -91,7 +88,7 @@ func (repository *gormBillingUsageRepository) RegisterReceivedMessage(ctx contex
UpdateColumn("received_messages", gorm.Expr("received_messages + ?", 1))
if result.Error == nil && result.RowsAffected == 0 {
- usage, err := repository.createBillingUsageForUser(ctx, userID, timestamp, 0, 1)
+ usage, err := repository.createBillingUsageForUser(ctx, tx, userID, timestamp, 0, 1)
if err != nil {
return err
}
@@ -119,7 +116,7 @@ func (repository *gormBillingUsageRepository) GetCurrent(ctx context.Context, us
First(&usage)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
- newUsage, createErr := repository.createBillingUsageForUser(ctx, userID, timestamp, 0, 0)
+ newUsage, createErr := repository.createBillingUsageForUser(ctx, tx, userID, timestamp, 0, 0)
if createErr != nil {
return createErr
}
@@ -165,9 +162,10 @@ func (repository *gormBillingUsageRepository) GetHistory(ctx context.Context, us
}
// createBillingUsageForUser loads the user to determine anchor day and computes cycle boundaries.
-func (repository *gormBillingUsageRepository) createBillingUsageForUser(ctx context.Context, userID entities.UserID, timestamp time.Time, sent uint, received uint) (*entities.BillingUsage, error) {
- user, err := repository.userRepository.Load(ctx, userID)
- if err != nil {
+// It accepts a tx to ensure the user read is part of the same transaction snapshot.
+func (repository *gormBillingUsageRepository) createBillingUsageForUser(ctx context.Context, tx *gorm.DB, userID entities.UserID, timestamp time.Time, sent uint, received uint) (*entities.BillingUsage, error) {
+ user := new(entities.User)
+ if err := tx.WithContext(ctx).First(user, userID).Error; err != nil {
return nil, stacktrace.Propagate(err, fmt.Sprintf("cannot load user [%s] to compute billing cycle", userID))
}
diff --git a/web/pages/billing/index.vue b/web/pages/billing/index.vue
index 436499db..25217a0a 100644
--- a/web/pages/billing/index.vue
+++ b/web/pages/billing/index.vue
@@ -233,7 +233,13 @@
v-if="$store.getters.getBillingUsage"
class="font-weight-bold"
>{{
- $store.getters.getBillingUsage.start_timestamp | billingPeriod
+ $store.getters.getBillingUsage.start_timestamp
+ | billingPeriodDate
+ }}
+ –
+ {{
+ $store.getters.getBillingUsage.end_timestamp
+ | billingPeriodDate
}}.
diff --git a/web/plugins/filters.ts b/web/plugins/filters.ts
index 64894a4f..efb7d3ac 100644
--- a/web/plugins/filters.ts
+++ b/web/plugins/filters.ts
@@ -45,22 +45,13 @@ Vue.filter('decimal', (value: string): string => {
})
Vue.filter('billingPeriod', (value: string): string => {
- const startDate = new Date(value)
+ const date = new Date(value)
const options: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
- }
- const optionsWithYear: Intl.DateTimeFormatOptions = {
- month: 'short',
- day: 'numeric',
year: 'numeric',
}
- const start = startDate.toLocaleDateString('en-US', options)
- const endDate = new Date(startDate)
- endDate.setMonth(endDate.getMonth() + 1)
- endDate.setDate(endDate.getDate() - 1)
- const end = endDate.toLocaleDateString('en-US', optionsWithYear)
- return `${start} – ${end}`
+ return date.toLocaleDateString('en-US', options)
})
Vue.filter('billingPeriodDate', (value: string): string => {
From e43376260435d740ee712364841713449fd910dc Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Fri, 29 May 2026 23:41:00 +0300
Subject: [PATCH 08/14] style(web): fix prettier formatting in billing page
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
web/pages/billing/index.vue | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/web/pages/billing/index.vue b/web/pages/billing/index.vue
index 25217a0a..fec013e0 100644
--- a/web/pages/billing/index.vue
+++ b/web/pages/billing/index.vue
@@ -401,13 +401,9 @@
:key="billingUsage.id"
>
- {{
- billingUsage.start_timestamp | billingPeriodDate
- }}
+ {{ billingUsage.start_timestamp | billingPeriodDate }}
–
- {{
- billingUsage.end_timestamp | billingPeriodDate
- }}
+ {{ billingUsage.end_timestamp | billingPeriodDate }}
|
{{ billingUsage.sent_messages | decimal }}
From 20ddf9832692ffe8a19fa271755791a863549ea4 Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Sat, 30 May 2026 00:02:05 +0300
Subject: [PATCH 09/14] fix(tests): poll message 2 status before asserting bulk
counts
The TestBulkSMS_Excel test had a race condition where message 2
might not have transitioned from pending to scheduled by the time
the bulk history endpoint was checked. Now we explicitly poll for
message 2 to reach scheduled status before verifying counts.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
tests/integration_test.go | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/tests/integration_test.go b/tests/integration_test.go
index 29d4ced8..66abecdd 100644
--- a/tests/integration_test.go
+++ b/tests/integration_test.go
@@ -545,6 +545,10 @@ func TestBulkSMS_Excel(t *testing.T) {
msg1 := pollMessageStatus(ctx, t, msgID1, "delivered", 15*time.Second)
assert.Equal(t, "delivered", msg1.Status)
+ // Poll until message 2 reaches "scheduled" (FCM push sent but no SENT event fired)
+ msg2 := pollMessageStatus(ctx, t, msgID2, "scheduled", 15*time.Second)
+ assert.Equal(t, "scheduled", msg2.Status)
+
// Verify bulk-messages history endpoint
entries := fetchBulkMessages(ctx, t)
entry := findBulkEntry(entries, requestID)
From b351f4d12b1f227044b0559c9f62136f11467077 Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Sat, 30 May 2026 00:40:39 +0300
Subject: [PATCH 10/14] ADD Mcp
---
.gitignore | 1 +
.mcp.json | 5 +++++
skills-lock.json | 47 +++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 53 insertions(+)
create mode 100644 skills-lock.json
diff --git a/.gitignore b/.gitignore
index f43a4040..ecc89986 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,4 @@ SECURITY_AUDIT_REPORT.md
docs/
.output
+.agents/
diff --git a/.mcp.json b/.mcp.json
index 1bb33a71..ef3d2cf0 100644
--- a/.mcp.json
+++ b/.mcp.json
@@ -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"]
}
}
}
diff --git a/skills-lock.json b/skills-lock.json
new file mode 100644
index 00000000..0418970e
--- /dev/null
+++ b/skills-lock.json
@@ -0,0 +1,47 @@
+{
+ "version": 1,
+ "skills": {
+ "axiom-alerting": {
+ "source": "axiomhq/skills",
+ "sourceType": "github",
+ "skillPath": "skills/axiom-alerting/SKILL.md",
+ "computedHash": "036e2660e10f17a2897da91ebe933aa00f82d3ef5d6f3f7027f0ff4a0fd5e78e"
+ },
+ "axiom-sre": {
+ "source": "axiomhq/skills",
+ "sourceType": "github",
+ "skillPath": "skills/sre/SKILL.md",
+ "computedHash": "7ab416fd3a6655bb30c6ac9c05c262957156157ca9916c095d232dd8b453aa92"
+ },
+ "building-dashboards": {
+ "source": "axiomhq/skills",
+ "sourceType": "github",
+ "skillPath": "skills/building-dashboards/SKILL.md",
+ "computedHash": "cd198cf4461e2720676bf6af2bffc4fd9f856a7867fb54a84510daca5bbafa80"
+ },
+ "controlling-costs": {
+ "source": "axiomhq/skills",
+ "sourceType": "github",
+ "skillPath": "skills/controlling-costs/SKILL.md",
+ "computedHash": "7a6d24cae6d99c6cc9b3659b89307a1b90bff8bfb636519e1728e3c44e640245"
+ },
+ "query-metrics": {
+ "source": "axiomhq/skills",
+ "sourceType": "github",
+ "skillPath": "skills/query-metrics/SKILL.md",
+ "computedHash": "ebdb47ef6080be0ee2ca7a4d68ca6bac626c7c593cd7304db41332d328ea5161"
+ },
+ "spl-to-apl": {
+ "source": "axiomhq/skills",
+ "sourceType": "github",
+ "skillPath": "skills/spl-to-apl/SKILL.md",
+ "computedHash": "f35342ffe5cdbcc63038b2edb44f4285607d6815a8315bd0bf9e3b08da4482ff"
+ },
+ "writing-evals": {
+ "source": "axiomhq/skills",
+ "sourceType": "github",
+ "skillPath": "skills/writing-evals/SKILL.md",
+ "computedHash": "3f1d246c4b7ee586efc460cc269e111cfe92af31ea8dc23c27dbd6d56c9d5db5"
+ }
+ }
+}
From 63095e196828fee15f80632d5d5826d7eacac12e Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Sat, 30 May 2026 00:43:50 +0300
Subject: [PATCH 11/14] Fix gormlogger
---
api/pkg/di/container.go | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go
index 85b566c7..45226e1b 100644
--- a/api/pkg/di/container.go
+++ b/api/pkg/di/container.go
@@ -313,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)
@@ -338,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)
From 823afc6d8fc064025ea70dc3fb3b786fab5fabe8 Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Sat, 30 May 2026 13:45:58 +0300
Subject: [PATCH 12/14] Add telemetry for cloudevents
---
api/pkg/di/container.go | 1 +
api/pkg/handlers/events_handler.go | 4 +---
api/pkg/repositories/billing_usage_repository.go | 2 +-
api/pkg/services/event_dispatcher_service.go | 16 +++++++++++++---
4 files changed, 16 insertions(+), 7 deletions(-)
diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go
index 45226e1b..7d67e175 100644
--- a/api/pkg/di/container.go
+++ b/api/pkg/di/container.go
@@ -1018,6 +1018,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")),
)
}
diff --git a/api/pkg/handlers/events_handler.go b/api/pkg/handlers/events_handler.go
index 16d0325d..821f99f6 100644
--- a/api/pkg/handlers/events_handler.go
+++ b/api/pkg/handlers/events_handler.go
@@ -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)
diff --git a/api/pkg/repositories/billing_usage_repository.go b/api/pkg/repositories/billing_usage_repository.go
index e9c4ffdb..e5973538 100644
--- a/api/pkg/repositories/billing_usage_repository.go
+++ b/api/pkg/repositories/billing_usage_repository.go
@@ -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
}
diff --git a/api/pkg/services/event_dispatcher_service.go b/api/pkg/services/event_dispatcher_service.go
index f9ec6b33..4b9445de 100644
--- a/api/pkg/services/event_dispatcher_service.go
+++ b/api/pkg/services/event_dispatcher_service.go
@@ -11,6 +11,7 @@ import (
"go.opentelemetry.io/otel/metric"
semconv "go.opentelemetry.io/otel/semconv/v1.18.0"
+ "go.opentelemetry.io/otel/trace"
"github.com/NdoleStudio/httpsms/pkg/events"
"github.com/NdoleStudio/httpsms/pkg/telemetry"
@@ -119,12 +120,12 @@ func (dispatcher *EventDispatcher) Subscribe(eventType string, listener events.E
// Publish an event to subscribers
func (dispatcher *EventDispatcher) Publish(ctx context.Context, event cloudevents.Event) {
- ctx, span := dispatcher.tracer.Start(ctx)
+ ctx, span, ctxLogger := dispatcher.tracer.StartWithLogger(ctx, dispatcher.logger)
defer span.End()
- start := time.Now()
+ dispatcher.addCloudEventAttributes(span, event)
- ctxLogger := dispatcher.tracer.CtxLogger(dispatcher.logger, span)
+ start := time.Now()
subscribers, ok := dispatcher.listeners[event.Type()]
if !ok {
@@ -156,6 +157,15 @@ func (dispatcher *EventDispatcher) Publish(ctx context.Context, event cloudevent
)
}
+func (dispatcher *EventDispatcher) addCloudEventAttributes(span trace.Span, event cloudevents.Event) {
+ span.SetAttributes(
+ semconv.CloudeventsEventType(event.Type()),
+ semconv.CloudeventsEventID(event.ID()),
+ semconv.CloudeventsEventSource(event.Source()),
+ semconv.CloudeventsEventSpecVersion(event.SpecVersion()),
+ )
+}
+
func (dispatcher *EventDispatcher) createCloudTask(event cloudevents.Event) (*PushQueueTask, error) {
eventContent, err := json.Marshal(event)
if err != nil {
From 98e5f8e00481fb0b9fc00d5dc71a379fc68cae30 Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Sun, 31 May 2026 18:35:12 +0300
Subject: [PATCH 13/14] Removing skills-lock.json
---
.gitignore | 1 +
skills-lock.json | 47 -----------------------------------------------
2 files changed, 1 insertion(+), 47 deletions(-)
delete mode 100644 skills-lock.json
diff --git a/.gitignore b/.gitignore
index ecc89986..e15d590a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,4 @@ SECURITY_AUDIT_REPORT.md
docs/
.output
.agents/
+skills-lock.json
diff --git a/skills-lock.json b/skills-lock.json
deleted file mode 100644
index 0418970e..00000000
--- a/skills-lock.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "version": 1,
- "skills": {
- "axiom-alerting": {
- "source": "axiomhq/skills",
- "sourceType": "github",
- "skillPath": "skills/axiom-alerting/SKILL.md",
- "computedHash": "036e2660e10f17a2897da91ebe933aa00f82d3ef5d6f3f7027f0ff4a0fd5e78e"
- },
- "axiom-sre": {
- "source": "axiomhq/skills",
- "sourceType": "github",
- "skillPath": "skills/sre/SKILL.md",
- "computedHash": "7ab416fd3a6655bb30c6ac9c05c262957156157ca9916c095d232dd8b453aa92"
- },
- "building-dashboards": {
- "source": "axiomhq/skills",
- "sourceType": "github",
- "skillPath": "skills/building-dashboards/SKILL.md",
- "computedHash": "cd198cf4461e2720676bf6af2bffc4fd9f856a7867fb54a84510daca5bbafa80"
- },
- "controlling-costs": {
- "source": "axiomhq/skills",
- "sourceType": "github",
- "skillPath": "skills/controlling-costs/SKILL.md",
- "computedHash": "7a6d24cae6d99c6cc9b3659b89307a1b90bff8bfb636519e1728e3c44e640245"
- },
- "query-metrics": {
- "source": "axiomhq/skills",
- "sourceType": "github",
- "skillPath": "skills/query-metrics/SKILL.md",
- "computedHash": "ebdb47ef6080be0ee2ca7a4d68ca6bac626c7c593cd7304db41332d328ea5161"
- },
- "spl-to-apl": {
- "source": "axiomhq/skills",
- "sourceType": "github",
- "skillPath": "skills/spl-to-apl/SKILL.md",
- "computedHash": "f35342ffe5cdbcc63038b2edb44f4285607d6815a8315bd0bf9e3b08da4482ff"
- },
- "writing-evals": {
- "source": "axiomhq/skills",
- "sourceType": "github",
- "skillPath": "skills/writing-evals/SKILL.md",
- "computedHash": "3f1d246c4b7ee586efc460cc269e111cfe92af31ea8dc23c27dbd6d56c9d5db5"
- }
- }
-}
From fd1d77380af280b5ba630a4636d6b2ad333b8295 Mon Sep 17 00:00:00 2001
From: Acho Arnold
Date: Sun, 31 May 2026 19:18:15 +0300
Subject: [PATCH 14/14] Change billing period to 30- days
---
api/pkg/di/container.go | 2 --
web/pages/billing/index.vue | 50 ++++++++++++++++++++++++-------------
web/plugins/filters.ts | 18 +++++++++++++
3 files changed, 51 insertions(+), 19 deletions(-)
diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go
index 7d67e175..ba0a4f2d 100644
--- a/api/pkg/di/container.go
+++ b/api/pkg/di/container.go
@@ -997,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()...),
)
}
@@ -1007,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()...),
)
}
diff --git a/web/pages/billing/index.vue b/web/pages/billing/index.vue
index fec013e0..ee98235c 100644
--- a/web/pages/billing/index.vue
+++ b/web/pages/billing/index.vue
@@ -232,16 +232,22 @@
{{
- $store.getters.getBillingUsage.start_timestamp
- | billingPeriodDate
- }}
- –
- {{
- $store.getters.getBillingUsage.end_timestamp
- | billingPeriodDate
- }}.
+ v-html="
+ $options.filters.billingPeriodDateOrdinal(
+ $store.getters.getBillingUsage.start_timestamp,
+ )
+ "
+ />
+ to
+ .
@@ -377,13 +383,14 @@
Usage History
Summary of all the sent and received messages in the past 12
- months
+ billing periods
- | Period |
+ Start Date |
+ End Date |
Sent
Messages
@@ -400,11 +407,20 @@
.getBillingUsageHistory"
:key="billingUsage.id"
>
- |
- {{ billingUsage.start_timestamp | billingPeriodDate }}
- –
- {{ billingUsage.end_timestamp | billingPeriodDate }}
- |
+ |
+ |
{{ billingUsage.sent_messages | decimal }}
|
diff --git a/web/plugins/filters.ts b/web/plugins/filters.ts
index efb7d3ac..f9355178 100644
--- a/web/plugins/filters.ts
+++ b/web/plugins/filters.ts
@@ -64,6 +64,24 @@ Vue.filter('billingPeriodDate', (value: string): string => {
return date.toLocaleDateString('en-US', options)
})
+Vue.filter('billingPeriodDateOrdinal', (value: string): string => {
+ const date = new Date(value)
+ const day = date.getDate()
+ const month = date.toLocaleDateString('en-US', { month: 'long' })
+ const year = date.getFullYear()
+
+ const suffix =
+ day % 10 === 1 && day !== 11
+ ? 'st'
+ : day % 10 === 2 && day !== 12
+ ? 'nd'
+ : day % 10 === 3 && day !== 13
+ ? 'rd'
+ : 'th'
+
+ return `${month} ${day}${suffix} ${year}`
+})
+
Vue.filter('humanizeTime', (value: string): string => {
const durations = intervalToDuration({
start: new Date(),
|