Skip to content

Commit 075f2b0

Browse files
AchoArnoldCopilot
andauthored
feat: add sent/received breakdown and billing period to usage emails (#944)
* 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> * 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> * 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> * style: fix logging message * 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> * fix: fix email subject --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 90dc0df commit 075f2b0

7 files changed

Lines changed: 144 additions & 15 deletions

api/pkg/emails/hermes_user_email_factory.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ type hermesUserEmailFactory struct {
1515
generator hermes.Hermes
1616
}
1717

18+
// formatBillingDate renders a date like "19 June 2026" in the user's timezone.
19+
func formatBillingDate(t time.Time, location *time.Location) string {
20+
return t.In(location).Format("2 January 2006")
21+
}
22+
1823
func (factory *hermesUserEmailFactory) APIKeyRotated(emailAddress string, timestamp time.Time, timezone string) (*Email, error) {
1924
location, err := time.LoadLocation(timezone)
2025
if err != nil {
@@ -64,11 +69,12 @@ func (factory *hermesUserEmailFactory) APIKeyRotated(emailAddress string, timest
6469
}
6570

6671
// UsageLimitExceeded is the email sent when the plan limit is reached
67-
func (factory *hermesUserEmailFactory) UsageLimitExceeded(user *entities.User) (*Email, error) {
72+
func (factory *hermesUserEmailFactory) UsageLimitExceeded(user *entities.User, usage *entities.BillingUsage) (*Email, error) {
6873
email := hermes.Email{
6974
Body: hermes.Body{
7075
Intros: []string{
71-
fmt.Sprintf("You have exceeded your limit of %d messages on your %s plan.", user.SubscriptionName.Limit(), user.SubscriptionName),
76+
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),
77+
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()),
7278
},
7379
Actions: []hermes.Action{
7480
{
@@ -113,8 +119,8 @@ func (factory *hermesUserEmailFactory) UsageLimitAlert(user *entities.User, usag
113119
email := hermes.Email{
114120
Body: hermes.Body{
115121
Intros: []string{
116-
fmt.Sprintf("This is a friendly notification that you have exceeded %d%% of your monthly SMS limit on the %s plan.", percent, user.SubscriptionName),
117-
fmt.Sprintf("You have sent %d messages and received %d messages using httpSMS this month.", usage.SentMessages, usage.ReceivedMessages),
122+
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),
123+
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()),
118124
},
119125
Actions: []hermes.Action{
120126
{
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package emails
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/NdoleStudio/httpsms/pkg/entities"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func testUserEmailFactory() UserEmailFactory {
12+
return NewHermesUserEmailFactory(&HermesGeneratorConfig{
13+
AppURL: "https://httpsms.com",
14+
AppName: "httpSMS",
15+
AppLogoURL: "https://httpsms.com/logo.png",
16+
})
17+
}
18+
19+
func TestFormatBillingDate_RendersInProvidedTimezone(t *testing.T) {
20+
// 2026-06-19 02:00 UTC
21+
timestamp := time.Date(2026, 6, 19, 2, 0, 0, 0, time.UTC)
22+
23+
// A timezone five hours behind UTC rolls back to the previous day.
24+
behind := time.FixedZone("UTC-5", -5*60*60)
25+
assert.Equal(t, "18 June 2026", formatBillingDate(timestamp, behind))
26+
27+
// A timezone ahead of UTC stays on the same day.
28+
ahead := time.FixedZone("UTC+10", 10*60*60)
29+
assert.Equal(t, "19 June 2026", formatBillingDate(timestamp, ahead))
30+
31+
// UTC renders the underlying date as-is.
32+
assert.Equal(t, "19 June 2026", formatBillingDate(timestamp, time.UTC))
33+
}
34+
35+
func TestUsageLimitExceeded_IncludesBreakdownAndBillingPeriod(t *testing.T) {
36+
factory := testUserEmailFactory()
37+
user := &entities.User{
38+
Email: "name@email.com",
39+
Timezone: "UTC",
40+
SubscriptionName: entities.SubscriptionNameProMonthly,
41+
}
42+
usage := &entities.BillingUsage{
43+
SentMessages: 3000,
44+
ReceivedMessages: 2000,
45+
StartTimestamp: time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC),
46+
EndTimestamp: time.Date(2026, 7, 18, 23, 59, 59, 0, time.UTC),
47+
}
48+
49+
email, err := factory.UsageLimitExceeded(user, usage)
50+
51+
assert.NoError(t, err)
52+
assert.Equal(t, "name@email.com", email.ToEmail)
53+
assert.Equal(t, "⚠️ You have exceeded your plan limit", email.Subject)
54+
assert.Contains(t, email.Text, "limit of 5000 messages")
55+
assert.Contains(t, email.Text, "Between 19 June 2026 and 18 July 2026")
56+
assert.Contains(t, email.Text, "you sent 3000 messages and received 2000")
57+
assert.Contains(t, email.Text, "for a total of 5000")
58+
}
59+
60+
func TestUsageLimitAlert_IncludesPercentBreakdownAndLimit(t *testing.T) {
61+
factory := testUserEmailFactory()
62+
user := &entities.User{
63+
Email: "name@email.com",
64+
Timezone: "UTC",
65+
SubscriptionName: entities.SubscriptionNameProMonthly,
66+
}
67+
usage := &entities.BillingUsage{
68+
SentMessages: 2500,
69+
ReceivedMessages: 1500,
70+
StartTimestamp: time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC),
71+
EndTimestamp: time.Date(2026, 7, 18, 23, 59, 59, 0, time.UTC),
72+
}
73+
74+
email, err := factory.UsageLimitAlert(user, usage)
75+
76+
assert.NoError(t, err)
77+
assert.Equal(t, "name@email.com", email.ToEmail)
78+
assert.Equal(t, "⚠️ 80% Usage Limit Alert", email.Subject)
79+
assert.Contains(t, email.Text, "used 80% of your monthly SMS limit")
80+
assert.Contains(t, email.Text, "Between 19 June 2026 and 18 July 2026")
81+
assert.Contains(t, email.Text, "you sent 2500 messages and received 1500")
82+
assert.Contains(t, email.Text, "for a total of 4000 out of your 5000 message limit")
83+
}

api/pkg/emails/user_email_factory.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ type UserEmailFactory interface {
1212
PhoneDead(user *entities.User, lastHeartbeatTimestamp time.Time, owner string) (*Email, error)
1313

1414
// UsageLimitExceeded sends an email when the user's limit is exceeded
15-
UsageLimitExceeded(user *entities.User) (*Email, error)
15+
UsageLimitExceeded(user *entities.User, usage *entities.BillingUsage) (*Email, error)
1616

1717
// UsageLimitAlert sends an email when a user is approaching the limit
1818
UsageLimitAlert(user *entities.User, usage *entities.BillingUsage) (*Email, error)

api/pkg/entities/billing_usage.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func (usage *BillingUsage) TotalMessages() uint {
2424
return usage.SentMessages + usage.ReceivedMessages
2525
}
2626

27-
// IsEntitled checks if a user can send `count` messages
27+
// IsEntitled checks if a user can send `count` messages without exceeding `limit`
2828
func (usage *BillingUsage) IsEntitled(count, limit uint) bool {
29-
return (usage.TotalMessages() + count) < limit
29+
return (usage.TotalMessages() + count) <= limit
3030
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package entities
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestBillingUsage_TotalMessages(t *testing.T) {
10+
usage := BillingUsage{SentMessages: 321, ReceivedMessages: 465}
11+
assert.Equal(t, uint(786), usage.TotalMessages())
12+
}
13+
14+
func TestBillingUsage_IsEntitled_BelowLimit(t *testing.T) {
15+
usage := BillingUsage{SentMessages: 100, ReceivedMessages: 100}
16+
assert.True(t, usage.IsEntitled(1, 500))
17+
}
18+
19+
func TestBillingUsage_IsEntitled_ReachingExactlyLimitIsEntitled(t *testing.T) {
20+
// total is one below the limit, sending one more brings the total to
21+
// exactly the limit, which should still be allowed.
22+
usage := BillingUsage{SentMessages: 300, ReceivedMessages: 199}
23+
assert.True(t, usage.IsEntitled(1, 500))
24+
}
25+
26+
func TestBillingUsage_IsEntitled_ExceedingLimitIsNotEntitled(t *testing.T) {
27+
// total already equals the limit, sending one more would exceed it.
28+
usage := BillingUsage{SentMessages: 300, ReceivedMessages: 200}
29+
assert.False(t, usage.IsEntitled(1, 500))
30+
}
31+
32+
func TestBillingUsage_IsEntitled_BulkCountFittingExactly(t *testing.T) {
33+
usage := BillingUsage{SentMessages: 250, ReceivedMessages: 248}
34+
assert.True(t, usage.IsEntitled(2, 500))
35+
}
36+
37+
func TestBillingUsage_IsEntitled_BulkCountExceeding(t *testing.T) {
38+
usage := BillingUsage{SentMessages: 250, ReceivedMessages: 248}
39+
assert.False(t, usage.IsEntitled(3, 500))
40+
}

api/pkg/services/billing_service.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,20 @@ func (service *BillingService) IsEntitledWithCount(ctx context.Context, userID e
5555

5656
user, err := service.userRepository.Load(ctx, userID)
5757
if err != nil {
58-
msg := fmt.Sprintf("cannot load user with ID [%s], entitlement successfull", userID)
58+
msg := fmt.Sprintf("cannot load user with ID [%s], entitlement successful", userID)
5959
ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)))
6060
return nil
6161
}
6262

6363
usage, err := service.billingUsageRepository.GetCurrent(ctx, userID)
6464
if err != nil {
65-
msg := fmt.Sprintf("cannot load billing usage for user with ID [%s], entitlement successfull", userID)
65+
msg := fmt.Sprintf("cannot load billing usage for user with ID [%s], entitlement successful", userID)
6666
ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)))
6767
return nil
6868
}
6969

7070
if !usage.IsEntitled(count, user.SubscriptionName.Limit()) {
71-
return service.handleLimitExceeded(ctx, user)
71+
return service.handleLimitExceeded(ctx, user, usage)
7272
}
7373

7474
return nil
@@ -79,11 +79,11 @@ func (service *BillingService) IsEntitled(ctx context.Context, userID entities.U
7979
return service.IsEntitledWithCount(ctx, userID, 1)
8080
}
8181

82-
func (service *BillingService) handleLimitExceeded(ctx context.Context, user *entities.User) *string {
82+
func (service *BillingService) handleLimitExceeded(ctx context.Context, user *entities.User, usage *entities.BillingUsage) *string {
8383
ctx, span := service.tracer.Start(ctx)
8484
defer span.End()
8585

86-
service.sendLimitExceededEmail(ctx, user)
86+
service.sendLimitExceededEmail(ctx, user, usage)
8787

8888
message := fmt.Sprintf(
8989
"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
9393
return &message
9494
}
9595

96-
func (service *BillingService) sendLimitExceededEmail(ctx context.Context, user *entities.User) {
96+
func (service *BillingService) sendLimitExceededEmail(ctx context.Context, user *entities.User, usage *entities.BillingUsage) {
9797
ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
9898
defer span.End()
9999

@@ -102,7 +102,7 @@ func (service *BillingService) sendLimitExceededEmail(ctx context.Context, user
102102
return
103103
}
104104

105-
email, err := service.emailFactory.UsageLimitExceeded(user)
105+
email, err := service.emailFactory.UsageLimitExceeded(user, usage)
106106
if err != nil {
107107
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot create usage limit email for user [%s]", user.ID)))
108108
return

api/pkg/services/phone_notification_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone
167167
params.MessageID,
168168
),
169169
))
170-
msg := fmt.Sprintf("cannot send notification for to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber)
170+
msg := fmt.Sprintf("cannot send notification to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber)
171171
return service.handleNotificationFailed(ctx, errors.New(msg), params)
172172
}
173173

0 commit comments

Comments
 (0)