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 +509 -33 1.6.1 → 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',
@@ -349,12 +371,13 @@
349 371
350 372 /**
351 373 * Verify the webhook signature
352 374 *
353 - * @param string $webhookId
354 - * @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
355 378 */
356 - public function verifyWebhook($webhookId)
379 + public function verifyWebhook($webhookId, $rawBody = null)
357 380 {
358 381 $disableWebhookVerification = apply_filters('fluent_cart/payments/paypal/disable_webhook_verification', 'no', []);
359 382 if ($disableWebhookVerification === 'yes') {
360 383 return true;
@@ -364,9 +387,9 @@
364 387 return new \WP_Error('webhook_id_missing', __('Webhook ID is missing.', 'fluent-cart'));
365 388 }
366 389
367 390 $webhookId = trim($webhookId);
368 - $header = getallheaders();
391 + $header = self::getRequestHeaders();
369 392
370 393 // make all headers lowercase
371 394 $header = array_change_key_case($header, CASE_LOWER);
372 395 if (!isset($header['paypal-auth-algo']) || !isset($header['paypal-cert-url']) ||
@@ -377,9 +400,12 @@
377 400 'headers' => $header
378 401 ]);
379 402 }
380 403
381 - $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);
382 408 $body = [
383 409 'auth_algo' => $header['paypal-auth-algo'],
384 410 'transmission_id' => $header['paypal-transmission-id'],
385 411 'transmission_time' => $header['paypal-transmission-time'],
@@ -419,14 +445,33 @@
419 445 }
420 446
421 447 public function processWebhook()
422 448 {
423 - $post_data = file_get_contents('php://input');
449 + $statusCode = $this->handleWebhookRequest(file_get_contents('php://input'));
424 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 +
425 470 $data = json_decode($post_data, true);
426 471
427 472 if (empty($data)) {
428 - return;
473 + return 200;
429 474 }
430 475
431 476 $webhookType = Arr::get($data, 'event_type', '');
432 477
@@ -439,8 +484,9 @@
439 484 'BILLING.SUBSCRIPTION.CANCELLED',
440 485 'BILLING.SUBSCRIPTION.EXPIRED',
441 486 'BILLING.SUBSCRIPTION.SUSPENDED',
442 487 'BILLING.SUBSCRIPTION.RE-ACTIVATED',
488 + 'BILLING.SUBSCRIPTION.PAYMENT.FAILED',
443 489 'PAYMENT.CAPTURE.COMPLETED',
444 490 'CUSTOMER.DISPUTE.CREATED',
445 491 'CUSTOMER.DISPUTE.UPDATED',
446 492 'CUSTOMER.DISPUTE.RESOLVED',
@@ -447,16 +493,11 @@
447 493 'CHECKOUT.ORDER.APPROVED' // we don't need this
448 494 ];
449 495
450 496 if (!in_array($webhookType, $webhookEvents)) {
451 - return;
497 + return 200;
452 498 }
453 499
454 - do_action('fluent_cart/paypal_webhook_received', [
455 - 'data' => $data,
456 - 'raw' => $post_data
457 - ]);
458 -
459 500 if (defined('FLUENT_CART_DEV_MODE')) {
460 501 do_action('fluent_cart/dev_log', [
461 502 'raw_data' => $post_data,
462 503 'status' => 'received',
@@ -485,9 +526,9 @@
485 526 ]);
486 527
487 528 if ($willVerify) {
488 529
489 - $verified = $this->verifyWebhook($webhookId);
530 + $verified = $this->verifyWebhook($webhookId, $post_data);
490 531
491 532 if (is_wp_error($verified)) {
492 533 $data = json_encode($verified->get_error_data());
493 534 fluent_cart_add_log($verified->get_error_message() . ' Webhook: ' . $webhookType, $data, 'error', [
@@ -495,16 +536,69 @@
495 536 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
496 537 'module_name' => 'PayPal',
497 538 ]);
498 539
499 - exit(400);
540 + return 400;
500 541 }
501 542 }
502 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 +
503 555 $this->processPaypalWebhookEvents($data);
504 - 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;
505 576 }
506 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 +
507 601 public function processSubscriptionActivated($data)
508 602 {
509 603 $paypalSubscription = Arr::get($data, 'paypal_subscription', []);
510 604 $vendorSubscriptionId = sanitize_text_field(Arr::get($paypalSubscription, 'id'));
@@ -532,8 +626,70 @@
532 626
533 627 (new Processor())->activateSubscription($paypalSubscription, $transaction, $subscriptionModel);
534 628 }
535 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 +
536 692 public function processRecurringPaymentReceived($data)
537 693 {
538 694 $charge = Arr::get($data, 'charge', []);
539 695 $vendorSubscriptionId = Arr::get($data, 'vendor_subscription_id', '');
@@ -550,8 +706,12 @@
550 706 if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal') {
551 707 return false;
552 708 }
553 709
710 + if ($vendorSubscriptionId && !$subscriptionModel->vendor_subscription_id) {
711 + $subscriptionModel->update(['vendor_subscription_id' => $vendorSubscriptionId]);
712 + }
713 +
554 714 $amount = Helper::toCent(Arr::get($charge, 'amount.total', 0));
555 715 $chargeId = Arr::get($charge, 'id');
556 716 if (!$amount || !$chargeId) {
557 717 return false;
@@ -598,20 +758,91 @@
598 758 // Latest charge transaction = pending one for initial subscription OR for renewal
599 759 $latestTransaction = $subscriptionModel->getLatestTransaction();
600 760
601 761 if ($latestTransaction && !$latestTransaction->vendor_charge_id && $latestTransaction->total) {
602 - if ($latestTransaction->status !== Status::TRANSACTION_SUCCEEDED) {
603 - (new Processor())->confirmPaymentSuccessByCharge($latestTransaction, [
604 - 'vendor_charge_id' => $chargeId,
605 - 'status' => Status::TRANSACTION_SUCCEEDED,
606 - 'total' => $amount,
607 - 'payment_method_type' => 'PayPal',
608 - ]);
609 - } else {
610 - // activateSubscription() already marked this succeeded (billing_info.last_payment matched),
611 - // but vendor_charge_id was not available at that point — fill it in now.
612 - $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;
613 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 +
614 845 return true;
615 846 }
616 847
617 848
@@ -660,9 +891,254 @@
660 891 'payer' => $payer
661 892 ]
662 893 ];
663 894
664 - 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;
665 1141 }
666 1142
667 1143 public function handleSinglePaymentRefund($data)
668 1144 {