Problem/Motivation
#3630969: Refuse an upgrade while a period charge is open or the subscription is past due, and validate the currency on plan change made upgrade() and scheduleDowngrade() validate the new plan's price against the subscription: currency, billing interval, and the amount offered. The validation runs only when PlanPricingConnectorInterface::getPrice() returns a price, and the interface tells connectors to return NULL for "a plan with several prices". So for a plan sold both monthly and yearly, the common shape, none of the checks fire and the engine trusts the caller's amount exactly as before: a yearly amount passed for a monthly subscriber is still billed monthly, and a price in another currency is not noticed. The test connector models this too, by returning only the plan's first price.
The page in #3629796: Let a charge-based subscriber switch plans on the native management page, upgrading now and downgrading at period end will pick a specific price, but the engine is the guard on money movement and should refuse an amount that is not one of the plan's prices in the subscription's currency and interval, whoever the caller is.
getPrice() was added by #3629242: Let subscribers choose a plan, not a connector: list plans in the subscribe flow and preselect the plan in redirectToSubscribe() after 1.0.0-beta6 and is in no tagged release; its only callers are the module's own pricing service and the test connector. "The single price" is derivable from a list, so a second method would only give connectors a consistency obligation nothing enforces. A headline or default price among several, if #3629911: Let a visitor pick a currency on the plan chooser when the offered plans are priced in more than one wants one, is a property of a price (list order, or a flag on PlanPrice), not a second lookup.
Proposed resolution
- Replace
getPrice(SubscriptionPlanEntity $plan): ?PlanPricewithgetPrices(SubscriptionPlanEntity $plan): arrayonPlanPricingConnectorInterface, returning everyPlanPricethe connector knows for the plan, empty when it cannot price it. PlanPricing::prices()returns that list and replacesPlanPricing::price(). The chooser andapi/my-subscriptioneach show a price only when the plan has exactly one, a one-line check at the point of use, so their shape is unchanged until #3629911: Let a visitor pick a currency on the plan chooser when the offered plans are priced in more than one decides how to show several; the two consumers will want different things then, so no shared helper.PlanChooser::price(), made public by #3629966: Return JSON as JSON, replace the raw prices map in api/my-subscription with a PlanPrice, and burn post-purchase tokens atomically only so the controller could reach the lookup, is removed; the controller injectsPlanPricing.- In the engine's price check, select from
prices()the one whose currency matches the subscription's (case-insensitively) and whose interval matches its billing interval with a count of one. No price in that currency:CURRENCY_MISMATCH. A price in the currency but none for the interval:INTERVAL_MISMATCH. One found: the amount offered must be its amount, elseAMOUNT_MISMATCH. An empty list keeps the current behaviour, trusting the caller, for connectors that genuinely cannot price. - Test connector:
getPrices()returns onePlanPriceper row of the plan'spricesfield. - Tests: a plan priced monthly and yearly in CAD accepts the monthly amount for a monthly subscriber and the yearly amount for a yearly one; the yearly amount for a monthly subscriber is
INTERVAL_MISMATCH; a plan priced only in USD isCURRENCY_MISMATCHfor a CAD subscriber; a wrong amount against the matching price isAMOUNT_MISMATCH; a plan with no prices is trusted; the chooser andapi/my-subscriptionshow a price only for a single-price plan. The existing single-price tests keep passing.
Remaining tasks
- Design pass: confirm the inline single-price rule for the chooser and the API.
- Implementation, tests, change record, edits to the #3629242: Let subscribers choose a plan, not a connector: list plans in the subscribe flow and preselect the plan in redirectToSubscribe() change record (
getPrice()togetPrices()) and the #3629966: Return JSON as JSON, replace the raw prices map in api/my-subscription with a PlanPrice, and burn post-purchase tokens atomically change record (PlanChooser::price()removed), connector catch-up line.
User interface changes
None.
API changes
PlanPricingConnectorInterface::getPrice() removed, getPrices() added (breaking for connectors that price plans; none released). PlanPricing::price() replaced by prices(); PlanChooser::price() removed (neither released).
Data model changes
None.
Comments