PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.1
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Modules / Subscriptions / Services / SystemChargeService.php

SystemChargeService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.1, at app/Modules/Subscriptions/Services/SystemChargeService.php

804 lines 32.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\Subscriptions\Services;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Helpers\Status;
7 use FluentCart\App\Models\Order;
8 use FluentCart\App\Models\OrderTransaction;
9 use FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway;
10 use FluentCart\App\Services\Payments\PaymentInstance;
11 use FluentCart\App\Services\Payments\SubscriptionHelper;
12 use FluentCart\Framework\Support\Arr;
13
14 /**
15 * Auto-charge engine for system (token-charged, store-billed) subscriptions.
16 * Charges the stored token off-session on the invoice due date; failure flips
17 * the invoice to `pending` and hands off to the normal manual dunning flow.
18 * Token is resolved at fire time, never snapshotted, so a mid-cycle payment
19 * method change is picked up by the next attempt.
20 */
21 class SystemChargeService
22 {
23 const HOOK = 'fluent_cart/subscriptions/system_charge_due';
24 const RECONCILE_HOOK = 'fluent_cart/subscriptions/system_charge_reconcile';
25 const SCHEDULER_GROUP = 'fluent-cart';
26
27 // Async charges are re-checked daily; after this many checks the invoice
28 // fails back to pending so normal dunning resumes.
29 const RECONCILE_INTERVAL = DAY_IN_SECONDS;
30 const MAX_RECONCILE_CHECKS = 7;
31
32 // hasQueuedCharge()/unscheduleCharges() sweep exactly this many slots —
33 // an attempt scheduled beyond it would be invisible to both.
34 const MAX_ATTEMPT_SLOTS = 10;
35
36 // Retry attempts as a fraction of the interval's grace period, so every
37 // attempt lands before the subscription expires regardless of cadence.
38 const RETRY_GRACE_FRACTIONS = [0.25, 0.6, 0.9];
39
40 public function register()
41 {
42 add_action(self::HOOK, [$this, 'executeCharge'], 10, 2);
43 add_action(self::RECONCILE_HOOK, [$this, 'reconcileProcessingCharge'], 10, 1);
44
45 // A manual payment (Pay Now) against a scheduled/pending system invoice
46 // makes the queued charge moot — unschedule it.
47 add_action('fluent_cart/renewal_paid', [$this, 'cancelPendingCharge'], 20, 1);
48 }
49
50 /**
51 * Kill switch for automatic system charging. Return false to stop every
52 * charge attempt — e.g. on a staging clone whose live tokens would otherwise
53 * double-charge real customers. Defaults on; billing is unaffected in prod.
54 */
55 public static function isSystemBillingEnabled(): bool
56 {
57 return (bool) apply_filters('fluent_cart/subscriptions/system_billing_enabled', true);
58 }
59
60 /**
61 * Drop the stale decline reason after the payment method is replaced.
62 * Retry bookkeeping (attempts, next_retry_at, processing marker) is kept —
63 * the next attempt just reads the new token at fire time.
64 */
65 public static function clearFailureState($subscription)
66 {
67 $state = $subscription->getMeta('system_charge_state', []) ?: [];
68
69 if (!isset($state['last_error'])) {
70 return;
71 }
72
73 unset($state['last_error'], $state['last_attempt_at']);
74
75 $hasBookkeeping = isset($state['status']) || isset($state['next_retry_at']) || isset($state['exhausted']);
76
77 if ($hasBookkeeping) {
78 $subscription->updateMeta('system_charge_state', $state);
79 return;
80 }
81
82 $subscription->deleteMeta('system_charge_state');
83 }
84
85 /**
86 * Whether a charge attempt is still queued for this invoice. While one is
87 * pending, the overdue scanner must not escalate the invoice out from under it.
88 *
89 * @param array|null $chargeState subscription's `system_charge_state` meta;
90 * null if caller doesn't have it loaded
91 */
92 public static function hasQueuedCharge($order, $chargeState = null): bool
93 {
94 if (!function_exists('as_next_scheduled_action')) {
95 return false;
96 }
97
98 foreach (self::queuedAttemptSlots($order, $chargeState) as $attempt) {
99 if (as_next_scheduled_action(self::HOOK, [$order->id, $attempt], self::SCHEDULER_GROUP)) {
100 return true;
101 }
102 }
103
104 return false;
105 }
106
107 /**
108 * Attempt slots that could plausibly hold a queued action. Only one attempt
109 * is ever queued at a time, so charge state pins the slot — except the retry
110 * is scheduled BEFORE state is written, so a crash mid-write can leave state
111 * one attempt behind the scheduler; probe a range, not just the one slot,
112 * to cover that gap. No state (null) gets the full sweep.
113 *
114 * @return array<int,int>
115 */
116 private static function queuedAttemptSlots($order, $chargeState): array
117 {
118 if ($chargeState === null) {
119 return range(1, self::MAX_ATTEMPT_SLOTS);
120 }
121
122 if (!$chargeState || (int) Arr::get($chargeState, 'order_id') !== (int) $order->id) {
123 return [1];
124 }
125
126 if (Arr::get($chargeState, 'exhausted') === 'yes' || Arr::get($chargeState, 'status') === 'processing') {
127 return [];
128 }
129
130 $attempts = max(1, (int) Arr::get($chargeState, 'attempts', 0));
131 $ceiling = max((int) Arr::get($chargeState, 'max_attempts', 0), $attempts + 1);
132
133 return range($attempts, min($ceiling, self::MAX_ATTEMPT_SLOTS));
134 }
135
136 /**
137 * Whether auto-retries are exhausted for THIS renewal order — source of
138 * truth for every Pay Now surface's exhaustion gate.
139 */
140 public static function isExhausted($subscription, Order $order): bool
141 {
142 $state = $subscription->getMeta('system_charge_state', []) ?: [];
143
144 if ((int) Arr::get($state, 'order_id') !== (int) $order->id) {
145 return false;
146 }
147
148 return Arr::get($state, 'exhausted') === 'yes';
149 }
150
151 /**
152 * Drop every queued attempt (and the reconciliation check) for one invoice.
153 */
154 public static function unscheduleCharges($order)
155 {
156 if (!function_exists('as_unschedule_action')) {
157 return;
158 }
159
160 for ($attempt = 1; $attempt <= self::MAX_ATTEMPT_SLOTS; $attempt++) {
161 as_unschedule_action(self::HOOK, [$order->id, $attempt], self::SCHEDULER_GROUP);
162 }
163
164 as_unschedule_action(self::RECONCILE_HOOK, [$order->id], self::SCHEDULER_GROUP);
165 }
166
167 public static function restoreScheduledChargesForSubscription($subscription): void
168 {
169 if (!$subscription || !$subscription->isSystem()) {
170 return;
171 }
172
173 $scheduledInvoices = Order::query()
174 ->where('parent_id', $subscription->parent_order_id)
175 ->where('type', Status::ORDER_TYPE_RENEWAL)
176 ->where('payment_status', Status::PAYMENT_SCHEDULED)
177 ->get();
178
179 foreach ($scheduledInvoices as $invoice) {
180 $chargeState = $subscription->getMeta('system_charge_state', []) ?: [];
181 $isSettling = Arr::get($chargeState, 'status') === 'processing'
182 && (int) Arr::get($chargeState, 'order_id') === (int) $invoice->id;
183
184 if ($isSettling || self::hasQueuedCharge($invoice, $chargeState)) {
185 continue;
186 }
187
188 self::scheduleCharge($invoice, $subscription);
189
190 $subscription->addLog(
191 'Automatic charge restored',
192 sprintf('Renewal order #%s automatic charge was re-queued after the subscription resumed.', $invoice->invoice_no ?: $invoice->id),
193 'info'
194 );
195 }
196 }
197
198 /**
199 * Retry offsets, in days after the invoice due date. Anchored to the
200 * interval's grace period so attempts always fit inside the dunning window.
201 *
202 * @return array<int,float> days after the due date, ascending
203 */
204 public static function getRetryOffsets($subscription): array
205 {
206 $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval($subscription->billing_interval);
207
208 $offsets = [];
209 foreach (self::RETRY_GRACE_FRACTIONS as $fraction) {
210 $offsets[] = round($graceDays * $fraction, 3);
211 }
212
213 $offsets = (array) apply_filters('fluent_cart/subscriptions/system_charge_retry_offsets', $offsets, [
214 'subscription' => $subscription,
215 'grace_days' => $graceDays,
216 ]);
217
218 $offsets = array_map('floatval', array_filter($offsets, function ($offset) {
219 return is_numeric($offset) && (float) $offset > 0;
220 }));
221
222 // A filter can hand back offsets in any order; keep them ascending.
223 sort($offsets);
224
225 // Attempt 1 is the due-date charge, so N offsets occupy slots 2..N+1 —
226 // truncate the tail so the last one still fits MAX_ATTEMPT_SLOTS.
227 if (count($offsets) > self::MAX_ATTEMPT_SLOTS - 1) {
228 $offsets = array_slice($offsets, 0, self::MAX_ATTEMPT_SLOTS - 1);
229 }
230
231 return $offsets;
232 }
233
234 /**
235 * Stop auto-charging a subscription whose payment method can no longer be
236 * token-charged (e.g. a failed invoice paid with a non-capable gateway),
237 * and hand it back to plain manual invoicing.
238 */
239 public static function reconcileGatewayCapability($subscription)
240 {
241 if (!$subscription || !$subscription->isSystem()) {
242 return;
243 }
244
245 // App::gateway(null) returns the gateway manager, not a gateway.
246 $gateway = $subscription->current_payment_method
247 ? App::gateway($subscription->current_payment_method)
248 : null;
249
250 if ($gateway && $gateway->has('system_subscription')) {
251 return;
252 }
253
254 $methodLabel = $gateway instanceof AbstractPaymentGateway
255 ? $gateway->getMeta('title')
256 : $subscription->current_payment_method;
257
258 self::demoteToManual($subscription, sprintf(
259 /* translators: %1$s: payment method name now on file */
260 __('%1$s cannot charge a saved payment method automatically.', 'fluent-cart'),
261 $methodLabel ?: __('The payment method on file', 'fluent-cart')
262 ));
263 }
264
265 /**
266 * system → manual: cancel the queued charges, drop the charge bookkeeping, and
267 * put any invoice that was waiting for an automatic charge back into the manual
268 * flow (pending + the pay-now invoice email it was deliberately not sent).
269 */
270 public static function demoteToManual($subscription, string $reason)
271 {
272 if ($subscription->collection_method === 'manual') {
273 return;
274 }
275
276 $subscription->collection_method = 'manual';
277 $subscription->save();
278
279 $subscription->deleteMeta('system_charge_state');
280
281 $openInvoices = Order::query()
282 ->where('parent_id', $subscription->parent_order_id)
283 ->where('type', Status::ORDER_TYPE_RENEWAL)
284 ->whereIn('payment_status', [Status::PAYMENT_SCHEDULED, Status::PAYMENT_PENDING])
285 ->get();
286
287 foreach ($openInvoices as $invoice) {
288 self::unscheduleCharges($invoice);
289
290 if ($invoice->payment_status !== Status::PAYMENT_SCHEDULED) {
291 continue;
292 }
293
294 // Already paid via a gateway that can't auto-charge — leave it alone,
295 // syncOrderStatuses is about to mark the order paid.
296 $paidTotal = OrderTransaction::query()
297 ->where('order_id', $invoice->id)
298 ->where('status', Status::TRANSACTION_SUCCEEDED)
299 ->sum('total');
300
301 if ($paidTotal >= $invoice->total_amount) {
302 continue;
303 }
304
305 $invoice->payment_status = Status::PAYMENT_PENDING;
306 $invoice->save();
307
308 // Created silently since a charge was coming — now it needs the
309 // pay-now email the manual flow normally sends at creation.
310 do_action('fluent_cart/renewal_created', [
311 'subscription' => $subscription,
312 'order' => $invoice,
313 'parent_order' => $subscription->order,
314 'customer' => $invoice->customer,
315 'transaction' => (new PaymentInstance($invoice))->transaction,
316 ]);
317 }
318
319 $subscription->addLog(
320 'Automatic charging disabled',
321 sprintf('%s Renewal orders will be sent for manual payment from now on.', $reason),
322 'warning'
323 );
324
325 do_action('fluent_cart/subscriptions/system_charge_disabled', [
326 'subscription' => $subscription,
327 'reason' => $reason,
328 ]);
329 }
330
331 /**
332 * Admin-triggered immediate charge attempt on a system subscription's open
333 * renewal invoice. One attempt per call — the retry ladder is not restarted.
334 *
335 * @return array|\WP_Error ['status' => 'paid'|'processing'|'failed', 'message' => string]
336 * on an executed attempt; WP_Error for state violations.
337 */
338 public static function chargeNow(Order $invoice, $subscription, $actorId = 0)
339 {
340 if (!self::isSystemBillingEnabled()) {
341 return new \WP_Error('system_billing_disabled', __('Automatic charging is disabled.', 'fluent-cart'));
342 }
343
344 if ($invoice->type !== Status::ORDER_TYPE_RENEWAL
345 || !in_array($invoice->payment_status, [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED], true)
346 ) {
347 return new \WP_Error('invalid_invoice', __('Only an open (pending or scheduled) renewal order can be charged.', 'fluent-cart'));
348 }
349
350 if (!$subscription || !$subscription->isSystem()) {
351 return new \WP_Error('not_system', __('Only auto-charged (system) subscriptions can be charged from here.', 'fluent-cart'));
352 }
353
354 // Same chargeable set as executeCharge(): expired IS chargeable — a late
355 // payment is exactly what brings the subscription back.
356 if (!in_array($subscription->status, [
357 Status::SUBSCRIPTION_ACTIVE,
358 Status::SUBSCRIPTION_TRIALING,
359 Status::SUBSCRIPTION_PAST_DUE,
360 Status::SUBSCRIPTION_EXPIRED,
361 ], true)) {
362 return new \WP_Error('invalid_status', sprintf(
363 /* translators: %1$s: current subscription status */
364 __('A %1$s subscription cannot be charged.', 'fluent-cart'),
365 $subscription->status
366 ));
367 }
368
369 $gateway = App::gateway($subscription->current_payment_method);
370 if (!$gateway instanceof AbstractPaymentGateway || !$gateway->has('system_subscription')) {
371 return new \WP_Error('gateway_unavailable', __('The payment method for this subscription is unavailable or no longer supports automatic charging.', 'fluent-cart'));
372 }
373
374 $chargeState = $subscription->getMeta('system_charge_state', []) ?: [];
375 $stateIsForThisInvoice = (int) Arr::get($chargeState, 'order_id') === (int) $invoice->id;
376
377 // A charge that was accepted and is settling may still succeed — charging
378 // again risks a double payment (same rule as the reconciliation loop).
379 if ($stateIsForThisInvoice && Arr::get($chargeState, 'status') === 'processing') {
380 return new \WP_Error('charge_settling', __('A charge for this invoice was already submitted and is awaiting confirmation from the payment provider.', 'fluent-cart'));
381 }
382
383 // Manual attempts consume real attempt slots so the per-attempt idempotency
384 // key semantics hold. When every slot is used, the customer's Pay Now link
385 // is the remaining path.
386 $attempts = $stateIsForThisInvoice ? max(0, (int) Arr::get($chargeState, 'attempts', 0)) : 0;
387 $slot = $attempts + 1;
388
389 if ($slot > self::MAX_ATTEMPT_SLOTS) {
390 return new \WP_Error('attempts_exhausted', __('All charge attempts for this invoice have been used. Ask the customer to pay through their Pay Now link.', 'fluent-cart'));
391 }
392
393 // Deliberate flag clear: the admin is overriding a recorded exhaustion.
394 if ($stateIsForThisInvoice && Arr::get($chargeState, 'exhausted') === 'yes') {
395 unset($chargeState['exhausted']);
396 $subscription->updateMeta('system_charge_state', $chargeState);
397 }
398
399 // The manual attempt supersedes any queued automatic one — never both.
400 self::unscheduleCharges($invoice);
401
402 (new static())->executeCharge($invoice->id, $slot);
403
404 // Derive the outcome from the state the attempt left behind.
405 $freshInvoice = Order::query()->find($invoice->id);
406 $freshState = $subscription->getMeta('system_charge_state', []) ?: [];
407
408 if ($freshInvoice && $freshInvoice->payment_status === Status::PAYMENT_PAID) {
409 $result = [
410 'status' => 'paid',
411 'message' => __('The invoice was charged successfully and the subscription has renewed.', 'fluent-cart'),
412 ];
413 } elseif ((int) Arr::get($freshState, 'order_id') === (int) $invoice->id
414 && Arr::get($freshState, 'status') === 'processing'
415 ) {
416 $result = [
417 'status' => 'processing',
418 'message' => __('The charge was submitted and is awaiting confirmation from the payment provider.', 'fluent-cart'),
419 ];
420 } else {
421 $lastError = (int) Arr::get($freshState, 'order_id') === (int) $invoice->id
422 ? (string) Arr::get($freshState, 'last_error', '')
423 : '';
424
425 $result = [
426 'status' => 'failed',
427 'message' => $lastError !== ''
428 ? $lastError
429 : __('The charge attempt did not complete. Check the subscription activity log for details.', 'fluent-cart'),
430 ];
431 }
432
433 $subscription->addLog(
434 'Automatic charge triggered by admin',
435 sprintf(
436 /* translators: %1$d: attempt number, %2$s: attempt outcome (paid, processing or failed) */
437 __('Charge attempt %1$d was triggered manually: %2$s', 'fluent-cart'),
438 $slot,
439 $result['status']
440 ),
441 $result['status'] === 'failed' ? 'warning' : 'info'
442 );
443
444 do_action('fluent_cart/subscriptions/system_charge_manual_triggered', [
445 'order' => $freshInvoice ?: $invoice,
446 'subscription' => $subscription,
447 'attempt' => $slot,
448 'actor_id' => (int) $actorId,
449 'result' => $result['status'],
450 ]);
451
452 return $result;
453 }
454
455 public static function scheduleCharge($order, $subscription, $attempt = 1)
456 {
457 if (!self::isSystemBillingEnabled() || !function_exists('as_schedule_single_action')) {
458 return;
459 }
460
461 $args = [$order->id, $attempt];
462
463 if (function_exists('as_next_scheduled_action') && as_next_scheduled_action(self::HOOK, $args, self::SCHEDULER_GROUP)) {
464 return;
465 }
466
467 $dueDate = $order->getMeta('due_date');
468 $timestamp = max(time(), $dueDate ? strtotime($dueDate) : time());
469
470 as_schedule_single_action($timestamp, self::HOOK, $args, self::SCHEDULER_GROUP);
471 }
472
473 /**
474 * Action Scheduler callback — guarded, idempotent charge attempt.
475 * Every guard logs-and-returns; this method never throws.
476 */
477 public function executeCharge($orderId, $attempt = 1)
478 {
479 // Env kill switch — the primary guard, because a cloned Action Scheduler
480 // job fires this hook directly, bypassing scheduleCharge().
481 if (!self::isSystemBillingEnabled()) {
482 return;
483 }
484
485 $order = Order::query()->find($orderId);
486
487 if (!$order || $order->type !== Status::ORDER_TYPE_RENEWAL) {
488 return;
489 }
490
491 // Paid (manually or by an earlier fire) or voided invoices are never charged.
492 if (!in_array($order->payment_status, [Status::PAYMENT_SCHEDULED, Status::PAYMENT_PENDING], true)) {
493 return;
494 }
495
496 $paymentInstance = new PaymentInstance($order);
497 $subscription = $paymentInstance->subscription;
498
499 if (!$subscription || !$subscription->isSystem()) {
500 return;
501 }
502
503 // Paused/canceled/completed must not be charged. Expired IS charged — a late
504 // retry is what reactivates it (handleRenewalPaid on payment).
505 if (!in_array($subscription->status, [
506 Status::SUBSCRIPTION_ACTIVE,
507 Status::SUBSCRIPTION_TRIALING,
508 Status::SUBSCRIPTION_PAST_DUE,
509 Status::SUBSCRIPTION_EXPIRED,
510 ], true)) {
511 $subscription->addLog(
512 'Automatic charge skipped',
513 sprintf('Scheduled charge for renewal order #%s skipped — subscription is %s.', $order->invoice_no ?: $order->id, $subscription->status),
514 'info'
515 );
516 return;
517 }
518
519 // Capability re-check at fire time: the gateway may have been deactivated
520 // or removed since the subscription was created.
521 $gateway = App::gateway($subscription->current_payment_method);
522 if (!$gateway instanceof AbstractPaymentGateway || !$gateway->has('system_subscription')) {
523 $this->handleFailure($order, $subscription, new \WP_Error(
524 'gateway_unavailable',
525 __('The payment method for this subscription is unavailable or no longer supports automatic charging.', 'fluent-cart')
526 ), $attempt);
527 return;
528 }
529
530 if (!$paymentInstance->transaction) {
531 $this->handleFailure($order, $subscription, new \WP_Error(
532 'missing_transaction',
533 __('No pending transaction found for this renewal order.', 'fluent-cart')
534 ), $attempt);
535 return;
536 }
537
538 $result = $gateway->chargeRenewal($paymentInstance, ['attempt' => $attempt]);
539
540 if (is_wp_error($result)) {
541 $this->handleFailure($order, $subscription, $result, $attempt);
542 return;
543 }
544
545 if ($result === 'processing') {
546 // Charge accepted but not settled (e.g. bank debits). Success fires later
547 // from renewal_paid; reconcileProcessingCharge polls daily so a
548 // lost webhook can't strand the invoice forever.
549 $subscription->updateMeta('system_charge_state', [
550 'status' => 'processing',
551 'order_id' => (int) $order->id,
552 'attempts' => (int) $attempt,
553 'reconcile_checks' => 0,
554 'last_attempt_at' => gmdate('Y-m-d H:i:s'),
555 ]);
556
557 if (function_exists('as_schedule_single_action')) {
558 as_schedule_single_action(time() + self::RECONCILE_INTERVAL, self::RECONCILE_HOOK, [$order->id], self::SCHEDULER_GROUP);
559 }
560
561 $subscription->addLog(
562 'Automatic charge initiated',
563 sprintf('Renewal order #%s charge submitted to the payment method and is awaiting confirmation.', $order->invoice_no ?: $order->id),
564 'info'
565 );
566 return;
567 }
568
569 $this->recordChargeSucceeded($order, $subscription, $attempt);
570 }
571
572 /**
573 * Success log + contract hook. Fired synchronously for confirmed charges, or
574 * from the renewal_paid listener once an async charge's webhook lands.
575 */
576 private function recordChargeSucceeded($order, $subscription, $attempt)
577 {
578 $subscription->addLog(
579 'Automatic charge succeeded',
580 sprintf('Renewal order #%s charged automatically to the saved payment method.', $order->invoice_no ?: $order->id),
581 'info'
582 );
583
584 do_action('fluent_cart/subscriptions/system_charge_succeeded', [
585 'order' => $order,
586 'subscription' => $subscription,
587 'attempt' => (int) $attempt,
588 ]);
589 }
590
591 /**
592 * Reconcile an async (processing) charge. Runs daily until the gateway confirms
593 * settlement, fails definitively, or the check budget runs out — then fails the
594 * invoice back to pending so normal dunning resumes.
595 */
596 public function reconcileProcessingCharge($orderId)
597 {
598 // Paused (e.g. staging): don't poll the live gateway — a retrieve that reads
599 // `succeeded` would settle a cloned renewal and email the real customer.
600 // Re-arm the daily check (budget untouched) so nothing is stranded once
601 // billing resumes.
602 if (!self::isSystemBillingEnabled()) {
603 if (function_exists('as_schedule_single_action')
604 && function_exists('as_next_scheduled_action')
605 && !as_next_scheduled_action(self::RECONCILE_HOOK, [(int) $orderId], self::SCHEDULER_GROUP)
606 ) {
607 as_schedule_single_action(time() + self::RECONCILE_INTERVAL, self::RECONCILE_HOOK, [(int) $orderId], self::SCHEDULER_GROUP);
608 }
609
610 return;
611 }
612
613 $order = Order::query()->find($orderId);
614
615 // Already resolved (webhook confirmed, manual payment, voided) — nothing to do.
616 if (!$order || $order->payment_status !== Status::PAYMENT_SCHEDULED) {
617 return;
618 }
619
620 $paymentInstance = new PaymentInstance($order);
621 $subscription = $paymentInstance->subscription;
622
623 if (!$subscription || !$subscription->isSystem()) {
624 return;
625 }
626
627 $chargeState = $subscription->getMeta('system_charge_state', []) ?: [];
628
629 if (Arr::get($chargeState, 'status') !== 'processing' || (int) Arr::get($chargeState, 'order_id') !== (int) $order->id) {
630 return;
631 }
632
633 $attempt = (int) Arr::get($chargeState, 'attempts', 1);
634 $gateway = App::gateway($subscription->current_payment_method);
635
636 $result = ($gateway instanceof AbstractPaymentGateway && $gateway->has('system_subscription'))
637 ? $gateway->reconcileRenewalCharge($paymentInstance)
638 : new \WP_Error('gateway_unavailable', __('The payment method for this subscription is unavailable.', 'fluent-cart'));
639
640 if ($result === true) {
641 // Settled payment recovered — renewal_paid's listener handles
642 // the deferred success and clears the marker.
643 return;
644 }
645
646 if ($result === 'processing') {
647 $checks = (int) Arr::get($chargeState, 'reconcile_checks', 0) + 1;
648
649 if ($checks < self::MAX_RECONCILE_CHECKS) {
650 $chargeState['reconcile_checks'] = $checks;
651 $subscription->updateMeta('system_charge_state', $chargeState);
652 as_schedule_single_action(time() + self::RECONCILE_INTERVAL, self::RECONCILE_HOOK, [$order->id], self::SCHEDULER_GROUP);
653 return;
654 }
655
656 $result = new \WP_Error('processing_timeout', sprintf(
657 /* translators: %1$d: number of days waited for payment confirmation */
658 __('The payment was not confirmed within %1$d days.', 'fluent-cart'),
659 self::MAX_RECONCILE_CHECKS
660 ));
661 }
662
663 $this->handleFailure($order, $subscription, $result, $attempt);
664 }
665
666 /**
667 * Failed off-session charge: flip invoice to `pending`, re-entering normal
668 * dunning (reminders, past_due → expired), send the charge-failed email
669 * (first failure only, filterable), and schedule the next retry per
670 * getRetryOffsets().
671 */
672 private function handleFailure($order, $subscription, \WP_Error $error, $attempt)
673 {
674 if ($order->payment_status === Status::PAYMENT_SCHEDULED) {
675 $order->payment_status = Status::PAYMENT_PENDING;
676 $order->save();
677 }
678
679 $offsets = self::getRetryOffsets($subscription);
680 $maxAttempts = count($offsets) + 1;
681
682 // Processing timeout may still settle at the gateway — no auto-retry to
683 // avoid a double charge; customer pays manually instead.
684 $allowRetry = $error->get_error_code() !== 'processing_timeout';
685
686 $nextRetryAt = null;
687 if ($allowRetry && $attempt < $maxAttempts && isset($offsets[$attempt - 1])) {
688 $dueDate = $order->getMeta('due_date');
689 $base = $dueDate ? strtotime($dueDate) : time();
690 $nextTimestamp = max(time() + 300, $base + (int) round((float) $offsets[$attempt - 1] * DAY_IN_SECONDS));
691
692 if (function_exists('as_schedule_single_action')
693 && !as_next_scheduled_action(self::HOOK, [$order->id, $attempt + 1], self::SCHEDULER_GROUP)
694 ) {
695 as_schedule_single_action($nextTimestamp, self::HOOK, [$order->id, $attempt + 1], self::SCHEDULER_GROUP);
696 }
697
698 $nextRetryAt = gmdate('Y-m-d H:i:s', $nextTimestamp);
699 }
700
701 $chargeState = [
702 'order_id' => (int) $order->id,
703 'attempts' => (int) $attempt,
704 'max_attempts' => $maxAttempts,
705 'last_error' => $error->get_error_message(),
706 'last_attempt_at' => gmdate('Y-m-d H:i:s'),
707 ];
708 if ($nextRetryAt) {
709 $chargeState['next_retry_at'] = $nextRetryAt;
710 } else {
711 $chargeState['exhausted'] = 'yes';
712 }
713 $subscription->updateMeta('system_charge_state', $chargeState);
714
715 $subscription->addLog(
716 'Automatic charge failed',
717 sprintf(
718 'Attempt %1$d of %2$d to charge renewal order #%3$s failed: %4$s %5$s',
719 $attempt,
720 $maxAttempts,
721 $order->invoice_no ?: $order->id,
722 $error->get_error_message(),
723 $nextRetryAt
724 ? sprintf('Next retry: %s.', $nextRetryAt)
725 : 'No further automatic retries.'
726 ),
727 'warning'
728 );
729
730 do_action('fluent_cart/subscriptions/system_charge_failed', [
731 'order' => $order,
732 'subscription' => $subscription,
733 'attempt' => (int) $attempt,
734 'error' => $error->get_error_message(),
735 'next_retry_at' => $nextRetryAt,
736 ]);
737
738 // First failure, or the ladder just exhausted — the latter is when the Pay
739 // Now CTA actually renders (see charge_failed/customer.php), so it must also
740 // trigger a notification, not just an early attempt no one can act on.
741 $shouldNotify = apply_filters('fluent_cart/subscriptions/system_charge_failure_notify', $attempt === 1 || self::isExhausted($subscription, $order), [
742 'order' => $order,
743 'subscription' => $subscription,
744 'attempt' => (int) $attempt,
745 ]);
746
747 if ($shouldNotify) {
748 do_action('fluent_cart/subscriptions/system_charge_failed_notification', [
749 'order' => $order,
750 'subscription' => $subscription,
751 'parent_order' => $subscription->order,
752 'customer' => $order->customer,
753 'transaction' => (new PaymentInstance($order))->transaction,
754 'error' => $error->get_error_message(),
755 'attempt' => (int) $attempt,
756 'next_retry_at' => $nextRetryAt,
757 ]);
758 }
759 }
760
761 /**
762 * fluent_cart/renewal_paid listener — unschedules any queued charge
763 * for the paid invoice, and fires the deferred success log/hook if this
764 * confirms an async (processing) system charge.
765 */
766 public function cancelPendingCharge($data)
767 {
768 $order = Arr::get($data, 'order');
769
770 if (!$order) {
771 return;
772 }
773
774 self::unscheduleCharges($order);
775
776 if (!$order->parent_id) {
777 return;
778 }
779
780 $subscription = \FluentCart\App\Models\Subscription::query()
781 ->where('parent_order_id', $order->parent_id)
782 ->first();
783
784 if (!$subscription || !$subscription->isSystem()) {
785 return;
786 }
787
788 $chargeState = $subscription->getMeta('system_charge_state', []) ?: [];
789
790 // The order_id scope prevents a stale marker from a previous cycle's voided
791 // invoice from emitting a bogus success for a later invoice's payment.
792 if ((int) Arr::get($chargeState, 'order_id') === (int) $order->id) {
793 $wasProcessing = Arr::get($chargeState, 'status') === 'processing';
794
795 // Paid — retry/failure bookkeeping for this invoice is complete.
796 $subscription->deleteMeta('system_charge_state');
797
798 if ($wasProcessing) {
799 $this->recordChargeSucceeded($order, $subscription, Arr::get($chargeState, 'attempts', 1));
800 }
801 }
802 }
803 }
804