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 + .

@@ -371,13 +383,14 @@
Usage History

Summary of all the sent and received messages in the past 12 - months + billing periods

diff --git a/web/plugins/filters.ts b/web/plugins/filters.ts index 2a7c1a99..f9355178 100644 --- a/web/plugins/filters.ts +++ b/web/plugins/filters.ts @@ -45,12 +45,41 @@ Vue.filter('decimal', (value: string): string => { }) Vue.filter('billingPeriod', (value: string): string => { - const options = { + const date = new Date(value) + const options: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', year: 'numeric', + } + return date.toLocaleDateString('en-US', options) +}) + +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('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 => {