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 +634 -36 1.3.26 → 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;
@@ -9,8 +10,9 @@
9 10 use FluentCart\App\Models\Subscription;
10 11 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API;
11 12 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
12 13 use FluentCart\App\Services\DateTime\DateTime;
14 +use FluentCart\App\Services\Payments\SubscriptionHelper;
13 15 use FluentCart\Framework\Support\Arr;
14 16
15 17 class IPN
16 18 {
@@ -15,10 +17,26 @@
15 17 class IPN
16 18 {
17 19 private const TEST_VERIFYING_URL = 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature';
18 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';
19 23 private static $paypalSettings = null;
20 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 +
21 39 public function init()
22 40 {
23 41 // New
24 42 add_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [$this, 'processChargeCaptured'], 10, 1);
@@ -31,8 +49,9 @@
31 49 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_cancelled', [$this, 'handleWebhookRecurringProfileCancelled'], 10, 1);
32 50 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_expired', [$this, 'handleWebhookRecurringProfileExpired'], 10, 1);
33 51 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_suspended', [$this, 'handleWebhookRecurringProfileSuspended'], 10, 1);
34 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);
35 54
36 55 // dispute
37 56 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_created', [$this, 'handleWebhookDisputeCreated'], 10, 1);
38 57 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_updated', [$this, 'handleWebhookDisputeUpdated'], 10, 1);
@@ -37,8 +56,9 @@
37 56 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_created', [$this, 'handleWebhookDisputeCreated'], 10, 1);
38 57 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_updated', [$this, 'handleWebhookDisputeUpdated'], 10, 1);
39 58 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_resolved', [$this, 'handleWebhookDisputeResolved'], 10, 1);
40 59
60 + add_action(self::RESYNC_RETRY_HOOK, [$this, 'handleResyncRetry'], 10, 2);
41 61 }
42 62
43 63 public function processPaypalWebhookEvents($event): void
44 64 {
@@ -54,12 +74,27 @@
54 74
55 75 if ($eventType === 'payment_sale_completed') {
56 76 $billingAgreementId = Arr::get($resource, 'billing_agreement_id', '');
57 77 if ($billingAgreementId) {
58 - do_action('fluent_cart/payments/paypal/webhook_subscription_payment_received', [
59 - 'charge' => $resource,
60 - 'vendor_subscription_id' => $billingAgreementId,
61 - ]);
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 + }
62 97 } else {
63 98 // do not need webhook for one time payment
64 99 do_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [
65 100 'charge' => $resource
@@ -93,9 +128,10 @@
93 128 * fluent_cart/payments/paypal/webhook_billing_subscription_suspended
94 129 * fluent_cart/payments/paypal/webhook_billing_subscription_re-activated
95 130 */
96 131 do_action('fluent_cart/payments/paypal/webhook_' . $eventType, [
97 - 'paypal_subscription' => $resource
132 + 'paypal_subscription' => $resource,
133 + 'webhook_event_id' => Arr::get($event, 'id', '')
98 134 ]);
99 135 }
100 136
101 137 }
@@ -105,9 +141,104 @@
105 141 {
106 142 $charge = Arr::get($data, 'charge', []);
107 143
108 144 $vendorChargeId = Arr::get($charge, 'id', '');
145 + $vendorSubscriptionId = Arr::get($data, 'vendor_subscription_id', '');
109 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 +
110 241 $transaction = OrderTransaction::query()->where('vendor_charge_id', $vendorChargeId)->first();
111 242
112 243 if (!$transaction) {
113 244 // We did not find the charge. So let's find the parent order ID and transactio reference
@@ -190,15 +321,17 @@
190 321 );
191 322 return;
192 323 }
193 324
194 - if ($transaction->total > 0 && $paidAmount != $transaction->total) {
325 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
326 +
327 + if ($transaction->total > 0 && $paidAmount != $expectedAmount) {
195 328 fluent_cart_add_log(
196 329 __('PayPal Webhook Amount Mismatch', 'fluent-cart'),
197 330 sprintf(
198 331 /* translators: %1$s: expected amount, %2$s: received amount, %3$s: transaction UUID */
199 332 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Order not confirmed.', 'fluent-cart'),
200 - Helper::toDecimal($transaction->total),
333 + Helper::toDecimal($expectedAmount),
201 334 Helper::toDecimal($paidAmount),
202 335 $transaction->uuid
203 336 ),
204 337 'error',
@@ -222,8 +355,12 @@
222 355 'payer' => Arr::get($paypalIntent, 'payer', [])
223 356 ]
224 357 ]);
225 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 +
226 363 }
227 364
228 365
229 366 // called only when webhook/ipn hits
@@ -234,12 +371,13 @@
234 371
235 372 /**
236 373 * Verify the webhook signature
237 374 *
238 - * @param string $webhookId
239 - * @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
240 378 */
241 - public function verifyWebhook($webhookId)
379 + public function verifyWebhook($webhookId, $rawBody = null)
242 380 {
243 381 $disableWebhookVerification = apply_filters('fluent_cart/payments/paypal/disable_webhook_verification', 'no', []);
244 382 if ($disableWebhookVerification === 'yes') {
245 383 return true;
@@ -249,9 +387,9 @@
249 387 return new \WP_Error('webhook_id_missing', __('Webhook ID is missing.', 'fluent-cart'));
250 388 }
251 389
252 390 $webhookId = trim($webhookId);
253 - $header = getallheaders();
391 + $header = self::getRequestHeaders();
254 392
255 393 // make all headers lowercase
256 394 $header = array_change_key_case($header, CASE_LOWER);
257 395 if (!isset($header['paypal-auth-algo']) || !isset($header['paypal-cert-url']) ||
@@ -262,9 +400,12 @@
262 400 'headers' => $header
263 401 ]);
264 402 }
265 403
266 - $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);
267 408 $body = [
268 409 'auth_algo' => $header['paypal-auth-algo'],
269 410 'transmission_id' => $header['paypal-transmission-id'],
270 411 'transmission_time' => $header['paypal-transmission-time'],
@@ -304,14 +445,33 @@
304 445 }
305 446
306 447 public function processWebhook()
307 448 {
308 - $post_data = file_get_contents('php://input');
449 + $statusCode = $this->handleWebhookRequest(file_get_contents('php://input'));
309 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 +
310 470 $data = json_decode($post_data, true);
311 471
312 472 if (empty($data)) {
313 - return;
473 + return 200;
314 474 }
315 475
316 476 $webhookType = Arr::get($data, 'event_type', '');
317 477
@@ -324,8 +484,9 @@
324 484 'BILLING.SUBSCRIPTION.CANCELLED',
325 485 'BILLING.SUBSCRIPTION.EXPIRED',
326 486 'BILLING.SUBSCRIPTION.SUSPENDED',
327 487 'BILLING.SUBSCRIPTION.RE-ACTIVATED',
488 + 'BILLING.SUBSCRIPTION.PAYMENT.FAILED',
328 489 'PAYMENT.CAPTURE.COMPLETED',
329 490 'CUSTOMER.DISPUTE.CREATED',
330 491 'CUSTOMER.DISPUTE.UPDATED',
331 492 'CUSTOMER.DISPUTE.RESOLVED',
@@ -332,16 +493,11 @@
332 493 'CHECKOUT.ORDER.APPROVED' // we don't need this
333 494 ];
334 495
335 496 if (!in_array($webhookType, $webhookEvents)) {
336 - return;
497 + return 200;
337 498 }
338 499
339 - do_action('fluent_cart/paypal_webhook_received', [
340 - 'data' => $data,
341 - 'raw' => $post_data
342 - ]);
343 -
344 500 if (defined('FLUENT_CART_DEV_MODE')) {
345 501 do_action('fluent_cart/dev_log', [
346 502 'raw_data' => $post_data,
347 503 'status' => 'received',
@@ -370,9 +526,9 @@
370 526 ]);
371 527
372 528 if ($willVerify) {
373 529
374 - $verified = $this->verifyWebhook($webhookId);
530 + $verified = $this->verifyWebhook($webhookId, $post_data);
375 531
376 532 if (is_wp_error($verified)) {
377 533 $data = json_encode($verified->get_error_data());
378 534 fluent_cart_add_log($verified->get_error_message() . ' Webhook: ' . $webhookType, $data, 'error', [
@@ -380,16 +536,69 @@
380 536 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
381 537 'module_name' => 'PayPal',
382 538 ]);
383 539
384 - exit(400);
540 + return 400;
385 541 }
386 542 }
387 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 +
388 555 $this->processPaypalWebhookEvents($data);
389 - 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;
390 576 }
391 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 +
392 601 public function processSubscriptionActivated($data)
393 602 {
394 603 $paypalSubscription = Arr::get($data, 'paypal_subscription', []);
395 604 $vendorSubscriptionId = sanitize_text_field(Arr::get($paypalSubscription, 'id'));
@@ -417,8 +626,70 @@
417 626
418 627 (new Processor())->activateSubscription($paypalSubscription, $transaction, $subscriptionModel);
419 628 }
420 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 +
421 692 public function processRecurringPaymentReceived($data)
422 693 {
423 694 $charge = Arr::get($data, 'charge', []);
424 695 $vendorSubscriptionId = Arr::get($data, 'vendor_subscription_id', '');
@@ -435,8 +706,12 @@
435 706 if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal') {
436 707 return false;
437 708 }
438 709
710 + if ($vendorSubscriptionId && !$subscriptionModel->vendor_subscription_id) {
711 + $subscriptionModel->update(['vendor_subscription_id' => $vendorSubscriptionId]);
712 + }
713 +
439 714 $amount = Helper::toCent(Arr::get($charge, 'amount.total', 0));
440 715 $chargeId = Arr::get($charge, 'id');
441 716 if (!$amount || !$chargeId) {
442 717 return false;
@@ -482,15 +757,92 @@
482 757
483 758 // Latest charge transaction = pending one for initial subscription OR for renewal
484 759 $latestTransaction = $subscriptionModel->getLatestTransaction();
485 760
486 - if ($latestTransaction && $latestTransaction->status !== Status::TRANSACTION_SUCCEEDED && !$latestTransaction->vendor_charge_id && $latestTransaction->total) {
487 - (new Processor())->confirmPaymentSuccessByCharge($latestTransaction, [
488 - 'vendor_charge_id' => $chargeId,
489 - 'status' => Status::TRANSACTION_SUCCEEDED,
490 - 'total' => $amount,
491 - 'payment_method_type' => 'PayPal',
492 - ]);
761 + if ($latestTransaction && !$latestTransaction->vendor_charge_id && $latestTransaction->total) {
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;
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 +
493 845 return true;
494 846 }
495 847
496 848
@@ -504,11 +856,12 @@
504 856 ];
505 857
506 858 $payer = ($paypalSubscription && !is_wp_error($paypalSubscription)) ? Arr::get($paypalSubscription, 'subscriber', []) : [];
507 859 if ($paypalSubscription && !is_wp_error($paypalSubscription)) {
860 + $subscriptionUpdateData['status'] = (new SubscriptionManager)->getCorrectSubscriptionStatus(Arr::get($paypalSubscription, 'status'));
508 861 $nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time');
509 862 if ($nextBillingDate) {
510 - $subscriptionUpdateData['next_billing_date'] = gmdate('Y-m-d H:i:s', strtotime($nextBillingDate));
863 + $subscriptionUpdateData['next_billing_date'] = SubscriptionHelper::safeTimestampToDatetime($nextBillingDate);
511 864 }
512 865
513 866 $payerId = Arr::get($paypalSubscription, 'subscriber.payer_id');
514 867
@@ -538,11 +891,256 @@
538 891 'payer' => $payer
539 892 ]
540 893 ];
541 894
542 - 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;
543 947 }
544 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 +
545 1143 public function handleSinglePaymentRefund($data)
546 1144 {
547 1145 $refundData = Arr::get($data, 'refund', []);
548 1146 $paypalRefundId = Arr::get($refundData, 'id', '');
@@ -629,12 +1227,8 @@
629 1227 $parentSubscription = Subscription::query()->where('uuid', $subscriptionHash)->first();
630 1228 $parentTransaction = $parentSubscription ? $parentSubscription->getLatestTransaction() : null;
631 1229 }
632 1230
633 - if ($parentTransaction->transaction_type === Status::TRANSACTION_FAILED) {
634 - return null;
635 - }
636 -
637 1231 if (!$parentTransaction) {
638 1232 do_action('fluent_cart/dev_log', [
639 1233 'raw_data' => $data,
640 1234 'status' => 'failed',
@@ -643,8 +1237,12 @@
643 1237 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
644 1238 'module_name' => 'PayPal'
645 1239 ]);
646 1240
1241 + return null;
1242 + }
1243 +
1244 + if ($parentTransaction->status === Status::TRANSACTION_FAILED) {
647 1245 return null;
648 1246 }
649 1247
650 1248 $paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.total', 0));