diff --git a/api/pkg/emails/hermes_user_email_factory.go b/api/pkg/emails/hermes_user_email_factory.go index 9ec5754af..cae4a3b9a 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" 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) { 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, user.Location()), formatBillingDate(usage.EndTimestamp, user.Location()), 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, user.Location()), formatBillingDate(usage.EndTimestamp, user.Location()), usage.SentMessages, usage.ReceivedMessages, usage.TotalMessages(), user.SubscriptionName.Limit()), }, Actions: []hermes.Action{ { 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 000000000..df322e926 --- /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/emails/user_email_factory.go b/api/pkg/emails/user_email_factory.go index a8c11d7a9..07e60a877 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/entities/billing_usage.go b/api/pkg/entities/billing_usage.go index 5c5c67d7e..8c9852b26 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 } diff --git a/api/pkg/entities/billing_usage_test.go b/api/pkg/entities/billing_usage_test.go new file mode 100644 index 000000000..fa981750a --- /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)) +} diff --git a/api/pkg/services/billing_service.go b/api/pkg/services/billing_service.go index 1d573dbd8..1a2f7da5e 100644 --- a/api/pkg/services/billing_service.go +++ b/api/pkg/services/billing_service.go @@ -55,20 +55,20 @@ 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 } 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 diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 79b430378..3ccca9aa2 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) }