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 +629 -39 1.4.0 → 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 {
@@ -55,12 +74,27 @@
55 74
56 75 if ($eventType === 'payment_sale_completed') {
57 76 $billingAgreementId = Arr::get($resource, 'billing_agreement_id', '');
58 77 if ($billingAgreementId) {
59 - do_action('fluent_cart/payments/paypal/webhook_subscription_payment_received', [
60 - 'charge' => $resource,
61 - 'vendor_subscription_id' => $billingAgreementId,
62 - ]);
78 + $subscriptionHash = Arr::get($resource, 'custom', '');
79 + $subscription = $subscriptionHash ? Subscription::query()
80 + ->where('uuid', $subscriptionHash)
81 + ->where('current_payment_method', 'paypal')
82 + ->first() : null;
83 +
84 + if ($subscription && $subscription->status === Status::SUBSCRIPTION_INTENDED) {
85 + // First payment - confirm initial order and activate subscription, rare case
86 + do_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [
87 + 'charge' => $resource,
88 + 'vendor_subscription_id' => $billingAgreementId,
89 + ]);
90 + } else {
91 + // Renewal payment
92 + do_action('fluent_cart/payments/paypal/webhook_subscription_payment_received', [
93 + 'charge' => $resource,
94 + 'vendor_subscription_id' => $billingAgreementId,
95 + ]);
96 + }
63 97 } else {
64 98 // do not need webhook for one time payment
65 99 do_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [
66 100 'charge' => $resource
@@ -94,9 +128,10 @@
94 128 * fluent_cart/payments/paypal/webhook_billing_subscription_suspended
95 129 * fluent_cart/payments/paypal/webhook_billing_subscription_re-activated
96 130 */
97 131 do_action('fluent_cart/payments/paypal/webhook_' . $eventType, [
98 - 'paypal_subscription' => $resource
132 + 'paypal_subscription' => $resource,
133 + 'webhook_event_id' => Arr::get($event, 'id', '')
99 134 ]);
100 135 }
101 136
102 137 }
@@ -106,9 +141,104 @@
106 141 {
107 142 $charge = Arr::get($data, 'charge', []);
108 143
109 144 $vendorChargeId = Arr::get($charge, 'id', '');
145 + $vendorSubscriptionId = Arr::get($data, 'vendor_subscription_id', '');
110 146
147 + // Handle first payment for intended subscriptions
148 + if ($vendorSubscriptionId) {
149 + // Same reasoning as processPaypalWebhookEvents(): match by uuid, not
150 + // vendor_subscription_id, which isn't set yet for an intended subscription.
151 + $subscriptionHash = Arr::get($charge, 'custom', '');
152 + $subscription = $subscriptionHash ? Subscription::query()
153 + ->where('uuid', $subscriptionHash)
154 + ->where('current_payment_method', 'paypal')
155 + ->first() : null;
156 +
157 + if ($subscription && $subscription->status === Status::SUBSCRIPTION_INTENDED) {
158 + $transaction = $subscription->getLatestTransaction();
159 + if ($transaction) {
160 + $mismatch = false;
161 +
162 + if ($transaction->status !== Status::TRANSACTION_SUCCEEDED) {
163 + $paidAmount = Helper::toCent(Arr::get($charge, 'amount.total', 0));
164 + $paidCurrency = strtoupper(Arr::get($charge, 'amount.currency', ''));
165 +
166 + if ($paidCurrency && $transaction->currency && strtoupper($transaction->currency) !== $paidCurrency) {
167 + $mismatch = true;
168 + fluent_cart_add_log(
169 + __('PayPal Webhook Currency Mismatch', 'fluent-cart'),
170 + sprintf(
171 + /* translators: %1$s: expected currency, %2$s: received currency, %3$s: transaction UUID */
172 + __('Payment currency mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Subscription not confirmed.', 'fluent-cart'),
173 + $transaction->currency,
174 + $paidCurrency,
175 + $transaction->uuid
176 + ),
177 + 'error',
178 + [
179 + 'module_name' => 'order',
180 + 'module_id' => $transaction->order_id,
181 + 'log_type' => 'webhook'
182 + ]
183 + );
184 + } else if ($transaction->total > 0 && $paidAmount != PayPalHelper::wireCents($transaction->total, $transaction->currency)) {
185 + $mismatch = true;
186 + fluent_cart_add_log(
187 + __('PayPal Webhook Amount Mismatch', 'fluent-cart'),
188 + sprintf(
189 + /* translators: %1$s: expected amount, %2$s: received amount, %3$s: transaction UUID */
190 + __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Subscription not confirmed.', 'fluent-cart'),
191 + Helper::toDecimal(PayPalHelper::wireCents($transaction->total, $transaction->currency)),
192 + Helper::toDecimal($paidAmount),
193 + $transaction->uuid
194 + ),
195 + 'error',
196 + [
197 + 'module_name' => 'order',
198 + 'module_id' => $transaction->order_id,
199 + 'log_type' => 'webhook'
200 + ]
201 + );
202 + } else {
203 + // Confirm transaction with actual charge amount from webhook
204 + (new Processor())->confirmPaymentSuccessByCharge($transaction, [
205 + 'vendor_charge_id' => $vendorChargeId,
206 + 'status' => Status::TRANSACTION_SUCCEEDED,
207 + 'total' => $paidAmount,
208 + 'payment_method_type' => 'PayPal',
209 + ]);
210 + }
211 + }
212 +
213 + if (!$mismatch) {
214 + // Activate even if the transaction was already confirmed elsewhere (e.g. AJAX return) — activateSubscription() guards against re-activating.
215 + $paypalSubscription = API::getResource('billing/subscriptions/' . $vendorSubscriptionId);
216 + if (!is_wp_error($paypalSubscription) && $paypalSubscription) {
217 + (new Processor())->activateSubscription($paypalSubscription, $transaction, $subscription);
218 + } else {
219 + fluent_cart_add_log(
220 + __('PayPal Subscription Activation Skipped', 'fluent-cart'),
221 + sprintf(
222 + /* translators: %1$s: subscription UUID, %2$s: vendor subscription ID */
223 + __('Could not fetch PayPal subscription resource to activate. Subscription: %1$s, Vendor Subscription ID: %2$s.', 'fluent-cart'),
224 + $subscription->uuid,
225 + $vendorSubscriptionId
226 + ),
227 + 'error',
228 + [
229 + 'module_name' => 'order',
230 + 'module_id' => $transaction->order_id,
231 + 'log_type' => 'webhook'
232 + ]
233 + );
234 + }
235 + }
236 + }
237 + return;
238 + }
239 + }
240 +
111 241 $transaction = OrderTransaction::query()->where('vendor_charge_id', $vendorChargeId)->first();
112 242
113 243 if (!$transaction) {
114 244 // We did not find the charge. So let's find the parent order ID and transactio reference
@@ -191,15 +321,17 @@
191 321 );
192 322 return;
193 323 }
194 324
195 - if ($transaction->total > 0 && $paidAmount != $transaction->total) {
325 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
326 +
327 + if ($transaction->total > 0 && $paidAmount != $expectedAmount) {
196 328 fluent_cart_add_log(
197 329 __('PayPal Webhook Amount Mismatch', 'fluent-cart'),
198 330 sprintf(
199 331 /* translators: %1$s: expected amount, %2$s: received amount, %3$s: transaction UUID */
200 332 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Order not confirmed.', 'fluent-cart'),
201 - Helper::toDecimal($transaction->total),
333 + Helper::toDecimal($expectedAmount),
202 334 Helper::toDecimal($paidAmount),
203 335 $transaction->uuid
204 336 ),
205 337 'error',
@@ -223,8 +355,12 @@
223 355 'payer' => Arr::get($paypalIntent, 'payer', [])
224 356 ]
225 357 ]);
226 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 +
227 363 }
228 364
229 365
230 366 // called only when webhook/ipn hits
@@ -235,12 +371,13 @@
235 371
236 372 /**
237 373 * Verify the webhook signature
238 374 *
239 - * @param string $webhookId
240 - * @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
241 378 */
242 - public function verifyWebhook($webhookId)
379 + public function verifyWebhook($webhookId, $rawBody = null)
243 380 {
244 381 $disableWebhookVerification = apply_filters('fluent_cart/payments/paypal/disable_webhook_verification', 'no', []);
245 382 if ($disableWebhookVerification === 'yes') {
246 383 return true;
@@ -250,9 +387,9 @@
250 387 return new \WP_Error('webhook_id_missing', __('Webhook ID is missing.', 'fluent-cart'));
251 388 }
252 389
253 390 $webhookId = trim($webhookId);
254 - $header = getallheaders();
391 + $header = self::getRequestHeaders();
255 392
256 393 // make all headers lowercase
257 394 $header = array_change_key_case($header, CASE_LOWER);
258 395 if (!isset($header['paypal-auth-algo']) || !isset($header['paypal-cert-url']) ||
@@ -263,9 +400,12 @@
263 400 'headers' => $header
264 401 ]);
265 402 }
266 403
267 - $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);
268 408 $body = [
269 409 'auth_algo' => $header['paypal-auth-algo'],
270 410 'transmission_id' => $header['paypal-transmission-id'],
271 411 'transmission_time' => $header['paypal-transmission-time'],
@@ -305,14 +445,33 @@
305 445 }
306 446
307 447 public function processWebhook()
308 448 {
309 - $post_data = file_get_contents('php://input');
449 + $statusCode = $this->handleWebhookRequest(file_get_contents('php://input'));
310 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 +
311 470 $data = json_decode($post_data, true);
312 471
313 472 if (empty($data)) {
314 - return;
473 + return 200;
315 474 }
316 475
317 476 $webhookType = Arr::get($data, 'event_type', '');
318 477
@@ -325,8 +484,9 @@
325 484 'BILLING.SUBSCRIPTION.CANCELLED',
326 485 'BILLING.SUBSCRIPTION.EXPIRED',
327 486 'BILLING.SUBSCRIPTION.SUSPENDED',
328 487 'BILLING.SUBSCRIPTION.RE-ACTIVATED',
488 + 'BILLING.SUBSCRIPTION.PAYMENT.FAILED',
329 489 'PAYMENT.CAPTURE.COMPLETED',
330 490 'CUSTOMER.DISPUTE.CREATED',
331 491 'CUSTOMER.DISPUTE.UPDATED',
332 492 'CUSTOMER.DISPUTE.RESOLVED',
@@ -333,16 +493,11 @@
333 493 'CHECKOUT.ORDER.APPROVED' // we don't need this
334 494 ];
335 495
336 496 if (!in_array($webhookType, $webhookEvents)) {
337 - return;
497 + return 200;
338 498 }
339 499
340 - do_action('fluent_cart/paypal_webhook_received', [
341 - 'data' => $data,
342 - 'raw' => $post_data
343 - ]);
344 -
345 500 if (defined('FLUENT_CART_DEV_MODE')) {
346 501 do_action('fluent_cart/dev_log', [
347 502 'raw_data' => $post_data,
348 503 'status' => 'received',
@@ -371,9 +526,9 @@
371 526 ]);
372 527
373 528 if ($willVerify) {
374 529
375 - $verified = $this->verifyWebhook($webhookId);
530 + $verified = $this->verifyWebhook($webhookId, $post_data);
376 531
377 532 if (is_wp_error($verified)) {
378 533 $data = json_encode($verified->get_error_data());
379 534 fluent_cart_add_log($verified->get_error_message() . ' Webhook: ' . $webhookType, $data, 'error', [
@@ -381,16 +536,69 @@
381 536 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
382 537 'module_name' => 'PayPal',
383 538 ]);
384 539
385 - exit(400);
540 + return 400;
386 541 }
387 542 }
388 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 +
389 555 $this->processPaypalWebhookEvents($data);
390 - 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;
391 576 }
392 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 +
393 601 public function processSubscriptionActivated($data)
394 602 {
395 603 $paypalSubscription = Arr::get($data, 'paypal_subscription', []);
396 604 $vendorSubscriptionId = sanitize_text_field(Arr::get($paypalSubscription, 'id'));
@@ -418,8 +626,70 @@
418 626
419 627 (new Processor())->activateSubscription($paypalSubscription, $transaction, $subscriptionModel);
420 628 }
421 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 +
422 692 public function processRecurringPaymentReceived($data)
423 693 {
424 694 $charge = Arr::get($data, 'charge', []);
425 695 $vendorSubscriptionId = Arr::get($data, 'vendor_subscription_id', '');
@@ -436,8 +706,12 @@
436 706 if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal') {
437 707 return false;
438 708 }
439 709
710 + if ($vendorSubscriptionId && !$subscriptionModel->vendor_subscription_id) {
711 + $subscriptionModel->update(['vendor_subscription_id' => $vendorSubscriptionId]);
712 + }
713 +
440 714 $amount = Helper::toCent(Arr::get($charge, 'amount.total', 0));
441 715 $chargeId = Arr::get($charge, 'id');
442 716 if (!$amount || !$chargeId) {
443 717 return false;
@@ -484,20 +758,91 @@
484 758 // Latest charge transaction = pending one for initial subscription OR for renewal
485 759 $latestTransaction = $subscriptionModel->getLatestTransaction();
486 760
487 761 if ($latestTransaction && !$latestTransaction->vendor_charge_id && $latestTransaction->total) {
488 - if ($latestTransaction->status !== Status::TRANSACTION_SUCCEEDED) {
489 - (new Processor())->confirmPaymentSuccessByCharge($latestTransaction, [
490 - 'vendor_charge_id' => $chargeId,
491 - 'status' => Status::TRANSACTION_SUCCEEDED,
492 - 'total' => $amount,
493 - 'payment_method_type' => 'PayPal',
494 - ]);
495 - } else {
496 - // activateSubscription() already marked this succeeded (billing_info.last_payment matched),
497 - // but vendor_charge_id was not available at that point — fill it in now.
498 - $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;
499 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 +
500 845 return true;
501 846 }
502 847
503 848
@@ -546,11 +891,256 @@
546 891 'payer' => $payer
547 892 ]
548 893 ];
549 894
550 - 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;
551 947 }
552 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;
1141 + }
1142 +
553 1143 public function handleSinglePaymentRefund($data)
554 1144 {
555 1145 $refundData = Arr::get($data, 'refund', []);
556 1146 $paypalRefundId = Arr::get($refundData, 'id', '');
@@ -637,12 +1227,8 @@
637 1227 $parentSubscription = Subscription::query()->where('uuid', $subscriptionHash)->first();
638 1228 $parentTransaction = $parentSubscription ? $parentSubscription->getLatestTransaction() : null;
639 1229 }
640 1230
641 - if ($parentTransaction->transaction_type === Status::TRANSACTION_FAILED) {
642 - return null;
643 - }
644 -
645 1231 if (!$parentTransaction) {
646 1232 do_action('fluent_cart/dev_log', [
647 1233 'raw_data' => $data,
648 1234 'status' => 'failed',
@@ -651,8 +1237,12 @@
651 1237 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
652 1238 'module_name' => 'PayPal'
653 1239 ]);
654 1240
1241 + return null;
1242 + }
1243 +
1244 + if ($parentTransaction->status === Status::TRANSACTION_FAILED) {
655 1245 return null;
656 1246 }
657 1247
658 1248 $paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.total', 0));