Summary
Three changes to how the cycle engine handles a plan change, all before any plan-switching UI exists (#3629796: Let a charge-based subscriber switch plans on the native management page, upgrading now and downgrading at period end builds on them).
A typed refusal with a reason
CycleEngine::upgrade() and scheduleDowngrade() now throw \Drupal\subscription_manager\Exception\PlanChangeRefusedException wherever they previously threw \InvalidArgumentException, and upgrade() throws it where #3630967: Lock the cycle per subscription, stop re-enqueuing past-due subscriptions, and refuse a pending charge result without a remote id briefly threw \RuntimeException for lock contention. The exception extends \RuntimeException and carries a public $reason, one of its constants:
PAST_DUE,PENDING_CANCELLATION,CROSS_CONNECTOR,NOT_CHARGE_BASED: the four existing refusals, messages unchanged.OPEN_CHARGE: a period charge is still settling (new, below).CURRENCY_MISMATCH,INTERVAL_MISMATCH,AMOUNT_MISMATCH: the plan's price does not fit (new, below).BUSY: another process held the subscription's billing lock for longer than the interactive wait.
Map the reason to wording and a next step; do not parse the message, which is for logs and stays untranslated.
// Before. try { $engine->upgrade($subscription, $plan, $amount); } catch (\InvalidArgumentException $e) { $this->messenger()->addError($e->getMessage()); } // After. try { $engine->upgrade($subscription, $plan, $amount); } catch (PlanChangeRefusedException $e) { $this->messenger()->addError(match ($e->reason) { PlanChangeRefusedException::OPEN_CHARGE, PlanChangeRefusedException::BUSY => $this->t('A payment is still being processed; please try again in a few minutes.'), PlanChangeRefusedException::PAST_DUE => $this->t('Please update your payment method first.'), default => $this->t('That plan change is not available.'), }); }
No upgrade while a period charge is open
During the settlement window of an asynchronous charge the cycle anchor has not advanced, so an upgrade computed a proration of zero and swapped the plan free while the old price settled. upgrade() now refuses with OPEN_CHARGE until the charge resolves; the check runs under the per-subscription lock. scheduleDowngrade() is unaffected, since it only writes the pending plan for the next anchor.
Validation against the plan's price
Plans carry no price of their own; the connector that owns a plan describes it through PlanPricingConnectorInterface::getPrice(). When it does, both methods now require that the price's currency match the subscription's, that its interval match the subscription's billing interval with an interval count of one (a subscription cannot bill a quarterly period), and that the $new_amount_basis offered equal the price's amount. A connector that does not price plans, or returns NULL for the plan, leaves the caller's amount trusted as before. The interval rule means a plan change cannot move a monthly subscriber to a yearly-priced plan: a plan change never changes the billing interval, so that would have billed the yearly amount monthly. Switching between monthly and yearly billing is a separate operation the engine does not yet offer (#3629796: Let a charge-based subscriber switch plans on the native management page, upgrading now and downgrading at period end). The lookup lives in a new subscription_manager.plan_pricing service (PlanPricing::price()); PlanChooser::price() delegates to it and keeps working.
Who is affected
Code calling upgrade() or scheduleDowngrade() and catching \InvalidArgumentException or \RuntimeException: catch PlanChangeRefusedException and switch on $reason. Code passing an amount that is not the connector's price for the plan now gets AMOUNT_MISMATCH. Connectors need no change.