Problem/Motivation
SubscriptionManagerConnectorInterface carries four methods — supportsInlineActions(), getSubscriptionDisplayData(), getAvailableActions(), executeAction() — written for a membership portal page that would show a user their subscription and, where the connector allows, inline pause/resume/cancel buttons. That page was never built: nothing in the module calls them (see #3616937: Cleanup omnibus: dead config keys, missing token dependency, per-role user saves, page_attachments overhead, drush alias, field length, translatability, misc, comment #6), every shipped connector answers supportsInlineActions() = FALSE, and the "manage subscription" route simply calls the connector's redirectToPortal().
That was tolerable while every connector had a hosted portal to redirect to. Charge-based connectors (#3618739: Add a charge-based connector mode with a local billing cycle engine) do not: the payment service only holds a mandate or a vaulted card, and Subscription Manager owns the billing cycle. For those subscriptions there is currently:
- nowhere to send the user —
redirectToPortal()has nothing to point at; - no way to cancel — the cycle engine bills every subscription with
status = 1whose anchor is due; nothing setscancel_dateor stops renewal at period end, short of an administrator editing the entity; - no entry point for the things the engine can already do: replace the payment instrument (
InstrumentReplacingConnectorInterface), upgrade, schedule a downgrade, see what is past due and why (#3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications).
The billing history page (#3621282: Show subscribers their invoice history in the membership portal) covers the read-only side. The management side is missing.
Proposed resolution
Build the portal page in the module, driven by capability interfaces rather than by the string-keyed action methods:
- A page at the existing manage-subscription route showing, from the local entity, the plan, status, billing state, the next charge date and amount for charge-based subscriptions, and a link to the billing history. A connector that has a hosted portal keeps redirecting to it — expressed as a capability (
HostedPortalConnectorInterface), so the native page is used exactly when there is no external one. - Actions appear only when they can work, and call the same code paths the rest of the module uses: cancel and resume for charge-based subscriptions (new cycle-engine operations — stop renewal at period end, record
cancel_date, keep access untilexpiry; immediate when past due; the stored instrument is revoked throughRemoteCancellingConnectorInterfacefrom #3616934: Deleting a user deletes local subscription entities but never cancels the remote contract, so billing continues when the subscription ends); replace payment method when the connector implementsInstrumentReplacingConnectorInterface; change plan as a link to the subscribe flow (in-page switching between plans is deferred). - Remove all four unused methods —
supportsInlineActions(),getSubscriptionDisplayData(),getAvailableActions(),executeAction()— from the connector interface, without replacement: the page is built from the local entity.RemoteCancellationloses itsexecuteAction('cancel')fallback. - Everything themable (a template, not inline markup) and cacheable per user with the
subscription_listtag. Cancel and resume are confirm forms; replace payment method is a CSRF-protected route. - Cross-connector switching made reachable:
?connector=is honoured for existing subscribers,?change=1shows the chooser with the current connector marked, and lapsed subscribers choose again. A superseded charge-based subscription is ended through the engine.
Remaining tasks
Review the MR.
User interface changes
A native "manage subscription" page for connectors without a hosted portal, with cancel / keep subscription / update payment method / change plan actions, and confirm forms for cancelling and resuming. The connector chooser marks the user's current connector when they are changing plan.
API changes
redirectToPortal() moved to HostedPortalConnectorInterface; four methods removed from SubscriptionManagerConnectorInterface; CycleEngine::cancel()/resume()/end()/paidThrough(); CancellationEvents; entity helpers; PortalBuilder service; api/subscribe-url connectors gain current; ?change= query parameter.
Data model changes
None (cancel_date and expiry reused; no new fields).
Issue fork subscription_manager-3627571
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 before implementation, checked against 1.0.x as of the cleanup omnibus (#3616937: Cleanup omnibus: dead config keys, missing token dependency, per-role user saves, page_attachments overhead, drush alias, field length, translatability, misc). The summary's direction holds; this pins down the interfaces, the cancellation semantics, and what stays out.
What the code says today
/user/{user}/manage-subscription) loads the user's subscription and returns$connector->redirectToPortal($user), unconditionally.redirectToPortal()is on the base connector interface, so a charge-based connector must implement it with nowhere to send anyone. Theapi/portal-urlendpoint does the same.executeAction('cancel')fallback inRemoteCancellation(#3616934: Deleting a user deletes local subscription entities but never cancels the remote contract, so billing continues), which exists only for them.status = 1and a duecycle_anchor. Nothing setscancel_date; nothing stops renewal. The cron expiry sweep already deactivates any active subscription whoseexpiryhas passed, and deactivation already revokes the granted roles.InstrumentReplacingConnectorInterface::replaceInstrument()exists and nothing calls it; the dunning mails link to the manage route, where today there is nothing to click.amount_basisis whatever the connector's return path stored, andCycleEngine::upgrade()/scheduleDowngrade()take the new amount as an argument. The plan'spricesfield is an opaque per-connector map.Proposal
1. Hosted portals become a capability.
redirectToPortal()moves offSubscriptionManagerConnectorInterfaceinto a newHostedPortalConnectorInterface. The manage route redirects when the subscription's connector implements it (today's behaviour for Stripe, Recharge, Chargebee, Shopify — they add the interface to theirimplementslist) and renders the native page otherwise.api/portal-urlreturns the hosted URL or the native page's URL accordingly. The existingmanage_routesetting still lets a site substitute its own page.2. The four portal methods are removed from the connector interface, with no replacement for
getSubscriptionDisplayData(): the page is built from the local entity (plan, status, billing state, next charge, pending cancellation), which is the same for every connector, and nothing has ever consumed the remote version. If a connector later wants to add remote details, an optional interface is a small additive change then.RemoteCancellationloses itsexecuteAction('cancel')fallback and becomes: the interface, or "cannot cancel by API".3. Cancellation for charge-based subscriptions — new cycle-engine operations, the only place billing state changes:
CycleEngine::cancel($subscription)— cancels at period end: recordscancel_date(when it was requested) and setsexpiryto the cycle anchor, i.e. the end of the period already paid for.statusstays 1, so access continues; the engine stops billing it (findDueSubscriptionIds()andprocessDueSubscription()both skip a subscription with a pending cancellation, explicitly, rather than relying on cron ordering); a scheduled downgrade is dropped. No refund, no proration: the current period stays as paid, as with downgrades.CycleEngine::resume($subscription)— before the expiry, clearscancel_dateandexpiry; renewal continues as if nothing happened. The stored instrument was never touched, so no new setup is needed.RemoteCancellation::cancel(), so a connector implementingRemoteCancellingConnectorInterfacerevokes the mandate or vaulted card. Until then the instrument is kept, which is what makes resuming free.cancel()takes an$immediatelyflag for administrators and site code (same path as the past-due case). The page offers users period-end cancellation only.SubscriptionEntity::willRenew()learns the local answer: FALSE with a pending cancellation, TRUE for an active charge-based subscription without one; the remote-status logic remains for self-scheduling subscriptions.CANCELLATION_SCHEDULEDandCANCELLATION_REVERTED, so a site can send a confirmation or react. No built-in mails here; the end of the subscription is already observable as the status change.4. The native page. Rendered at the manage route for a connector without a hosted portal: plan name, status, billing state (reusing the past-due notice from the billing history page), next charge date and base amount, or "Cancels on date"; a link to the billing history (#3621282: Show subscribers their invoice history in the membership portal); and actions, each present only when it can work:
…/manage-subscription/canceland…/resume.InstrumentReplacingConnectorInterface; a CSRF-protected route that returnsreplaceInstrument()'s redirect. This is the landing spot the dunning mails have been linking to.A theme hook and template (no inline markup), cached per user with the
subscription_listtag, access through the existingmanageSubscriptionAccess()(owner with manage own subscriptions, or an administrator). A self-scheduling connector that has neither a hosted portal nor these capabilities gets the read-only page.5. Plan changes are a link, not a form — for now — and the link has to work across connectors. Two cases:
upgrade()(immediately, prorated) orscheduleDowngrade()(at period end); nothing is cancelled. Doing this in the page itself — a list of plans, the price difference, the effective date — needs to know what a plan costs, and there is no upstream way to ask: prices are an opaque per-connector map andamount_basisis supplied by the connector. That price-resolution seam is a design of its own and gets a follow-up issue.?connector=only for users who have none. That is fixed here, since the portal's link is useless without it:?connector=is honoured for existing subscribers; the portal's link opens the chooser (when more than one connector is available) with the current connector marked; and a user whose only subscription has lapsed gets the chooser like a new user rather than being pinned to the old connector.A cross-connector switch is immediate: the old subscription is deactivated when the new one is saved, and the remainder of its paid period is not carried over (a customer who wants to keep it cancels at period end and switches afterwards). New here: when the single-active rule supersedes a charge-based subscription, its stored instrument is revoked through
RemoteCancellation, as at any other end of a subscription; today the superseded row is only marked inactive.6. Self-scheduling connectors are otherwise untouched. A user-facing cancel through
RemoteCancellingConnectorInterfacewould cancel immediately (that is its contract, for discarded subscriptions), which is the wrong default for a customer who has paid through the month; period-end cancellation on a remote contract is a different remote operation. Those connectors keep their hosted portals. Out of scope too: pause, refunds, and collecting failed charges.Data model
No new fields.
cancel_date(described today as the upstream cancellation timestamp) becomes "when cancellation was requested", for both modes;expiryis already the access-until date the sweep acts on. A pending cancellation is: active,cancel_dateset,expiryin the future.Tests
Kernel: cancel sets the fields and the engine does not bill at the anchor; resume restores renewal; expiry deactivates, revokes roles and calls the connector's remote cancellation once; past-due cancel is immediate and stops retries; the immediate flag; events; a pending downgrade is dropped;
willRenew(). Page: redirect for a hosted-portal connector, native render otherwise, each action's presence per capability, access (owner, other user, administrator), confirm forms through the form builder, the replace-instrument route's redirect and CSRF requirement, cache metadata,api/portal-urlin both shapes. Subscribe route:?connector=honoured for an existing subscriber, the chooser for a lapsed one, the current connector marked, and a superseded charge-based subscription's instrument revoked. The inline-action tests added in #3616934: Deleting a user deletes local subscription entities but never cancels the remote contract, so billing continues/#3616937: Cleanup omnibus: dead config keys, missing token dependency, per-role user saves, page_attachments overhead, drush alias, field length, translatability, misc are reworked onto the interfaces.Connector impact (breaking, for the catch-up issues)
Every connector: delete the four portal methods; add
HostedPortalConnectorInterfaceto keepredirectToPortal()working. Charge-based connectors: implementRemoteCancellingConnectorInterfaceandInstrumentReplacingConnectorInterfaceto light up the corresponding actions; drop any stubredirectToPortal().Decisions to ratify: (1)
redirectToPortal()moves toHostedPortalConnectorInterface; (2) the four methods go, with no replacement for display data; (3) cancellation at period end, immediate when past due, resumable until expiry, instrument revoked at the end, two events and no built-in mails; (4) the page as described; (5) plan change as a link to the subscribe flow, which is opened up to existing and lapsed subscribers so cross-connector switches are reachable, with the superseded instrument revoked; in-page same-connector switching deferred to a follow-up; (6) native actions for charge-based subscriptions only.Comment #4
colanImplemented in the MR as ratified in #2, in eight commits — four themes, each followed by its tests — meant to be reviewed in order.
CycleEngine::cancel($subscription, $immediately = FALSE)schedules the end of a charge-based subscription for the end of the period already paid for:cancel_daterecords the request,expirythe cycle anchor, a scheduled downgrade is dropped, and bothfindDueSubscriptionIds()andprocessDueSubscription()skip it explicitly.resume()withdraws that before the expiry.end()deactivates, clears dunning (so no retry can charge or revive it) and asks the connector to revoke the instrument throughRemoteCancellation; it runs for immediate and past-due cancellations and from the cron expiry sweep. Two cases the design did not spell out, handled the conservative way:Plan changes are refused while a cancellation is pending.
SubscriptionEntitygainsgetCancelDate(),getExpiry(),hasPendingCancellation()andisChargeBased(), andwillRenew()answers locally for charge-based subscriptions. Events:CancellationEvents::SCHEDULEDand::REVERTED.redirectToPortal()moves to the newHostedPortalConnectorInterface; the manage route andapi/portal-urluse the hosted portal when the connector implements it and the native page otherwise. The page is built by aPortalBuilderservice (the controller was long enough) and asubscription-manager-portal.html.twigtemplate: plan, state (active / cancelling / past due / ended), next charge and base amount or the end date, the past-due warning shared with the billing history, and the actions the subscription supports. Cancel and resume are confirm forms; Update payment method is a CSRF-protected route intoreplaceInstrument()— the landing spot the dunning mails have been linking to since #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications.SubscriptionManagerConnectorInterface, andRemoteCancellationno longer falls back toexecuteAction('cancel').subscribeTarget()): an explicit?connector=is honoured for everyone,?change=1(the page's Change plan link) reopens the chooser for an active subscriber with their current connector marked, and a lapsed subscriber chooses like a new one instead of being pinned to the old connector. When the single-active rule supersedes a charge-based subscription, the engine ends it — out of dunning, instrument revoked — with one guard the design did not mention: not when the replacement charges the same instrument on the same connector, which would have revoked the new subscription's own mandate.Tests.
SubscriptionCancellationTest(11) andSubscriptionPortalTest(8) are new;ConnectorChooserTestgains the switching and lapsed-subscriber cases; the duplicate-resolution and user-deletion tests move off the removed fallback. The test connector splits into a portal-less base and a hosted-portal subclass so the charge-based test connectors exercise the native page. Suite: 198 kernel tests, 2820 assertions; phpcs clean.Not here, as agreed: in-page switching between plans of one connector (needs a price-resolution seam; follow-up if wanted), period-end cancellation of remote contracts, pause, refunds. No update hook: no schema or config changes.
Change record drafted; every connector is affected (see it for the checklist).
Comment #5
colanUpdated issue summary.
Comment #15
colan