Problem/Motivation
Eight related defects in CycleEngine, DunningManager and DuplicateSubscriptionResolver:
- A transport failure is recorded as a decline, and the retry cannot be deduplicated. Every
charge()call site wraps the connector incatch (\Throwable)and turns the exception intoChargeResult::failed(). Dunning then retries withattempt2, andChargeBasedConnectorInterfacerequires the idempotency key to differ per attempt. If the processor accepted the debit before the response was lost, the customer is charged twice and the module records one succeeded charge. The interface promises that re-submitting the same attempt is safe, but nothing re-submits the same attempt. - Submitted charges are never reaped. The record is saved before the connector call; a PHP timeout inside the call leaves it
submittedwith no remote ID.hasOpenCharge()has no age cutoff, so every later cycle run logs "still settling" and skips the subscription. Access continues unpaid indefinitely, with no dunning, no event and no operator signal. - A dead subscription can be revived and end the customer's live one.
suspend()sets status off but leaves the retry schedule;DunningRetryWorkergates on past-due andnext_retryonly;markRecovered()sets status on unconditionally;SubscriptionEntity::preSave()then supersedes any other active subscription, revoking its instrument. A suspended subscription whose later retry succeeds charges the old plan, comes back to life and ends the subscription the customer bought meanwhile. The same path opens when a failure webhook arrives after an immediate cancellation (recordChargeFailure()tests onlyhasPendingCancellation(), false once status is off, so the ended subscription enters dunning) and whenrebillReversedCharge()succeeds on an ended subscription. - Recovery after suspension back-bills every elapsed period.
processDueSubscription()bills one period per run from the stale anchor; after recovery it charges every period that passed while access was revoked. A customer who pays a sixty-day-old failed charge is then charged two further full periods for service not rendered. upgrade()does not clear a scheduled downgrade. Schedule a downgrade, then upgrade and pay the proration; at the anchor the downgrade silently applies.cancel()clears the pending fields;upgrade()does not.- The duplicates resolver deactivates a charge-based subscription with
set('status', 0)instead ofCycleEngine::end(), leaving dunning fields intact and feeding item 3. - A pending refund is recorded as completed.
refund()accumulatesrefund_amountand records the credit note on any non-failed result; an asynchronous refund that later fails leaves the balance wrong with no resolve path. - Plausible: a crash between the two saves of a successful charge re-bills the period.
applyChargeResult()saves the succeeded charge, then saves the subscription with the advanced anchor; the second save runspreSave()(role changes, a user save, supersede), and if it throws, the next run finds no open charge and an unchanged anchor and opens a second charge for the same period.
Proposed resolution
- Unknown outcomes. An exception from
charge()is not a decline. The record stayssubmittedwith the message, and the next cycle run re-submits it with the samecharge_uuidandattempt, so the processor's idempotency dedupes a debit that went through. After N re-submissions (setting, default 3) without a result, mark it failed and hand it to dunning, whose retry carries a new attempt number as now. Connectors that can look a charge up by its idempotency key may do so insidecharge(); nothing requires it. - Stale submitted charges. The same re-submission covers the timeout case: a submitted charge with no remote ID older than a threshold (setting, default one hour) is re-submitted rather than waited on; one with a remote ID (an asynchronous rail) is left to its webhook but logged as a warning past the threshold and listed by a new Drush command for the operator.
- Liveness.
markRecovered()never turns status on: recovery means "no longer past due" and applies only to a subscription whose status is already on.suspend()clears the retry schedule and ends the dunning run; the customer recovers by paying through the native page or a new checkout, as #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications already describes for late recovery.recordChargeFailure()andrebillReversedCharge()refuse a subscription whose status is off, settling the charge record only. The retry worker checks status. - No back-billing. Resolved by construction: once recovery never changes status and suspension ends the dunning run, nothing revives a suspended subscription, so no elapsed period is ever billed. A Pay now / reactivate action for suspended subscriptions is #3630230: Add a "Pay now" action for past-due subscriptions and a "Reactivate" action for suspended ones to the management page (Phase 5).
upgrade()clearspending_plan_idandpending_amount_basis.- The resolver calls
CycleEngine::end()for charge-based subscriptions, as the cron sweep already does. - Refunds. A pending refund result is recorded as pending on the charge (new
pending_refund_amount), and a newCycleEngine::resolveRefund($charge, $success)commits or discards it; connectors with asynchronous refunds call it from their webhook handler. - Period guard. Before opening a period charge, look for a succeeded charge with the same
period_start; if one exists, advance the anchor to itsperiod_endinstead of charging.
Kernel tests for each: re-submission with the same key after an exception, then failure after N; a stale submitted charge re-submitted; a suspended subscription whose retry succeeds staying off and the customer's newer subscription untouched; a failure webhook after immediate cancellation not entering dunning; the rebill of an ended subscription's reversed charge; recovery after suspension re-anchoring with no catch-up charges; upgrade clearing the pending plan; the resolver ending a past-due charge-based loser with no later retry; a pending refund resolved both ways; the period guard. Existing tests that encode the old behaviour (DunningTest::testRetryTransportFailureReentersSchedule, the catch-up comment's test if any) change accordingly.
Remaining tasks
- Design pass on the settings' names and defaults; implementation; tests; change record.
- Review the MR.
User interface changes
Two new settings on the dunning section of the settings form (re-submission count, stale threshold).
API changes
New settings; a Drush command listing stale charges; CycleEngine::resolveRefund(); markRecovered() no longer changes status; suspend() ends the retry schedule. No connector interface changes: the idempotency contract is unchanged and finally used as written. Connectors with asynchronous refunds gain a method to call.
CycleEngine::settleOpenCharges(), staleCharges(), resolveRefund(); SubscriptionChargeEntityInterface::getAttempt(), getResubmissions(), getLastError(); the subscription_manager:stale-charges Drush command.
Data model changes
A resubmissions counter and a pending_refund_amount on the charge entity (update hook; the update-path tests extend).
Update 10028: attempt, resubmissions, pending_refund_amount on subscription_charge.
Issue fork subscription_manager-3629968
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, from reading every path in
CycleEngine,DunningManager, the retry worker and the resolver. One finding from the summary turns out to be resolved by construction once the others are fixed (item 4 below), and one needs a cron-level sweep rather than the cycle step (item 2).1. An exception is an unknown outcome, re-submitted with the same key
A charge record gains an
attemptfield (the attempt number its open submission used; 1 on creation, the claimed number on a retry) and aresubmissionscounter. Whencharge()throws, at any of the four call sites, the record stayssubmittedwith the message indata['last_error']; nothing enters dunning and no event fires. The next settlement sweep re-submits it to the connector with the samecharge_uuidand the sameattempt, so the key is identical and the processor's idempotency returns the original charge if the first submission went through. Each re-submission increments the counter; when it passes the limit (setting, default 3) the record transitions tofailedwith the last error and goes to dunning as a decline does today, whose retry claims a new attempt number as now. The interface's contract — stable key per attempt, distinct per retry — is unchanged and finally used as written. Connectors need no change; one that can look up a charge by idempotency key may do so insidecharge(), but nothing requires it.2. A settlement sweep on cron, not in the cycle step
Open charges are not only period charges: a proration charge from
upgrade()belongs to a subscription whose anchor is in the future, soprocessDueSubscription()never sees it. A newCycleEngine::settleOpenCharges(), run from cron before the cycle, loads everysubmittedcharge whosechangedis older than the settlement wait (setting, default 1 hour). Those without a remote charge ID are re-submitted as in item 1 (that covers the PHP-timeout case: the record was saved, the call never returned, so nothing was recorded). Those with a remote charge ID are an asynchronous rail's charge awaiting its webhook: left alone, logged as a warning once per sweep past the wait, and listed by a new Drush commandsubscription_manager:stale-chargesfor the operator.hasOpenCharge()keeps the cycle step out while a charge is open, unchanged. A pending result that carries no remote charge ID is treated as unknown and re-submitted; with an idempotent key that is harmless, and it is the connector's bug to fix.3. Liveness
markRecovered()no longer setsstatus: recovery means "no longer past due" and applies only when status is on; on a subscription that is off it is a no-op (the charge is still recorded as settled — money moved — but nothing revives).suspend()clearsnext_retry: suspension is the end of the dunning run.billing_statestays past due for display. With the default offsets (3, 7, 14 days) and grace (21), every retry already happens before suspension, so in practice this changes nothing for default sites; it closes the door for sites with retries after grace.recordChargeFailure()on a subscription whose status is off marks the charge failed and logs, without entering dunning. Covers the failure webhook arriving after an immediate cancellation, and after anend()for any reason.rebillReversedCharge()refuses a subscription whose status is off, like its other refusals.DunningRetryWorkeralso checks status, belt and braces.4. No back-billing after suspension — resolved by construction
With item 3, a suspended subscription cannot come back to life through the engine: nothing retries it and a late webhook success no longer flips status. A subscription that is off is never due, so no elapsed period is ever billed. Recovery within grace is unchanged and correct — the customer had access, the elapsed periods are owed. So no re-anchoring code is needed. What a suspended customer needs is a way back that does re-anchor to now: a Pay now / reactivate action on the native page. That is new self-service, and I propose it as a Phase 5 issue next to #3629796: Let a charge-based subscriber switch plans on the native management page, upgrading now and downgrading at period end rather than here; until then a suspended customer subscribes again through the chooser, as the page already offers.
5–8. The small ones
upgrade()clearspending_plan_idandpending_amount_basis.CycleEngine::end($loser, revoke_instrument: FALSE)— it has already called remote cancellation itself — so dunning fields are cleared with the status.pending_refund_amounton the charge, not inrefund_amount;CycleEngine::resolveRefund($charge, bool $success)moves it intorefund_amountand records the credit note, or discards it. A second refund request while one is pending is refused.processDueSubscription()looks for asucceededcharge of the same subscription andperiod_start; if found, it advances the anchor to that charge'speriod_endand logs, instead of charging.Data model, settings, tests
Three charge fields (
attemptinteger default 1,resubmissionsinteger default 0,pending_refund_amountdecimal nullable), installed by update 10028 through the entity definition update manager; the update-path tests extend their schema expectations. Two settings underdunning:resubmission_limit(3) andsettlement_wait_hours(1), on the dunning section of the settings form, defaults set by the same update. Kernel tests: each item above, plus the existingtestRetryTransportFailureReentersSchedulerewritten for the new semantics (a throw during a retry leaves the charge submitted with the same attempt; the sweep re-submits with the same key, asserted through the test connector's recorded context; the limit turns it into a failure that re-enters the schedule). A change record covers the behaviour and the two settings; no connector interface changes.Decisions to ratify
submittedand are re-submitted with the same key up to a limit, rather than a new charge state.status; a Pay now / reactivate action for suspended subscriptions becomes a Phase 5 issue rather than part of this one.resolveRefund()now, rather than waiting for a connector that needs it.Comment #4
colanImplemented in the MR, in eight commits, following the six decisions ratified in #2 with one deviation on the defaults in decision 2, explained under Data model and settings.
Unknown outcomes and the settlement sweep
All four charge call sites (period, retry, proration, re-bill) go through one
CycleEngine::submit()that reads the attempt number from the charge record — the newattemptfield, 1 on creation and the claimed dunning attempt on a retry, stored before the call. An exception from the connector leaves the recordsubmittedwith the message indata['last_error']; nothing enters dunning and no event fires.CycleEngine::settleOpenCharges(), run from cron before the cycle, loads every submitted charge whosechangedis older than the settlement wait. Those without a remote charge ID are re-submitted with the samecharge_uuidandattempt— the test connector records every context it receives, and the tests assert the key is identical across the original and the re-submission — incrementingresubmissions; at the limit the record transitions to failed with the last error and enters dunning as a decline does. Those with a remote ID are logged once per sweep and listed bydrush subscription_manager:stale-charges(sm-stale-charges). The sweep also covers a record saved before a call that never returned, which used to block the cycle forever.Liveness
markRecovered()no longer sets status and is a no-op on a subscription that is off;suspend()clearsnext_retry;recordChargeFailure()on an inactive subscription settles the record without dunning;rebillReversedCharge()refuses an inactive subscription; the retry worker checks status. The tests cover the three triggers from the summary: a suspended subscription whose forced retry is refused and whose late webhook success does not revive it (and does not end a replacement subscription the customer bought meanwhile), a failure webhook after an immediate cancellation, and a re-bill on an ended subscription.As the design pass said, no re-anchoring code was needed: with the above, nothing bills a suspended subscription's elapsed periods because nothing revives it. The settings-form description of the dunning section now says so. The Pay now / reactivate action from decision 3 is filed as #3630230: Add a "Pay now" action for past-due subscriptions and a "Reactivate" action for suspended ones to the management page.
The rest
upgrade()clears the pending plan; a test schedules a downgrade, upgrades, and asserts the upgraded basis bills at the anchor.CycleEngine::end($loser, revoke_instrument: FALSE)(it has already revoked remotely); a test seeds a past-due loser and asserts the schedule is gone and cron retries nothing.processDueSubscription()advances the anchor past a period that already has a succeeded charge instead of charging. One existing reversal test had to move its seeded paid charge back a period, since it rewound the anchor onto a paid period to force a second charge.pending_refund_amountandCycleEngine::resolveRefund($charge, bool $success); one refund pending at a time; the test connector gained a pending refund behaviour.Data model and settings
Update 10028 installs
attempt,resubmissionsandpending_refund_amounton the charge entity and setsdunning.settlement_wait_hoursanddunning.resubmission_limit; both are on the settings form. The update-path tests expect the fields and the settings.Deviation from decision 2: the defaults are 4 hours and 5, not 1 hour and 3. Three re-submissions an hour apart gave a provider outage about four hours before every open charge turned into a failure, with a "payment failed" mail to the subscriber and, three days later, a dunning retry under a new key, which is the double-charge the issue set out to prevent. The sweep also only runs on cron, so on a site with Drupal's default three-hourly automated cron the real window was twelve hours and on an hourly one four, which the setting should not hide. Six four-hour windows give about a day on either, and a day is also about how long processors remember idempotency keys (Stripe: 24 hours), so a longer budget would not add protection; past it, a same-key re-submission is a fresh charge anyway. The subscriber keeps access throughout, since an open charge keeps the cycle out and nothing is past due until the record fails. Both descriptions on the form say this.
SubscriptionChargeEntityInterfacegainsgetAttempt(),getResubmissions()andgetLastError().Tests
Three tests that encoded the old behaviour are rewritten (a throw during a retry; a late success after suspension; the mails scenario, which now recovers within grace and suspends in a second episode) and ten added; the settlement tests age charges by the engine's configured wait, so they do not encode the default. across
DunningTest,CycleEngineTest,ChargeRecordTest,ChargeReversalTest,SubscriptionCancellationTestandMaintenanceServicesTest. Suite: 209 Kernel tests, 6 Functional tests; phpcs clean. Change record drafted. The README's dunning and operator sections describe the new behaviour and the command.Comment #5
colanAdded missing reference.
Comment #15
colan