Problem/Motivation
Every current connector assumes the remote billing service owns the subscription lifecycle: the processor renews the subscription on its own schedule and the module reacts via webhooks. Call these self-scheduling connectors.
A second class of processors provides only a payment primitive: a reusable authorization (bank-debit mandate, vaulted card) that can be charged arbitrary amounts on demand. GoCardless (#3616779: GoCardless connector module (bank debit: Bacs, SEPA, ACH, PAD)) and card gateways like Helcim (#3618742: Helcim connector module (charge-based card payments, CAD/USD)) fit this shape. For these, someone must own the billing cycle, and that someone is Subscription Manager. Call these charge-based connectors. This mode is also what makes variable per-period amounts (base plus usage, #3615735: Add a rating step for usage-based charges, delivered per connector scheduling mode) trivial: the amount is computed locally before each charge, so the processor never needs a metering or plan concept.
Proposed resolution
- Add a scheduling-mode concept: connectors declare which modes they support (annotation/attribute key, default
self_schedulingonly, so existing connectors are untouched), and each plan selects its mode from its connector's supported set. Per-plan selection matters because one connector can serve both shapes, e.g. a card processor handling self-scheduled paid plans and a charge-based free tier side by side. - Add fields to the subscription entity used by charge-based subscriptions: billing interval, cycle anchor (next charge timestamp), and per-period amount basis (from the plan's price data).
- Cycle engine: a QueueWorker-driven process (building on #3616880: Move _subscription_manager_sync_remote_subscriptions() into SubscriptionManagerService and convert the cron queue drain to a QueueWorker plugin's pattern) that finds charge-based subscriptions whose anchor has arrived, computes the period amount (plan base plus #3615735: Add a rating step for usage-based charges, delivered per connector scheduling mode rated usage, through the #3618741: Add a pluggable tax/invoice service seam (calculation, evidence, invoice reference), keeping tax engines out of core seam), and calls a new connector method, e.g.
charge($subscription, $amount, $currency, array $context): ChargeResult, defined in a separate interface implemented only by charge-based connectors. - Charge outcomes: immediate results and asynchronous ones (bank debit fails days later) both resolve via the connector's webhooks updating a per-period charge record; failures hand off to #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications. On success, advance the anchor.
- Timing rules stay connector-side: e.g. a GoCardless connector submits charges early enough for scheme lead times and relies on GoCardless's automatic advance notifications.
- Plan changes with immediate upgrades: an upgrade takes effect immediately: the engine computes the prorated difference between plan bases for the remainder of the current cycle, requests it as an immediate charge, updates the subscription's plan and role snapshot at once, and leaves the cycle anchor unchanged so the next full charge lands on schedule at the new rate. On asynchronous rails the charge is submitted immediately and clears per scheme timing; access is granted on submission, with a failure resolving through the dunning framework like any other charge. Downgrades take effect at the next cycle anchor (no mid-cycle credits or refunds), which avoids credit bookkeeping entirely; immediate downgrades with credit are a possible follow-up.
- Zero-amount plans: when a period computes to zero (e.g. a free tier modelled as a $0 charge-based plan, before any usage), the engine advances the anchor without requesting a charge; the subscription stays active with its roles. This lets free tiers run through the same entity, role, and upgrade machinery as paid plans, including the immediate-upgrade path with the instrument already on file. Instrument-uniqueness and anti-fraud policies (e.g. one account per card fingerprint, blocking prepaid cards) are deliberately out of scope: they are site or connector-module concerns.
- Instrument setup without a charge: charge-based support requires establishing a payment instrument with no initial payment (a setup flow: mandate authorization or card vaulting only). This is one connector method serving three callers: initial subscribe to a zero-amount plan, initial subscribe to any charge-based plan whose first charge the engine requests separately, and the instrument-replacement flow above.
- Payment-instrument replacement: an optional connector method for updating the stored instrument (new mandate via a fresh hosted flow, or new vaulted card), exposed as a portal action and linked from dunning notifications, with the engine swapping the subscription's instrument reference on completion. The old instrument is cancelled/detached connector-side.
- Refund initiation: a
refund($charge_record, $amount)connector method plus an admin action on the per-period charge record, recording the refund against the charge and passing the credit through the tax/invoice seam (#3618741: Add a pluggable tax/invoice service seam (calculation, evidence, invoice reference), keeping tax engines out of core) so credit notes reach tax reporting.
Remaining tasks
Interface and field design review; patch with a test charge-based connector; kernel tests for anchor advancement, amount computation, and async failure resolution; change record. Blocks #3616779: GoCardless connector module (bank debit: Bacs, SEPA, ACH, PAD) and #3618742: Helcim connector module (charge-based card payments, CAD/USD); feeds #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications.
User interface changes
None directly; plan forms gain interval settings for charge-based plans.
API changes
New optional connector interface (charge(), refund(), and a single instrument-setup method serving initial setup and replacement), new plugin definition key for supported modes, per-plan mode selection, new cycle-engine service with proration logic for immediate upgrades and zero-amount handling. Existing connectors unaffected.
Data model changes
New base fields on the subscription entity (interval, cycle anchor), a scheduling-mode setting on the plan entity, and a per-period charge record (new entity or field storage; decide in review) including refund state, installed via update hook.
Issue fork subscription_manager-3618739
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
colanComment #4
colanComment #5
colanScope note: the charge-outcome model here (submitted, then succeeded or failed via webhook, failures to #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications, merchant refunds via
refund()) deliberately treats succeeded as terminal. Payer-initiated reversals after success — SEPA's eight-week refund right, Bacs indemnity claims, ACH/PAD consumer returns, card chargebacks — are split into #3619074: Succeeded charge records are terminal, so payer-initiated reversals (SEPA refunds, Bacs indemnity claims, chargebacks) have nowhere to land. For this issue, the only ask is that the charge-record state design leave room for a later reversed state reachable from succeeded (i.e. don't hard-code succeeded as final in the storage or state machine).Comment #6
colanDesign review ahead of implementation, resolving what the summary deferred and recording the decisions:
1. The per-period charge record is a content entity (
subscription_charge), not field storage on the subscription. Every consumer wants entity affordances: the dunning framework (#3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications) queries by state, the refund admin action wants a route and list builder, the tax seam (#3618741: Add a pluggable tax/invoice service seam (calculation, evidence, invoice reference), keeping tax engines out of core) stores an invoice reference per charge, and #3619074: Succeeded charge records are terminal, so payer-initiated reversals (SEPA refunds, Bacs indemnity claims, chargebacks) have nowhere to land adds reason-code/fee fields later. Fields: subscription reference, period start/end, amount (decimal) + currency, state, remote_charge_id, refund amount/state, raw-data map. No revisions — the row itself is the audit trail.2. State machine:
submitted → succeeded | failed, with the allowed-transitions map defined once as entity-level data so #3619074: Succeeded charge records are terminal, so payer-initiated reversals (SEPA refunds, Bacs indemnity claims, chargebacks) have nowhere to land addssucceeded → reversedwithout rewriting logic (per the scope note in #6), and #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications models a retry asfailed → submittedon the SAME record — one record per period is the invariant. A second, documented creation path makes records directly insucceededwith amount 0 and no remote id ("settled locally", for zero-amount periods below).3. Anchor semantics and idempotency: the anchor advances only on success, per the summary — which means a pending asynchronous charge leaves the anchor in the past, so the due-query must exclude subscriptions holding an open (
submitted) charge record: the charge record is the idempotency key. Its UUID is also passed to the connector incharge()'s context as the remote-side idempotency key, so a crashed-and-retried worker cannot double-charge. Anchor arithmetic clamps month-end days (Jan 31 → Feb 28 → Mar 31) by preserving the original billing day rather than recomputing from the previous charge date, and all proration math runs in integer minor units, never floats.4. Interfaces: three small ones rather than one fat one, so connectors opt in per capability and
instanceofroutes.ChargeBasedConnectorInterface:charge($subscription, $amount, $currency, array $context): ChargeResult,setupInstrument()(the single method serving free-plan signup, first setup, and replacement completion — it starts the hosted flow; the connector's return path reports the instrument reference back to the engine), andrefund()(a charge-based connector without refunds is not shippable).InstrumentReplacingConnectorInterfacecarries the optional replacement flow.ChargeResultis a value object:succeeded | pending | failed, remote id, message —pendingis the async-rail answer, resolved later by the connector's webhook updating the charge record. Mode declaration: asupported_modeskey on the connector attribute/annotation defaulting to['self_scheduling'](existing connectors untouched), plus ascheduling_modeplan field validated against the connector's set.5. Two subscription fields the summary implies but does not name:
instrument_id(the vaulted-token/mandate referencecharge()draws against, and what replacement swaps —customer_id/subscription_idmean remote-service concepts and stay as they are), andpending_plan_id(where a scheduled downgrade lives until the engine applies it at the next anchor).6. Billing intervals: monthly and yearly only for now, as an extensible allowed-values list — a third interval later is configuration and tests, not surgery.
7. Zero-amount periods: whether they produce a charge record is configurable —
record_zero_amount_periodsinsubscription_manager.settings, default TRUE. Recording by default gives every site an audit trail while the engine bеds in (and a visible "free period renewed" history); sites can later turn it off to stop accumulating rows. Turning it off stops future rows only; a mixed history is expected and the setting description says so. With recording off the engine still logs the zero-amount anchor advance at INFO. Either way the anchor advances without contacting any connector.8. Seams kept open, not built:
computePeriodAmount()is the single documented insertion point where #3615735: Add a rating step for usage-based charges, delivered per connector scheduling mode adds rated usage and #3618741: Add a pluggable tax/invoice service seam (calculation, evidence, invoice reference), keeping tax engines out of core adds tax; its v1 returns the plan base price. A failed charge in v1 is record-and-log, explicitly interim until #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications supplies past_due and retries.Implementation lands as one MR with staged commits (mode declaration → charge entity → interfaces → engine + QueueWorker → plan changes → tests throughout), including the test charge-based connector the remaining tasks call for.
Comment #8
colanMR opened, implementing the design from #7 as five staged commits (mode declaration → charge entity → interfaces → engine + QueueWorker → test connector and tests):
supported_modeson the connector attribute/annotation (default self-scheduling only — existing connectors untouched), ascheduling_modeplan field, and a validation constraint enforcing the connector's declared set.subscription_charge: one record per (subscription, period), declared with the modern ContentEntityType attribute; the state machine is a single ALLOWED_TRANSITIONS map with a guardedtransitionTo(), leaving #3619074: Succeeded charge records are terminal, so payer-initiated reversals (SEPA refunds, Bacs indemnity claims, chargebacks) have nowhere to land's reversed state and #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications's failed→submitted retries as data extensions per the scope note in #6.ChargeBasedConnectorInterface(charge(),setupInstrument(),refund()),InstrumentReplacingConnectorInterface, and theChargeResultvalue object (succeeded | pending | failed).CycleWorkerdrains with per-item isolation. The anchor advances only on success; the open submitted record is the local idempotency key and its UUID travels to the connector as the remote one;resolveCharge()is the webhook-side resolution API. Billing-day clamping survives short months; money math is integer minor units end to end. Zero-amount periods advance without a connector, recording a settled-locally row per the newrecord_zero_amount_periodssetting (default TRUE). Upgrades apply immediately with a prorated charge and an untouched anchor; downgrades store pending fields and apply at the anchor; plan changes cannot cross connectors.computePeriodAmount()is the documented seam for #3615735: Add a rating step for usage-based charges, delivered per connector scheduling mode and #3618741: Add a pluggable tax/invoice service seam (calculation, evidence, invoice reference), keeping tax engines out of core; a failed charge is record-and-log, interim until #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications.Two discoveries along the way: the role-swap tests exposed a latent plan-change bug — preSave's old-role removal saves the owner, whose presave union reads the database row (still active, old snapshot) and resurrected the roles being revoked; the changing subscription is now excluded from the union during removal, the mechanism preDelete() already used. And plan validation surfaces a pre-existing primitive-type violation on the
showfield (nothing ever calledvalidate()on plans before); left for 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.Change record drafted covering the new interfaces, entity, fields, setting, and mode declaration.
Comment #18
colan