Preview meter-cycle usage on its own upcoming charge
We didn't show the meter charges separately in the customer portal, which could lead to surprise charges and showed a wrong 'Metered usage' line on the annual renewal charge as well.
Split the preview up into two separate components when relevant:
Sorry for the big diff, it's a lot of boilerplate.
Summary
Some subscriptions have a meter cycle that ends before the billing period, for example a yearly plan with monthly usage. Their usage is billed on its own order when each meter period ends. The charge preview showed that usage on the renewal invoice instead, and applied fixed discounts to it. This PR gives that usage its own preview endpoint and its own card in the customer portal and on the dashboard.
What
Backend
- New
GET /v1/subscriptions/{id}/meter-charge-previewandGET /v1/customer-portal/subscriptions/{id}/meter-charge-previewendpoints. Both returnSubscriptionMeterChargePreview(metered, subtotal, discount, net, tax and total amounts). They return 404 when usage is billed on the renewal instead. SubscriptionService.bills_usage_on_meter_cycle()decides whether usage is billed separately. It holds when the subscription is active or trialing, has a meter interval and meters, and the meter period ends before the billing period.calculate_charge_previewleaves usage out when that rule holds, sometered_amountis 0. The response has no new fields.- The rule that drops fixed discounts on meter-cycle orders moves from
_create_orderintobuild_subscription_order, so the real meter-cycle order and the preview share it. The usage preview also skips a discount that will have expired by the end of the meter period.
Frontend
- New
UpcomingUsageChargecard in the customer portal andSubscriptionUsageChargePreviewsection on the dashboard, rendered before the renewal card. They show only meters with usage above zero. CurrentPeriodOverviewandSubscriptionInvoicePreviewleave out usage that is billed separately, and are titled "Upcoming Renewal" / "Upcoming renewal". On a canceled subscription whose usage is billed separately, the empty final-charge card is hidden.billsUsageOnMeterCycle()inutils/subscription.tsrepeats the backend rule, without the meter-interval check, which the frontend doesn't receive. This lets each card decide on its own, likemeterInterval.tsalready does for another backend rule.
Why
The preview should match what the customer will actually be charged. Before this change, a yearly customer with monthly overage saw the usage on an invoice up to a year away, with a fixed discount the real meter-cycle order never applies. Adding a separate endpoint and separate components leaves the existing charge preview and its UI almost unchanged.
How
- The usage preview prices the meter total through
build_subscription_orderwithbilling_reason=subscription_meter_cycle. It does this inside a savepoint that is rolled back, using the same non-locking path as the charge preview (_build_preview_order). - The charge preview checks the rule before applying any pending update, because usage for the current meter cycle is billed before that update takes effect.
- The wallet balance is applied only on the renewal preview. The usage preview's total is before the wallet balance.
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
Tests: the backend subscription, order and customer-portal subscription test suites pass (789 tests), and mypy is clean. Frontend: vitest on the touched components and utils passes (24 tests), and tsc and oxlint --type-aware are clean.
π€ Generated with Claude Code
https://claude.ai/code/session_011dWLTYWm3uhSDvaZMTaPU4
Generated by Claude Code