PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
1.6.6 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 All 49 releases
← All changes | app/Modules/PaymentMethods/PayPalGateway/IPN.php +513 -33 1.5.5 → 1.6.6 View file →
@@ -2,8 +2,9 @@
2 2
3 3 namespace FluentCart\App\Modules\PaymentMethods\PayPalGateway;
4 4
5 5 use FluentCart\Api\StoreSettings;
6 +use FluentCart\App\Events\Subscription\SubscriptionRenewalFailed;
6 7 use FluentCart\App\Helpers\Helper;
7 8 use FluentCart\App\Helpers\Status;
8 9 use FluentCart\App\Models\OrderTransaction;
9 10 use FluentCart\App\Models\Subscription;
@@ -16,10 +17,26 @@
16 17 class IPN
17 18 {
18 19 private const TEST_VERIFYING_URL = 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature';
19 20 private const LIVE_VERIFYING_URL = 'https://api-m.paypal.com/v1/notifications/verify-webhook-signature';
21 + private const RESYNC_RETRY_HOOK = 'fluent_cart/paypal/subscription_resync_retry';
22 + private const PENDING_SALES_META = 'paypal_pending_sales';
20 23 private static $paypalSettings = null;
21 24
25 + /** @var \WP_Error|null unacknowledged failure from the recurring-payment handler */
26 + private static $recurringPaymentError = null;
27 +
28 + /**
29 + * Indirection so PHPStan reads the declared property type instead of
30 + * narrowing it to the literal null assigned right before processPaypalWebhookEvents().
31 + *
32 + * @return \WP_Error|null
33 + */
34 + private static function getRecurringPaymentError()
35 + {
36 + return self::$recurringPaymentError;
37 + }
38 +
22 39 public function init()
23 40 {
24 41 // New
25 42 add_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [$this, 'processChargeCaptured'], 10, 1);
@@ -32,8 +49,9 @@
32 49 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_cancelled', [$this, 'handleWebhookRecurringProfileCancelled'], 10, 1);
33 50 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_expired', [$this, 'handleWebhookRecurringProfileExpired'], 10, 1);
34 51 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_suspended', [$this, 'handleWebhookRecurringProfileSuspended'], 10, 1);
35 52 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_re-activated', [$this, 'handleWebhookRecurringProfileReactivated'], 10, 1);
53 + add_action('fluent_cart/payments/paypal/webhook_billing_subscription_payment_failed', [$this, 'processSubscriptionPaymentFailed'], 10, 1);
36 54
37 55 // dispute
38 56 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_created', [$this, 'handleWebhookDisputeCreated'], 10, 1);
39 57 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_updated', [$this, 'handleWebhookDisputeUpdated'], 10, 1);
@@ -38,8 +56,9 @@
38 56 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_created', [$this, 'handleWebhookDisputeCreated'], 10, 1);
39 57 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_updated', [$this, 'handleWebhookDisputeUpdated'], 10, 1);
40 58 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_resolved', [$this, 'handleWebhookDisputeResolved'], 10, 1);
41 59
60 + add_action(self::RESYNC_RETRY_HOOK, [$this, 'handleResyncRetry'], 10, 2);
42 61 }
43 62
44 63 public function processPaypalWebhookEvents($event): void
45 64 {
@@ -109,9 +128,10 @@
109 128 * fluent_cart/payments/paypal/webhook_billing_subscription_suspended
110 129 * fluent_cart/payments/paypal/webhook_billing_subscription_re-activated
111 130 */
112 131 do_action('fluent_cart/payments/paypal/webhook_' . $eventType, [
113 - 'paypal_subscription' => $resource
132 + 'paypal_subscription' => $resource,
133 + 'webhook_event_id' => Arr::get($event, 'id', '')
114 134 ]);
115 135 }
116 136
117 137 }
@@ -160,9 +180,9 @@
160 180 'module_id' => $transaction->order_id,
161 181 'log_type' => 'webhook'
162 182 ]
163 183 );
164 - } else if ($transaction->total > 0 && $paidAmount != $transaction->total) {
184 + } else if ($transaction->total > 0 && $paidAmount != PayPalHelper::wireCents($transaction->total, $transaction->currency)) {
165 185 $mismatch = true;
166 186 fluent_cart_add_log(
167 187 __('PayPal Webhook Amount Mismatch', 'fluent-cart'),
168 188 sprintf(
@@ -167,9 +187,9 @@
167 187 __('PayPal Webhook Amount Mismatch', 'fluent-cart'),
168 188 sprintf(
169 189 /* translators: %1$s: expected amount, %2$s: received amount, %3$s: transaction UUID */
170 190 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Subscription not confirmed.', 'fluent-cart'),
171 - Helper::toDecimal($transaction->total),
191 + Helper::toDecimal(PayPalHelper::wireCents($transaction->total, $transaction->currency)),
172 192 Helper::toDecimal($paidAmount),
173 193 $transaction->uuid
174 194 ),
175 195 'error',
@@ -301,15 +321,17 @@
301 321 );
302 322 return;
303 323 }
304 324
305 - if ($transaction->total > 0 && $paidAmount != $transaction->total) {
325 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
326 +
327 + if ($transaction->total > 0 && $paidAmount != $expectedAmount) {
306 328 fluent_cart_add_log(
307 329 __('PayPal Webhook Amount Mismatch', 'fluent-cart'),
308 330 sprintf(
309 331 /* translators: %1$s: expected amount, %2$s: received amount, %3$s: transaction UUID */
310 332 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Order not confirmed.', 'fluent-cart'),
311 - Helper::toDecimal($transaction->total),
333 + Helper::toDecimal($expectedAmount),
312 334 Helper::toDecimal($paidAmount),
313 335 $transaction->uuid
314 336 ),
315 337 'error',
@@ -333,8 +355,12 @@
333 355 'payer' => Arr::get($paypalIntent, 'payer', [])
334 356 ]
335 357 ]);
336 358
359 + // System subscription: persist the vault token from the captured order
360 + // (idempotent — the AJAX confirmation may have done it already).
361 + (new Processor())->maybePersistVaultToken($transaction, $paypalIntent);
362 +
337 363 }
338 364
339 365
340 366 // called only when webhook/ipn hits
@@ -345,12 +371,13 @@
345 371
346 372 /**
347 373 * Verify the webhook signature
348 374 *
349 - * @param string $webhookId
350 - * @return bool|\WP_Error
375 + * @param string $webhookId PayPal webhook ID for the current mode.
376 + * @param string|null $rawBody Raw request body; read from php://input when omitted.
377 + * @return true|\WP_Error
351 378 */
352 - public function verifyWebhook($webhookId)
379 + public function verifyWebhook($webhookId, $rawBody = null)
353 380 {
354 381 $disableWebhookVerification = apply_filters('fluent_cart/payments/paypal/disable_webhook_verification', 'no', []);
355 382 if ($disableWebhookVerification === 'yes') {
356 383 return true;
@@ -360,9 +387,9 @@
360 387 return new \WP_Error('webhook_id_missing', __('Webhook ID is missing.', 'fluent-cart'));
361 388 }
362 389
363 390 $webhookId = trim($webhookId);
364 - $header = getallheaders();
391 + $header = self::getRequestHeaders();
365 392
366 393 // make all headers lowercase
367 394 $header = array_change_key_case($header, CASE_LOWER);
368 395 if (!isset($header['paypal-auth-algo']) || !isset($header['paypal-cert-url']) ||
@@ -373,9 +400,12 @@
373 400 'headers' => $header
374 401 ]);
375 402 }
376 403
377 - $webhookEvent = json_decode(file_get_contents('php://input'));
404 + if ($rawBody === null) {
405 + $rawBody = file_get_contents('php://input');
406 + }
407 + $webhookEvent = json_decode($rawBody);
378 408 $body = [
379 409 'auth_algo' => $header['paypal-auth-algo'],
380 410 'transmission_id' => $header['paypal-transmission-id'],
381 411 'transmission_time' => $header['paypal-transmission-time'],
@@ -415,14 +445,33 @@
415 445 }
416 446
417 447 public function processWebhook()
418 448 {
419 - $post_data = file_get_contents('php://input');
449 + $statusCode = $this->handleWebhookRequest(file_get_contents('php://input'));
420 450
451 + // exit(int) only sets the process exit code; the HTTP status has to be
452 + // sent explicitly or PayPal records every rejection as delivered.
453 + status_header($statusCode);
454 + exit;
455 + }
456 +
457 + /**
458 + * Handle one PayPal webhook delivery and return the HTTP status to answer with.
459 + *
460 + * Separated from processWebhook() so the request body, headers and status
461 + * can be exercised without php://input or exit().
462 + *
463 + * @param string $rawBody Raw JSON request body.
464 + * @return int
465 + */
466 + public function handleWebhookRequest($rawBody): int
467 + {
468 + $post_data = (string) $rawBody;
469 +
421 470 $data = json_decode($post_data, true);
422 471
423 472 if (empty($data)) {
424 - return;
473 + return 200;
425 474 }
426 475
427 476 $webhookType = Arr::get($data, 'event_type', '');
428 477
@@ -435,8 +484,9 @@
435 484 'BILLING.SUBSCRIPTION.CANCELLED',
436 485 'BILLING.SUBSCRIPTION.EXPIRED',
437 486 'BILLING.SUBSCRIPTION.SUSPENDED',
438 487 'BILLING.SUBSCRIPTION.RE-ACTIVATED',
488 + 'BILLING.SUBSCRIPTION.PAYMENT.FAILED',
439 489 'PAYMENT.CAPTURE.COMPLETED',
440 490 'CUSTOMER.DISPUTE.CREATED',
441 491 'CUSTOMER.DISPUTE.UPDATED',
442 492 'CUSTOMER.DISPUTE.RESOLVED',
@@ -443,16 +493,11 @@
443 493 'CHECKOUT.ORDER.APPROVED' // we don't need this
444 494 ];
445 495
446 496 if (!in_array($webhookType, $webhookEvents)) {
447 - return;
497 + return 200;
448 498 }
449 499
450 - do_action('fluent_cart/paypal_webhook_received', [
451 - 'data' => $data,
452 - 'raw' => $post_data
453 - ]);
454 -
455 500 if (defined('FLUENT_CART_DEV_MODE')) {
456 501 do_action('fluent_cart/dev_log', [
457 502 'raw_data' => $post_data,
458 503 'status' => 'received',
@@ -481,9 +526,9 @@
481 526 ]);
482 527
483 528 if ($willVerify) {
484 529
485 - $verified = $this->verifyWebhook($webhookId);
530 + $verified = $this->verifyWebhook($webhookId, $post_data);
486 531
487 532 if (is_wp_error($verified)) {
488 533 $data = json_encode($verified->get_error_data());
489 534 fluent_cart_add_log($verified->get_error_message() . ' Webhook: ' . $webhookType, $data, 'error', [
@@ -491,16 +536,69 @@
491 536 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
492 537 'module_name' => 'PayPal',
493 538 ]);
494 539
495 - exit(400);
540 + return 400;
496 541 }
497 542 }
498 543
544 + // Only a delivery that passed signature verification (or whose verification
545 + // the site explicitly bypassed above) may reach extension listeners.
546 + // Firing this earlier let an anonymous sender feed forged events to every
547 + // listener even though the request was then rejected (FC-SEC-05).
548 + do_action('fluent_cart/paypal_webhook_received', [
549 + 'data' => $data,
550 + 'raw' => $post_data
551 + ]);
552 +
553 + self::$recurringPaymentError = null;
554 +
499 555 $this->processPaypalWebhookEvents($data);
500 - exit(200);
556 +
557 + $recurringPaymentError = self::getRecurringPaymentError();
558 +
559 + if (is_wp_error($recurringPaymentError)) {
560 + fluent_cart_add_log(
561 + 'PayPal renewal processing failed: ' . $recurringPaymentError->get_error_message() . ' Webhook: ' . $webhookType,
562 + json_encode($recurringPaymentError->get_error_data()),
563 + 'error',
564 + [
565 + 'log_type' => 'webhook',
566 + 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
567 + 'module_name' => 'PayPal',
568 + ]
569 + );
570 +
571 + // Non-2xx so PayPal redelivers; the payment is not recorded yet.
572 + return 500;
573 + }
574 +
575 + return 200;
501 576 }
502 577
578 + /**
579 + * Request headers, falling back to $_SERVER for SAPIs without getallheaders().
580 + *
581 + * @return array<string, string>
582 + */
583 + private static function getRequestHeaders(): array
584 + {
585 + if (function_exists('getallheaders')) {
586 + $headers = getallheaders();
587 + return is_array($headers) ? $headers : [];
588 + }
589 +
590 + $headers = [];
591 + foreach ($_SERVER as $key => $value) {
592 + if (strpos($key, 'HTTP_') === 0) {
593 + $name = str_replace('_', '-', substr($key, 5));
594 + $headers[$name] = $value;
595 + }
596 + }
597 +
598 + return $headers;
599 + }
600 +
503 601 public function processSubscriptionActivated($data)
504 602 {
505 603 $paypalSubscription = Arr::get($data, 'paypal_subscription', []);
506 604 $vendorSubscriptionId = sanitize_text_field(Arr::get($paypalSubscription, 'id'));
@@ -528,8 +626,70 @@
528 626
529 627 (new Processor())->activateSubscription($paypalSubscription, $transaction, $subscriptionModel);
530 628 }
531 629
630 + public function processSubscriptionPaymentFailed($data)
631 + {
632 + $paypalSubscription = Arr::get($data, 'paypal_subscription', []);
633 + $vendorSubscriptionId = sanitize_text_field(Arr::get($paypalSubscription, 'id'));
634 +
635 + $subscriptionModel = $vendorSubscriptionId ? Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId)->first() : null;
636 +
637 + if (!$subscriptionModel) {
638 + $subscriptionHash = Arr::get($paypalSubscription, 'custom_id', '');
639 + if ($subscriptionHash) {
640 + $subscriptionModel = Subscription::query()->where('uuid', $subscriptionHash)->first();
641 + }
642 + }
643 +
644 + if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal') {
645 + return false;
646 + }
647 +
648 + $order = $subscriptionModel->order;
649 + if (!$order) {
650 + return false;
651 + }
652 +
653 + $failedCount = Arr::get($paypalSubscription, 'billing_info.failed_payments_count');
654 + $webhookEventId = sanitize_text_field(Arr::get($data, 'webhook_event_id', ''));
655 +
656 + // One notification per failed attempt. The webhook event ID is the durable
657 + // discriminator — unique per PayPal delivery, stable across a redelivery of
658 + // that same event (mirrors the Stripe invoice-id claim in
659 + // StripeGateway/Webhook/IPN.php). failed_payments_count is NOT usable for this:
660 + // it resets to 0 on the next successful payment, so a later billing cycle that
661 + // reaches the same failure count would reuse an old permanent claim key and
662 + // silently suppress its own notification.
663 + $claimDiscriminator = $webhookEventId !== '' ? $webhookEventId : ($failedCount !== null ? (string)(int)$failedCount : '');
664 +
665 + if ($claimDiscriminator !== '') {
666 + $claimKey = 'fct_sub_renewal_failed_' . $subscriptionModel->id . '_' . $claimDiscriminator;
667 + if (!add_option($claimKey, '1', '', false)) {
668 + return true; // already notified for this failed attempt
669 + }
670 + }
671 +
672 + $error = $failedCount !== null
673 + ? sprintf(
674 + /* translators: %d: number of consecutive failed payments reported by PayPal */
675 + __('PayPal reported a failed subscription payment (failed attempts: %d).', 'fluent-cart'),
676 + (int)$failedCount
677 + )
678 + : __('PayPal reported a failed subscription payment.', 'fluent-cart');
679 +
680 + try {
681 + (new SubscriptionRenewalFailed($subscriptionModel, $order, $subscriptionModel->customer, $error))->dispatch();
682 + } catch (\Throwable $e) {
683 + if (isset($claimKey)) {
684 + delete_option($claimKey);
685 + }
686 + throw $e;
687 + }
688 +
689 + return true;
690 + }
691 +
532 692 public function processRecurringPaymentReceived($data)
533 693 {
534 694 $charge = Arr::get($data, 'charge', []);
535 695 $vendorSubscriptionId = Arr::get($data, 'vendor_subscription_id', '');
@@ -546,8 +706,12 @@
546 706 if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal') {
547 707 return false;
548 708 }
549 709
710 + if ($vendorSubscriptionId && !$subscriptionModel->vendor_subscription_id) {
711 + $subscriptionModel->update(['vendor_subscription_id' => $vendorSubscriptionId]);
712 + }
713 +
550 714 $amount = Helper::toCent(Arr::get($charge, 'amount.total', 0));
551 715 $chargeId = Arr::get($charge, 'id');
552 716 if (!$amount || !$chargeId) {
553 717 return false;
@@ -594,20 +758,91 @@
594 758 // Latest charge transaction = pending one for initial subscription OR for renewal
595 759 $latestTransaction = $subscriptionModel->getLatestTransaction();
596 760
597 761 if ($latestTransaction && !$latestTransaction->vendor_charge_id && $latestTransaction->total) {
598 - if ($latestTransaction->status !== Status::TRANSACTION_SUCCEEDED) {
599 - (new Processor())->confirmPaymentSuccessByCharge($latestTransaction, [
600 - 'vendor_charge_id' => $chargeId,
601 - 'status' => Status::TRANSACTION_SUCCEEDED,
602 - 'total' => $amount,
603 - 'payment_method_type' => 'PayPal',
604 - ]);
605 - } else {
606 - // activateSubscription() already marked this succeeded (billing_info.last_payment matched),
607 - // but vendor_charge_id was not available at that point — fill it in now.
608 - $latestTransaction->update(['vendor_charge_id' => $chargeId]);
762 +
763 + if (!is_array($paypalSubscription)) {
764 + self::$recurringPaymentError = is_wp_error($paypalSubscription)
765 + ? $paypalSubscription
766 + : new \WP_Error('paypal_subscription_fetch_failed', __('Could not fetch the PayPal subscription to confirm this payment.', 'fluent-cart'));
767 + if (self::isRetryableRemoteError(self::$recurringPaymentError)) {
768 + self::scheduleResyncRetry($subscriptionModel, $chargeId);
769 + }
770 + return false;
609 771 }
772 +
773 + $paypalSubscriptions = new PayPalSubscriptions();
774 +
775 + // One remote pull decides everything below — the sorted list answers
776 + // the first-payment question and, on a mismatch, feeds the resync.
777 + $remoteTransactions = $paypalSubscriptions->fetchSortedRemoteTransactions($subscriptionModel, $paypalSubscription);
778 +
779 + if (is_wp_error($remoteTransactions)) {
780 + if (self::isTerminalRenewalError($remoteTransactions, $subscriptionModel)) {
781 + return true;
782 + }
783 +
784 + self::$recurringPaymentError = $remoteTransactions;
785 + if (self::isRetryableRemoteError($remoteTransactions)) {
786 + self::scheduleResyncRetry($subscriptionModel, $chargeId);
787 + }
788 + return false;
789 + }
790 +
791 + // The sale must be on the list AND completed there — a listed-but-pending
792 + // entry binds nothing downstream (the resync loop only consumes completed
793 + // sales), so it takes the same lag path as an absent one.
794 + $saleInRemoteList = false;
795 + foreach ($remoteTransactions as $remoteTransaction) {
796 + if (Arr::get($remoteTransaction, 'id') === $chargeId
797 + && strtolower((string) Arr::get($remoteTransaction, 'status')) === 'completed'
798 + ) {
799 + $saleInRemoteList = true;
800 + break;
801 + }
802 + }
803 +
804 + if (!$saleInRemoteList) {
805 + self::$recurringPaymentError = new \WP_Error(
806 + 'paypal_transaction_list_lagging',
807 + __('The incoming sale is not yet on the PayPal transaction list.', 'fluent-cart')
808 + );
809 + self::scheduleResyncRetry($subscriptionModel, $chargeId);
810 + return false;
811 + }
812 +
813 + $earliestSale = $paypalSubscriptions->getEarliestCompletedRemoteSale($remoteTransactions);
814 +
815 + if ($earliestSale && Arr::get($earliestSale, 'id') === $chargeId) {
816 + $paypalSubscriptions->bindSaleToTransaction(
817 + $latestTransaction,
818 + $chargeId,
819 + $amount,
820 + Arr::get($paypalSubscription, 'subscriber', []),
821 + DateTime::anyTimeToGmt(Arr::get($earliestSale, 'time'))->format('Y-m-d H:i:s')
822 + );
823 +
824 + return true;
825 + }
826 +
827 + $result = $paypalSubscriptions->reSyncSubscriptionFromRemote(
828 + $subscriptionModel,
829 + $paypalSubscription,
830 + $remoteTransactions
831 + );
832 +
833 + if (is_wp_error($result)) {
834 + if (self::isTerminalRenewalError($result, $subscriptionModel)) {
835 + return true;
836 + }
837 +
838 + self::$recurringPaymentError = $result;
839 + if (self::isRetryableRemoteError($result)) {
840 + self::scheduleResyncRetry($subscriptionModel, $chargeId);
841 + }
842 + return false;
843 + }
844 +
610 845 return true;
611 846 }
612 847
613 848
@@ -656,9 +891,254 @@
656 891 'payer' => $payer
657 892 ]
658 893 ];
659 894
660 - return SubscriptionService::recordRenewalPayment($transactionData, $subscriptionModel, $subscriptionUpdateData);
895 + // Credited before recording: recordRenewalPayment() recomputes bill_count
896 + // and the installment end-of-term inside itself, so an outstanding-balance
897 + // collection's extra cycles must already be on the books when it runs.
898 + // Idempotent per sale id, so a failed record retried later credits once.
899 + $paypalSubscriptions = new PayPalSubscriptions();
900 + $credited = $paypalSubscriptions->creditOutstandingCollection($subscriptionModel, $chargeId, $amount);
901 +
902 + // Credit undecided: record nothing. Once a transaction exists this
903 + // method returns early on every redelivery, so the missed cycles would
904 + // become unreachable — leaving the sale unrecorded keeps both recovery
905 + // channels (PayPal redelivery, the resync ladder) able to repair it.
906 + if (is_wp_error($credited)) {
907 + self::$recurringPaymentError = $credited;
908 + self::scheduleResyncRetry($subscriptionModel, $chargeId);
909 + return false;
910 + }
911 +
912 + $result = SubscriptionService::recordRenewalPayment($transactionData, $subscriptionModel, $subscriptionUpdateData);
913 +
914 + if (is_wp_error($result)) {
915 + if ($result->get_error_code() === 'transaction_exists') {
916 + return true;
917 + }
918 +
919 + if ($result->get_error_code() === 'lock_failed') {
920 + // Contention is not proof of a committed payment. Keep the credit
921 + // available to the lock holder, but retry until the sale is recorded.
922 + if ($paypalSubscriptions->hasRecordedSale($subscriptionModel, $chargeId)) {
923 + return true;
924 + }
925 +
926 + self::$recurringPaymentError = $result;
927 + self::scheduleResyncRetry($subscriptionModel, $chargeId);
928 + return false;
929 + }
930 +
931 + if ($credited) {
932 + $paypalSubscriptions->revokeOutstandingCollection($subscriptionModel, $chargeId);
933 + }
934 +
935 + if (self::isTerminalRenewalError($result, $subscriptionModel)) {
936 + return true;
937 + }
938 +
939 + self::$recurringPaymentError = $result;
940 + if (self::isRetryableRemoteError($result)) {
941 + self::scheduleResyncRetry($subscriptionModel, $chargeId);
942 + }
943 + return false;
944 + }
945 +
946 + return true;
947 + }
948 +
949 + /**
950 + * Queue a resync at +1h / +6h / +24h as a backstop to PayPal's finite redelivery
951 + * window. One ladder per subscription (args are [subscription, attempt]); the
952 + * sale ids it chases accumulate in PENDING_SALES_META.
953 + *
954 + * @param Subscription $subscriptionModel
955 + * @param string|null $saleId the sale this attempt is chasing, if any
956 + * @param int $attempt 1-based position on the delay ladder
957 + * @return void
958 + */
959 + private static function scheduleResyncRetry(Subscription $subscriptionModel, $saleId = null, $attempt = 1)
960 + {
961 + // After the guard: without Action Scheduler no worker ever prunes the set.
962 + if (!function_exists('as_schedule_single_action') || !function_exists('as_next_scheduled_action')) {
963 + return;
964 + }
965 +
966 + if ($saleId) {
967 + $pending = self::getPendingSales($subscriptionModel);
968 + if (!in_array($saleId, $pending, true)) {
969 + $pending[] = $saleId;
970 + $subscriptionModel->updateMeta(self::PENDING_SALES_META, $pending);
971 + }
972 + }
973 +
974 + $delays = [HOUR_IN_SECONDS, 6 * HOUR_IN_SECONDS, DAY_IN_SECONDS];
975 +
976 + if (!isset($delays[$attempt - 1])) {
977 + fluent_cart_add_log(
978 + __('PayPal renewal resync retries exhausted — needs manual review', 'fluent-cart'),
979 + sprintf(
980 + /* translators: 1: subscription ID, 2: comma separated PayPal sale IDs */
981 + __('All scheduled resync retries failed. Subscription ID: %1$d. Unresolved PayPal sales: %2$s', 'fluent-cart'),
982 + $subscriptionModel->id,
983 + implode(', ', self::getPendingSales($subscriptionModel)) ?: __('none recorded', 'fluent-cart')
984 + ),
985 + 'error',
986 + [
987 + 'module_type' => 'FluentCart\App\Models\Subscription',
988 + 'module_id' => $subscriptionModel->id,
989 + 'module_name' => 'subscription',
990 + 'log_type' => 'webhook'
991 + ]
992 + );
993 + $subscriptionModel->deleteMeta(self::PENDING_SALES_META);
994 + return;
995 + }
996 +
997 + // Start at $attempt so the running attempt's own in-progress action
998 + // (which as_next_scheduled_action reports as scheduled) can't block
999 + // its follow-up.
1000 + for ($pending = $attempt; $pending <= count($delays); $pending++) {
1001 + if (as_next_scheduled_action(self::RESYNC_RETRY_HOOK, [$subscriptionModel->id, $pending], 'fluent-cart')) {
1002 + return;
1003 + }
1004 + }
1005 +
1006 + as_schedule_single_action(
1007 + time() + $delays[$attempt - 1],
1008 + self::RESYNC_RETRY_HOOK,
1009 + [$subscriptionModel->id, $attempt],
1010 + 'fluent-cart'
1011 + );
1012 + }
1013 +
1014 + public function handleResyncRetry($subscriptionId, $attempt = 1)
1015 + {
1016 + /** @var Subscription|null $subscriptionModel */
1017 + $subscriptionModel = Subscription::query()->find($subscriptionId);
1018 +
1019 + if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal' || !$subscriptionModel->vendor_subscription_id) {
1020 + return;
1021 + }
1022 +
1023 + $result = (new PayPalSubscriptions())->reSyncSubscriptionFromRemote($subscriptionModel);
1024 +
1025 + if (is_wp_error($result)) {
1026 + if (self::isTerminalRenewalError($result, $subscriptionModel) || !self::isRetryableRemoteError($result)) {
1027 + $subscriptionModel->deleteMeta(self::PENDING_SALES_META);
1028 + return;
1029 + }
1030 +
1031 + self::scheduleResyncRetry($subscriptionModel, null, (int) $attempt + 1);
1032 + return;
1033 + }
1034 +
1035 + // A successful resync only means PayPal answered. Its transaction list
1036 + // lags, so the sale that armed this ladder can still be absent (or
1037 + // listed as non-completed, which binds nothing). Local rows are the
1038 + // only proof the payment landed.
1039 + if (self::pruneResolvedSales($subscriptionModel)) {
1040 + self::scheduleResyncRetry($subscriptionModel, null, (int) $attempt + 1);
1041 + }
1042 + }
1043 +
1044 + /**
1045 + * @param Subscription $subscriptionModel
1046 + * @return string[]
1047 + */
1048 + private static function getPendingSales(Subscription $subscriptionModel)
1049 + {
1050 + $pending = $subscriptionModel->getMeta(self::PENDING_SALES_META, []);
1051 +
1052 + return array_values(array_filter((array) $pending, 'is_string'));
1053 + }
1054 +
1055 + /**
1056 + * Drop the sales that now have a local transaction, keep the rest.
1057 + * A recorded-then-refunded row still counts as recorded.
1058 + *
1059 + * @param Subscription $subscriptionModel
1060 + * @return bool true while at least one sale is still unaccounted for
1061 + */
1062 + private static function pruneResolvedSales(Subscription $subscriptionModel)
1063 + {
1064 + $pending = self::getPendingSales($subscriptionModel);
1065 +
1066 + if (!$pending) {
1067 + return false;
1068 + }
1069 +
1070 + $recorded = OrderTransaction::query()
1071 + ->where('subscription_id', $subscriptionModel->id)
1072 + ->whereIn('vendor_charge_id', $pending)
1073 + ->get(['vendor_charge_id'])
1074 + ->pluck('vendor_charge_id')
1075 + ->toArray();
1076 +
1077 + $unresolved = array_values(array_diff($pending, $recorded));
1078 +
1079 + if ($unresolved === $pending) {
1080 + return true;
1081 + }
1082 +
1083 + // An empty array does not survive the meta cast round-trip, so drop the row.
1084 + if ($unresolved) {
1085 + $subscriptionModel->updateMeta(self::PENDING_SALES_META, $unresolved);
1086 + } else {
1087 + $subscriptionModel->deleteMeta(self::PENDING_SALES_META);
1088 + }
1089 +
1090 + return (bool) $unresolved;
1091 + }
1092 +
1093 + /**
1094 + * A retry only helps against transient remote failures. PayPal reports a
1095 + * missing/invalid resource in the error body's `name` field (the WP_Error
1096 + * code stays `general_error` for REST errors), so inspect the data.
1097 + *
1098 + * @param \WP_Error $error
1099 + * @return bool
1100 + */
1101 + private static function isRetryableRemoteError($error)
1102 + {
1103 + $data = $error->get_error_data();
1104 + $name = is_array($data) ? Arr::get($data, 'name', '') : '';
1105 +
1106 + return !in_array($name, ['RESOURCE_NOT_FOUND', 'INVALID_RESOURCE_ID'], true);
1107 + }
1108 +
1109 + /**
1110 + * Errors redelivery can never fix (local records gone): log loudly and ack,
1111 + * since a 500 would make PayPal retry for days and can disable the endpoint.
1112 + *
1113 + * @param \WP_Error $error
1114 + * @param Subscription $subscriptionModel
1115 + * @return bool true when the error was terminal and has been logged
1116 + */
1117 + private static function isTerminalRenewalError($error, $subscriptionModel)
1118 + {
1119 + if (!in_array($error->get_error_code(), ['subscription_not_found', 'parent_order_not_found'], true)) {
1120 + return false;
1121 + }
1122 +
1123 + fluent_cart_add_log(
1124 + __('PayPal renewal payment could not be recorded — needs manual review', 'fluent-cart'),
1125 + sprintf(
1126 + /* translators: %1$s: error message, %2$d: subscription ID */
1127 + __('%1$s Subscription ID: %2$d', 'fluent-cart'),
1128 + $error->get_error_message(),
1129 + $subscriptionModel->id
1130 + ),
1131 + 'error',
1132 + [
1133 + 'module_type' => 'FluentCart\App\Models\Subscription',
1134 + 'module_id' => $subscriptionModel->id,
1135 + 'module_name' => 'subscription',
1136 + 'log_type' => 'webhook'
1137 + ]
1138 + );
1139 +
1140 + return true;
661 1141 }
662 1142
663 1143 public function handleSinglePaymentRefund($data)
664 1144 {