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
upgrade()throws (or returns a typed refusal) when a charge is open or the subscription is past due. The management page and the plan-switching API in #3629796: Let a charge-based subscriber switch plans on the native management page, upgrading now and downgrading at period end show "a payment is still being processed, try again shortly" for the open case and point at Pay now (#3630230: Add a "Pay now" action for past-due subscriptions and a "Reactivate" action for suspended ones to the management page) for the past-due case.scheduleDowngrade()stays allowed in both states, since it only takes effect at the next anchor.assertChargeBasedPlanChange()also verifies that the new plan's price currency matches the subscription's currency; the currency switcher (#3629911: Let a visitor pick a currency on the plan chooser when the offered plans are priced in more than one) is the sanctioned way to change it.- Tests: upgrade during an open charge is refused and nothing changes; upgrade while past due is refused; downgrade scheduling during both succeeds; plan change across currencies is refused. Rewrite the second half of
testUpgradeWithoutProrationSwapsPlanOnlyto assert the refusal.
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.
Issue fork subscription_manager-3630969
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
Comment #2
colanDesign 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 bothupgrade()andscheduleDowngrade()call, refuses while past due and while a cancellation is pending, andDunningTest::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, inupgradeLocked(), sincehasOpenCharge()is only race-free there; the static checks stay inassertChargeBasedPlanChange()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 aPlanPricewith amount, currency, interval and interval count. The engine today takes$new_amount_basisfrom 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 matchesgetCurrency(); the interval matchesgetBillingInterval()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_basisequals 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 smallPlanPricingservice (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
\InvalidArgumentExceptionand 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: onePlanChangeRefusedException(extending\RuntimeException) with a public$reasondrawn 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, forOPEN_CHARGEandBUSY, to "try again shortly"; forPAST_DUEit 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\InvalidArgumentExceptionand add the new refusals to it, with no reason field; cheaper, but every caller then parses messages.Tests
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.DunningTest,SubscriptionCancellationTest,CycleEngineTest::testPlanChangeCannotCrossConnectors()) assert the new class and reason;CycleConcurrencyTest's held-lock upgrade assertsBUSY.Change record and connectors
One change record: plan-change refusals throw
PlanChangeRefusedExceptionwith a reason instead of\InvalidArgumentExceptionor\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
upgradeLocked();scheduleDowngrade()unaffected by an open charge.PlanPricingservice shared by engine and chooser;PlanChooser::price()delegates.PlanChangeRefusedExceptionwith reason constants replaces\InvalidArgumentExceptionand the lock's\RuntimeExceptionfor plan changes (alternative: keep\InvalidArgumentException, no reasons).Comment #4
colanImplemented 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$reasonand nine constants: the four existing refusals,OPEN_CHARGE,CURRENCY_MISMATCH,INTERVAL_MISMATCH,AMOUNT_MISMATCHandBUSY.assertChargeBasedPlanChange()throws it in place of\InvalidArgumentException, andupgrade()'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 throwsBUSYin place of\RuntimeException. Messages are unchanged and stay untranslated: they are for logs, and the reason is the seam a page translates from.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.PlanPricingservice (decision 5),subscription_manager.plan_pricing, holding the connector lookup that lived inPlanChooser::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.assertPlanPriceFits(), called last fromassertChargeBasedPlanChange(), which gained the$new_amount_basisparameter: 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 changesbilling_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
scheduleDowngrade()'s docblock now says an open charge is no obstacle to it, since the question will come up on #3629796: Let a charge-based subscriber switch plans on the native management page, upgrading now and downgrading at period end.Tests
CycleEngineTest::testUpgradeRefusedWhileChargeIsOpen(): pending period charge open, upgrade refused withOPEN_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.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.
Comment #5
colanTwo commits on the MR are not this issue's, for the record:
Comment #12
colan