Preview meter-cycle usage on its own upcoming charge

Merged
PI
12d ago
+1236-41

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:

image

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-preview and GET /v1/customer-portal/subscriptions/{id}/meter-charge-preview endpoints. Both return SubscriptionMeterChargePreview (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_preview leaves usage out when that rule holds, so metered_amount is 0. The response has no new fields.
  • The rule that drops fixed discounts on meter-cycle orders moves from _create_order into build_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 UpcomingUsageCharge card in the customer portal and SubscriptionUsageChargePreview section on the dashboard, rendered before the renewal card. They show only meters with usage above zero.
  • CurrentPeriodOverview and SubscriptionInvoicePreview leave 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() in utils/subscription.ts repeats the backend rule, without the meter-interval check, which the frontend doesn't receive. This lets each card decide on its own, like meterInterval.ts already 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_order with billing_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