Summary
A charge record held one remote id. After the settlement sweep gave up on an unacknowledged submission and dunning retried it under a new idempotency key, a late acknowledgement of the original was dropped as a replay, or taken as the retry's success, and a second capture of the same period went unrecorded. The engine now records every attempt, works out which attempt a webhook is about, settles a late acknowledgement instead of retrying, and flags a second capture for review.
Connector contract: pass the processor's id to resolveCharge()
CycleEngine::resolveCharge($charge, $success, $message = NULL, ?string $remote_charge_id = NULL, ?int $attempt = NULL). Both new parameters are optional, so existing calls keep today's behaviour, but every connector should now pass the processor's charge id from the webhook. It is what lets the engine tell a late acknowledgement from a replay and a duplicate capture from the retry's own success. The attempt number from the charge() context, if the connector stored it on the remote object, is accepted instead.
// Before. $engine->resolveCharge($charge, $event->succeeded, $event->failureMessage); // After. $engine->resolveCharge($charge, $event->succeeded, $event->failureMessage, $event->paymentId);
Attribution: an id that matches a recorded attempt names it; an id matching nothing is the acknowledgement of the most recent attempt that never got one; an id matching nothing when every attempt already has an id is ignored with a warning, since it is not this charge's business. With neither parameter, the resolution is about the attempt in flight, as before.
Per-attempt bookkeeping
data['attempts'][n] on the charge records each attempt's state (submitted, unknown, succeeded, failed, given_up), remote id, submission and resolution timestamps and last error. The remote_charge_id base field is now a projection of it, written only by the engine, holding the id of the attempt that settled the record or of the one in flight; it stays because it is queryable. No schema change.
Late acknowledgements settle instead of retrying
A success for a charge in the failed state after the sweep gave up on it is accepted: a new failed → succeeded transition settles the record, advances the anchor and recovers dunning, which drops the pending retry. Before, the webhook was dropped and the retry captured the period a second time. SubscriptionChargeEntity::ALLOWED_TRANSITIONS reflects this.
Duplicate captures are flagged, not refunded
A success for an attempt other than the one that settled the record, or for the second of two attempts that both succeeded, is a duplicate capture. The engine records the attempt, sets data['duplicate_capture'] (settled attempt, duplicate attempts, both remote ids, amount, detection time), adds the charge to a review list, logs at critical and dispatches the new ChargeEvents::DUPLICATE_CAPTURE (ChargeDuplicateCaptureEvent: charge, subscription, amount, settled attempt, duplicate attempt, remote ids). The record's state and the anchor do not change. Nothing is refunded automatically: the processor's own duplicate detection or a dispute may already be returning it, so the decision is left to an operator or a site subscribing to the event.
Surfacing
The admin charge listing shows "succeeded (duplicate capture)" until the charge is cleared. New drush subscription_manager:duplicate-captures lists flagged charges with both remote ids; --resolve=<charge> clears one once the duplicate has been returned, leaving the flag on the record as history.
Who is affected
Connector maintainers: pass the processor's id to resolveCharge(). Operators: watch the critical log line or the new command, return one capture at the processor or with subscription_manager:refund, then clear the charge. Sites: subscribe to ChargeEvents::DUPLICATE_CAPTURE to notify or to refund automatically if that is the site's policy.