Dedicated usage invoice email, and show meter credits on usage lines
Send a separate subscription_usage_invoice email when metered usage is billed outside of a subscription cycle.
Summary
Related Issue: N/A
Meter-cycle (usage) orders now send customers their own "usage invoice" email instead of reusing the subscription_cycled renewal email. Usage periods fully covered by meter credits no longer send a $0.00 email, and metered usage lines now show credited units.
What
Usage invoice email
- New email template
subscription_usage_invoice(server/emails/src/emails/subscription_usage_invoice.tsx), plusSubscriptionUsageInvoiceProps/SubscriptionUsageInvoiceEmailinserver/polar/email/schemas.py. Regeneratedserver/emails/src/types/openapi.ts. send_confirmation_emailuses the new template forsubscription_meter_cycleorders.- The email stays under the existing
subscription_cycledcustomer email setting, the same waysubscription_final_invoicealready does. The dashboard description for that setting now reads "Sent when a subscription renews or is invoiced for usage".
No email for fully-credited periods
send_confirmation_emailskips meter-cycle orders whosesubtotal_amountis 0. The subtotal is the sum of the usage lines after meter credits but before discounts, so only credits trigger the skip. The order is still created. A usage period that a 100% discount reduces to $0, or that the customer balance pays for, still gets the email.
Credited units on usage lines
get_amount_and_labelon metered prices (server/polar/models/product_price.py) now takes consumed and credited units separately. It used to receive consumed minus credited, so a customer who used 42 units with 30 credited saw "(12 consumed units)". The label now reads "(42 consumed units - 30 credited units) × $0.25"; tiered prices read "(500 consumed units - 100 credited units, volume pricing)". The label is stored on the order item, so the PDF invoice, customer portal and API show the new wording for new orders; existing orders keep their labels.- Credits are now subtracted in
Decimal, not floating point. The float subtraction, which this PR moved from the billing entry service into the price model, could land just under a rounding threshold: 100.1 units minus 100 credits at 5¢ per unit billed 0¢ instead of 1¢. Apart from that rounding edge case, amounts are unchanged.
Tests
- Meter-cycle orders send
SubscriptionUsageInvoiceEmail; turning offsubscription_cycledsuppresses it; a $0 subtotal sends nothing; a fully discounted usage order still sends. - Label and amount with credits, for both unit and tiered metered prices, and the 100.1 − 100 rounding case.
Screenshots
Neither screenshot uses preview data. A yearly "API Pro" subscription with a monthly meter interval recorded 42 usage events at $0.25 per unit. order_service.settle_meter_cycle_order created the order, send_confirmation_email built the email, and the production renderer binary (render_email_template) produced the HTML. The only test values are the 20% tax (the test suite's mocked tax service) and the invoice number prefix (the test organization's).
No credits:
30 units credited:
Why
- The subject already said "usage invoice", but the body said "Your subscription renewed" / "subscription has just renewed". Customers on yearly plans with monthly usage billing got a "renewed" email every month.
- A customer whose meter credits covered all their usage got a $0.00 "usage invoice" email every period.
- Credits were invisible on the invoice, and the usage it did show was lower than what the customer actually used.
How
The template follows subscription_final_invoice: the same attachment, receipt link, "Manage subscription" button and order summary, with usage-specific copy. Unlike the renewal emails, it does not show the "Billing has moved to Polar" migration notice. The template's preview props are a metered usage order, so the react-email dev preview matches the real email. Merchants are unaffected: they get no notification for usage invoices, either before or after this change.
Checklist
- This PR addresses a single concern (one bug fix, one feature, one refactor)
- The diff is reasonably sized and easy to review
- New functionality is covered by tests
- Linting and type checking pass (
uv run task lint && uv run task lint_types) - No unrelated changes or drive-by fixes are included
The screenshots are hosted at commits on this branch; the commit after each deletes it, so the PR diff doesn't include them.
🤖 Generated with Claude Code