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(orcharged_back) state to the per-period charge record, reachable only fromsucceeded, 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.
Issue fork subscription_manager-3619074
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
colanTest-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 therefund_amountupdate are all unexercised (ChargeTestConnectoralready has aREFUNDS_KEYwaiting 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 thereversedstate to the transition map.Comment #3
colanProperly rendered links to other issues.
Comment #4
colanDesign 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
reversedstate, reachable only fromsucceededand terminal. The kind of reversal (SEPA refund, Bacs indemnity claim, ACH return, PAD dispute, card chargeback) is metadata rather than separate states: a newreversal_reasonfield on the charge (a scalar that will be filtered on — fraud review, the admin listing — so a real field, by the same rule that gaveinvoice_referenceone), 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 downrefund()andtransitionTo()'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 ofresolveCharge(). 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 isreversed, notfailed, soretryCharge()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_overridesmap 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
ChargeReversedEventcarries 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 existingTaxInvoiceServiceInterface::recordRefund(), mirroringrefund(), 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.
Comment #6
colanImplemented 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):
ChargeRecordTestfixes the state guard's rejection path andrefund()'s guards, bookkeeping, and connector-failure handling before the transition map changes.State and settings. One terminal
reversedstate, reachable only fromsucceeded; the kind of reversal is a new queryablereversal_reasonfield, 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 gaindunning.reversal_policyand a per-connectordunning.reversal_policy_overridesmap (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 ofresolveCharge(): idempotent on repeated deliveries; a never-succeeded charge is ignored; the amount defaults to the charge less anything already refunded; the credit goes throughTaxInvoiceServiceInterface::recordRefund()exactly as a merchant refund does (the two now share one helper); the configured policy is applied; aChargeReversedEventis 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, notfailed, soretryCharge()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 acause(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) plusChargeRecordTest(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.
Comment #13
colan