Problem/Motivation

CycleEngine::upgrade() has no hasOpenCharge() or isPastDue() check, and it prorates only when the cycle anchor is in the future. During the settlement window the anchor is at or before now (the period charge was submitted but has not resolved, so the anchor has not advanced), so an upgrade requested then computes a proration of zero and simply swaps the plan and amount basis. The customer gets the higher plan's roles at once, pays the old price for the period that is settling, and is first billed at the new rate a full period later. The same holds while past due: the failed charge is retried at the old amount while the plan has already changed. The coverage round's own test testUpgradeWithoutProrationSwapsPlanOnly encodes that path as the current behaviour.

Separately, neither upgrade() nor scheduleDowngrade() checks that the new plan's price is in the subscription's currency; assertChargeBasedPlanChange() verifies the connector and the scheduling mode only. A plan change to a plan priced in another currency produces a charge in the subscription's currency for the other plan's number.

Proposed resolution

Remaining tasks

  • Design pass: exception class versus a result object for the refusal, and whether an immediate upgrade while past due should instead be allowed and recomputed once the retry succeeds (recommended: refuse).
  • Implementation, tests, change record.

User interface changes

None in this module; #3629796: Let a charge-based subscriber switch plans on the native management page, upgrading now and downgrading at period end surfaces the refusal.

API changes

CycleEngine::upgrade() refuses in two new states; plan changes validate currency.

Data model changes

None.

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

colan created an issue. See original summary.

colan’s picture

Design pass against 1.0.x at 95bbc85, after #3630967: Lock the cycle per subscription, stop re-enqueuing past-due subscriptions, and refuse a pending charge result without a remote id landed. One correction to the summary first: the past-due half is already in place. assertChargeBasedPlanChange(), which both upgrade() and scheduleDowngrade() call, refuses while past due and while a cancellation is pending, and DunningTest::testPlanChangesBlockedWhilePastDue() pins it for both methods. So this issue reduces to the open-charge gap, the price validation, and the shape of the refusal. Notes, then decisions.

The open-charge gap

Confirmed as described: nothing in upgrade() looks at open charges, and the proration is zero whenever the anchor is at or before now, which is exactly the state during the settlement window (the anchor advances only when the charge resolves). The check belongs inside the lock, in upgradeLocked(), since hasOpenCharge() is only race-free there; the static checks stay in assertChargeBasedPlanChange() ahead of the lock. scheduleDowngrade() is unaffected by an open charge: it only writes the pending plan, applied at the next anchor after the charge resolves.

One adjacent path stays as it is: an anchor in the past with no open charge (cron has not run yet, or skipped the row) still swaps the plan with zero proration. The period starting at that anchor has not been billed, so the next cycle step bills the new price for the new period; the customer gets the higher plan free for the minutes until cron runs, not for a period. Not worth a refusal; noted so the existing testUpgradeWithoutProrationSwapsPlanOnly() keeps that half.

Price validation

Plans carry no currency of their own; a plan's price comes from its connector through PlanPricingConnectorInterface::getPrice(), returning a PlanPrice with amount, currency, interval and interval count. The engine today takes $new_amount_basis from the caller and never consults the price. So when the connector prices the plan, the engine can check three things against the subscription: the currency matches getCurrency(); the interval matches getBillingInterval() with an interval count of one, since a subscription cannot represent a quarterly period and would otherwise bill the quarterly amount monthly; and the caller's $new_amount_basis equals the price's amount, so a stale or hand-rolled amount is refused rather than billed. When the connector does not price plans, none of this is knowable and the caller's basis is trusted, as now; the docblock says so. The currency switcher (#3629911: Let a visitor pick a currency on the plan chooser when the offered plans are priced in more than one) remains the sanctioned way to change currency.

PlanChooser::price() already resolves the connector and asks it, but the chooser is a UI service the engine should not depend on. Proposed: a small PlanPricing service (subscription_manager.plan_pricing) holding that logic, used by the engine and the chooser; PlanChooser::price() stays and delegates, so the controller and #3629966: Return JSON as JSON, replace the raw prices map in api/my-subscription with a PlanPrice, and burn post-purchase tokens atomically's API are untouched.

The shape of the refusal

Today the four static refusals throw \InvalidArgumentException and the lock contention from #3630967: Lock the cycle per subscription, stop re-enqueuing past-due subscriptions, and refuse a pending charge result without a remote id throws \RuntimeException. Neither is an invalid argument or a runtime fault: they are states the caller must present to a person. Proposed: one PlanChangeRefusedException (extending \RuntimeException) with a public $reason drawn from class constants: PAST_DUE, PENDING_CANCELLATION, CROSS_CONNECTOR, NOT_CHARGE_BASED, OPEN_CHARGE, CURRENCY_MISMATCH, INTERVAL_MISMATCH, AMOUNT_MISMATCH, BUSY. The messages stay as they are. #3629796: Let a charge-based subscriber switch plans on the native management page, upgrading now and downgrading at period end's page maps reasons to wording and, for OPEN_CHARGE and BUSY, to "try again shortly"; for PAST_DUE it points at Pay now (#3630230: Add a "Pay now" action for past-due subscriptions and a "Reactivate" action for suspended ones to the management page). This replaces the exception class connectors or sites may be catching, hence beta7. The alternative is to keep \InvalidArgumentException and add the new refusals to it, with no reason field; cheaper, but every caller then parses messages.

Tests

  • Upgrade during an open charge: pending behaviour on the test connector, cycle step opens the charge, upgrade throws OPEN_CHARGE, plan and basis unchanged, one charge record, one connector call; scheduling a downgrade in the same state succeeds and applies after the charge resolves and the anchor arrives.
  • Price validation, with the test connector given a configurable price through state: wrong currency, wrong interval, interval count above one, and a basis that does not match the amount are each refused with their reason; a matching price passes; a connector without pricing passes with the caller's basis.
  • Reasons: the four existing refusal tests (DunningTest, SubscriptionCancellationTest, CycleEngineTest::testPlanChangeCannotCrossConnectors()) assert the new class and reason; CycleConcurrencyTest's held-lock upgrade asserts BUSY.

Change record and connectors

One change record: plan-change refusals throw PlanChangeRefusedException with a reason instead of \InvalidArgumentException or \RuntimeException; upgrade() refuses during an open charge; when the connector prices the plan, currency, interval and amount are validated. No connector interface changes; connectors that price plans gain validation for free.

Decisions to ratify

  1. Open-charge refusal inside the lock in upgradeLocked(); scheduleDowngrade() unaffected by an open charge.
  2. Past-due behaviour kept as it already is: both upgrade and downgrade scheduling refused (the summary assumed downgrades were allowed; the existing test says otherwise, and allowing them adds a path with no benefit while the retry still charges the old amount).
  3. An anchor in the past with no open charge keeps swapping at zero proration; the existing test keeps that half.
  4. Price validation when the connector prices the plan: currency, interval with count one, and the caller's basis against the amount; trust the caller otherwise.
  5. New PlanPricing service shared by engine and chooser; PlanChooser::price() delegates.
  6. PlanChangeRefusedException with reason constants replaces \InvalidArgumentException and the lock's \RuntimeException for plan changes (alternative: keep \InvalidArgumentException, no reasons).
  7. Tests as listed; one change record; no connector catch-up item.

colan’s picture

Status: Active » Needs review

Implemented on the MR in five commits per the ratified decisions: the exception, the open-charge refusal, the price validation, tests, docs. No deviations; two small additions noted below.

What changed

  • PlanChangeRefusedException (decision 6), extending \RuntimeException, with a public $reason and nine constants: the four existing refusals, OPEN_CHARGE, CURRENCY_MISMATCH, INTERVAL_MISMATCH, AMOUNT_MISMATCH and BUSY. assertChargeBasedPlanChange() throws it in place of \InvalidArgumentException, and upgrade()'s lock contention from #3630967: Lock the cycle per subscription, stop re-enqueuing past-due subscriptions, and refuse a pending charge result without a remote id now throws BUSY in place of \RuntimeException. Messages are unchanged and stay untranslated: they are for logs, and the reason is the seam a page translates from.
  • Open-charge refusal (decision 1) at the top of upgradeLocked(), under the lock, so the answer cannot change before the swap. scheduleDowngrade() is untouched. Past-due handling (decision 2) and the zero-proration swap with no open charge (decision 3) are as they were.
  • PlanPricing service (decision 5), subscription_manager.plan_pricing, holding the connector lookup that lived in PlanChooser::price(); the chooser now delegates and no longer needs the connector manager at all. The controller's API and #3629966: Return JSON as JSON, replace the raw prices map in api/my-subscription with a PlanPrice, and burn post-purchase tokens atomically's shape are untouched.
  • Price validation (decision 4) in a new assertPlanPriceFits(), called last from assertChargeBasedPlanChange(), which gained the $new_amount_basis parameter: currency compared case-insensitively, interval equal to the subscription's with an interval count of one, amount compared in minor units. A connector without pricing, or a plan it cannot price, is trusted as before. Note that the interval check also refuses a monthly subscriber moving to a yearly-priced plan: a plan change never changes billing_interval, so without the check the yearly amount would bill monthly. Switching to annual is an interval change, not a plan change, and needs its own engine path; noted on #3629796: Let a charge-based subscriber switch plans on the native management page, upgrading now and downgrading at period end.

Additions

Tests

  • CycleEngineTest::testUpgradeRefusedWhileChargeIsOpen(): pending period charge open, upgrade refused with OPEN_CHARGE, plan and basis unchanged, one record, one connector call, no premium role; a downgrade schedules in the same state; once the charge resolves the upgrade goes through, prorated.
  • CycleEngineTest::testPlanChangeValidatedAgainstPrice(): four priced plans (USD, yearly, quarterly, wrong amount) each refused by both methods with the right reason, nothing applied, no connector call; a matching lowercase-currency price passes; an unpriced plan accepts the caller's amount.
  • The four existing refusal tests and CycleConcurrencyTest's held-lock upgrade now assert the class and the reason.

Kernel suite: 234 tests green, the one pre-existing deprecation; Functional suite green (6 tests) in the clean codebase.

Change record

Drafted alongside: the exception and its reasons with a before/after for callers, the open-charge refusal, the price validation and the new service. No connector interface changes, so no catch-up item.

colan’s picture

Two commits on the MR are not this issue's, for the record:

  • colan committed ab9aedc5 on 1.0.x
    Merge branch '3630969-plan-change-open-charge' into '1.0.x'
    
    Resolve #...

  • colan committed 6996c312 on 1.0.x
    Issue #3630969: Document the plan-change refusals and price validation...

  • colan committed 68bf4731 on 1.0.x
    Issue #3630969: Test the open-charge refusal and the price validation...

  • colan committed 95b54715 on 1.0.x
    Issue #3630969: Validate a plan change against the plan's list price...

  • colan committed b5de78d0 on 1.0.x
    Issue #3630969: Refuse an upgrade while a period charge is still...

  • colan committed 22766fe9 on 1.0.x
    Issue #3630969: Refuse plan changes with a typed, reasoned exception...
colan’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.