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_scheduling only, 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.

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

Issue summary: View changes
colan’s picture

Issue summary: View changes
colan’s picture

Issue summary: View changes
colan’s picture

Scope 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).

colan’s picture

Design 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 adds succeeded → reversed without 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 as failed → submitted on the SAME record — one record per period is the invariant. A second, documented creation path makes records directly in succeeded with 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 in charge()'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 instanceof routes. 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), and refund() (a charge-based connector without refunds is not shippable). InstrumentReplacingConnectorInterface carries the optional replacement flow. ChargeResult is a value object: succeeded | pending | failed, remote id, message — pending is the async-rail answer, resolved later by the connector's webhook updating the charge record. Mode declaration: a supported_modes key on the connector attribute/annotation defaulting to ['self_scheduling'] (existing connectors untouched), plus a scheduling_mode plan field validated against the connector's set.

5. Two subscription fields the summary implies but does not name: instrument_id (the vaulted-token/mandate reference charge() draws against, and what replacement swaps — customer_id/subscription_id mean remote-service concepts and stay as they are), and pending_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_periods in subscription_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.

colan’s picture

Status: Active » Needs review

MR opened, implementing the design from #7 as five staged commits (mode declaration → charge entity → interfaces → engine + QueueWorker → test connector and tests):

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 show field (nothing ever called validate() 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.

  • colan committed 6714c4a5 on 1.0.x
    Issue #3618739: Ship default admin views for all three entities
    
    One...

  • colan committed aa6bc3e0 on 1.0.x
    Issue #3618739: Declare Views data for subscription charges
    
    Both...

  • colan committed 7f567e96 on 1.0.x
    Issue #3618739: Document the same-connector requirement on plan changes...

  • colan committed eb4d6515 on 1.0.x
    Issue #3618739: Baseline the charge list builder's storage constructor...

  • colan committed c67ea5cc on 1.0.x
    Issue #3618739: Add the charge-based test connector and cycle-engine...

  • colan committed 60ce796d on 1.0.x
    Issue #3618739: Add the cycle engine, its QueueWorker, and the cycle...

  • colan committed 44d345ca on 1.0.x
    Issue #3618739: Add the charge-based connector interfaces and...

  • colan committed 3afc11b4 on 1.0.x
    Issue #3618739: Add the subscription_charge entity, one record per...

  • colan committed 99194819 on 1.0.x
    Issue #3618739: Add connector scheduling-mode declaration and per-plan...
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.