Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions api/pkg/emails/hermes_user_email_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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{
{
Expand Down Expand Up @@ -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{
{
Expand Down
83 changes: 83 additions & 0 deletions api/pkg/emails/hermes_user_email_factory_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
2 changes: 1 addition & 1 deletion api/pkg/emails/user_email_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions api/pkg/entities/billing_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
40 changes: 40 additions & 0 deletions api/pkg/entities/billing_usage_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
14 changes: 7 additions & 7 deletions api/pkg/services/billing_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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()

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/pkg/services/phone_notification_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading