Failed renewal payments no longer flip a subscription straight to inactive. Subscription Manager now has a dunning framework (#3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications): a past due state with scheduled retries, a grace period before access is suspended, and events at each transition.
How it behaves
Access and recovery are independent. When a charge fails, the subscription becomes past due but keeps its roles. The grace period (default 21 days) decides when access is suspended: at its end the subscription's status is switched off and the granted roles are revoked. The retry schedule (default 3, 7 and 14 days after the initial failure) re-attempts the charge regardless — before or after suspension — and any success returns the subscription to current and reactivates it. Running out of retries only ends the schedule; the subscription can still recover through a later manual retry, for example after the customer replaces their payment instrument.
Plan changes (CycleEngine::upgrade() and scheduleDowngrade()) are refused with an \InvalidArgumentException while a subscription is past due: the customer fixes payment first.
Settings
The settings form gains a Dunning section: retry schedule, grace period, and whether to send the built-in mails (off by default). In config: dunning.retry_offsets, dunning.grace_period_days, dunning.send_mails.
Data model
The subscription's status keeps meaning "confers access". New fields carry the dunning bookkeeping: billing_state (current or past_due), past_due_since, next_retry, retry_attempts (update 10021; update 10022 adds the settings). Queries that want paying-but-late subscribers should filter on billing_state.
Events
Four events (constants on \Drupal\subscription_manager\Event\DunningEvents), each carrying a SubscriptionDunningEvent with the subscription, the failed charge where applicable, and for retry failures the attempt number and whether the schedule is exhausted: PAST_DUE, RETRY_FAILED, SUSPENDED, RECOVERED. Subscribe to them to send your own notifications; the built-in mails are one such subscriber.
For connector authors
- The charge context passed to
ChargeBasedConnectorInterface::charge()now includesattempt(1 for the first submission, incremented per retry of the same charge record). Derive the remote idempotency key fromcharge_uuidandattempttogether, so a retry is a fresh remote attempt rather than a deduplicated no-op. - Declare
managed_retries: TRUEon the connector attribute (or annotation) when the processor runs its own retry schedule (e.g. GoCardless Success+): the framework then schedules no retries, while grace and suspension still apply locally. Report each outcome viaCycleEngine::resolveCharge(). - Self-scheduling connectors map remote states onto the same lifecycle from their webhook handlers via the
subscription_manager.dunningservice:markPastDue(),markRecovered(),suspend(). CycleEngine::retryCharge($charge)re-submits a failed charge on demand — the hook for an instrument-replacement or "pay now" flow.
Portal
The billing-history page shows a notice while a subscription is past due, and the my-subscription API response includes billing_state.