PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 trunk All 48 releases
fluent-cart / app / Modules / PaymentMethods / PayPalGateway / IPN.php

IPN.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Modules/PaymentMethods/PayPalGateway/IPN.php

1,535 lines 62.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\PayPalGateway;
4
5 use FluentCart\Api\StoreSettings;
6 use FluentCart\App\Events\Subscription\SubscriptionRenewalFailed;
7 use FluentCart\App\Helpers\Helper;
8 use FluentCart\App\Helpers\Status;
9 use FluentCart\App\Models\OrderTransaction;
10 use FluentCart\App\Models\Subscription;
11 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API;
12 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
13 use FluentCart\App\Services\DateTime\DateTime;
14 use FluentCart\App\Services\Payments\SubscriptionHelper;
15 use FluentCart\Framework\Support\Arr;
16
17 class IPN
18 {
19 private const TEST_VERIFYING_URL = 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature';
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';
23 private static $paypalSettings = null;
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
39 public function init()
40 {
41 // New
42 add_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [$this, 'processChargeCaptured'], 10, 1);
43
44 // reviewed.
45 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_activated', [$this, 'processSubscriptionActivated'], 10, 1);
46 add_action('fluent_cart/payments/paypal/webhook_subscription_payment_received', [$this, 'processRecurringPaymentReceived'], 10, 1);
47 add_action('fluent_cart/payments/paypal/webhook_payment_capture_refunded', [$this, 'handleSinglePaymentRefund']);
48 add_action('fluent_cart/payments/paypal/webhook_payment_sale_refunded', [$this, 'handleWebhookRecurringPaymentRefunded'], 10, 1);
49 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_cancelled', [$this, 'handleWebhookRecurringProfileCancelled'], 10, 1);
50 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_expired', [$this, 'handleWebhookRecurringProfileExpired'], 10, 1);
51 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_suspended', [$this, 'handleWebhookRecurringProfileSuspended'], 10, 1);
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);
54
55 // dispute
56 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_created', [$this, 'handleWebhookDisputeCreated'], 10, 1);
57 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_updated', [$this, 'handleWebhookDisputeUpdated'], 10, 1);
58 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_resolved', [$this, 'handleWebhookDisputeResolved'], 10, 1);
59
60 add_action(self::RESYNC_RETRY_HOOK, [$this, 'handleResyncRetry'], 10, 2);
61 }
62
63 public function processPaypalWebhookEvents($event): void
64 {
65 $eventType = Arr::get($event, 'event_type', '');
66 $resource = Arr::get($event, 'resource', []);
67
68 if (empty($resource)) {
69 return;
70 }
71
72 // convert event to snake case ex: PAYMENT.SALE.COMPLETED to payment_sale_completed
73 $eventType = strtolower(str_replace('.', '_', $eventType));
74
75 if ($eventType === 'payment_sale_completed') {
76 $billingAgreementId = Arr::get($resource, 'billing_agreement_id', '');
77 if ($billingAgreementId) {
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 }
97 } else {
98 // do not need webhook for one time payment
99 do_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [
100 'charge' => $resource
101 ]);
102 }
103 } else if ($eventType === 'payment_sale_refunded') {
104 // recurring payment refund
105 do_action('fluent_cart/payments/paypal/webhook_payment_sale_refunded', [
106 'refund' => $resource
107 ]);
108 } else if ($eventType === 'payment_capture_refunded') { // this is manly the refund for one time items
109 do_action('fluent_cart/payments/paypal/webhook_payment_capture_refunded', [
110 'refund' => $resource
111 ]);
112 } else if ($eventType === 'payment_capture_completed') {
113 do_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [
114 'charge' => $resource,
115 ]);
116 } else if ( $eventType === 'customer_dispute_created' ||$eventType == 'customer_dispute_updated' || $eventType === 'customer_dispute_resolved') {
117 do_action('fluent_cart/payments/paypal/webhook_' . $eventType, [
118 'dispute' => $resource
119 ]);
120 }
121 else {
122 /**
123 *
124 * fluent_cart/payments/paypal/webhook_billing_subscription_activated
125 * fluent_cart/payments/paypal/webhook_billing_subscription_created
126 * fluent_cart/payments/paypal/webhook_billing_subscription_cancelled
127 * fluent_cart/payments/paypal/webhook_billing_subscription_expired
128 * fluent_cart/payments/paypal/webhook_billing_subscription_suspended
129 * fluent_cart/payments/paypal/webhook_billing_subscription_re-activated
130 */
131 do_action('fluent_cart/payments/paypal/webhook_' . $eventType, [
132 'paypal_subscription' => $resource,
133 'webhook_event_id' => Arr::get($event, 'id', '')
134 ]);
135 }
136
137 }
138
139
140 public function processChargeCaptured($data)
141 {
142 $charge = Arr::get($data, 'charge', []);
143
144 $vendorChargeId = Arr::get($charge, 'id', '');
145 $vendorSubscriptionId = Arr::get($data, 'vendor_subscription_id', '');
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
241 $transaction = OrderTransaction::query()->where('vendor_charge_id', $vendorChargeId)->first();
242
243 if (!$transaction) {
244 // We did not find the charge. So let's find the parent order ID and transactio reference
245 $parentIntentId = Arr::get($charge, 'supplementary_data.related_ids.order_id', '');
246 if ($parentIntentId) {
247 $paypalIntent = API::verifyPayment($parentIntentId);
248 if (is_wp_error($paypalIntent)) {
249 return;
250 }
251
252 $transactionHash = Arr::get($paypalIntent, 'purchase_units.0.reference_id', '');
253 if ($transactionHash) {
254 $transaction = OrderTransaction::query()
255 ->where('uuid', $transactionHash)
256 ->first();
257 }
258 }
259 }
260
261 if (!$transaction) {
262 // not our transaction!
263 return;
264 }
265
266 if ($transaction->status == Status::TRANSACTION_SUCCEEDED) {
267 if (!$transaction->vendor_charge_id) {
268 // We are just updating the vendor charge ID
269 $transaction->vendor_charge_id = $vendorChargeId;
270 $transaction->save();
271 }
272
273 // already processed
274 return;
275 }
276
277 // get full payment intent
278 $paypalOrderId = Arr::get($charge, 'supplementary_data.related_ids.order_id', '');
279 $paypalIntent = API::verifyPayment($paypalOrderId);
280
281 if (is_wp_error($paypalIntent)) {
282 fluent_cart_add_log(
283 __('PayPal Webhook Verification Failed', 'fluent-cart'),
284 __('Could not verify PayPal payment from webhook. Charge ID: ', 'fluent-cart') . $vendorChargeId,
285 'error',
286 [
287 'module_name' => 'order',
288 'module_id' => $transaction->order_id,
289 'log_type' => 'webhook'
290 ]
291 );
292 return;
293 }
294
295 // Verify that the paid amount and currency match the expected transaction
296 $paidAmount = 0;
297 $paidCurrency = '';
298 foreach (Arr::get($paypalIntent, 'purchase_units', []) as $unit) {
299 $paidAmount += Helper::toCent(Arr::get($unit, 'amount.value', 0));
300 if (!$paidCurrency) {
301 $paidCurrency = strtoupper(Arr::get($unit, 'amount.currency_code', ''));
302 }
303 }
304
305 if ($paidCurrency && $transaction->currency && strtoupper($transaction->currency) !== $paidCurrency) {
306 fluent_cart_add_log(
307 __('PayPal Webhook Currency Mismatch', 'fluent-cart'),
308 sprintf(
309 /* translators: %1$s: expected currency, %2$s: received currency, %3$s: transaction UUID */
310 __('Payment currency mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Order not confirmed.', 'fluent-cart'),
311 $transaction->currency,
312 $paidCurrency,
313 $transaction->uuid
314 ),
315 'error',
316 [
317 'module_name' => 'order',
318 'module_id' => $transaction->order_id,
319 'log_type' => 'webhook'
320 ]
321 );
322 return;
323 }
324
325 $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
326
327 if ($transaction->total > 0 && $paidAmount != $expectedAmount) {
328 fluent_cart_add_log(
329 __('PayPal Webhook Amount Mismatch', 'fluent-cart'),
330 sprintf(
331 /* translators: %1$s: expected amount, %2$s: received amount, %3$s: transaction UUID */
332 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Order not confirmed.', 'fluent-cart'),
333 Helper::toDecimal($expectedAmount),
334 Helper::toDecimal($paidAmount),
335 $transaction->uuid
336 ),
337 'error',
338 [
339 'module_name' => 'order',
340 'module_id' => $transaction->order_id,
341 'log_type' => 'webhook'
342 ]
343 );
344 return;
345 }
346
347 // All Verified! Let's update the transaction and order
348 (new Processor())->confirmPaymentSuccessByCharge($transaction, [
349 'vendor_charge_id' => $vendorChargeId,
350 'payment_method_type' => 'PayPal',
351 'status' => Status::TRANSACTION_SUCCEEDED,
352 'total' => $paidAmount,
353 'payment_source' => Arr::get($paypalIntent, 'payment_source', []),
354 'meta' => [
355 'payer' => Arr::get($paypalIntent, 'payer', [])
356 ]
357 ]);
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
363 }
364
365
366 // called only when webhook/ipn hits
367 public function verifyAndProcess($data = []): void
368 {
369 $this->processWebhook();
370 }
371
372 /**
373 * Verify the webhook signature
374 *
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
378 */
379 public function verifyWebhook($webhookId, $rawBody = null)
380 {
381 $disableWebhookVerification = apply_filters('fluent_cart/payments/paypal/disable_webhook_verification', 'no', []);
382 if ($disableWebhookVerification === 'yes') {
383 return true;
384 }
385
386 if (empty($webhookId)) {
387 return new \WP_Error('webhook_id_missing', __('Webhook ID is missing.', 'fluent-cart'));
388 }
389
390 $webhookId = trim($webhookId);
391 $header = self::getRequestHeaders();
392
393 // make all headers lowercase
394 $header = array_change_key_case($header, CASE_LOWER);
395 if (!isset($header['paypal-auth-algo']) || !isset($header['paypal-cert-url']) ||
396 !isset($header['paypal-transmission-id']) || !isset($header['paypal-transmission-sig']) ||
397 !isset($header['paypal-transmission-time'])) {
398
399 return new \WP_Error('webhook_header_missing', __('Required PayPal webhook headers are missing.', 'fluent-cart'), [
400 'headers' => $header
401 ]);
402 }
403
404 if ($rawBody === null) {
405 $rawBody = file_get_contents('php://input');
406 }
407 $webhookEvent = json_decode($rawBody);
408 $body = [
409 'auth_algo' => $header['paypal-auth-algo'],
410 'transmission_id' => $header['paypal-transmission-id'],
411 'transmission_time' => $header['paypal-transmission-time'],
412 'cert_url' => $header['paypal-cert-url'],
413 'transmission_sig' => $header['paypal-transmission-sig'],
414 'webhook_id' => $webhookId,
415 'webhook_event' => $webhookEvent
416 ];
417
418 $response = API::verifyWebhookSignature($body);
419
420 if (is_wp_error($response)) {
421 do_action('fluent_cart/dev_log', [
422 'raw_data' => $body,
423 'status' => 'failed',
424 'title' => __('Failed to verify PayPal webhook signature', 'fluent-cart'),
425 'log_type' => 'webhook',
426 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
427 'module_name' => 'PayPal'
428 ]);
429
430 return $response;
431 }
432
433 $http_code = wp_remote_retrieve_response_code($response);
434 $response_body = wp_remote_retrieve_body($response);
435 $response_data = json_decode($response_body, true);
436
437 if ($http_code !== 200 || empty($response_data['verification_status']) || $response_data['verification_status'] !== 'SUCCESS') {
438 return new \WP_Error('webhook_verification_failed', __('Webhook verification failed.', 'fluent-cart'), [
439 'http_code' => $http_code,
440 'response' => $response_data
441 ]);
442 }
443
444 return true;
445 }
446
447 public function processWebhook()
448 {
449 $statusCode = $this->handleWebhookRequest(file_get_contents('php://input'));
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
470 $data = json_decode($post_data, true);
471
472 if (empty($data)) {
473 return 200;
474 }
475
476 $webhookType = Arr::get($data, 'event_type', '');
477
478 $webhookEvents = [
479 'PAYMENT.SALE.COMPLETED',
480 'PAYMENT.SALE.REFUNDED',
481 'PAYMENT.CAPTURE.REFUNDED',
482 'BILLING.SUBSCRIPTION.CREATED',
483 'BILLING.SUBSCRIPTION.ACTIVATED',
484 'BILLING.SUBSCRIPTION.CANCELLED',
485 'BILLING.SUBSCRIPTION.EXPIRED',
486 'BILLING.SUBSCRIPTION.SUSPENDED',
487 'BILLING.SUBSCRIPTION.RE-ACTIVATED',
488 'BILLING.SUBSCRIPTION.PAYMENT.FAILED',
489 'PAYMENT.CAPTURE.COMPLETED',
490 'CUSTOMER.DISPUTE.CREATED',
491 'CUSTOMER.DISPUTE.UPDATED',
492 'CUSTOMER.DISPUTE.RESOLVED',
493 'CHECKOUT.ORDER.APPROVED' // we don't need this
494 ];
495
496 if (!in_array($webhookType, $webhookEvents)) {
497 return 200;
498 }
499
500 if (defined('FLUENT_CART_DEV_MODE')) {
501 do_action('fluent_cart/dev_log', [
502 'raw_data' => $post_data,
503 'status' => 'received',
504 'title' => __('PayPal Webhook Received', 'fluent-cart'),
505 'log_type' => 'webhook',
506 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
507 'module_name' => 'PayPal'
508 ]);
509 }
510
511 $paymentSettings = self::getPayPalSettings()->get();
512
513 $mode = (new StoreSettings)->get('order_mode');
514
515 // FCT_PAYPAL_LIVE_WEBHOOK_ID
516 if ($mode === 'test') {
517 $webhookId = defined('FCT_PAYPAL_TEST_WEBHOOK_ID') ? FCT_PAYPAL_TEST_WEBHOOK_ID : Arr::get($paymentSettings, $mode . '_webhook_id', '');
518 } else {
519 $webhookId = defined('FCT_PAYPAL_LIVE_WEBHOOK_ID') ? FCT_PAYPAL_LIVE_WEBHOOK_ID : Arr::get($paymentSettings, $mode . '_webhook_id', '');
520 }
521
522 $willVerify = apply_filters('fluent_cart/payments/paypal/verify_webhook', true, [
523 'data' => $data,
524 'mode' => $mode,
525 'type' => $webhookType
526 ]);
527
528 if ($willVerify) {
529
530 $verified = $this->verifyWebhook($webhookId, $post_data);
531
532 if (is_wp_error($verified)) {
533 $data = json_encode($verified->get_error_data());
534 fluent_cart_add_log($verified->get_error_message() . ' Webhook: ' . $webhookType, $data, 'error', [
535 'log_type' => 'webhook',
536 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
537 'module_name' => 'PayPal',
538 ]);
539
540 return 400;
541 }
542 }
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
555 $this->processPaypalWebhookEvents($data);
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;
576 }
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
601 public function processSubscriptionActivated($data)
602 {
603 $paypalSubscription = Arr::get($data, 'paypal_subscription', []);
604 $vendorSubscriptionId = sanitize_text_field(Arr::get($paypalSubscription, 'id'));
605 if (empty($vendorSubscriptionId)) {
606 return;
607 }
608
609 $subscriptionModel = Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId)->first();
610 if (!$subscriptionModel) {
611 $subscriptionHash = Arr::get($paypalSubscription, 'custom_id', '');
612
613 if ($subscriptionHash) {
614 $subscriptionModel = Subscription::query()->where('uuid', $subscriptionHash)->first();
615 }
616 }
617
618 if (!$subscriptionModel || $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
619 return;
620 }
621
622 $transaction = $subscriptionModel->getLatestTransaction();
623 if (!$transaction) {
624 return;
625 }
626
627 (new Processor())->activateSubscription($paypalSubscription, $transaction, $subscriptionModel);
628 }
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
692 public function processRecurringPaymentReceived($data)
693 {
694 $charge = Arr::get($data, 'charge', []);
695 $vendorSubscriptionId = Arr::get($data, 'vendor_subscription_id', '');
696
697 $subscriptionModel = $vendorSubscriptionId ? Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId)->with('order')->first() : null;
698
699 if (!$subscriptionModel) {
700 $subscriptionHash = Arr::get($charge, 'custom', '');
701 if ($subscriptionHash) {
702 $subscriptionModel = Subscription::query()->where('uuid', $subscriptionHash)->first();
703 }
704 }
705
706 if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal') {
707 return false;
708 }
709
710 if ($vendorSubscriptionId && !$subscriptionModel->vendor_subscription_id) {
711 $subscriptionModel->update(['vendor_subscription_id' => $vendorSubscriptionId]);
712 }
713
714 $amount = Helper::toCent(Arr::get($charge, 'amount.total', 0));
715 $chargeId = Arr::get($charge, 'id');
716 if (!$amount || !$chargeId) {
717 return false;
718 }
719
720 // find the OrderTransaction
721 $transaction = OrderTransaction::query()->where('vendor_charge_id', $chargeId)
722 ->where('subscription_id', $subscriptionModel->id)
723 ->where('payment_method', 'paypal')
724 ->first();
725
726 if ($transaction) {
727 return true;
728 }
729
730 // Fetch PayPal subscription data once — used for plan verification and renewal processing
731 $paypalSubscription = $vendorSubscriptionId ? API::getResource('billing/subscriptions/' . $vendorSubscriptionId) : null;
732
733 // Verify the PayPal subscription plan matches the expected plan
734 if ($subscriptionModel->vendor_plan_id && $paypalSubscription && !is_wp_error($paypalSubscription)) {
735 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
736 if ($paypalPlanId && $paypalPlanId !== $subscriptionModel->vendor_plan_id) {
737 fluent_cart_add_log(
738 __('PayPal Recurring Plan Mismatch', 'fluent-cart'),
739 sprintf(
740 /* translators: %1$s: expected plan ID, %2$s: received plan ID, %3$d: subscription ID */
741 __('Recurring payment plan mismatch. Expected: %1$s, Received: %2$s. Subscription ID: %3$d. Payment not recorded.', 'fluent-cart'),
742 $subscriptionModel->vendor_plan_id,
743 $paypalPlanId,
744 $subscriptionModel->id
745 ),
746 'error',
747 [
748 'module_type' => 'FluentCart\App\Models\Subscription',
749 'module_id' => $subscriptionModel->id,
750 'module_name' => 'subscription',
751 'log_type' => 'webhook'
752 ]
753 );
754 return false;
755 }
756 }
757
758 // Latest charge transaction = pending one for initial subscription OR for renewal
759 $latestTransaction = $subscriptionModel->getLatestTransaction();
760
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
845 return true;
846 }
847
848
849 // Now we are sure, we have a renewal payment for this subscription!
850
851 // we will just create the transaction here
852
853 $subscriptionUpdateData = [
854 'current_payment_method' => 'paypal',
855 'vendor_subscription_id' => $vendorSubscriptionId
856 ];
857
858 $payer = ($paypalSubscription && !is_wp_error($paypalSubscription)) ? Arr::get($paypalSubscription, 'subscriber', []) : [];
859 if ($paypalSubscription && !is_wp_error($paypalSubscription)) {
860 $subscriptionUpdateData['status'] = (new SubscriptionManager)->getCorrectSubscriptionStatus(Arr::get($paypalSubscription, 'status'));
861 $nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time');
862 if ($nextBillingDate) {
863 $subscriptionUpdateData['next_billing_date'] = SubscriptionHelper::safeTimestampToDatetime($nextBillingDate);
864 }
865
866 $payerId = Arr::get($paypalSubscription, 'subscriber.payer_id');
867
868 if ($payerId) {
869 $subscriptionUpdateData['vendor_customer_id'] = $payerId;
870 }
871
872 if (!empty($paypalSubscription['plan_id'])) {
873 $subscriptionUpdateData['vendor_plan_id'] = $paypalSubscription['plan_id'];
874 }
875
876 if (Arr::get($paypalSubscription, 'status') === 'CANCELLED') {
877 $statusUpdateTime = Arr::get($paypalSubscription, 'status_update_time');
878 if ($statusUpdateTime) {
879 $subscriptionUpdateData['canceled_at'] = gmdate('Y-m-d H:i:s', strtotime($statusUpdateTime));
880 }
881 }
882
883 }
884
885 $transactionData = [
886 'payment_method' => 'paypal',
887 'total' => $amount,
888 'vendor_charge_id' => $chargeId,
889 'payment_method_type' => 'paypal',
890 'meta' => [
891 'payer' => $payer
892 ]
893 ];
894
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;
1141 }
1142
1143 public function handleSinglePaymentRefund($data)
1144 {
1145 $refundData = Arr::get($data, 'refund', []);
1146 $paypalRefundId = Arr::get($refundData, 'id', '');
1147 $paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.value', 0));
1148
1149 // Let's guess the transaction ID from links
1150
1151 $paypalTransactionId = '';
1152
1153 foreach (Arr::get($refundData, 'links', []) as $link) {
1154 if (Arr::get($link, 'rel') !== 'up') {
1155 continue;
1156 }
1157
1158 $href = Arr::get($link, 'href', '');
1159 $paypalTransactionId = basename($href);
1160 if ($paypalTransactionId) {
1161 break;
1162 }
1163 }
1164
1165 if (!$paypalTransactionId) {
1166
1167 do_action('fluent_cart/dev_log', [
1168 'raw_data' => $refundData,
1169 'status' => 'failed',
1170 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
1171 'log_type' => 'webhook',
1172 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1173 'module_name' => 'PayPal'
1174 ]);
1175
1176 return false; // We are really sorry that we could not get the transaction ID.
1177 }
1178
1179 $parentTransaction = OrderTransaction::query()
1180 ->where('vendor_charge_id', $paypalTransactionId)
1181 ->where('status', Status::TRANSACTION_SUCCEEDED)
1182 ->first();
1183
1184 if (!$parentTransaction) {
1185
1186 do_action('fluent_cart/dev_log', [
1187 'raw_data' => $refundData,
1188 'status' => 'failed',
1189 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
1190 'log_type' => 'webhook',
1191 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1192 'module_name' => 'PayPal'
1193 ]);
1194
1195 return false; // not our transaction, we are not handling this refund
1196 }
1197
1198 return \FluentCart\App\Services\Payments\Refund::createOrRecordRefund([
1199 'vendor_charge_id' => $paypalRefundId,
1200 'payment_method' => 'paypal',
1201 'total' => $paypalRefundAmount,
1202 ], $parentTransaction);
1203
1204 }
1205
1206
1207 public function handleWebhookRecurringPaymentRefunded($data)
1208 {
1209 $refundData = Arr::get($data, 'refund', []);
1210
1211 if (Arr::get($refundData, 'state') !== 'completed') {
1212 return false;
1213 }
1214
1215 $parentTxnId = Arr::get($refundData, 'sale_id', '');
1216 if (!$parentTxnId) {
1217 return false;
1218 }
1219
1220 $subscriptionHash = sanitize_text_field(Arr::get($data, 'custom', ''));
1221
1222 $parentTransaction = OrderTransaction::query()->where('vendor_charge_id', $parentTxnId)
1223 ->where('status', Status::TRANSACTION_SUCCEEDED)
1224 ->first();
1225
1226 if (!$parentTransaction && $subscriptionHash) {
1227 $parentSubscription = Subscription::query()->where('uuid', $subscriptionHash)->first();
1228 $parentTransaction = $parentSubscription ? $parentSubscription->getLatestTransaction() : null;
1229 }
1230
1231 if (!$parentTransaction) {
1232 do_action('fluent_cart/dev_log', [
1233 'raw_data' => $data,
1234 'status' => 'failed',
1235 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
1236 'log_type' => 'webhook',
1237 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1238 'module_name' => 'PayPal'
1239 ]);
1240
1241 return null;
1242 }
1243
1244 if ($parentTransaction->status === Status::TRANSACTION_FAILED) {
1245 return null;
1246 }
1247
1248 $paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.total', 0));
1249
1250 return \FluentCart\App\Services\Payments\Refund::createOrRecordRefund([
1251 'vendor_charge_id' => Arr::get($refundData, 'id'),
1252 'payment_method' => 'paypal',
1253 'total' => $paypalRefundAmount,
1254 'reason' => Arr::get($refundData, 'description'),
1255 ], $parentTransaction);
1256 }
1257
1258 public function handleWebhookRecurringProfileCancelled($data)
1259 {
1260 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
1261 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
1262
1263 if (!$subscriptionModel) {
1264 do_action('fluent_cart/dev_log', [
1265 'raw_data' => $subscriptionInfo,
1266 'status' => 'failed',
1267 'title' => __('Failed to find Subscription for PayPal Cancel webhook', 'fluent-cart'),
1268 'log_type' => 'webhook',
1269 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1270 'module_name' => 'PayPal'
1271 ]);
1272 return;
1273 }
1274
1275 if ($subscriptionModel->status === Status::SUBSCRIPTION_CANCELED || $subscriptionModel->current_payment_method !== 'paypal') {
1276 return;
1277 }
1278
1279 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
1280 'status' => Status::SUBSCRIPTION_CANCELED,
1281 'canceled_at' => DateTime::anyTimeToGmt(Arr::get($subscriptionInfo, 'status_update_time'))->format('Y-m-d H:i:s'),
1282 'meta' => [
1283 'cancellation_reason' => Arr::get($subscriptionInfo, 'status_change_note', ''),
1284 ]
1285 ]);
1286 }
1287
1288 public function handleWebhookRecurringProfileExpired($data)
1289 {
1290 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
1291 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
1292
1293 if (!$subscriptionModel) {
1294 do_action('fluent_cart/dev_log', [
1295 'raw_data' => $subscriptionInfo,
1296 'status' => 'failed',
1297 'title' => __('Failed to find Subscription for PayPal Subscription Expired webhook', 'fluent-cart'),
1298 'log_type' => 'webhook',
1299 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1300 'module_name' => 'PayPal'
1301 ]);
1302 return;
1303 }
1304
1305 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
1306 'status' => Status::SUBSCRIPTION_EXPIRED
1307 ]);
1308 }
1309
1310 public function handleWebhookRecurringProfileSuspended($data)
1311 {
1312 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
1313 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
1314
1315 if (!$subscriptionModel) {
1316 do_action('fluent_cart/dev_log', [
1317 'raw_data' => $subscriptionInfo,
1318 'status' => 'failed',
1319 'title' => __('Failed to find Subscription for PayPal Subscription Suspended webhook', 'fluent-cart'),
1320 'log_type' => 'webhook',
1321 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1322 'module_name' => 'PayPal'
1323 ]);
1324 return;
1325 }
1326
1327 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
1328 'status' => Status::SUBSCRIPTION_PAUSED
1329 ]);
1330
1331 }
1332
1333 public function handleWebhookRecurringProfileReactivated($data)
1334 {
1335 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
1336 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
1337
1338 if (!$subscriptionModel) {
1339 do_action('fluent_cart/dev_log', [
1340 'raw_data' => $subscriptionInfo,
1341 'status' => 'failed',
1342 'title' => __('Failed to find Subscription for PayPal Subscription Reactive webhook', 'fluent-cart'),
1343 'log_type' => 'webhook',
1344 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1345 'module_name' => 'PayPal'
1346 ]);
1347 return;
1348 }
1349
1350 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
1351 'status' => Status::SUBSCRIPTION_ACTIVE
1352 ]);
1353 }
1354
1355 public function handleWebhookDisputeCreated($data)
1356 {
1357 $disputeInfo = Arr::get($data, 'dispute', []);
1358 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
1359 if (empty($disputeId)) {
1360 return false;
1361 }
1362
1363 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
1364
1365 if (count($disputedTransactions) > 1) {
1366 return false;
1367 }
1368
1369 $status = Arr::get($disputeInfo, 'status', '');
1370 $stage = Arr::get($disputeInfo, 'dispute_life_cycle_stage', '');
1371 $reason = Arr::get($disputeInfo, 'reason', '');
1372
1373 $fluentCartTransactions = [];
1374 foreach ($disputedTransactions as $transaction) {
1375 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
1376 if ($transaction) {
1377 $fluentCartTransactions[] = $transactionModel;
1378 }
1379 }
1380 if (empty($fluentCartTransactions)) {
1381 return false;
1382 }
1383
1384 $isChargeRefundable = in_array($status, ['OPEN', 'WAITING_FOR_SELLER_RESPONSE']);
1385
1386 $transactionModel = $fluentCartTransactions[0];
1387 $transactionModel->update([
1388 'transaction_type' => Status::TRANSACTION_TYPE_DISPUTE,
1389 'meta' => array_merge($transactionModel->meta, [
1390 'dispute_id' => $disputeId,
1391 'dispute_reason' => $reason,
1392 'is_dispute_actionable' => in_array($stage, ['CHARGEBACK', 'REVIEW']),
1393 'is_charge_refundable' => $isChargeRefundable,
1394 'status' => $status
1395 ])
1396 ]);
1397
1398 return true;
1399 }
1400
1401 public function handleWebhookDisputeUpdated($data)
1402 {
1403 $disputeInfo = Arr::get($data, 'dispute', []);
1404 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
1405 if (empty($disputeId)) {
1406 return false;
1407 }
1408
1409 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
1410
1411 if (count($disputedTransactions) > 1) {
1412 return false;
1413 }
1414
1415 $stage = Arr::get($disputeInfo, 'dispute_life_cycle_stage', '');
1416
1417 $fluentCartTransactions = [];
1418 foreach ($disputedTransactions as $transaction) {
1419 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
1420 if (!$transactionModel) {
1421 continue;
1422 }
1423 $fluentCartTransactions[] = $transactionModel;
1424 }
1425
1426 if (empty($fluentCartTransactions)) {
1427 return false;
1428 }
1429
1430 $transactionModel = $fluentCartTransactions[0];
1431 $status = Arr::get($disputeInfo, 'status', '');
1432 $isChargeRefundable = in_array($status, ['OPEN', 'WAITING_FOR_SELLER_RESPONSE']) || in_array($stage, ['CHARGEBACK', 'INQUIRY']);
1433
1434 if ($stage === 'CHARGEBACK') {
1435 $transactionModel->update([
1436 'meta' => array_merge($transactionModel->meta, [
1437 'is_dispute_actionable' => true,
1438 'dispute_status' => Arr::get($disputeInfo, 'status', ''),
1439 'is_charge_refundable' => $isChargeRefundable
1440 ])
1441 ]);
1442 } else {
1443 $transactionModel->update([
1444 'meta' => array_merge($transactionModel->meta, [
1445 'is_dispute_actionable' => false,
1446 'dispute_status' => Arr::get($disputeInfo, 'status', ''),
1447 'is_charge_refundable' => $isChargeRefundable
1448 ])
1449 ]);
1450 }
1451
1452 return true;
1453
1454 }
1455
1456 public function handleWebhookDisputeResolved($data)
1457 {
1458 $disputeInfo = Arr::get($data, 'dispute', []);
1459 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
1460 if (empty($disputeId)) {
1461 return false;
1462 }
1463
1464 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
1465 if (count($disputedTransactions) > 1) {
1466 return false;
1467 }
1468
1469 $status = Arr::get($disputeInfo, 'status', '');
1470 if ($status !== 'RESOLVED') {
1471 return false;
1472 }
1473
1474 $fluentCartTransactions = [];
1475 foreach ($disputedTransactions as $transaction) {
1476 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
1477 if (!$transactionModel) {
1478 continue;
1479 }
1480 $fluentCartTransactions[] = $transactionModel;
1481 }
1482
1483 if (empty($fluentCartTransactions)) {
1484 return false;
1485 }
1486
1487 // we are handling disputes only with one transaction - PayPal allow user to select multiple transactions on dispute creation
1488 $transactionModel = $fluentCartTransactions[0];
1489
1490 if ($transactionModel->status === Status::TRANSACTION_DISPUTE_LOST) { // already dispute claim accepted via admin dashboard
1491 return false;
1492 }
1493
1494 // dispute always resolved via refund in PayPal if outcome favoured buyer. Regardless! the main transaction remains as charge if not dispute claim already accepted via admin dashboard
1495 $transactionModel->update([
1496 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
1497 'meta' => array_merge($transactionModel->meta, [
1498 'is_dispute_actionable' => false,
1499 'is_charge_refundable' => false,
1500 'dispute_status' => $status
1501 ])
1502 ]);
1503
1504 return true;
1505 }
1506
1507 private function getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo = [])
1508 {
1509 $id = Arr::get($subscriptionInfo, 'id', '');
1510 if (empty($id)) {
1511 return null;
1512 }
1513
1514 $subscription = Subscription::query()->where('vendor_subscription_id', $id)->first();
1515
1516 if (!$subscription) {
1517 $subscriptionHash = Arr::get($subscriptionInfo, 'custom_id', '');
1518 if ($subscriptionHash) {
1519 $subscription = Subscription::query()->where('uuid', $subscriptionHash)->first();
1520 }
1521 }
1522
1523 return $subscription;
1524 }
1525
1526
1527 private static function getPayPalSettings()
1528 {
1529 if (!self::$paypalSettings) {
1530 self::$paypalSettings = new PayPalSettingsBase();
1531 }
1532 return self::$paypalSettings;
1533 }
1534 }
1535