Problem/Motivation
Subscription Manager's data model already supports multiple connectors running simultaneously: both subscription and subscription_plan entities carry a required connector_plugin_id field, SubscriptionManagerService::syncPlans() iterates all installed connector definitions, and the post-purchase routes (manageSubscription(), getPortalUrl()) dispatch on the subscription's stored connector.
The acquisition path does not. In SubscriptionManagerController::subscribe() and getSubscribeUrl(), a user with no existing subscription is sent unconditionally to the connector named in the default_connector setting. A site offering, for example, card payments through one connector and bank debit through another cannot let the visitor pick a payment rail: the second connector's plans are unreachable from the subscribe flow.
Steps to reproduce
- Install two connector modules and configure plans for each (each plan stores its own
connector_plugin_id). - Set
default_connectorto connector A. - As a user with no subscription, visit
/subscription-manager/subscribe. - You are redirected to connector A's checkout. There is no path to subscribe via connector B.
Proposed resolution
- When exactly one connector provides visible plans, keep the current behavior (direct redirect); no UI change for existing single-connector sites.
- When more than one connector provides visible plans, render a chooser step before delegating to a connector's
redirectToSubscribe(). The chooser lists the available connectors using the plugin annotation'stitleanddescription, so a site can frame the choice as a payment-method decision (for example "Pay by card" versus "Pay by bank debit"). - Apply the same logic to the
getSubscribeUrl()JSON endpoint: when multiple connectors are available and the user has no subscription, return the list of candidate connectors (id, title, description, subscribe URL each) instead of a single URL, so decoupled front ends can render their own chooser. - Users who already hold a subscription keep the existing behavior: they are routed to their subscription's stored connector.
Remaining tasks
- Agree on the chooser UX (render array on the subscribe route versus a separate route).
- Decide whether an
enabled_connectorsallow-list setting is needed, or whether "connector has at least one plan withshow = TRUE" is a sufficient availability test. - Patch, kernel tests (single-connector passthrough, multi-connector chooser, existing-subscriber bypass), and a change record.
User interface changes
A new chooser page appears in the subscribe flow, only on sites with plans from more than one installed connector.
API changes
/subscription-manager/api/subscribe-url gains a multi-connector response shape (additive; single-connector sites see no change).
Data model changes
None. Possibly one new setting (enabled_connectors) in subscription_manager.settings, pending the discussion above.
Issue fork subscription_manager-3616769
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
colanComment #3
colanFeature request from a planned deployment running bank debit and cards side by side: sites that deliberately steer users toward one rail (cheaper fees, lower failure rates) need control over how the chooser presents connectors. Two additions to the proposed resolution:
enabled_connectorsallow-list from the remaining tasks is adopted as a sequence, ordering falls out of it naturally; otherwise a weight per connector on the chooser.The multi-connector response shape on
/subscription-manager/api/subscribe-urlwould carry the same information: candidates in configured order plus a default/recommended flag, so decoupled front ends can reproduce the steering.Worth having upstream rather than leaving to site-side theming because the choice is a commercial surface, not a cosmetic one: plans (and therefore prices) differ per connector, so which rail is listed first and preselected directly shapes which price book most subscribers land in.
Comment #4
colanDesign for this, resolving the open questions in the summary and folding in the steering additions from #3:
Availability test: visible plans, no
enabled_connectorsallow-listA connector is available when it has at least one enabled plan with
show = TRUE. An allow-list setting would duplicate module-install state and drift from it; uninstalling a connector module or hiding its plans already expresses the site's intent. The chooser engages only when two or more connectors are available: with zero or one, the existingresolveConnector()path runs untouched, so single-connector sites — including fresh installs that have not synced plans yet — see no behavior change at all.Chooser UX: same route, render array, theme hook
subscribe()keeps redirecting in every current case (existing subscriber → stored connector; zero or one available → legacy path). Only in the multi-available, no-subscription case does it return a render array instead: a new theme hook with a Twig template listing each available connector as a link. Each link targets the same subscribe route with?connector=ID; the controller honors that parameter — validated against the available set, ignored otherwise, and only for users without a subscription — and delegates to that connector'sredirectToSubscribe(). No separate route, no form ceremony, and the render array is kernel-testable.Steering: ordering, copy overrides, and a recommended flag
One new settings key,
chooser_connectors: a sequence of mappings, one per connector:id: the connector plugin ID.labelanddescription: optional site overrides for the chooser copy. Empty or missing falls back to the plugin definition'stitle/description, so unconfigured sites get sensible defaults. Schema typeslabel/textkeep both translatable. This is what lets a site write its own steering copy ("Pay by bank debit — costs less and fails far less often than cards") rather than being limited to whatever the connector author put in the plugin definition.Sequence order is display order; connectors not listed sort after listed ones, alphabetically. The existing
default_connectorsetting doubles as the recommended connector — no new setting — flagged in the render array and listed first when no explicit order says otherwise. Labels are plain text; descriptions allow restricted markup the way core treats admin-entered help text — rendered as#markup, filtered throughXss::filterAdmin()(links, emphasis, lists and the like survive; scripts and event attributes are stripped) — so steering copy with a link or a short bullet list needs no template override. The admin settings form presents this as a tabledrag table of the discovered connectors with per-row label and description fields, the description's help text naming the filtering.API shape: additive, no premature checkout sessions
When multiple connectors are available and the user has no subscription,
/subscription-manager/api/subscribe-urlreturnssubscribe_url: NULLplusconnectors: a list of{id, label, description, subscribe_url, recommended}in configured order, carrying the resolved (overridden-or-fallback) copy so decoupled front ends reproduce the same steering; descriptions arrive as the sameXss::filterAdmin()-filtered HTML the chooser page renders. Each candidate'ssubscribe_urlpoints back to this endpoint with?connector=IDrather than to a live checkout URL: resolving every connector'sredirectToSubscribe()on a listing GET would create a remote checkout session per connector per page view. The session is created only once the visitor has chosen; the endpoint with?connector=IDthen returns the single-URL shape exactly as today. Single-connector sites and existing subscribers see the current response, unchanged.Scope
No entity or data-model changes. One new config key (
chooser_connectors, default empty) with schema and an update hook. Kernel tests: single-connector passthrough, multi-connector chooser (ordering, copy overrides, recommended flag), existing-subscriber bypass,?connector=honored and invalid values ignored, availability filtering (a connector with no visible plans is excluded), and both API response shapes. Change record for the additive API shape.Comment #5
colanAmendment to #4, from implementation review: the
default_connectorsetting is removed rather than given a second job.Once steering is expressed by dragging connectors into order, "recommended = whatever sits first" is the obvious contract, and a separate default-connector select becomes a second control that can only agree with or confusingly contradict the first row. So:
chooser_connectorsis the default and the recommended connector.getDefaultConnector()stays as public API with an unchanged signature — it now resolves to the first entry in the sequence whose plugin is installed, keeps the single-installed-connector inference from #3616932: Subscribe routes fatal on fresh installs: default_connector is absent from config/install and passed unguarded to createInstance() as the fallback for empty config, and returns NULL otherwise. Every consumer (the controller's fallback paths,custom_subscribe_url's target, connector modules calling it) keeps working unchanged. A stale entry whose plugin was uninstalled is simply skipped in favor of the next installed one — slightly better than the old all-or-nothing fallback.chooser_connectorswith the site's currentdefault_connectoras the sole entry and drops the old key, so nobody's default or steering changes. Unlisted connectors always append after listed ones, so a one-entry list is complete. Thedefault_connectorselect disappears from the settings form; the tabledrag help text states that the first row is the default and recommended connector.Since this removes a settings key, the change record grows a small "removed" section alongside the additive API shape; doing it now, pre-1.0, avoids carrying the redundant setting into stable and deprecating it later.
Comment #7
colanImplemented in the MR, per #4 as amended by #5, in three commits.
Foundation.
getAvailableConnectorIds()— a connector is available when it has at least one enabled, shown plan.getChooserItems()resolves the display list:chooser_connectorsorder first, unlisted available connectors appended alphabetically, labels and descriptions falling back from the site overrides to the plugin definition, first entry flagged recommended.getDefaultConnector()keeps its signature and now resolves to the first entry of the sequence whose plugin is installed (skipping stale entries), with the single-installed-connector inference retained for empty config; update 10019 seeds the sequence from each site's removeddefault_connectorvalue, so no default changes anywhere. The settings form replaces the default-connector select with a tabledrag table of the discovered connectors, with per-row label and description overrides.Flow.
subscribe()and the subscribe-url API keep redirecting in every current case: existing subscribers to their stored connector, zero available connectors down the legacy default path, and exactly one available connector straight to it — the one available connector deliberately wins over the default, whose plans may be hidden (the sunsetting case from #5). Only a no-subscription user facing two or more available connectors sees anything new: the page renders the chooser (new theme hooksubscription_manager_connector_chooserwith a Twig template — item list with per-item classes, a recommended badge, and admin-filtered description markup), and the API returnssubscribe_url: NULLplus the ordered candidate list (id, resolvedlabel/description,recommended, and asubscribe_urlpointing back with?connector=ID, so no remote checkout session is created until the visitor chooses). Theconnectorquery parameter is validated against the available set and ignored otherwise, and is only honored for users without a subscription.Tests. New
ConnectorChooserTestwith eight scenarios: single-available passthrough, availability filtering (hidden and disabled plans keep a connector out), chooser rendering with the alphabetical fallback order, configured order + copy overrides (safe markup survives, scripts stripped, empty overrides fall back), existing-subscriber bypass, connector-parameter validation, both JSON shapes, and default resolution skipping uninstalled entries. Suite is at 84 kernel tests / 1017 assertions; update 10019 verified on a live site.Change record drafted covering the API shape and the
default_connectorremoval.Comment #11
colan