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