Change record status: 
Introduced in branch: 
1.0.x
Introduced in version: 
1.0.0-beta7
Description: 

Summary

Four tightenings in the charge-based billing cycle, all guarding against doing one period's work twice. One changes the connector contract.

Connector contract: pending() requires an identifier

ChargeResult::pending() now takes a non-nullable string $remote_charge_id. A pending result without the processor's identifier was indistinguishable from a submission the processor never received: the settlement sweep re-submitted it until the limit, then failed it into dunning, whose first retry used a fresh idempotency key while the original might still settle. A connector that returns pending with no identifier now fails in its own tests with a TypeError; in production the error is caught inside the engine's submission and takes the unknown-outcome path, so the charge is re-submitted under the same key. A blank identifier is treated the same way. If your connector has no identifier yet, it has not been acknowledged: throw instead.

// Before (accepted, and mishandled downstream).
return ChargeResult::pending();

// After.
return ChargeResult::pending($response->payment_id);
// Or, when the processor gave you nothing back:
throw new \RuntimeException('No acknowledgement from the processor.');

The same applies to refund() returning pending. succeeded() and failed() keep their nullable identifier.

Per-subscription lock

CycleEngine::processDueSubscription(), retryCharge(), the settlement sweep's re-submission and upgrade() run under core's lock service, named subscription_manager:cycle:{sid} (CycleEngine::lockName()). Background callers return without doing anything when another process holds it, with an info log line; upgrade() waits CycleEngine::LOCK_WAIT_SECONDS and then throws a \RuntimeException. The lock expiry and both queue workers' cron budget read CycleEngine::WORKER_TIME_BUDGET. resolveCharge(), which connectors call from webhook handling, is not locked and is unaffected.

Due query and queue

Past-due subscriptions are no longer returned by CycleEngine::findDueSubscriptionIds(); dunning owns their failed charge. A new cycle_queued timestamp base field on the subscription entity (update 10029) records the last time cron enqueued it; a row stamped within the settlement wait is not enqueued again, so a billing-day backlog no longer grows the queue by one full set per cron run. The stamp is written by CycleEngine::markQueued() with a direct update, not an entity save, and carries no meaning beyond "already on the queue". Sites with custom code that enqueues cycle items should call markQueued() with the ids they enqueue.

Instrument guard

A due subscription with no stored instrument_id is no longer handed to the connector. The engine logs an error and leaves the anchor untouched, so the row stays visibly due until an operator fixes it. Zero-amount periods, which need no instrument, are unaffected. SubscriptionEntity::hasInstrument() is new.

Who is affected

Connector maintainers: check every pending() call passes an identifier, and that a connector call with no acknowledgement throws. Site builders: run database updates for the new field. Nothing else changes for subscribers or operators.

Impacts: 
Site builders, administrators, editors
Module developers