Skip to content

Comments

feat: add email field for invoicing#253

Merged
konsalex merged 1 commit intomainfrom
HOPP-516
Feb 4, 2026
Merged

feat: add email field for invoicing#253
konsalex merged 1 commit intomainfrom
HOPP-516

Conversation

@konsalex
Copy link
Contributor

@konsalex konsalex commented Feb 4, 2026

Closes #252

This PR adds explicit functionality to send invoice email. It relies on Stripe's invoice.payment_succeeded event.

How email looks like:
CleanShot 2026-02-04 at 21 41 29@2x

How UI looks like:
CleanShot 2026-02-04 at 21 41 55@2x

To test, first ensure you have a in Stripe Sandbox a current subscription. Then you can use the following to start forwarding webhook calls locally to the server:

stripe listen --forward-to https://localhost:1926/api/billing/webhook

Create a webhook event:

stripe invoices create --customer cus_<GET ID FROM DB> | jq -r '.id' | xargs -I {} sh -c 'stripe invoices finalize_invoice {} && stripe invoices pay {}'

Summary by CodeRabbit

  • New Features
    • Added billing settings management to configure invoice email addresses
    • Automated invoice payment notifications sent to the configured billing email address
    • New Invoice Settings interface to manage and update billing email preferences

@netlify
Copy link

netlify bot commented Feb 4, 2026

Deploy Preview for hoppdocs ready!

Name Link
🔨 Latest commit 380950e
🔍 Latest deploy log https://app.netlify.com/projects/hoppdocs/deploys/6983b14be8979a0008c66607
😎 Deploy Preview https://deploy-preview-253--hoppdocs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link

coderabbitai bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

This pull request adds a billing email management system for invoices. It introduces API endpoints (GET/PUT) for billing settings, stores billing email addresses on the Team model, sends invoice payment emails to configured billing addresses, generates an HTML invoice email template, and adds UI for managing billing email in the subscription settings page.

Changes

Cohort / File(s) Summary
API Specification
backend/api-files/openapi.yaml
Defines two new schemas (BillingSettingsRequest, BillingSettingsResponse) and a new /api/auth/billing/settings endpoint with GET and PUT operations for retrieving and updating billing email settings.
Backend Handlers
backend/internal/handlers/billing.go
Implements GetBillingSettings and UpdateBillingSettings handler methods for managing team billing email; adds handleInvoicePaymentSucceeded to process invoice payment webhooks and email configured billing addresses.
Backend Services
backend/internal/email/email.go
Adds InvoiceEmailData struct and SendInvoiceEmail method to EmailClient interface and ResendEmailClient implementation; supports dynamic invoice email template rendering with placeholder replacement.
Backend Models & Routes
backend/internal/models/team.go, backend/internal/server/server.go
Adds BillingEmail nullable string field to Team model; registers new protected GET and PUT routes under /auth/billing/settings.
Email Template
backend/web/emails/hopp-invoice.html
New HTML email template for invoices featuring logo, invoice number, period, and action links for viewing and downloading invoices with inline styling and accessibility elements.
Frontend Type Definitions
tauri/src/openapi.d.ts, web-app/src/openapi.d.ts
Adds OpenAPI type definitions for BillingSettingsRequest/Response schemas and /api/auth/billing/settings endpoint operations to support frontend integration.
Frontend UI
web-app/src/pages/Subscription.tsx
Introduces Invoice Settings card with form to view and manage billing email; includes fetch logic for billing settings (admin-only), update/delete mutations, and UI state management for save actions.

Sequence Diagram

sequenceDiagram
    participant Stripe as Stripe<br/>(External)
    participant Handler as BillingHandler
    participant DB as Database
    participant Email as EmailClient
    
    Stripe->>Handler: POST /webhook<br/>invoice.payment_succeeded
    Handler->>Handler: Validate event signature
    Handler->>Handler: Extract customer ID
    Handler->>DB: Query Subscription<br/>by customer
    DB-->>Handler: Subscription with Team
    Handler->>DB: Get Team details<br/>(with BillingEmail)
    DB-->>Handler: Team data
    alt BillingEmail configured
        Handler->>Email: SendInvoiceEmail()<br/>(toEmail, data)
        Email->>Email: Load template
        Email->>Email: Replace placeholders
        Email->>Stripe: Send async
        Stripe-->>Email: Queued
    else BillingEmail not set
        Handler->>Handler: Skip email
    end
    Handler-->>Stripe: 200 OK
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A billing email takes flight,
Through webhooks and handlers at night,
Invoices now send their way,
To admins who save the day,
With templates and UI done right! 📧

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add email field for invoicing' accurately describes the main change—adding a billing email field to configure invoice delivery.
Linked Issues check ✅ Passed The PR successfully implements the requirement to add an option to send invoices to an admin email [#252]. It includes billing email configuration UI, email sending functionality, and webhook integration.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing billing email configuration and invoice email functionality. No unrelated or out-of-scope modifications were detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch HOPP-516

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@backend/api-files/openapi.yaml`:
- Around line 159-175: Update the OpenAPI schemas so the API permits disabling
invoice emails via an empty value: add nullable: true to
BillingSettingsRequest.properties.billing_email and to
BillingSettingsResponse.properties.billing_email and update their descriptions
to state that an empty string (or null) disables invoice emails; keep the
BillingSettingsResponse.required list if you want the key present but allow its
value to be empty/null. This ensures the schema matches the backend
validate:"omitempty,email" behavior and the UI that sends "".

In `@backend/internal/handlers/billing.go`:
- Around line 542-549: The log statement currently emits PII by printing the
full billing email; update the logging in the invoice send block (around
bh.EmailClient, SendInvoiceEmail, team.BillingEmail and c.Logger().Infof) to
avoid logging the raw email — either redact the email (e.g., mask local part) or
remove it entirely and instead log non-PII identifiers such as team.ID and
invoice.Number (e.g., "Sent invoice email for %s to team %s"). Ensure the
SendInvoiceEmail call remains unchanged but replace the c.Logger().Infof call to
only include invoice.Number and team.ID or a redacted email string.
- Around line 504-549: Add idempotency to handleInvoicePaymentSucceeded by
recording processed Stripe event IDs in the DB and skipping duplicates: create a
simple model/table (e.g., ProcessedWebhook with EventID string unique) and in
handleInvoicePaymentSucceeded check bh.DB for the event.ID at the start; if
found return nil to acknowledge the webhook; otherwise insert a new
ProcessedWebhook row in the same transactional flow (or attempt insert and treat
a unique-constraint error as “already processed”) before calling
bh.EmailClient.SendInvoiceEmail; ensure the check/insert is done atomically or
handle unique-constraint errors to avoid race conditions so duplicate stripe
event retries do not send duplicate emails.

In `@web-app/src/pages/Subscription.tsx`:
- Around line 141-172: The invalidateQueries call in handleSaveBillingEmail and
handleDeleteBillingEmail is using ["get", "/api/auth/billing/settings"] which
doesn't match the query's custom queryHash ('billing-settings-${authToken}'), so
cache invalidation silently fails; update the invalidation to use the same
queryHash (e.g., queryClient.invalidateQueries({ queryKey:
['billing-settings-${authToken}'] })) or call the refetch callback returned by
the billing settings query hook instead; change the invalidateQueries calls in
handleSaveBillingEmail and handleDeleteBillingEmail (and keep
updateBillingSettingsMutation usage) to target the matching queryHash or use the
existing refetch function like other components (Teammates.tsx).
🧹 Nitpick comments (1)
web-app/src/pages/Subscription.tsx (1)

338-385: Disable the input while saving to avoid mid-save edits.

This prevents users from editing during an in-flight save/delete and makes the UI feel more deterministic.

Proposed tweak
-                  disabled={billingSettingsLoading}
+                  disabled={billingSettingsLoading || billingEmailSaving}

Comment on lines +159 to +175
BillingSettingsRequest:
type: object
properties:
billing_email:
type: string
format: email
description: Email address where invoices will be sent (leave empty to disable invoice emails)

BillingSettingsResponse:
type: object
required:
- billing_email
properties:
billing_email:
type: string
description: Email address where invoices are sent (empty string if not configured)

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, check the exact content of the openapi.yaml file at lines 159-175
sed -n '159,175p' backend/api-files/openapi.yaml

Repository: gethopp/hopp

Length of output: 543


🏁 Script executed:

# Search for all references to billing_email to understand how it's handled
rg -n -C5 'billing_email'

Repository: gethopp/hopp

Length of output: 10942


The OpenAPI schema doesn't accurately reflect that empty strings are valid.

The BillingSettingsRequest schema specifies format: email, which forbids empty strings per OpenAPI spec. However, the backend uses validate:"omitempty,email" (which allows empty) and the UI sends "" to disable invoice emails. Either add nullable: true to the schema to clarify empty is permitted, or document that the field can be omitted entirely instead of sent as empty string.

🤖 Prompt for AI Agents
In `@backend/api-files/openapi.yaml` around lines 159 - 175, Update the OpenAPI
schemas so the API permits disabling invoice emails via an empty value: add
nullable: true to BillingSettingsRequest.properties.billing_email and to
BillingSettingsResponse.properties.billing_email and update their descriptions
to state that an empty string (or null) disables invoice emails; keep the
BillingSettingsResponse.required list if you want the key present but allow its
value to be empty/null. This ensures the schema matches the backend
validate:"omitempty,email" behavior and the UI that sends "".

Comment on lines +504 to +549
// handleInvoicePaymentSucceeded handles the invoice.payment_succeeded webhook event
// It sends an invoice email to the team's billing email if configured
func (bh *BillingHandler) handleInvoicePaymentSucceeded(c echo.Context, event stripe.Event) error {
var invoice stripe.Invoice
if err := json.Unmarshal(event.Data.Raw, &invoice); err != nil {
c.Logger().Errorf("Failed to unmarshal invoice: %v", err)
return err
}

// Skip if invoice has no customer
if invoice.Customer == nil || invoice.Customer.ID == "" {
c.Logger().Info("Invoice has no customer, skipping invoice email")
return nil
}

// Look up subscription by Stripe customer ID
var subscription models.Subscription
if err := bh.DB.Preload("Team").Where("stripe_customer_id = ?", invoice.Customer.ID).First(&subscription).Error; err != nil {
c.Logger().Infof("Could not find subscription for customer %s: %v", invoice.Customer.ID, err)
// Return nil to acknowledge the webhook as subscription might not exist yet
return nil
}

team := subscription.Team

// Only send if team has billing
// email set else skip
if team.BillingEmail == nil || *team.BillingEmail == "" {
c.Logger().Infof("Team %d has no billing email set, skipping invoice email", team.ID)
return nil
}

// Format period from timestamps (period_start, period_end)
periodStart := time.Unix(invoice.PeriodStart, 0)
periodEnd := time.Unix(invoice.PeriodEnd, 0)
period := fmt.Sprintf("%s - %s", periodStart.Format("Jan 2"), periodEnd.Format("Jan 2, 2006"))

// Send invoice email
if bh.EmailClient != nil {
bh.EmailClient.SendInvoiceEmail(*team.BillingEmail, email.InvoiceEmailData{
InvoiceNumber: invoice.Number,
Period: period,
HostedInvoiceURL: invoice.HostedInvoiceURL,
InvoicePDFURL: invoice.InvoicePDF,
})
c.Logger().Infof("Sent invoice email for %s to %s", invoice.Number, *team.BillingEmail)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Search for Stripe webhook event handling and any idempotency mechanisms
rg -n "stripe.*[Ee]vent|event_id|invoice_id|webhook.*dedup|idempotent|processed.*event|stripe\\.Event" backend --type go -A 3 -B 3

Repository: gethopp/hopp

Length of output: 2908


🏁 Script executed:

# Check the billing handler file for webhook processing context
fd "billing" backend --type go | head -20

Repository: gethopp/hopp

Length of output: 225


🏁 Script executed:

# Search for webhook route handling
rg -n "webhook|Webhook" backend --type go -A 2 | head -50

Repository: gethopp/hopp

Length of output: 2952


🏁 Script executed:

# Check for any database tracking of processed events
rg -n "processed_event|event_tracking|webhook_event" backend --type go

Repository: gethopp/hopp

Length of output: 38


🌐 Web query:

Stripe webhook delivery guarantee at-least-once retry policy invoice.payment_succeeded

💡 Result:

Stripe webhooks (including invoice.payment_succeeded) are at-least-once delivery: your endpoint can receive the same event more than once, so you must make processing idempotent (for example, store and de-dupe by event.id). [1]

If Stripe doesn’t get a successful 2xx response (including timeouts), it will automatically retry delivery:

  • Live mode: retries for up to 3 days using exponential backoff. [1]
  • Sandbox/test: retries 3 times over a few hours. [1]

Stripe also doesn’t guarantee event ordering, so don’t assume invoice.payment_succeeded arrives in sequence relative to other invoice/subscription events. [1]

Practical takeaway: treat invoice.payment_succeeded as “delivered at least once, possibly duplicated, possibly out of order,” and always reconcile against the API object state if needed. [1]

Sources:
[1] Stripe Docs — Webhooks: Event delivery behaviors (automatic retries, ordering) + best practice “Handle duplicate events” https://docs.stripe.com/webhooks


Implement idempotency to prevent duplicate invoice emails on webhook retries.

Stripe webhooks guarantee at-least-once delivery with automatic retries for up to 3 days. Without deduplication, duplicate invoice.payment_succeeded events will trigger duplicate invoice emails. Add idempotency by either: (1) tracking processed event.ID with a unique constraint in the database, or (2) recording last_invoice_emailed_at per invoice and skipping if already sent.

🤖 Prompt for AI Agents
In `@backend/internal/handlers/billing.go` around lines 504 - 549, Add idempotency
to handleInvoicePaymentSucceeded by recording processed Stripe event IDs in the
DB and skipping duplicates: create a simple model/table (e.g., ProcessedWebhook
with EventID string unique) and in handleInvoicePaymentSucceeded check bh.DB for
the event.ID at the start; if found return nil to acknowledge the webhook;
otherwise insert a new ProcessedWebhook row in the same transactional flow (or
attempt insert and treat a unique-constraint error as “already processed”)
before calling bh.EmailClient.SendInvoiceEmail; ensure the check/insert is done
atomically or handle unique-constraint errors to avoid race conditions so
duplicate stripe event retries do not send duplicate emails.

Comment on lines +542 to +549
if bh.EmailClient != nil {
bh.EmailClient.SendInvoiceEmail(*team.BillingEmail, email.InvoiceEmailData{
InvoiceNumber: invoice.Number,
Period: period,
HostedInvoiceURL: invoice.HostedInvoiceURL,
InvoicePDFURL: invoice.InvoicePDF,
})
c.Logger().Infof("Sent invoice email for %s to %s", invoice.Number, *team.BillingEmail)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid logging full billing email addresses (PII).

Logging full emails can violate privacy/compliance expectations. Prefer redaction or log team ID + invoice number.

Suggested change
-		c.Logger().Infof("Sent invoice email for %s to %s", invoice.Number, *team.BillingEmail)
+		c.Logger().Infof("Sent invoice email for %s to team %d", invoice.Number, team.ID)
🤖 Prompt for AI Agents
In `@backend/internal/handlers/billing.go` around lines 542 - 549, The log
statement currently emits PII by printing the full billing email; update the
logging in the invoice send block (around bh.EmailClient, SendInvoiceEmail,
team.BillingEmail and c.Logger().Infof) to avoid logging the raw email — either
redact the email (e.g., mask local part) or remove it entirely and instead log
non-PII identifiers such as team.ID and invoice.Number (e.g., "Sent invoice
email for %s to team %s"). Ensure the SendInvoiceEmail call remains unchanged
but replace the c.Logger().Infof call to only include invoice.Number and team.ID
or a redacted email string.

Comment on lines +141 to +172
const handleSaveBillingEmail = async () => {
setBillingEmailSaving(true);
try {
await updateBillingSettingsMutation.mutateAsync({
body: {
billing_email: billingEmail,
},
});
// Invalidate the billing settings query to refetch
queryClient.invalidateQueries({ queryKey: ["get", "/api/auth/billing/settings"] });
toast.success("Billing email saved");
} catch (error: unknown) {
console.error("Error saving billing email:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to save billing email";
toast.error(errorMessage);
} finally {
setBillingEmailSaving(false);
}
};

const handleDeleteBillingEmail = async () => {
setBillingEmailSaving(true);
try {
await updateBillingSettingsMutation.mutateAsync({
body: {
billing_email: "",
},
});
setBillingEmail("");
// Invalidate the billing settings query to refetch
queryClient.invalidateQueries({ queryKey: ["get", "/api/auth/billing/settings"] });
toast.success("Billing email removed");
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find useAPI implementation and related hook files
fd -e ts -e tsx 'useAPI|useQuery' web-app/src/hooks

Repository: gethopp/hopp

Length of output: 93


🏁 Script executed:

# Check the actual Subscription.tsx file to see full context
sed -n '130,180p' web-app/src/pages/Subscription.tsx

Repository: gethopp/hopp

Length of output: 1814


🏁 Script executed:

# Search for useAPI hook implementation and key construction
rg -n 'useAPI|queryKey.*billing|queryHash' web-app/src/hooks -A 5 -B 2

Repository: gethopp/hopp

Length of output: 617


🏁 Script executed:

# Search for billing settings query/hook usage
rg -n 'billing.*settings|getBillingSettings|useBillingSettings' web-app/src -A 5 -B 2

Repository: gethopp/hopp

Length of output: 6075


🏁 Script executed:

# Look at the full useQueryClients.tsx to understand query key construction
cat -n web-app/src/hooks/useQueryClients.tsx

Repository: gethopp/hopp

Length of output: 3092


🏁 Script executed:

# Search for where queryKey is constructed, especially with "get" prefix
rg -n 'queryKey.*\["get"' web-app/src -A 2 -B 2

Repository: gethopp/hopp

Length of output: 933


🏁 Script executed:

# Check how queryHash is used in openapi-react-query and if there are other invalidation patterns in the codebase
rg -n 'queryHash|invalidateQueries' web-app/src -B 3 -A 3

Repository: gethopp/hopp

Length of output: 6565


🏁 Script executed:

# Look for package.json to understand openapi-react-query version/behavior
cat web-app/package.json | grep -A 2 -B 2 'openapi'

Repository: gethopp/hopp

Length of output: 38


🏁 Script executed:

# Find and display package.json
fd -name package.json web-app -x cat

Repository: gethopp/hopp

Length of output: 286


🏁 Script executed:

# Search for how other mutations handle invalidateQueries to find the pattern
rg -n 'useMutation.*invalidateQueries|invalidateQueries.*queryHash' web-app/src -B 5 -A 5

Repository: gethopp/hopp

Length of output: 38


🏁 Script executed:

# Find package.json properly
find web-app -name package.json -exec cat {} \;

Repository: gethopp/hopp

Length of output: 2154


🏁 Script executed:

# Search for other mutation examples in the codebase
rg 'useMutation' web-app/src/pages -A 10 | head -100

Repository: gethopp/hopp

Length of output: 6713


🏁 Script executed:

# Look for how other queries with queryHash handle invalidation
rg 'invalidateQueries' web-app/src -B 5 -A 2

Repository: gethopp/hopp

Length of output: 2404


Fix cache invalidation key to match the query's queryHash.

The billing settings query uses a custom queryHash: 'billing-settings-${authToken}' (line 68), but invalidateQueries targets ["get", "/api/auth/billing/settings"]. These keys don't match—openapi-react-query caches under the queryHash value, so the invalidation silently fails and stale data persists. Either pass the matching queryHash to invalidateQueries or use the refetch callback from the query hook, as done in other components like Teammates.tsx.

🤖 Prompt for AI Agents
In `@web-app/src/pages/Subscription.tsx` around lines 141 - 172, The
invalidateQueries call in handleSaveBillingEmail and handleDeleteBillingEmail is
using ["get", "/api/auth/billing/settings"] which doesn't match the query's
custom queryHash ('billing-settings-${authToken}'), so cache invalidation
silently fails; update the invalidation to use the same queryHash (e.g.,
queryClient.invalidateQueries({ queryKey: ['billing-settings-${authToken}'] }))
or call the refetch callback returned by the billing settings query hook
instead; change the invalidateQueries calls in handleSaveBillingEmail and
handleDeleteBillingEmail (and keep updateBillingSettingsMutation usage) to
target the matching queryHash or use the existing refetch function like other
components (Teammates.tsx).

@konsalex konsalex merged commit f88aad9 into main Feb 4, 2026
16 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.

feat: add option to send invoices to admin email

1 participant