Skip to content

feat: add sent/received breakdown and billing period to usage emails#944

Merged
AchoArnold merged 6 commits into
mainfrom
AchoArnold/usage-email-message-breakdown
Jul 8, 2026
Merged

feat: add sent/received breakdown and billing period to usage emails#944
AchoArnold merged 6 commits into
mainfrom
AchoArnold/usage-email-message-breakdown

Conversation

@AchoArnold

Copy link
Copy Markdown
Member

Summary

The usage limit alert and limit exceeded emails previously only mentioned the total plan limit. They now include:

  • A breakdown of sent vs received messages
  • The current billing period formatted clearly (e.g. 19 June 2026 to 19 July 2026)

The already-loaded BillingUsage is threaded through handleLimitExceeded -> sendLimitExceededEmail -> UsageLimitExceeded, so no extra DB query is needed.

Example copy

Limit exceeded:

You've reached your limit of 500 messages on the pro plan, so new messages will not be processed until your usage resets.
Between 19 June 2026 and 19 July 2026 you sent 321 messages and received 465, for a total of 786.

Usage alert:

This is a friendly heads-up that you've used 80%% of your monthly SMS limit on the pro plan.
Between 19 June 2026 and 19 July 2026 you sent 321 messages and received 465, for a total of 786 out of your 500 message limit.

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>
@codacy-production

codacy-production Bot commented Jul 8, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 10 complexity · 4 duplication

Metric Results
Complexity 10
Duplication 4

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR enriches the usage limit-exceeded and usage alert emails with a sent/received message breakdown and the current billing period dates. The already-loaded BillingUsage is threaded through handleLimitExceededsendLimitExceededEmailUsageLimitExceeded, so no additional database queries are introduced.

  • hermes_user_email_factory.go: Adds a formatBillingDate helper and rewrites both email bodies to include StartTimestampEndTimestamp range, per-direction counts, and a running total.
  • user_email_factory.go: Updates the UserEmailFactory interface to pass *entities.BillingUsage to UsageLimitExceeded.
  • billing_service.go: Passes the already-loaded usage into handleLimitExceeded and sendLimitExceededEmail without any other behavioural change.

Confidence Score: 4/5

Safe to merge with awareness of the off-by-one copy issue in the limit-exceeded email.

The threading of the already-loaded usage is correct and the email copy improvements are clear. The one substantive concern is that the strict < limit entitlement check means the first limit-exceeded email a user receives will say 'reached your limit of N' while the breakdown shows a total of N-1, a contradiction that is now visible because the breakdown is newly included in this email. This is a real user-facing discrepancy, not just a theoretical edge case, since the cache deduplication ensures it is typically the first (and most prominent) email the user sees when they hit their cap.

api/pkg/emails/hermes_user_email_factory.go — the limit-exceeded email body at line 77 where the stated plan limit and the displayed total can differ by one.

Important Files Changed

Filename Overview
api/pkg/emails/hermes_user_email_factory.go Added formatBillingDate helper and updated UsageLimitExceeded/UsageLimitAlert email bodies to include sent/received breakdown and billing period; the new breakdown can show a total one less than the plan limit (e.g. "total of 499" with "limit of 500") due to the strict-less-than entitlement check
api/pkg/emails/user_email_factory.go Interface updated to pass *entities.BillingUsage to UsageLimitExceeded; change is minimal and consistent with the implementation update
api/pkg/services/billing_service.go Already-loaded BillingUsage is cleanly threaded through handleLimitExceeded → sendLimitExceededEmail → UsageLimitExceeded with no extra DB queries; logic is correct

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant BillingService
    participant BillingUsageRepo
    participant UserRepo
    participant EmailFactory
    participant Mailer
    participant Cache

    Caller->>BillingService: IsEntitled(ctx, userID)
    BillingService->>BillingService: IsEntitledWithCount(ctx, userID, 1)
    BillingService->>UserRepo: Load(ctx, userID)
    UserRepo-->>BillingService: user
    BillingService->>BillingUsageRepo: GetCurrent(ctx, userID)
    BillingUsageRepo-->>BillingService: usage
    BillingService->>BillingService: usage.IsEntitled(count, limit)
    alt limit exceeded
        BillingService->>BillingService: handleLimitExceeded(ctx, user, usage)
        BillingService->>BillingService: sendLimitExceededEmail(ctx, user, usage)
        BillingService->>Cache: Get(limitExceededKey)
        alt cache miss (first time in 12h)
            BillingService->>EmailFactory: UsageLimitExceeded(user, usage)
            Note over EmailFactory: Formats billing period dates<br/>Shows sent/received breakdown<br/>Shows total vs plan limit
            EmailFactory-->>BillingService: email
            BillingService->>Mailer: Send(ctx, email)
            BillingService->>Cache: Set(limitExceededKey, 12h TTL)
        end
        BillingService-->>Caller: "&limitExceededMessage"
    else entitled
        BillingService-->>Caller: nil
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant BillingService
    participant BillingUsageRepo
    participant UserRepo
    participant EmailFactory
    participant Mailer
    participant Cache

    Caller->>BillingService: IsEntitled(ctx, userID)
    BillingService->>BillingService: IsEntitledWithCount(ctx, userID, 1)
    BillingService->>UserRepo: Load(ctx, userID)
    UserRepo-->>BillingService: user
    BillingService->>BillingUsageRepo: GetCurrent(ctx, userID)
    BillingUsageRepo-->>BillingService: usage
    BillingService->>BillingService: usage.IsEntitled(count, limit)
    alt limit exceeded
        BillingService->>BillingService: handleLimitExceeded(ctx, user, usage)
        BillingService->>BillingService: sendLimitExceededEmail(ctx, user, usage)
        BillingService->>Cache: Get(limitExceededKey)
        alt cache miss (first time in 12h)
            BillingService->>EmailFactory: UsageLimitExceeded(user, usage)
            Note over EmailFactory: Formats billing period dates<br/>Shows sent/received breakdown<br/>Shows total vs plan limit
            EmailFactory-->>BillingService: email
            BillingService->>Mailer: Send(ctx, email)
            BillingService->>Cache: Set(limitExceededKey, 12h TTL)
        end
        BillingService-->>Caller: "&limitExceededMessage"
    else entitled
        BillingService-->>Caller: nil
    end
Loading

Reviews (1): Last reviewed commit: "feat: add sent/received breakdown and bi..." | Re-trigger Greptile

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()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Total in breakdown can be one less than the stated limit

IsEntitled uses a strict < limit check, so it fires when TotalMessages() + count >= limit. For the most common case (count = 1), the first limit-exceeded email is triggered when TotalMessages() = limit - 1. The email will therefore say "You've reached your limit of 500 messages" while the breakdown line below reads "for a total of 499" — a one-message gap that users are likely to question or find confusing. The cache key suppresses re-sends for 12 h, so this off-by-one copy is what most users will see on their first block.

Consider either adjusting the IsEntitled check to <= limit (if the intent is to allow exactly limit messages), or updating the copy to say "approaching your limit" rather than "reached your limit" when the total is below the plan ceiling.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0c46667. Changed IsEntitled from (total + count) < limit to <= limit, so a plan grants the full N messages and the exceeded email now fires at otal = limit — the stated limit and the breakdown total match.

Comment on lines +19 to +21
func formatBillingDate(t time.Time) string {
return t.Format("2 January 2006")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Billing period dates formatted in whichever timezone the database returns

StartTimestamp and EndTimestamp are plain time.Time values stored in BillingUsage. If the database driver attaches UTC (or any other timezone) to them, the formatted date is correct for that zone but may differ from what the user expects. For billing period boundaries this is usually fine, but it's worth confirming that the repository always returns these timestamps in UTC (or consistently normalises them) so the rendered date matches the subscription anniversary the user sees in the billing UI.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0c46667. computeBillingCycle builds the boundaries with ime.UTC, so ormatBillingDate now calls .UTC().Format(...) to render the date in the same zone the cycle is defined in, regardless of what timezone the driver attaches on read-back.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 13ca142 to render the billing dates in the user's configured timezone via user.Location() (falls back to UTC on an invalid timezone), matching the existing PhoneDead / UserTimeString pattern, rather than forcing UTC.

AchoArnold and others added 5 commits July 8, 2026 09:58
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>
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>
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>
@AchoArnold AchoArnold merged commit 075f2b0 into main Jul 8, 2026
7 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant