Problem/Motivation

The cycle engine can already change a charge-based subscription's plan (#3618739: Add a charge-based connector mode with a local billing cycle engine): CycleEngine::upgrade() applies a new plan immediately and charges the prorated difference for the rest of the period; scheduleDowngrade() records a pending plan that takes effect at the next cycle anchor, with no mid-cycle credit. Both take the new plan and its per-period base amount, and both refuse while the subscription is past due or has a pending cancellation.

Nothing lets a subscriber reach them. The native management page's Change plan action (#3627571: Build the membership portal page on capability interfaces, replacing the unused inline-actions API) links to the subscribe flow, which — after #3629242: Let subscribers choose a plan, not a connector: list plans in the subscribe flow and preselect the plan in redirectToSubscribe() — lists plans and starts a new checkout for the chosen one through setupInstrument(): a fresh instrument setup for a subscriber who already has one, and the old subscription ended by the single-active rule when the new one is saved. For a same-connector change that is the wrong tool: the subscriber should keep their subscription, their instrument and their billing history, and only the plan and amount should change, with the engine's proration or period-end timing.

This was deferred in #3627571: Build the membership portal page on capability interfaces, replacing the unused inline-actions API because the page could not tell the subscriber what another plan costs. #3629242: Let subscribers choose a plan, not a connector: list plans in the subscribe flow and preselect the plan in redirectToSubscribe()'s PlanPricingConnectorInterface answers that for connectors that implement it.

Proposed resolution

  • A plan list on the management page, for an active charge-based subscription whose connector implements PlanPricingConnectorInterface: the connector's other enabled, shown, charge-based plans, each with its price, the current plan marked. Plans whose price the connector cannot give are left out — the page must not offer a change it cannot describe. Plans on other connectors are not listed here; they remain a new checkout through the subscribe flow, which the Change plan link still offers.
  • Two confirm forms. Choosing a plan with a higher base amount leads to an Upgrade confirmation stating the new amount, the prorated charge for the rest of the current period (computed the way upgrade() will compute it, so the number shown is the number charged) and that it is charged now; confirming calls upgrade() and reports the charge's outcome (immediate success, pending on an asynchronous rail, or failure — which puts the subscription into dunning as any failed charge does). Choosing a lower amount leads to a Downgrade confirmation stating the new amount and the date it takes effect (the cycle anchor), and that the current period stays as paid; confirming calls scheduleDowngrade(). The same amount on a different plan is treated as a downgrade (no charge, effective at the anchor).
  • A pending downgrade is shown and can be withdrawn: the page says "Changes to Plan on date", with a Keep current plan confirmation that clears pending_plan_id and pending_amount_basis. Choosing another plan while one is pending replaces it.
  • Guards mirror the engine's: no plan list while past due or while a cancellation is pending (the page already says why in those states), and the forms re-check at submit time.
  • Routes under /user/{user}/manage-subscription/plan/{plan} with the manage route's access check; the usual {user} upcasting; the plan resolved by its connector-scoped plan ID. Events: PlanChangeEvents::UPGRADED, DOWNGRADE_SCHEDULED, DOWNGRADE_WITHDRAWN, so a site can confirm by mail.
  • The api/my-subscription endpoint gains the pending plan (pending_plan_id, effective date) so a decoupled front end can show the same.

Remaining tasks

  • Design pass: how the proration preview is obtained without duplicating the engine's arithmetic (a CycleEngine::previewUpgrade() returning the prorated minor amount is the obvious shape); copy for the three confirmations; whether a plan change with a zero prorated difference still confirms.
  • Implementation with Kernel tests (the list and its exclusions, upgrade and downgrade through the forms, withdrawal, the guards, the API field) and a browser test for the page.
  • Change record (additive: new routes, events, API field; no connector impact).

User interface changes

The native management page lists the connector's other plans with prices and offers upgrade, downgrade and withdrawal confirmations; a pending downgrade is shown.

API changes

New routes and events; api/my-subscription gains the pending plan; possibly CycleEngine::previewUpgrade(). No connector interface changes.

Data model changes

None: pending_plan_id and pending_amount_basis already exist.

Comments

colan created an issue.

colan’s picture

Scope note from #3630969: Refuse an upgrade while a period charge is open or the subscription is past due, and validate the currency on plan change, so it is not discovered at build time.

That issue validates a plan change against the connector's price for the new plan, and one of the checks is that the price's billing interval matches the subscription's. The reason is mechanical: upgrade() and scheduleDowngrade() swap the plan and the amount and never touch billing_interval, so without the check a monthly subscriber moving to a yearly-priced plan would have been billed the yearly amount every month. The change is refused with PlanChangeRefusedException::INTERVAL_MISMATCH.

The consequence for this page: "switch to annual and save" is not a plan change but an interval change, and the engine has no operation for it yet. Offering it here needs a new engine method and two decisions: whether the switch takes effect now or at the next anchor, and what happens to the remainder of the current paid period (credited against the first yearly charge, or forfeited with the yearly cycle starting at the next anchor, which is the simpler and probably the fairer default since the customer keeps what they paid for). Suggested handling: either scope this page to same-interval switches and file the interval change as its own issue, or add the interval change to this issue's design pass. Not blocking beta7 either way.