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:

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=1 marks 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 the chooser_connectors entries 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's upgrade() and scheduleDowngrade() 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-url endpoint gains the same shape: connector groups carrying recommended, current and expanded, 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.

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, 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

  • The chooser's inputs already exist: getAvailableConnectorIds() (connectors with at least one enabled, shown plan), getChooserItems() (configured order, labels, descriptions, the default first), and plans with status, show, weight and a name. Nothing lists plans to a subscriber yet; the chooser template renders one link per connector.
  • A plan's prices is 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 in data. 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 a price_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.
  • For a charge-based connector, "start a checkout for this plan" is exactly 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, where PlanPrice carries amount (major units, decimal string), currency (ISO 4217), interval (one of the subscription's billing-interval values) and intervalCount, plus an optional free-text description for 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 new amount_basis from.

3. The URL contract. /subscription-manager/subscribe?connector=ID&amp;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). The api/subscribe-url candidate URLs point back at the API with the same two parameters.

4. What the subscriber sees.

  • One <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.
  • The default connector's group is open and 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.
  • Shortcuts, as today: exactly one shown plan on the whole site hands over to it directly; an active subscriber without ?change=1 goes to their connector's flow with no plan, unchanged.
  • A new theme hook, subscription_manager_plan_chooser, with its template; the connector chooser hook and template go (their variables no longer describe the page). The change and connector query arguments stay in the cache contexts; the listing depends on subscription_plan_list, subscription_list and the settings.

5. The API. api/subscribe-url keeps its two shapes. The multi-rail one's connectors entries gain expanded and plans, each plan with plan_id, name, price (the value object's fields, or NULL), current and subscribe_url; recommended and current stay on the connector entry. Choosing with ?connector=&amp;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 call setupInstrument(); Recharge, Chargebee and Shopify may ignore the plan to begin with. Optional: implement PlanPricingConnectorInterface to show prices. One bullet each in the catch-up issues; one change record here.

Decisions to ratify:

  1. the plan parameter on the base interface rather than a capability interface;
  2. the PlanPrice value object as described, structured fields plus an optional description;
  3. ?connector=&amp;plan= with the connector's plan ID, validated, falling back to the chooser;
  4. the display rules in 4, including the new theme hook replacing the connector chooser's;
  5. the API shape in 5;
  6. in-page switching split into its own issue.

colan’s picture

Issue summary: View changes
Status: Active » Needs review

Implemented in the MR, in six commits, following the six decisions ratified in #3.

The pricing interface

PlanPricingConnectorInterface::getPrice(SubscriptionPlanEntity $plan): ?PlanPrice is optional. PlanPrice is 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 returns NULL for 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) becomes redirectToSubscribe($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 PlanChooser service 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 is open and marked "Recommended"; every other group is collapsed, with no small-count exception. A subscriber arriving with ?change=1 sees 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&amp;plan=PLAN_ID (the connector offers plans; the plan is enabled, shown and on it) hands over directly; ?connector=ID alone 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 add url.query_args:plan.

The subscription_manager_plan_chooser theme hook and subscription-manager-plan-chooser.html.twig replace subscription_manager_connector_chooser and 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: each connectors[] entry keeps id, label, description, recommended, current, gains expanded and plans[] — plan_id, name, price (the PlanPrice fields or null), current, and a subscribe_url back to the API with ?connector=&amp;plan= — and loses its connector-level subscribe_url. The single-URL shape is unchanged and the chosen plan is passed to the connector.

Tests

ConnectorChooserTest is now PlanChooserTest (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. PlanChooserBrowserTest renders 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 a TrustedRedirectResponse, 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.

colan’s picture

Two more commits on the MR, for a bug the hands-on check found.

Symptom: on a site with Dynamic Page Cache, visiting /subscription-manager/subscribe as an active subscriber and then /subscription-manager/subscribe?change=1 gave the connector's checkout redirect both times instead of the chooser, until the caches were cleared.

Cause: a TrustedRedirectResponse is 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 made redirectToPortal() 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.

  • colan committed e08fff58 on 1.0.x
    Merge branch '3629242-plan-chooser' into '1.0.x'
    
    Resolve #3629242 "Let...

  • colan committed 46fd4e4d on 1.0.x
    Issue #3629242: Tests that connector redirects are trusted and...

  • colan committed fd69d535 on 1.0.x
    Issue #3629242: Never let Dynamic Page Cache keep a connector's redirect...

  • colan committed 2e158732 on 1.0.x
    Issue #3629242: Set the chooser's badges in parentheses and the price...

  • colan committed 74104942 on 1.0.x
    Issue #3629242: Browser test for the plan chooser page
    
    The rendered...

  • colan committed 7737cfea on 1.0.x
    Issue #3629242: Kernel tests for the plan chooser
    
    ConnectorChooserTest...

  • colan committed b01159c7 on 1.0.x
    Issue #3629242: Documented the plan chooser and the pricing interface...

  • colan committed e976808a on 1.0.x
    Issue #3629242: Replaced the connector chooser with a plan chooser
    
    The...

  • colan committed f6184a95 on 1.0.x
    Issue #3629242: Let redirectToSubscribe() take the chosen plan
    
    The...

  • colan committed c7276d61 on 1.0.x
    Issue #3629242: Added PlanPrice and the optional...
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.