Problem/Motivation

#3618739: Add a charge-based connector mode with a local billing cycle engine models per-period charge records whose outcomes resolve asynchronously via connector webhooks: submitted, then succeeded or failed, with failures handing off to #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications and merchant-initiated refunds covered by its refund() method. What neither issue models is the payer reversing a charge after it succeeded. Every planned rail allows this: SEPA Core grants an eight-week no-questions refund right (thirteen months for unauthorized claims); Bacs indemnity claims under the Direct Debit Guarantee are effectively unlimited in time; ACH consumer returns run up to sixty days; PAD disputes up to ninety days; card chargebacks typically up to one hundred twenty days.

A succeeded charge record is therefore not terminal. Without a reversal model, the connector webhook reporting one has nowhere to land: the subscription stays active as if paid, the charge history overstates collected revenue, and, where a tax service is attached, the reversal never reaches tax reporting as a credit.

Proposed resolution

  • Add a reversed (or charged_back) state to the per-period charge record, reachable only from succeeded, set by connector webhook handlers (e.g. GoCardless refund and chargeback events; card-connector chargeback events). Record scheme metadata where reported: reason code, and any indemnity or chargeback fee.
  • Reversal is not retry-able failure. Automatically re-charging is prohibited or hostile on most schemes (a SEPA refund or a chargeback signals a dispute, not a bounced payment), so the dunning framework must never schedule automatic retries against a reversed charge. Recovery goes through notification and the payment-instrument-replacement flow, or manual contact.
  • Consequence policy, configurable per site: default hands the subscription to #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications's past_due/exhaustion machinery (grace, notifications, eventual role revocation via the granted_roles snapshot); alternatives are immediate suspension or hold-for-manual-review, since a chargeback on a service account is often a fraud signal rather than a payment hiccup.
  • Emit an event at the transition (parallel to the dunning lifecycle events) so sites can attach email or fraud tooling.
  • Push a credit against the original charge through the #3618741: Add a pluggable tax/invoice service seam (calculation, evidence, invoice reference), keeping tax engines out of core, keeping tax engines out of core seam, mirroring what refund() does, so credit notes reach tax reporting.

Sequencing: depends on the charge record from #3618739: Add a charge-based connector mode with a local billing cycle engine and the state machine from #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications; belongs in the roadmap's Phase 4 immediately after the latter.

Remaining tasks

Design review of the state-machine interplay with #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications (in particular the no-auto-retry rule); patch; kernel tests for reversal-after-success, retry suppression, consequence policies, and credit emission through the seam; change record.

User interface changes

Reversal state and reason on the charge-record admin listing; a reversed period should be visible in the membership portal's history.

API changes

New charge-record state and transition event; webhook-mapping guidance for connector implementers. Pre-stable, so no BC obligations beyond a change record.

Data model changes

New state value plus optional reason-code/fee fields on the per-period charge record.

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

Test-coverage note from the #3618739: Add a charge-based connector mode with a local billing cycle engine review: two pieces of the charge surface landed there without direct coverage, and this issue reworks exactly that surface, so the tests should be the first commits here (they document current behavior before we change it):

  • CycleEngine::refund() has no coverage at all — the non-succeeded-state guard, the connector call, and the refund_amount update are all unexercised (ChargeTestConnector already has a REFUNDS_KEY waiting to be used).
  • SubscriptionChargeEntity::transitionTo()'s rejection path is untested: valid transitions run everywhere, but nothing asserts that an invalid transition (e.g. succeeded → failed) throws. Worth pinning down before this issue adds the reversed state to the transition map.
colan’s picture

Issue summary: View changes

Properly rendered links to other issues.

colan’s picture

Design for this, building on the dunning framework from #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications and the tax seam from #3618741: Add a pluggable tax/invoice service seam (calculation, evidence, invoice reference), keeping tax engines out of core:

One new state: reversed

A single reversed state, reachable only from succeeded and terminal. The kind of reversal (SEPA refund, Bacs indemnity claim, ACH return, PAD dispute, card chargeback) is metadata rather than separate states: a new reversal_reason field on the charge (a scalar that will be filtered on — fraud review, the admin listing — so a real field, by the same rule that gave invoice_reference one), with the reversed amount, any scheme fee, the timestamp, and the credit-note reference in the charge's data map. An update hook refreshes the state field's allowed values and installs the new field. Per #2, the first commits pin down refund() and transitionTo()'s rejection path before the transition map changes.

Entry point: CycleEngine::reverseCharge()

reverseCharge($charge, $reason, $fee, $amount) is what connector webhook handlers call, the sibling of resolveCharge(). It is idempotent — webhook deliveries repeat, so a second call on an already-reversed charge logs and returns without a second credit note, event, or consequence. The amount defaults to the charge amount less anything already refunded. The cycle anchor is never rolled back.

No automatic retries, by construction

Re-charging after a payer-initiated reversal is prohibited or hostile on most schemes. In the dunning framework the only code that schedules a retry is recordFailure(), and a reversal never calls it: the charge is reversed, not failed, so retryCharge() refuses it and the retry worker never finds it.

Consequence policy

What a reversal does to the subscription is site policy, dunning.reversal_policy:

  • past_due (default): the subscription goes past due with no retry schedule; access continues through the grace period, then the normal suspension sweep applies. Suits bank debits, where reversals are frequently benign.
  • suspend: the same bookkeeping, but access is suspended immediately. Suits card chargebacks, which are a fraud signal more often than a payment hiccup.
  • review: the subscription is untouched; only the event and a warning are produced, and the site's tooling or staff decide. Requires an operational process — left alone, the customer keeps unpaid service and the next period bills the same instrument.

Because one site can run rails that deserve different answers (debits and cards side by side), an optional dunning.reversal_policy_overrides map sets the policy per connector, falling back to the site default; the settings form offers one select per installed charge-based connector.

Recovery: an explicit, manual re-bill

A reversed charge cannot be retried, so without something more the only exits would be suspension or settling outside the system. CycleEngine::rebillReversedCharge($charge) creates one new charge for the reversed amount over the same period, cross-linked with the original, and refuses a second re-bill of the same charge. It is never invoked automatically — it is the hook for staff action or a future portal "pay now", after the customer has fixed their payment instrument — and its success recovers the subscription like any other charge.

Event, mails, tax

A ChargeReversedEvent carries the charge, subscription, reason, fee, amount, and the policy applied, for notification and fraud tooling. The dunning events gain a cause (charge failed, remote-reported, reversal) so the optional built-in mails can word a reversal correctly rather than promising automatic retries, and send one mail rather than two when suspension is immediate. The credit goes through the existing TaxInvoiceServiceInterface::recordRefund(), mirroring refund(), with the credit-note reference stored on the charge — no interface change; a bridge that needs to distinguish a chargeback from a refund reads the state and reason off the charge.

UI, docs, tests

The charge admin listing shows the reversed state with its reason, and the subscriber's billing history shows the state. Webhook-mapping guidance for connector authors goes on the interface documentation and in the change record. Kernel tests: reversal after success with metadata recorded, duplicate delivery, reversal after a partial refund, retry suppression, each consequence policy and the per-connector override, the re-bill path including its refusal cases and recovery on success, credit emission through the seam, and the mail wording.

colan’s picture

Implemented in the MR, per the design in #4, in five commits (plus the merge of #3624585: Circular service reference breaks sites running Symfony Mailer (constructor config load plus hook_mail on a service-dependent hook class), which the branch needed to boot).

Pin-down first (#2): ChargeRecordTest fixes the state guard's rejection path and refund()'s guards, bookkeeping, and connector-failure handling before the transition map changes.

State and settings. One terminal reversed state, reachable only from succeeded; the kind of reversal is a new queryable reversal_reason field, with the amount, scheme fee, timestamp, and credit-note reference in the charge's data map (update 10023 refreshes the state field's allowed values and installs the field). Settings gain dunning.reversal_policy and a per-connector dunning.reversal_policy_overrides map (update 10024), with selects on the settings form: the site default, plus one per installed charge-based connector.

CycleEngine::reverseCharge($charge, $reason, $fee, $amount) is the webhook entry point, the sibling of resolveCharge(): idempotent on repeated deliveries; a never-succeeded charge is ignored; the amount defaults to the charge less anything already refunded; the credit goes through TaxInvoiceServiceInterface::recordRefund() exactly as a merchant refund does (the two now share one helper); the configured policy is applied; a ChargeReversedEvent is dispatched last, so subscribers see the final state. The anchor is never rolled back.

No automatic retries, by construction. The only code that schedules a retry is the charge-failure path, which a reversal never touches: the record is reversed, not failed, so retryCharge() refuses it and the retry worker never finds it. Covered by a test that forces a retry pass and asserts the connector was never called again.

Policies. past_due (default): past due with no retry, access through the grace period, then the normal suspension sweep. suspend: immediate. review: subscription untouched, only the event and a warning. The dunning events gained a cause (charge failed / remote / reversal), so the built-in mails word a reversal correctly rather than promising a retry, and send one mail rather than two when suspension is immediate.

Recovery: CycleEngine::rebillReversedCharge($charge), never automatic — one new charge for the reversed amount over the same period, cross-linked with the original both ways, refused for a second re-bill or a non-reversed charge, submitted under a fresh idempotency key that also names the original. Success recovers the subscription (anchor untouched: the period was already paid once); failure enters the ordinary dunning schedule as a normal failed charge.

UI. The charge admin listing shows the reversal reason next to the state; the subscriber's billing history shows the state label.

Tests. ChargeReversalTest (twelve scenarios: recording with credit note and unretried past-due, idempotency and the never-succeeded guard, reversal after a partial refund, no-retry under a forced pass, the suspend and review policies, the per-connector override with fallback for unknown values, re-bill recovering once only with cross-links, a failed re-bill entering dunning, mail wording, the settings form, a reversal during an existing dunning episode leaving its retry schedule untouched, and an asynchronous re-bill recovering on webhook resolution) plus ChargeRecordTest (four). Suite is at 145 kernel tests / 2002 assertions; updates 10023–10024 verified on a live site.

Change record drafted, including webhook-mapping guidance for connector authors.

  • colan committed 08b56034 on 1.0.x
    Merge branch '3619074-charge-reversals' into '1.0.x'
    
    Resolve #3619074 "...

  • colan committed 04a7cd88 on 1.0.x
    Issue #3619074: Cover reversals during dunning and asynchronous re-bills...

  • colan committed 13d041d0 on 1.0.x
    Issue #3619074: Cover charge reversals
    
    Ten scenarios: a reversal...

  • colan committed 7f7ab3d4 on 1.0.x
    Issue #3619074: Record payer-initiated reversals and apply the policy...

  • colan committed ea1fabd4 on 1.0.x
    Issue #3619074: Add the reversed charge state, reason field, and policy...

  • colan committed 949b72e4 on 1.0.x
    Issue #3619074: Pin the charge state guard and refund() behavior
    
    Before...
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.