diff --git a/.gitignore b/.gitignore
index f43a4040..e15d590a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,5 @@ SECURITY_AUDIT_REPORT.md
docs/
.output
+.agents/
+skills-lock.json
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/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..ba0a4f2d 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()))
@@ -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)
@@ -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)
@@ -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()...),
)
}
@@ -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()...),
)
}
@@ -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")),
)
}
@@ -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),
)
@@ -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),
)
@@ -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,
}
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()
+}
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())
+}
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/repositories/billing_usage_repository_test.go b/api/pkg/repositories/billing_usage_repository_test.go
new file mode 100644
index 00000000..93a39d6f
--- /dev/null
+++ b/api/pkg/repositories/billing_usage_repository_test.go
@@ -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))
+}
diff --git a/api/pkg/repositories/gorm_billing_usage_repository.go b/api/pkg/repositories/gorm_billing_usage_repository.go
index 4df8e65d..33db958d 100644
--- a/api/pkg/repositories/gorm_billing_usage_repository.go
+++ b/api/pkg/repositories/gorm_billing_usage_repository.go
@@ -10,7 +10,6 @@ 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"
)
@@ -57,12 +56,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, tx, userID, timestamp, 1, 0)
+ if err != nil {
+ return err
+ }
+ return tx.Create(usage).Error
}
return result.Error
},
@@ -78,12 +82,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, tx, userID, timestamp, 0, 1)
+ if err != nil {
+ return err
+ }
+ return tx.Create(usage).Error
}
return result.Error
},
@@ -96,29 +105,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, tx, 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 +142,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 +158,59 @@ 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.
+// 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))
+ }
+
+ start, end := 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
+}
+
+// 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()
}
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 {
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)
diff --git a/web/pages/billing/index.vue b/web/pages/billing/index.vue
index 039f129f..ee98235c 100644
--- a/web/pages/billing/index.vue
+++ b/web/pages/billing/index.vue
@@ -232,10 +232,22 @@
{{
- $store.getters.getBillingUsage.start_timestamp | billingPeriod
- }}.
+ v-html="
+ $options.filters.billingPeriodDateOrdinal(
+ $store.getters.getBillingUsage.start_timestamp,
+ )
+ "
+ />
+ to
+ .
Summary of all the sent and received messages in the past 12 - months + billing periods