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

  1. Install two connector modules and configure plans for each (each plan stores its own connector_plugin_id).
  2. Set default_connector to connector A.
  3. As a user with no subscription, visit /subscription-manager/subscribe.
  4. 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's title and description, 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_connectors allow-list setting is needed, or whether "connector has at least one plan with show = 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.

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

colan’s picture

Feature 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:

  • Configurable connector ordering. If the enabled_connectors allow-list from the remaining tasks is adopted as a sequence, ordering falls out of it naturally; otherwise a weight per connector on the chooser.
  • An optional preselected default, so the chooser renders with the site's preferred rail already selected (e.g. "Pay by bank debit" first and preselected, card as the visible-but-secondary alternative).

The multi-connector response shape on /subscription-manager/api/subscribe-url would 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.

colan’s picture

Design for this, resolving the open questions in the summary and folding in the steering additions from #3:

Availability test: visible plans, no enabled_connectors allow-list

A 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 existing resolveConnector() 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's redirectToSubscribe(). 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.
  • label and description: optional site overrides for the chooser copy. Empty or missing falls back to the plugin definition's title/description, so unconfigured sites get sensible defaults. Schema types label/text keep 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_connector setting 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 through Xss::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-url returns subscribe_url: NULL plus connectors: 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 same Xss::filterAdmin()-filtered HTML the chooser page renders. Each candidate's subscribe_url points back to this endpoint with ?connector=ID rather than to a live checkout URL: resolving every connector's redirectToSubscribe() 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=ID then 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.

colan’s picture

Amendment to #4, from implementation review: the default_connector setting 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:

  • The first entry in chooser_connectors is 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.
  • Migration: the update hook seeds chooser_connectors with the site's current default_connector as 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. The default_connector select disappears from the settings form; the tabledrag help text states that the first row is the default and recommended connector.
  • Installed vs. available stays a deliberate split. The default uses the looser test — first installed entry — so a fresh site that has not synced plans yet still resolves a default and redirects, exactly as today. Availability (at least one enabled, shown plan) gates only whether the chooser page appears. That split is what lets a site sunset a rail: keep the connector module installed so existing subscriptions stay serviceable (portal, sync, roles), but hide its plans to withdraw it from acquisition — the chooser drops it, and with one rail left the flow degrades to the direct redirect. Module enablement alone cannot express "stop selling, keep servicing", which is why availability is not reducible to installation.

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.

colan’s picture

Status: Active » Needs review

Implemented 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_connectors order 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 removed default_connector value, 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 hook subscription_manager_connector_chooser with a Twig template — item list with per-item classes, a recommended badge, and admin-filtered description markup), and the API returns subscribe_url: NULL plus the ordered candidate list (id, resolved label/description, recommended, and a subscribe_url pointing back with ?connector=ID, so no remote checkout session is created until the visitor chooses). The connector query parameter is validated against the available set and ignored otherwise, and is only honored for users without a subscription.

Tests. New ConnectorChooserTest with 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_connector removal.

  • colan committed 1357f8b6 on 1.0.x
    Issue #3616769: Cover the chooser, its steering config, and both API...

  • colan committed 7181f301 on 1.0.x
    Issue #3616769: Render the chooser and the multi-connector API shape...

  • colan committed 8d7589d6 on 1.0.x
    Issue #3616769: Replace default_connector with the chooser_connectors...
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.