feat(api): retire prices safely and add a self-serve switch (#2637)

This commit is contained in:
Hampus
2026-09-10 17:28:16 +02:00
committed by GitHub
parent d79cd99050
commit 4a93b677af
77 changed files with 9242 additions and 2542 deletions
@@ -731,6 +731,71 @@ A scheduled change becomes visible as `billing.pending_subscription_change` in [
5 requests per minute for each authenticated user, on the `stripe:subscription:change` bucket.
## Switch subscription to the list price
<RouteHeader method="POST" path="/v1/premium/switch-to-list-price" />
Moves a subscription that still bills on a retired price onto the current list price for the same currency and [billing cycle](#billing-cycles), taking effect at the end of the current period. The request takes no body: the target price is resolved on the server, so a client cannot name the price it moves to.
The switch only ever lowers the amount charged. When the current list price is the same as, or higher than, the price the subscription bills on, the request is refused rather than applied.
Nothing is charged when the switch is scheduled. The billing date does not move, the billing cycle does not change, and no proration is invoiced or credited.
A deployment that configures no payment provider receives 400 `STRIPE_PAYMENT_NOT_AVAILABLE`, an account with no stored subscription receives 400 `STRIPE_NO_ACTIVE_SUBSCRIPTION`, and a provider failure receives 400 `STRIPE_ERROR`.
:::note[A subscription that is already cancelling is refused]
A subscription set to cancel has no next billing period, so the switch would have nothing to apply to. It is refused with `subscription_cancelling` and the pending cancellation is left untouched.
:::
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [switch result](#switch-result) | The switch was scheduled, was already scheduled, or was refused |
| 400<sup>1</sup> | [error response](/http-api/#error-response) | The payment provider is not configured, the account has no stored subscription, or the provider rejected the request |
| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists and the request returns `UNKNOWN_USER` |
<sup>1</sup> A refusal that the account can act on is reported as a 200 with `status` `ineligible`, not as an error
### Switch result
| Field | Type | Description |
| --- | --- | --- |
| status | string | `scheduled`, `already_scheduled`, or `ineligible` |
| effective_at<sup>1</sup> | ISO8601 timestamp | The end of the current period, when the switch takes effect |
| target_price_id<sup>1</sup> | string | The price ID the subscription bills on after the switch |
| target_amount_minor<sup>1</sup> | integer | The amount billed after the switch, in the minor unit of `currency` |
| current_amount_minor<sup>1</sup> | integer | The amount billed before the switch, in the minor unit of `currency` |
| currency<sup>1</sup> | string | [Display currency](#display-currencies) of both amounts |
| reason<sup>2</sup> | string | Why the switch was refused |
<sup>1</sup> Present when `status` is `scheduled` or `already_scheduled`
<sup>2</sup> Present when `status` is `ineligible`
### Refusal reasons
- `feature_unavailable`: the deployment configures no payment provider
- `no_active_subscription`: the account has no stored subscription
- `subscription_not_chargeable`: the subscription is neither active nor trialing
- `unsupported_subscription`: the subscription has no recurring primary item with a resolvable amount, currency and interval
- `no_list_price`: no list price is configured for that currency and billing cycle
- `already_on_list_price`: the subscription already bills on the current list price
- `not_a_price_decrease`: the current list price is not lower than the price the subscription bills on
- `subscription_cancelling`: the subscription is set to cancel, so it has no next billing period
- `cancellation_managed_by_schedule`: the subscription schedule ends by cancelling the subscription
- `conflicting_pending_change`: a different change is already scheduled
- `missing_period_end`: the subscription has no future period end
- `switch_in_progress`: another switch for the same account is still being applied
### Side effects
A scheduled switch becomes visible as `billing.pending_subscription_change` with `change_kind` `price` in [get premium state](#get-premium-state), and `billing.list_price_switch` reports `pending`. Every account session receives [User Update](/gateway/events/#user-update). A pending cancellation is never cleared by this route.
### Rate limit
5 requests per minute for each authenticated user, on the `stripe:subscription:change` bucket.
## Cancel pending subscription change
<RouteHeader method="POST" path="/v1/premium/cancel-pending-subscription-change" />
@@ -920,6 +920,12 @@ Default `{}`. Every price ID at once. JSON object. The individual price variable
Twenty-eight individual price variables also exist, one per product and currency: `FLUXER_STRIPE_PRICE_MONTHLY_` and `FLUXER_STRIPE_PRICE_YEARLY_` in USD, EUR, BRL, INR, PLN, and TRY, `FLUXER_STRIPE_PRICE_VISIONARY_` and `FLUXER_STRIPE_PRICE_GIFT_VISIONARY_` in USD and EUR, and `FLUXER_STRIPE_PRICE_GIFT_1_MONTH_` and `FLUXER_STRIPE_PRICE_GIFT_1_YEAR_` in the same six currencies.
#### `FLUXER_STRIPE_LEGACY_PRICES`
Default `{}`. Retired price IDs, keyed by the same slot names `FLUXER_STRIPE_PRICES` uses, each mapped to a list: `{"monthly_brl": ["price_..."]}`. JSON object. A subscription still billing on one of these keeps renewing, while the localized checkout catalog is still built from `FLUXER_STRIPE_PRICES` alone.
Repricing a slot is ordered, and the order is not reversible without failed invoices. Create the new price in Stripe, move the ID it replaces into this variable, roll the API, and only then point `FLUXER_STRIPE_PRICES` at the new price. A price ID that neither variable names is unknown to the API: a renewal invoice on it fails the webhook with `Unknown product for invoice renewal`, a checkout completing on it fails with `Unknown price ID for checkout session`, and both keep failing until the ID is registered. The API answers Stripe as soon as the signature verifies and hands the event to `worker`, so the retries are the `processStripeWebhook` job's own: the Stripe dashboard shows the delivery as succeeded and the error is in the `worker` logs. Keep a retired ID listed for as long as any subscription still bills on it, which for a yearly price is at least a year after the switch.
## Moderation and abuse
All are optional.