PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Modules / Subscriptions / Services / SystemChargeService.php

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

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