Problem/Motivation
A charge record holds one remote_charge_id. When the settlement sweep gives up on an unacknowledged submission (failUnknown() after the resubmission limit), dunning takes over and its first retry claims attempt two, which is a new idempotency key by design. If the original attempt did reach the processor and completes later, the processor now holds two captures for the same period: the original and the retry. On our side, the late webhook for the original either finds a record that is no longer submitted and is ignored by resolveCharge(), or, if the connector maps by remote id, lands on the record the retry already succeeded on. Either way the books show one payment and the customer was charged twice, with nothing logged at a level anyone watches.
With the beta7 defaults the window is about a day, which is also how long most processors remember idempotency keys, so the overlap is narrow but real: a provider outage long enough to exhaust the sweep and then recover is exactly when it happens.
Proposed resolution
- Record the remote id and the outcome per attempt on the charge (
data['attempts'][n] = ['remote_charge_id' => ..., 'state' => ...], no schema change), keepingremote_charge_idas the current attempt's id for existing callers. resolveCharge()accepts an attempt (or a remote id) and, when a success arrives for an attempt other than the one that already succeeded, records it, logs at critical, dispatches a charge event so a site can act, and marks the charge for operator review (adata['duplicate_capture']flag the admin listing and the Drush stale-charges report surface). Automatic refund of the duplicate is a later decision; the refund path from the integrity issue makes it possible.- Connector docs: pass the attempt or remote id when resolving, so the engine can tell the two apart.
- Tests: original attempt resolves after the retry succeeded; both ids recorded; one critical log line; the flag set; the anchor advanced once.
Remaining tasks
- Design pass: data-key versus a real field for attempts; whether to auto-refund the duplicate or only flag it (recommended for beta7: flag).
- Implementation, tests, change record, connector catch-up for the resolve signature.
User interface changes
A "duplicate capture" marker in the admin charge listing.
API changes
CycleEngine::resolveCharge() takes the attempt or remote id being resolved; a new charge event for duplicate captures.
Data model changes
Per-attempt entries in the charge's data; no schema change.
Issue fork subscription_manager-3630971
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 against 1.0.x at 2b5af13, with #3630967: Lock the cycle per subscription, stop re-enqueuing past-due subscriptions, and refuse a pending charge result without a remote id through #3630970: Make refunds idempotent, guard refund resolution, cap and reconcile reversals against pending refunds, and carry rated and tax lines into re-bills in. The gap is as the summary describes, and reading the paths turned up two more shapes of it and one cheap way to close most of the window before any duplicate exists. Details, then the decisions.
Three shapes of the same gap
Setting: attempt 1 of a period charge is submitted, the connector throws every time, the sweep exhausts its re-submissions and
failUnknown()marks the record failed with no remote id. Dunning schedules a retry, which claims attempt 2 and a new idempotency key. The processor did receive attempt 1 and completes it later.failed.resolveCharge()refuses anything notsubmitted, so it logs "already in state failed" and drops it. The retry then runs with attempt 2 and the customer pays twice. This is the shape nothing in the summary mentions, and it is the easiest to close: accept the late success. The state machine gainsfailed → succeeded, and the resolution settles the record, advances the anchor, and callsmarkRecovered(), which clearsnext_retry, so the queued retry item finds nothing to do (DunningRetryWorkerre-checks it). No second capture ever happens.succeededthrough attempt 2. The webhook for attempt 1 is logged as a replay, "already in state succeeded", at warning, and that is the last anyone hears of the second capture.submittedunder attempt 2 with attempt 2's remote id. The webhook for attempt 1 is taken as attempt 2's success: the record settles with the wrong remote id, and attempt 2's own webhook is then dropped as a replay. Two captures, one recorded, and the recorded id is the wrong one.Shapes 2 and 3 cannot be prevented, only detected: by the time the second success arrives the money has moved. Detection needs the engine to know which attempt a resolution belongs to, which is what the summary asks for.
Per-attempt bookkeeping
data['attempts'][$n]on the charge, no schema change:remote_charge_id,state(submitted,unknown,succeeded,failed,given_up),submitted_at,resolved_atand the last error. Written bysubmit()when an attempt goes out, byapplyChargeResult()andrecordUnacknowledged()on the synchronous answer, byfailUnknown()when the sweep gives up, and byresolveCharge().The
remote_charge_idbase field stays, as a projection rather than a second source: the map is the record of every attempt and its id, and the field is derived from it by the engine alone, holding the id of the attempt that settled the record, or of the attempt in flight until one does, and never an id the map does not hold. It stays because it is queryable and the map is not: a connector's webhook handler finds the charge by the processor's id with an entity query on it, the listing and the Drush reports read it, and the sweep tells an acknowledged submission from a lost one by whether it is set.resolveCharge() learns which attempt it is resolving
resolveCharge($charge, $success, $message = NULL, ?string $remote_charge_id = NULL, ?int $attempt = NULL). Both new parameters optional, so existing connectors keep working with today's semantics, but the connector catch-up asks every connector to pass the processor's charge id from the webhook, which it always has. Attribution: a remote id that matches a recorded attempt names it; a remote id matching nothing is attributed to the current attempt when that attempt has no id yet (it was never acknowledged, so this is its acknowledgement) and recorded there; an explicit attempt is taken as given; neither means the current attempt, as today.Then the rules. A resolution for the current attempt of a
submittedrecord behaves exactly as today. A success for any attempt of afailedrecord is shape 1: accepted, settled, recovered. A success for an attempt when the record is alreadysucceeded, or issubmittedunder a different attempt that later also succeeds, is a duplicate capture: the attempt is recorded as succeeded with its id,data['duplicate_capture']is set with both attempts, their ids and the amount, a critical log line is written, and a newChargeEvents::DUPLICATE_CAPTURE(ChargeDuplicateCaptureEvent: charge, subscription, amount, the two attempts) is dispatched so a site can notify or refund. The record's state and the anchor do not change: the period was paid once and the books stay right about the period; the duplicate is money to return, which #3630970: Make refunds idempotent, guard refund resolution, cap and reconcile reversals against pending refunds, and carry rated and tax lines into re-bills's refund path can now do, by hand or from the event. Shape 3's first success settles the record under the attempt that actually succeeded, with that attempt's id, so the second success is the one flagged. A failure for a non-current attempt is only recorded; a replay of the same outcome for the same attempt is ignored as today.No automatic refund in this issue, as the summary recommends: a duplicate capture against a card is often reversed by the processor's own duplicate detection or disputed by the customer, and refunding on top would double the credit. The flag, the event and the listing make it a decision an operator or a site takes with the processor's dashboard open.
Surfacing
The admin charge listing's state column reads "succeeded (duplicate capture)" for a flagged charge, and a new
drush subscription_manager:duplicate-captureslists flagged charges with both remote ids, so an operator can find them without reading logs;stale-chargesis unchanged, since a flagged charge is not stale. The optional Views listing is left alone.Tests
succeededwith that id, anchor advanced once, dunning recovered,next_retrycleared, the queued retry item drops, one connector call in total.Change record and connectors
One change record: the two optional parameters on
resolveCharge()and the request that connectors pass the remote id;failed → succeededfor late acknowledgements; the attempts bookkeeping; the duplicate flag, event and Drush command. The connector catch-up template gains theresolveCharge()line.Decisions to ratify
data['attempts']is the record; theremote_charge_idfield is a projection of it written only by the engine, kept because it is queryable.resolveCharge()gains optional$remote_charge_idand$attempt; attribution by remote id first, explicit attempt second, current attempt by default; connectors asked to pass the remote id.failedrecord is accepted: newfailed → succeededtransition, settle, recover, the pending retry drops. (Addition to the summary; it prevents the duplicate in shape 1.)ChargeEvents::DUPLICATE_CAPTURE, no state or anchor change, no automatic refund.drush subscription_manager:duplicate-captures; Views untouched.resolveCharge().Comment #4
colanImplemented on the MR in four commits per the ratified decisions: the attempt bookkeeping and resolution, the surfacing, tests, docs. One correction made during implementation, noted below; otherwise no deviations.
What changed
recordAttempt()writesdata['attempts'][n]fromsubmit(),applyChargeResult(),recordUnacknowledged(),failUnknown()and the resolution paths, with state, remote id, UNIX timestamps and last error. Theremote_charge_idfield is written only alongside the map, as the projection the design describes.resolveCharge()gained?string $remote_charge_idand?int $attempt;attributeResolution()applies the rules in the order ratified: explicit attempt, then a matching recorded id, then the most recent unacknowledged attempt for an unknown id, else the attempt in flight.failed → succeededadded toALLOWED_TRANSITIONS; a success for a given-up record settles it through the sharedsettleResolvedCharge(), which advances the anchor and callsmarkRecovered(), clearingnext_retryso the queued retry item drops.recordDuplicateCapture(): the attempt recorded,data['duplicate_capture']set, the charge added to a State list, a critical log line,ChargeEvents::DUPLICATE_CAPTUREdispatched. State and anchor untouched, no refund. Shape 3 settles under the first attempt to succeed, with its id, so the retry's own success is the one flagged.drush subscription_manager:duplicate-captureslists flagged charges with both ids and--resolveclears one, stampingcleared_aton the flag. The review list lives in State underCycleEngine::DUPLICATE_CAPTURES_KEY, since the data map is not queryable; the engine gained thestateservice.Correction during implementation
The design said an unknown remote id is attributed to the most recent attempt without one. The first cut fell through to the attempt in flight when every attempt already had an id, overwriting its recorded id and resolving it on a stranger's outcome: a stray webhook carrying an unrelated id would have settled or failed the charge. It is now ignored with a warning, and the test
testUnknownRemoteIdAttribution()pins both halves: a stray id on a fully acknowledged charge changes nothing, and the first id to arrive for an unacknowledged attempt is its acknowledgement.Tests
New
DuplicateCaptureTest, seven tests, each starting from a submission the sweep gave up on (re-submission limit set to one to keep them short):ChargeRecordTest::testTransitionGuard()now listsfailed → succeededas legal. Kernel suite: 260 tests green, the one pre-existing deprecation; Functional suite green (6 tests) in the clean codebase.Change record
Drafted alongside: the two optional parameters with a before/after and the request to pass the id, the transition, the bookkeeping and the projection rule, the flag, event and command. The connector catch-up template gains the
resolveCharge()line.