Problem/Motivation

Eight related defects in CycleEngine, DunningManager and DuplicateSubscriptionResolver:

  1. A transport failure is recorded as a decline, and the retry cannot be deduplicated. Every charge() call site wraps the connector in catch (\Throwable) and turns the exception into ChargeResult::failed(). Dunning then retries with attempt 2, and ChargeBasedConnectorInterface requires 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.
  2. Submitted charges are never reaped. The record is saved before the connector call; a PHP timeout inside the call leaves it submitted with 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.
  3. A dead subscription can be revived and end the customer's live one. suspend() sets status off but leaves the retry schedule; DunningRetryWorker gates on past-due and next_retry only; 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 only hasPendingCancellation(), false once status is off, so the ended subscription enters dunning) and when rebillReversedCharge() succeeds on an ended subscription.
  4. 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.
  5. 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.
  6. The duplicates resolver deactivates a charge-based subscription with set('status', 0) instead of CycleEngine::end(), leaving dunning fields intact and feeding item 3.
  7. A pending refund is recorded as completed. refund() accumulates refund_amount and records the credit note on any non-failed result; an asynchronous refund that later fails leaves the balance wrong with no resolve path.
  8. 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 runs preSave() (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 stays submitted with the message, and the next cycle run re-submits it with the same charge_uuid and attempt, 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 inside charge(); 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() and rebillReversedCharge() 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() clears pending_plan_id and pending_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 new CycleEngine::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 its period_end instead 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.

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

Design 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 attempt field (the attempt number its open submission used; 1 on creation, the claimed number on a retry) and a resubmissions counter. When charge() throws, at any of the four call sites, the record stays submitted with the message in data['last_error']; nothing enters dunning and no event fires. The next settlement sweep re-submits it to the connector with the same charge_uuid and the same attempt, 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 to failed with 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 inside charge(), 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, so processDueSubscription() never sees it. A new CycleEngine::settleOpenCharges(), run from cron before the cycle, loads every submitted charge whose changed is 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 command subscription_manager:stale-charges for 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 sets status: 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() clears next_retry: suspension is the end of the dunning run. billing_state stays 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 an end() for any reason.
  • rebillReversedCharge() refuses a subscription whose status is off, like its other refusals.
  • DunningRetryWorker also 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() clears pending_plan_id and pending_amount_basis.
  • The resolver calls CycleEngine::end($loser, revoke_instrument: FALSE) — it has already called remote cancellation itself — so dunning fields are cleared with the status.
  • A pending refund result is recorded in a new pending_refund_amount on the charge, not in refund_amount; CycleEngine::resolveRefund($charge, bool $success) moves it into refund_amount and records the credit note, or discards it. A second refund request while one is pending is refused.
  • Period guard: before opening a period charge, processDueSubscription() looks for a succeeded charge of the same subscription and period_start; if found, it advances the anchor to that charge's period_end and logs, instead of charging.

Data model, settings, tests

Three charge fields (attempt integer default 1, resubmissions integer default 0, pending_refund_amount decimal nullable), installed by update 10028 through the entity definition update manager; the update-path tests extend their schema expectations. Two settings under dunning: resubmission_limit (3) and settlement_wait_hours (1), on the dunning section of the settings form, defaults set by the same update. Kernel tests: each item above, plus the existing testRetryTransportFailureReentersSchedule rewritten 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

  1. Unknown outcomes stay submitted and are re-submitted with the same key up to a limit, rather than a new charge state.
  2. The settlement sweep runs on cron over all open charges, with a settlement wait of 1 hour and a limit of 3 re-submissions as defaults.
  3. Suspension ends the dunning run, and recovery never changes status; a Pay now / reactivate action for suspended subscriptions becomes a Phase 5 issue rather than part of this one.
  4. No re-anchoring code: back-billing of suspended time is impossible once 3 holds.
  5. Pending refunds get a field and resolveRefund() now, rather than waiting for a connector that needs it.
  6. The period guard advances the anchor past an already-paid period instead of charging.

colan’s picture

Issue summary: View changes
Status: Active » Needs review

Implemented 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 new attempt field, 1 on creation and the claimed dunning attempt on a retry, stored before the call. An exception from the connector leaves the record submitted with the message in data['last_error']; nothing enters dunning and no event fires.

CycleEngine::settleOpenCharges(), run from cron before the cycle, loads every submitted charge whose changed is older than the settlement wait. Those without a remote charge ID are re-submitted with the same charge_uuid and attempt — the test connector records every context it receives, and the tests assert the key is identical across the original and the re-submission — incrementing resubmissions; 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 by drush 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() clears next_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.
  • The duplicates resolver ends a charge-based loser through 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.
  • Period guard: 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 refunds: pending_refund_amount and CycleEngine::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, resubmissions and pending_refund_amount on the charge entity and sets dunning.settlement_wait_hours and dunning.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.

SubscriptionChargeEntityInterface gains getAttempt(), getResubmissions() and getLastError().

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, SubscriptionCancellationTest and MaintenanceServicesTest. 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.

colan’s picture

Issue summary: View changes

Added missing reference.

  • colan committed d0858347 on 1.0.x
    Merge branch '3629968-charge-outcomes' into '1.0.x'
    
    Resolve #3629968 "...

  • colan committed f3f76a1a on 1.0.x
    Issue #3629968: Raised the settlement defaults to a day of outage budget...

  • colan committed f6372ce4 on 1.0.x
    Issue #3629968: Documented unknown outcomes, the settlement sweep, and...

  • colan committed 16c50c6f on 1.0.x
    Issue #3629968: Tests for unknown outcomes, settlement, liveness,...

  • colan committed 5af930f5 on 1.0.x
    Issue #3629968: Hold a pending refund apart until the connector resolves...

  • colan committed 3db23bc0 on 1.0.x
    Issue #3629968: Clear a scheduled downgrade on upgrade, end resolved...

  • colan committed 825b103a on 1.0.x
    Issue #3629968: Never revive or chase a subscription that is no longer...

  • colan committed d17de531 on 1.0.x
    Issue #3629968: Treat a charge exception as an unknown outcome and...

  • colan committed 3d7b37f4 on 1.0.x
    Issue #3629968: Added charge attempt, re-submission and pending-refund...
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.