From b660f09ad1d7a6e8b5917b2dda9d237a59f96f19 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 8 Jul 2026 09:43:29 +0300 Subject: [PATCH 1/6] feat: add sent/received breakdown and billing period to usage emails The usage limit alert and limit exceeded emails now include a breakdown of sent vs received messages and the current billing period (e.g. 19 June 2026 to 19 July 2026) so users have clearer context on their usage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/emails/hermes_user_email_factory.go | 14 ++++++++++---- api/pkg/emails/user_email_factory.go | 2 +- api/pkg/services/billing_service.go | 10 +++++----- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/api/pkg/emails/hermes_user_email_factory.go b/api/pkg/emails/hermes_user_email_factory.go index 9ec5754a..ff8fcad5 100644 --- a/api/pkg/emails/hermes_user_email_factory.go +++ b/api/pkg/emails/hermes_user_email_factory.go @@ -15,6 +15,11 @@ type hermesUserEmailFactory struct { generator hermes.Hermes } +// formatBillingDate renders a date like "19 June 2026" +func formatBillingDate(t time.Time) string { + return t.Format("2 January 2006") +} + func (factory *hermesUserEmailFactory) APIKeyRotated(emailAddress string, timestamp time.Time, timezone string) (*Email, error) { location, err := time.LoadLocation(timezone) if err != nil { @@ -64,11 +69,12 @@ func (factory *hermesUserEmailFactory) APIKeyRotated(emailAddress string, timest } // UsageLimitExceeded is the email sent when the plan limit is reached -func (factory *hermesUserEmailFactory) UsageLimitExceeded(user *entities.User) (*Email, error) { +func (factory *hermesUserEmailFactory) UsageLimitExceeded(user *entities.User, usage *entities.BillingUsage) (*Email, error) { email := hermes.Email{ Body: hermes.Body{ Intros: []string{ - fmt.Sprintf("You have exceeded your limit of %d messages on your %s plan.", user.SubscriptionName.Limit(), user.SubscriptionName), + fmt.Sprintf("You've reached your limit of %d messages on the %s plan, so new messages will not be processed until your usage resets.", user.SubscriptionName.Limit(), user.SubscriptionName), + fmt.Sprintf("Between %s and %s you sent %d messages and received %d, for a total of %d.", formatBillingDate(usage.StartTimestamp), formatBillingDate(usage.EndTimestamp), usage.SentMessages, usage.ReceivedMessages, usage.TotalMessages()), }, Actions: []hermes.Action{ { @@ -113,8 +119,8 @@ func (factory *hermesUserEmailFactory) UsageLimitAlert(user *entities.User, usag email := hermes.Email{ Body: hermes.Body{ Intros: []string{ - fmt.Sprintf("This is a friendly notification that you have exceeded %d%% of your monthly SMS limit on the %s plan.", percent, user.SubscriptionName), - fmt.Sprintf("You have sent %d messages and received %d messages using httpSMS this month.", usage.SentMessages, usage.ReceivedMessages), + fmt.Sprintf("This is a friendly heads-up that you've used %d%% of your monthly SMS limit on the %s plan.", percent, user.SubscriptionName), + fmt.Sprintf("Between %s and %s you sent %d messages and received %d, for a total of %d out of your %d message limit.", formatBillingDate(usage.StartTimestamp), formatBillingDate(usage.EndTimestamp), usage.SentMessages, usage.ReceivedMessages, usage.TotalMessages(), user.SubscriptionName.Limit()), }, Actions: []hermes.Action{ { diff --git a/api/pkg/emails/user_email_factory.go b/api/pkg/emails/user_email_factory.go index a8c11d7a..07e60a87 100644 --- a/api/pkg/emails/user_email_factory.go +++ b/api/pkg/emails/user_email_factory.go @@ -12,7 +12,7 @@ type UserEmailFactory interface { PhoneDead(user *entities.User, lastHeartbeatTimestamp time.Time, owner string) (*Email, error) // UsageLimitExceeded sends an email when the user's limit is exceeded - UsageLimitExceeded(user *entities.User) (*Email, error) + UsageLimitExceeded(user *entities.User, usage *entities.BillingUsage) (*Email, error) // UsageLimitAlert sends an email when a user is approaching the limit UsageLimitAlert(user *entities.User, usage *entities.BillingUsage) (*Email, error) diff --git a/api/pkg/services/billing_service.go b/api/pkg/services/billing_service.go index 1d573dbd..0c77449a 100644 --- a/api/pkg/services/billing_service.go +++ b/api/pkg/services/billing_service.go @@ -68,7 +68,7 @@ func (service *BillingService) IsEntitledWithCount(ctx context.Context, userID e } if !usage.IsEntitled(count, user.SubscriptionName.Limit()) { - return service.handleLimitExceeded(ctx, user) + return service.handleLimitExceeded(ctx, user, usage) } return nil @@ -79,11 +79,11 @@ func (service *BillingService) IsEntitled(ctx context.Context, userID entities.U return service.IsEntitledWithCount(ctx, userID, 1) } -func (service *BillingService) handleLimitExceeded(ctx context.Context, user *entities.User) *string { +func (service *BillingService) handleLimitExceeded(ctx context.Context, user *entities.User, usage *entities.BillingUsage) *string { ctx, span := service.tracer.Start(ctx) defer span.End() - service.sendLimitExceededEmail(ctx, user) + service.sendLimitExceededEmail(ctx, user, usage) message := fmt.Sprintf( "You have exceeded your limit of [%d] messages on your [%s] plan. Upgrade to send more messages on https://httpsms.com/billing", @@ -93,7 +93,7 @@ func (service *BillingService) handleLimitExceeded(ctx context.Context, user *en return &message } -func (service *BillingService) sendLimitExceededEmail(ctx context.Context, user *entities.User) { +func (service *BillingService) sendLimitExceededEmail(ctx context.Context, user *entities.User, usage *entities.BillingUsage) { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() @@ -102,7 +102,7 @@ func (service *BillingService) sendLimitExceededEmail(ctx context.Context, user return } - email, err := service.emailFactory.UsageLimitExceeded(user) + email, err := service.emailFactory.UsageLimitExceeded(user, usage) if err != nil { ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot create usage limit email for user [%s]", user.ID))) return From 0c466675d31ef0fc44428a09a37cd5754ced3ddd Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 8 Jul 2026 09:58:19 +0300 Subject: [PATCH 2/6] fix: address usage email review feedback Format billing period dates in UTC to match how billing cycle boundaries are computed, and change IsEntitled to <= limit so users get the full message allowance and the exceeded email total matches the stated limit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/emails/hermes_user_email_factory.go | 5 +++-- api/pkg/entities/billing_usage.go | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/api/pkg/emails/hermes_user_email_factory.go b/api/pkg/emails/hermes_user_email_factory.go index ff8fcad5..25f9aabe 100644 --- a/api/pkg/emails/hermes_user_email_factory.go +++ b/api/pkg/emails/hermes_user_email_factory.go @@ -15,9 +15,10 @@ type hermesUserEmailFactory struct { generator hermes.Hermes } -// formatBillingDate renders a date like "19 June 2026" +// formatBillingDate renders a date like "19 June 2026" in UTC, matching how +// the billing cycle boundaries are computed in the billing usage repository. func formatBillingDate(t time.Time) string { - return t.Format("2 January 2006") + return t.UTC().Format("2 January 2006") } func (factory *hermesUserEmailFactory) APIKeyRotated(emailAddress string, timestamp time.Time, timezone string) (*Email, error) { diff --git a/api/pkg/entities/billing_usage.go b/api/pkg/entities/billing_usage.go index 5c5c67d7..8c9852b2 100644 --- a/api/pkg/entities/billing_usage.go +++ b/api/pkg/entities/billing_usage.go @@ -24,7 +24,7 @@ func (usage *BillingUsage) TotalMessages() uint { return usage.SentMessages + usage.ReceivedMessages } -// IsEntitled checks if a user can send `count` messages +// IsEntitled checks if a user can send `count` messages without exceeding `limit` func (usage *BillingUsage) IsEntitled(count, limit uint) bool { - return (usage.TotalMessages() + count) < limit + return (usage.TotalMessages() + count) <= limit } From 13ca142b3e06c6d853dcf8ef03db6d923befd4ff Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 8 Jul 2026 10:00:55 +0300 Subject: [PATCH 3/6] fix: render billing dates in the user's timezone Format the billing period using the user's configured timezone via user.Location() instead of UTC, so dates match what the user sees. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/emails/hermes_user_email_factory.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/api/pkg/emails/hermes_user_email_factory.go b/api/pkg/emails/hermes_user_email_factory.go index 25f9aabe..cae4a3b9 100644 --- a/api/pkg/emails/hermes_user_email_factory.go +++ b/api/pkg/emails/hermes_user_email_factory.go @@ -15,10 +15,9 @@ type hermesUserEmailFactory struct { generator hermes.Hermes } -// formatBillingDate renders a date like "19 June 2026" in UTC, matching how -// the billing cycle boundaries are computed in the billing usage repository. -func formatBillingDate(t time.Time) string { - return t.UTC().Format("2 January 2006") +// formatBillingDate renders a date like "19 June 2026" in the user's timezone. +func formatBillingDate(t time.Time, location *time.Location) string { + return t.In(location).Format("2 January 2006") } func (factory *hermesUserEmailFactory) APIKeyRotated(emailAddress string, timestamp time.Time, timezone string) (*Email, error) { @@ -75,7 +74,7 @@ func (factory *hermesUserEmailFactory) UsageLimitExceeded(user *entities.User, u Body: hermes.Body{ Intros: []string{ fmt.Sprintf("You've reached your limit of %d messages on the %s plan, so new messages will not be processed until your usage resets.", user.SubscriptionName.Limit(), user.SubscriptionName), - fmt.Sprintf("Between %s and %s you sent %d messages and received %d, for a total of %d.", formatBillingDate(usage.StartTimestamp), formatBillingDate(usage.EndTimestamp), usage.SentMessages, usage.ReceivedMessages, usage.TotalMessages()), + fmt.Sprintf("Between %s and %s you sent %d messages and received %d, for a total of %d.", formatBillingDate(usage.StartTimestamp, user.Location()), formatBillingDate(usage.EndTimestamp, user.Location()), usage.SentMessages, usage.ReceivedMessages, usage.TotalMessages()), }, Actions: []hermes.Action{ { @@ -121,7 +120,7 @@ func (factory *hermesUserEmailFactory) UsageLimitAlert(user *entities.User, usag Body: hermes.Body{ Intros: []string{ fmt.Sprintf("This is a friendly heads-up that you've used %d%% of your monthly SMS limit on the %s plan.", percent, user.SubscriptionName), - fmt.Sprintf("Between %s and %s you sent %d messages and received %d, for a total of %d out of your %d message limit.", formatBillingDate(usage.StartTimestamp), formatBillingDate(usage.EndTimestamp), usage.SentMessages, usage.ReceivedMessages, usage.TotalMessages(), user.SubscriptionName.Limit()), + fmt.Sprintf("Between %s and %s you sent %d messages and received %d, for a total of %d out of your %d message limit.", formatBillingDate(usage.StartTimestamp, user.Location()), formatBillingDate(usage.EndTimestamp, user.Location()), usage.SentMessages, usage.ReceivedMessages, usage.TotalMessages(), user.SubscriptionName.Limit()), }, Actions: []hermes.Action{ { From 711688cb4e140626f778e9fa17358af12f32be86 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 8 Jul 2026 10:01:35 +0300 Subject: [PATCH 4/6] style: fix logging message --- api/pkg/services/billing_service.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/pkg/services/billing_service.go b/api/pkg/services/billing_service.go index 0c77449a..1a2f7da5 100644 --- a/api/pkg/services/billing_service.go +++ b/api/pkg/services/billing_service.go @@ -55,14 +55,14 @@ func (service *BillingService) IsEntitledWithCount(ctx context.Context, userID e user, err := service.userRepository.Load(ctx, userID) if err != nil { - msg := fmt.Sprintf("cannot load user with ID [%s], entitlement successfull", userID) + msg := fmt.Sprintf("cannot load user with ID [%s], entitlement successful", userID) ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) return nil } usage, err := service.billingUsageRepository.GetCurrent(ctx, userID) if err != nil { - msg := fmt.Sprintf("cannot load billing usage for user with ID [%s], entitlement successfull", userID) + msg := fmt.Sprintf("cannot load billing usage for user with ID [%s], entitlement successful", userID) ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) return nil } From c256cfcbefd01e948259f43d8e3369a66f0814fd Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 8 Jul 2026 10:07:04 +0300 Subject: [PATCH 5/6] test: cover usage email breakdown and entitlement boundary Add tests for BillingUsage.IsEntitled at the exact limit boundary, and for the usage limit emails' sent/received breakdown, billing period rendering in the user's timezone, and formatBillingDate timezone conversion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../emails/hermes_user_email_factory_test.go | 83 +++++++++++++++++++ api/pkg/entities/billing_usage_test.go | 40 +++++++++ 2 files changed, 123 insertions(+) create mode 100644 api/pkg/emails/hermes_user_email_factory_test.go create mode 100644 api/pkg/entities/billing_usage_test.go diff --git a/api/pkg/emails/hermes_user_email_factory_test.go b/api/pkg/emails/hermes_user_email_factory_test.go new file mode 100644 index 00000000..df322e92 --- /dev/null +++ b/api/pkg/emails/hermes_user_email_factory_test.go @@ -0,0 +1,83 @@ +package emails + +import ( + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/stretchr/testify/assert" +) + +func testUserEmailFactory() UserEmailFactory { + return NewHermesUserEmailFactory(&HermesGeneratorConfig{ + AppURL: "https://httpsms.com", + AppName: "httpSMS", + AppLogoURL: "https://httpsms.com/logo.png", + }) +} + +func TestFormatBillingDate_RendersInProvidedTimezone(t *testing.T) { + // 2026-06-19 02:00 UTC + timestamp := time.Date(2026, 6, 19, 2, 0, 0, 0, time.UTC) + + // A timezone five hours behind UTC rolls back to the previous day. + behind := time.FixedZone("UTC-5", -5*60*60) + assert.Equal(t, "18 June 2026", formatBillingDate(timestamp, behind)) + + // A timezone ahead of UTC stays on the same day. + ahead := time.FixedZone("UTC+10", 10*60*60) + assert.Equal(t, "19 June 2026", formatBillingDate(timestamp, ahead)) + + // UTC renders the underlying date as-is. + assert.Equal(t, "19 June 2026", formatBillingDate(timestamp, time.UTC)) +} + +func TestUsageLimitExceeded_IncludesBreakdownAndBillingPeriod(t *testing.T) { + factory := testUserEmailFactory() + user := &entities.User{ + Email: "name@email.com", + Timezone: "UTC", + SubscriptionName: entities.SubscriptionNameProMonthly, + } + usage := &entities.BillingUsage{ + SentMessages: 3000, + ReceivedMessages: 2000, + StartTimestamp: time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC), + EndTimestamp: time.Date(2026, 7, 18, 23, 59, 59, 0, time.UTC), + } + + email, err := factory.UsageLimitExceeded(user, usage) + + assert.NoError(t, err) + assert.Equal(t, "name@email.com", email.ToEmail) + assert.Equal(t, "⚠️ You have exceeded your plan limit", email.Subject) + assert.Contains(t, email.Text, "limit of 5000 messages") + assert.Contains(t, email.Text, "Between 19 June 2026 and 18 July 2026") + assert.Contains(t, email.Text, "you sent 3000 messages and received 2000") + assert.Contains(t, email.Text, "for a total of 5000") +} + +func TestUsageLimitAlert_IncludesPercentBreakdownAndLimit(t *testing.T) { + factory := testUserEmailFactory() + user := &entities.User{ + Email: "name@email.com", + Timezone: "UTC", + SubscriptionName: entities.SubscriptionNameProMonthly, + } + usage := &entities.BillingUsage{ + SentMessages: 2500, + ReceivedMessages: 1500, + StartTimestamp: time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC), + EndTimestamp: time.Date(2026, 7, 18, 23, 59, 59, 0, time.UTC), + } + + email, err := factory.UsageLimitAlert(user, usage) + + assert.NoError(t, err) + assert.Equal(t, "name@email.com", email.ToEmail) + assert.Equal(t, "⚠️ 80% Usage Limit Alert", email.Subject) + assert.Contains(t, email.Text, "used 80% of your monthly SMS limit") + assert.Contains(t, email.Text, "Between 19 June 2026 and 18 July 2026") + assert.Contains(t, email.Text, "you sent 2500 messages and received 1500") + assert.Contains(t, email.Text, "for a total of 4000 out of your 5000 message limit") +} diff --git a/api/pkg/entities/billing_usage_test.go b/api/pkg/entities/billing_usage_test.go new file mode 100644 index 00000000..fa981750 --- /dev/null +++ b/api/pkg/entities/billing_usage_test.go @@ -0,0 +1,40 @@ +package entities + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBillingUsage_TotalMessages(t *testing.T) { + usage := BillingUsage{SentMessages: 321, ReceivedMessages: 465} + assert.Equal(t, uint(786), usage.TotalMessages()) +} + +func TestBillingUsage_IsEntitled_BelowLimit(t *testing.T) { + usage := BillingUsage{SentMessages: 100, ReceivedMessages: 100} + assert.True(t, usage.IsEntitled(1, 500)) +} + +func TestBillingUsage_IsEntitled_ReachingExactlyLimitIsEntitled(t *testing.T) { + // total is one below the limit, sending one more brings the total to + // exactly the limit, which should still be allowed. + usage := BillingUsage{SentMessages: 300, ReceivedMessages: 199} + assert.True(t, usage.IsEntitled(1, 500)) +} + +func TestBillingUsage_IsEntitled_ExceedingLimitIsNotEntitled(t *testing.T) { + // total already equals the limit, sending one more would exceed it. + usage := BillingUsage{SentMessages: 300, ReceivedMessages: 200} + assert.False(t, usage.IsEntitled(1, 500)) +} + +func TestBillingUsage_IsEntitled_BulkCountFittingExactly(t *testing.T) { + usage := BillingUsage{SentMessages: 250, ReceivedMessages: 248} + assert.True(t, usage.IsEntitled(2, 500)) +} + +func TestBillingUsage_IsEntitled_BulkCountExceeding(t *testing.T) { + usage := BillingUsage{SentMessages: 250, ReceivedMessages: 248} + assert.False(t, usage.IsEntitled(3, 500)) +} From a151e608da9931aa08c13099152a13e41676bda4 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 8 Jul 2026 12:24:31 +0300 Subject: [PATCH 6/6] fix: fix email subject --- api/pkg/services/phone_notification_service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 79b43037..3ccca9aa 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -167,7 +167,7 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone params.MessageID, ), )) - msg := fmt.Sprintf("cannot send notification for to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber) + msg := fmt.Sprintf("cannot send notification to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber) return service.handleNotificationFailed(ctx, errors.New(msg), params) }