Problem/Motivation
The subscribe flow's chooser (#3616769: Add a connector chooser to the subscribe flow when multiple connectors provide plans) lists connectors: a subscriber picks how to pay, and that connector's own flow then presents its plans and checkout. That was the right first step — Subscription Manager has no price model (a plan's prices is an opaque per-connector map), and the connector interface offers no way to start a checkout for a particular plan — but it shows on a multi-rail site:
- The native management page's Change plan action (#3627571: Build the membership portal page on capability interfaces, replacing the unused inline-actions API) opens
/subscription-manager/subscribe?change=1, which lists connectors, not plans. A site with three visible plans on two connectors shows two options. - A subscriber comparing plans across rails (a free plan on one connector, paid plans on another) has to enter each connector's flow to see what is on offer.
- In-page switching between plans of one connector — upgrade now, downgrade at period end, which the cycle engine already supports — was deferred in #3627571: Build the membership portal page on capability interfaces, replacing the unused inline-actions API because the page cannot tell the subscriber what the other plan costs, nor hand the choice to the connector.
Plans are what a customer buys; the connector is a property of the plan. The flow should start from plans.
Proposed resolution
- A plan chooser. The subscribe route lists the visible, enabled plans grouped by connector — name, connector label, and a price description when available — each a link that starts that connector's checkout with the plan preselected. The connector chooser's configured order, labels and descriptions carry over as the group headings. A single visible plan skips the chooser, as a single connector does today;
?connector=keeps working and narrows the list to that connector's plans;?change=1marks the subscriber's current plan. - Groups steer. Each connector group is a
<details>element (no JavaScript needed, keyboard-accessible). The default connector's group is open and carries the "Recommended" badge; every other group is collapsed, always — the extra click is deliberate, so a site can steer subscribers towards its preferred rail (bank debit over cards, say) while the collapsed groups' headings and descriptions still show that the other rails exist and what they cost. The one exception: a subscriber whose current plan is on another connector sees that group open instead, with the current plan marked "Your current plan" and not linked (a link per plan needs no submit step and no "you chose the plan you already have" handling). If a site ever needs a second group open, a per-connector "collapsed" flag on thechooser_connectorsentries is the knob to add; it is not designed in here, and a theme can force a group open by overriding the template. - Plan preselection.
SubscriptionManagerConnectorInterface::redirectToSubscribe($user, ?SubscriptionPlanEntity $plan = NULL): a connector that receives a plan starts the checkout for it (Stripe: that price; a charge-based connector:setupInstrument()for that plan); without one it behaves as today. Every connector must adopt the new signature — a breaking change, to be bundled with the beta7 catch-up. - Price descriptions. An optional
PlanPricingConnectorInterface::describePrice(SubscriptionPlanEntity $plan): ?string(or a richer value object: amount, currency, interval) so the list can say "$10.00 CAD / month". Connectors without it are listed without a price. This is also the seam in-page plan switching needs: the cycle engine'supgrade()andscheduleDowngrade()take the new amount basis, which the same interface can supply for charge-based plans. - Same-connector switching on the native page (#3627571: Build the membership portal page on capability interfaces, replacing the unused inline-actions API's deferred piece): with a price seam, the management page can list the connector's other plans and offer the upgrade (immediate, prorated) or the downgrade (at period end) directly, without leaving the site. Can be split out if the chooser lands first.
- The
api/subscribe-urlendpoint gains the same shape: connector groups carryingrecommended,currentandexpanded, each with its plans and their subscribe URLs, so a decoupled front end can mirror the steering.
Remaining tasks
- Design pass: the pricing interface's shape; how the plan is passed on the subscribe route (
?plan=with the connector's plan ID, scoped by connector); the template's markup for the groups. - Implementation with Kernel tests for the listing, the preselection hand-off and the API shape; a browser test for the page.
- Change record; a bullet for the connector catch-up issues.
- Review the MR.
User interface changes
The subscribe flow lists plans instead of connectors; the chooser template changes accordingly. Optionally, plan switching on the native management page.
API changes
redirectToSubscribe() gains an optional plan parameter (breaking for connectors). New optional PlanPricingConnectorInterface and PlanPrice. api/subscribe-url connectors[] entries gain expanded and plans[] and lose subscribe_url. Theme hook subscription_manager_connector_chooser replaced by subscription_manager_plan_chooser.
Data model changes
None.
Issue fork subscription_manager-3629242
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, checked against 1.0.x and the four connectors' current code. The summary's shape holds; this pins down the interface, the data each list row needs, the URL contract, and what to leave for later.
What the code says
getAvailableConnectorIds()(connectors with at least one enabled, shown plan),getChooserItems()(configured order, labels, descriptions, the default first), and plans withstatus,show,weightand a name. Nothing lists plans to a subscriber yet; the chooser template renders one link per connector.pricesis a free-form multi-value map written by the connector during sync; Chargebee and Shopify populate it, Recharge writes an empty list, Stripe keeps its price ID indata. There is no field the module could read an amount from.redirectToSubscribe($user)takes no plan. Stripe's flow renders a Stripe pricing table (a hosted embed where the customer picks the price) and then creates a Checkout session for aprice_id— so starting a checkout for a known plan is one call it already has. Recharge and Chargebee redirect to their own subscribe routes; Shopify to the custom subscribe URL or the user page.setupInstrument($user, $plan), which already takes the plan.Proposal
1. The plan goes into the base interface.
redirectToSubscribe($user, ?SubscriptionPlanEntity $plan = NULL): RedirectResponse. With a plan the connector starts the checkout for it; without one it behaves as today. I considered a separate capability interface instead, so un-updated connectors would keep compiling, but a connector that cannot honour the plan would still have its plans listed one by one and silently drop the choice — worse than a clear signature change. Every connector is being updated for beta7 anyway. A connector whose service has no per-plan checkout (Shopify's storefront) documents that it ignores the hint and sends the subscriber to its plan picker; the module cannot tell, so the connector's own listing is where that is said.2. Prices come from an optional interface, as a value object.
PlanPricingConnectorInterface::getPrice(SubscriptionPlanEntity $plan): ?PlanPrice, wherePlanPricecarriesamount(major units, decimal string),currency(ISO 4217),interval(one of the subscription's billing-interval values) andintervalCount, plus an optional free-textdescriptionfor anything that does not fit ("from 5.00 CAD/month plus usage"). The chooser renders "10.00 CAD / month" from the structured fields, or the description when given, and lists plans without a price when the connector lacks the interface or returns NULL. No currency formatting beyond amount and code: locale-aware money formatting is a theme concern, and the fields are all in the template. For a charge-based connector the same object is where a future in-page plan change takes the newamount_basisfrom.3. The URL contract.
/subscription-manager/subscribe?connector=ID&plan=PLAN_ID, the plan ID being the connector's remote plan ID, scoped by the connector (two connectors may both have a "pro-monthly"). Both are validated: the connector must be available and the plan enabled, shown and on that connector; anything else falls back to the chooser.?connector=alone narrows the chooser to that connector's plans (or hands over directly when it has exactly one). Theapi/subscribe-urlcandidate URLs point back at the API with the same two parameters.4. What the subscriber sees.
<details>group per available connector, in chooser order, headed by the connector's chooser label and description. Inside, its enabled and shown plans by weight, then name: name, price when known, and a link that hands over with the plan.openand badged "Recommended"; every other group is collapsed. For a subscriber whose current plan is on another connector, that group is the open one instead (the default's stays collapsed, badge kept), and the current plan is badged "Your current plan" and not linked. Nothing else about the rule is configurable; a per-connector flag is the knob to add if a site ever asks, and a theme can override the template.?change=1goes to their connector's flow with no plan, unchanged.subscription_manager_plan_chooser, with its template; the connector chooser hook and template go (their variables no longer describe the page). Thechangeandconnectorquery arguments stay in the cache contexts; the listing depends onsubscription_plan_list,subscription_listand the settings.5. The API.
api/subscribe-urlkeeps its two shapes. The multi-rail one'sconnectorsentries gainexpandedandplans, each plan withplan_id,name,price(the value object's fields, or NULL),currentandsubscribe_url;recommendedandcurrentstay on the connector entry. Choosing with?connector=&plan=yields the single-URL shape.6. In-page plan switching is split out. With the pricing interface in place, the management page can later list the connector's other plans and call
upgrade()/scheduleDowngrade()directly. It needs its own confirm forms, proration copy and tests, and nothing in this issue depends on it, so I would file it separately rather than grow this one — the "Change plan" action keeps pointing at the chooser, which after this issue does show plans.Tests
Kernel: the listing (groups in chooser order, plans by weight, hidden and disabled plans excluded, prices from a test connector implementing the pricing interface, none from one that does not), the open group (default; current plan's connector for a subscriber), the badges, the parameter validation (unknown connector, plan on another connector, hidden plan), the hand-off (the test connector records the plan it was given; none when absent), the single-plan shortcut, and both API shapes. Browser: the page as a new subscriber and as one changing plan — which group is open, the badges, and a plan link leading to the connector's redirect with the plan.
Connector impact
Breaking for every connector: adopt the new
redirectToSubscribe()signature. Stripe honours the plan by creating a Checkout session for the plan's price ID; the charge-based connectors callsetupInstrument(); Recharge, Chargebee and Shopify may ignore the plan to begin with. Optional: implementPlanPricingConnectorInterfaceto show prices. One bullet each in the catch-up issues; one change record here.Decisions to ratify:
PlanPricevalue object as described, structured fields plus an optional description;?connector=&plan=with the connector's plan ID, validated, falling back to the chooser;Comment #4
colanImplemented in the MR, in six commits, following the six decisions ratified in #3.
The pricing interface
PlanPricingConnectorInterface::getPrice(SubscriptionPlanEntity $plan): ?PlanPriceis optional.PlanPriceis a small value object: amount as a decimal string, ISO currency, interval (monthly/yearly), interval count, and an optional free-text description that the chooser shows instead of the structured fields when given ("from 5.00 CAD/month plus usage"). A connector returnsNULLfor a plan it cannot price, and that plan is listed without one. The charge-based test connector implements it from the plan's prices map.The base interface
redirectToSubscribe($user)becomesredirectToSubscribe($user, ?SubscriptionPlanEntity $plan = NULL): start the checkout for that plan when given, behave as before when not. Breaking for every connector, as agreed — PHP requires the signature — and bundled into the beta7 connector catch-up issue with the Stripe/charge-based/Shopify guidance from the design comment. The interface's doc block says a connector without per-plan checkout may ignore the plan and should document that.The chooser
A new
PlanChooserservice builds the page: one<details>per connector offering shown, enabled plans, in chooser order, with the configured label and description as its heading; the plans inside by weight then name, each with its price where known. The default connector's group isopenand marked "Recommended"; every other group is collapsed, with no small-count exception. A subscriber arriving with?change=1sees their own connector's group open instead (the "Recommended" badge stays where it was), their current plan badged "Your current plan" and unlinked. With one connector offering plans, or?connector=narrowing to one, that lone group is open.Resolution order in
subscribeTarget(): a valid?connector=ID&plan=PLAN_ID(the connector offers plans; the plan is enabled, shown and on it) hands over directly;?connector=IDalone hands over its only plan or shows the chooser narrowed to it; an active subscriber without?change=goes to their own connector's flow with no plan, as before; otherwise the chooser — or the direct hand-off when a single plan is on offer anywhere, which keeps the one-connector-one-plan site free of an extra page. An invalid pair falls back to the chooser rather than failing. Cache contexts addurl.query_args:plan.The
subscription_manager_plan_choosertheme hook andsubscription-manager-plan-chooser.html.twigreplacesubscription_manager_connector_chooserand its template, which are deleted. The connector chooser configuration keeps its meaning (order and copy of the groups, first row default); the README's wording follows.The API
api/subscribe-url's chooser shape: eachconnectors[]entry keepsid,label,description,recommended,current, gainsexpandedandplans[]—plan_id,name,price(thePlanPricefields ornull),current, and asubscribe_urlback to the API with?connector=&plan=— and loses its connector-levelsubscribe_url. The single-URL shape is unchanged and the chosen plan is passed to the connector.Tests
ConnectorChooserTestis nowPlanChooserTest(11 Kernel tests): the lone-plan hand-off, hidden and disabled plans, one connector with several plans and their price labels, groups steering towards the default, configured order and copy, the subscriber's group opening on?change=1, lapsed subscribers, every parameter combination, the API shape.PlanChooserBrowserTestrenders the page in Stark for a new subscriber and for one changing plan, checks which group is open, and follows a plan link into the connector's redirect. The test connectors now redirect off-site through aTrustedRedirectResponse, which the browser test needed and which #3621280: Add update-path and browser test coverage from a pre-roadmap database fixture established for connectors.Suite: 200 Kernel tests, 6 Functional tests; phpcs clean. Change record drafted (beta7). The in-page plan switching that decision 6 split out is filed as #3629796: Let a charge-based subscriber switch plans on the native management page, upgrading now and downgrading at period end.
Comment #5
colanTwo more commits on the MR, for a bug the hands-on check found.
Symptom: on a site with Dynamic Page Cache, visiting
/subscription-manager/subscribeas an active subscriber and then/subscription-manager/subscribe?change=1gave the connector's checkout redirect both times instead of the chooser, until the caches were cleared.Cause: a
TrustedRedirectResponseis a cacheable response. Dynamic Page Cache stores one by route and the required cache contexts (language, theme, permissions) — not the query string, and not the user — and serves it to the next request for the same route that matches those. The connector's redirect carried no cacheability of its own, so the first hand-off was stored and served to?change=1. The same would happen on the manage-subscription route of a hosted-portal connector: one user's portal session URL served to the next user with the same permissions. That part predates this issue — it has been possible since #3627571: Build the membership portal page on capability interfaces, replacing the unused inline-actions API maderedirectToPortal()return a trusted redirect — but nothing released has it, since both land in beta7.Fix: every place a connector's response is returned to the browser (subscribe, manage-subscription, payment-method) now goes through
ConnectorResponseTrait::passThrough(), which makes a plain redirect trusted, as the payment-method route already did, and sets max-age 0 on any cacheable response. Connectors need not add cacheability themselves. Kernel tests assert both for all three routes, including a connector returning a plain redirect.Suite: 201 Kernel tests, 6 Functional tests; phpcs clean.
Comment #16
colan