A review of the money paths before 1.0.0-beta7 found several ways the cycle engine could charge twice, charge a subscription that was no longer live, or revive one. All are fixed in beta7 (#3629968: Treat a charge exception as an unknown outcome, reap stale submitted charges, never bill or revive a subscription that is no longer live, and stop back-billing suspended time). Nothing in the connector interfaces changes; the idempotency contract connectors already implement is now used as written.
An exception from charge() is an unknown outcome
Before beta7, an exception thrown by a connector's charge() was recorded as a failed charge and retried by dunning with the next attempt number, which the interface requires to carry a different idempotency key. If the processor had accepted the debit before the response was lost, the customer paid twice.
From beta7 the charge record stays submitted, with the message in data['last_error'], and a settlement sweep on cron re-submits it with the same charge_uuid and attempt — the same key — so the processor returns the original charge if it went through. After the configured number of re-submissions without a result (Re-submission limit, default 5) the record counts as failed and enters dunning as a decline does. Connectors need no change. A connector that can look a charge up by its idempotency key may do so inside charge() and return the earlier result; nothing requires it.
Stale submitted charges are settled by cron
A charge record saved before a connector call that never returned (a PHP timeout) used to stay submitted forever, and every later cycle run skipped the subscription as "still settling" — access continued unpaid with no signal. From beta7 the sweep treats a submitted charge older than the Settlement wait (default 4 hours) with no remote charge ID as unacknowledged and re-submits it as above. A submitted charge with a remote charge ID is an asynchronous rail's, awaiting its webhook: it is left alone, logged as a warning once per sweep, and listed by the new command:
drush subscription_manager:stale-chargesBoth settings are on the dunning section of the settings form. The sweep runs on cron, so the effective wait is the longer of the setting and the cron interval. With the defaults a provider outage has about a day — six four-hour windows — before any charge counts as failed and the subscriber hears of it; that matches how long processors keep idempotency keys, past which a same-key re-submission is a fresh charge anyway, so a longer budget adds no protection. Subscribers keep access while a charge is open.
Suspension ends the dunning run; recovery never revives
Before beta7, suspension kept the retry schedule running, and a later successful retry — or a late success reported by webhook — set the subscription's status back on. Through the single-active rule that ended whatever subscription the customer had bought in the meantime and revoked its payment instrument.
From beta7:
DunningManager::suspend()clears the retry schedule. The billing state stays past due for display.DunningManager::markRecovered()no longer changesstatus: recovery means "no longer past due" and applies only to a subscription that is on. A late success on a suspended, cancelled or superseded subscription settles the charge record and nothing else.- A charge failure on a subscription whose status is off (a failure webhook arriving after an immediate cancellation) settles the record without entering dunning.
CycleEngine::rebillReversedCharge()refuses an inactive subscription. The retry worker checks status.
With the default retry offsets (3, 7, 14 days) and grace period (21 days) every retry already happened before suspension, so nothing changes for a default site. A suspended customer comes back by subscribing again through the subscribe flow; a "Pay now / reactivate" action on the management page is planned in #3630230: Add a "Pay now" action for past-due subscriptions and a "Reactivate" action for suspended ones to the management page. Because a suspended subscription can no longer come back through the engine, the catch-up billing of periods elapsed during suspension — charging for time the customer had no access — cannot happen either.
Smaller fixes
CycleEngine::upgrade()clears a scheduled downgrade. Before, a downgrade scheduled earlier silently applied at the anchor to the plan the customer had just paid a proration for.- The Drush duplicates command ends a charge-based loser through the engine, clearing its dunning bookkeeping, instead of flipping status alone.
- Before opening a period charge, the cycle looks for a succeeded charge of the same period — left by a crash between recording the charge and advancing the anchor — and catches the anchor up instead of charging twice.
Pending refunds
CycleEngine::refund() recorded any non-failed result as a completed refund. From beta7 a pending result is held in the new pending_refund_amount field, and the connector's webhook handler calls the new
CycleEngine::resolveRefund(SubscriptionChargeEntityInterface $charge, bool $success): voidto settle it into the refunded total with its credit note, or to discard it. One refund may be pending per charge at a time. Connectors whose refund() returns ChargeResult::pending() must call it; connectors returning succeeded or failed are unaffected.
Data model
Update 10028 adds attempt (integer, default 1), resubmissions (integer, default 0) and pending_refund_amount (decimal, nullable) to the subscription charge entity, and sets the two new settings to their defaults (4 hours, 5). SubscriptionChargeEntityInterface gains getAttempt(), getResubmissions() and getLastError().