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

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

937 lines 36.6 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\Helpers\Helper;
7 use FluentCart\App\Helpers\Status;
8 use FluentCart\App\Models\OrderTransaction;
9 use FluentCart\App\Models\Subscription;
10 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API;
11 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
12 use FluentCart\App\Services\DateTime\DateTime;
13 use FluentCart\Framework\Support\Arr;
14
15 class IPN
16 {
17 private const TEST_VERIFYING_URL = 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature';
18 private const LIVE_VERIFYING_URL = 'https://api-m.paypal.com/v1/notifications/verify-webhook-signature';
19 private static $paypalSettings = null;
20
21 public function init()
22 {
23 // New
24 add_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [$this, 'processChargeCaptured'], 10, 1);
25
26 // reviewed.
27 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_activated', [$this, 'processSubscriptionActivated'], 10, 1);
28 add_action('fluent_cart/payments/paypal/webhook_subscription_payment_received', [$this, 'processRecurringPaymentReceived'], 10, 1);
29 add_action('fluent_cart/payments/paypal/webhook_payment_capture_refunded', [$this, 'handleSinglePaymentRefund']);
30 add_action('fluent_cart/payments/paypal/webhook_payment_sale_refunded', [$this, 'handleWebhookRecurringPaymentRefunded'], 10, 1);
31 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_cancelled', [$this, 'handleWebhookRecurringProfileCancelled'], 10, 1);
32 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_expired', [$this, 'handleWebhookRecurringProfileExpired'], 10, 1);
33 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_suspended', [$this, 'handleWebhookRecurringProfileSuspended'], 10, 1);
34 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_re-activated', [$this, 'handleWebhookRecurringProfileReactivated'], 10, 1);
35
36 // dispute
37 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_created', [$this, 'handleWebhookDisputeCreated'], 10, 1);
38 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_updated', [$this, 'handleWebhookDisputeUpdated'], 10, 1);
39 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_resolved', [$this, 'handleWebhookDisputeResolved'], 10, 1);
40
41 }
42
43 public function processPaypalWebhookEvents($event): void
44 {
45 $eventType = Arr::get($event, 'event_type', '');
46 $resource = Arr::get($event, 'resource', []);
47
48 if (empty($resource)) {
49 return;
50 }
51
52 // convert event to snake case ex: PAYMENT.SALE.COMPLETED to payment_sale_completed
53 $eventType = strtolower(str_replace('.', '_', $eventType));
54
55 if ($eventType === 'payment_sale_completed') {
56 $billingAgreementId = Arr::get($resource, 'billing_agreement_id', '');
57 if ($billingAgreementId) {
58 do_action('fluent_cart/payments/paypal/webhook_subscription_payment_received', [
59 'charge' => $resource,
60 'vendor_subscription_id' => $billingAgreementId,
61 ]);
62 } else {
63 // do not need webhook for one time payment
64 do_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [
65 'charge' => $resource
66 ]);
67 }
68 } else if ($eventType === 'payment_sale_refunded') {
69 // recurring payment refund
70 do_action('fluent_cart/payments/paypal/webhook_payment_sale_refunded', [
71 'refund' => $resource
72 ]);
73 } else if ($eventType === 'payment_capture_refunded') { // this is manly the refund for one time items
74 do_action('fluent_cart/payments/paypal/webhook_payment_capture_refunded', [
75 'refund' => $resource
76 ]);
77 } else if ($eventType === 'payment_capture_completed') {
78 do_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [
79 'charge' => $resource,
80 ]);
81 } else if ( $eventType === 'customer_dispute_created' ||$eventType == 'customer_dispute_updated' || $eventType === 'customer_dispute_resolved') {
82 do_action('fluent_cart/payments/paypal/webhook_' . $eventType, [
83 'dispute' => $resource
84 ]);
85 }
86 else {
87 /**
88 *
89 * fluent_cart/payments/paypal/webhook_billing_subscription_activated
90 * fluent_cart/payments/paypal/webhook_billing_subscription_created
91 * fluent_cart/payments/paypal/webhook_billing_subscription_cancelled
92 * fluent_cart/payments/paypal/webhook_billing_subscription_expired
93 * fluent_cart/payments/paypal/webhook_billing_subscription_suspended
94 * fluent_cart/payments/paypal/webhook_billing_subscription_re-activated
95 */
96 do_action('fluent_cart/payments/paypal/webhook_' . $eventType, [
97 'paypal_subscription' => $resource
98 ]);
99 }
100
101 }
102
103
104 public function processChargeCaptured($data)
105 {
106 $charge = Arr::get($data, 'charge', []);
107
108 $vendorChargeId = Arr::get($charge, 'id', '');
109
110 $transaction = OrderTransaction::query()->where('vendor_charge_id', $vendorChargeId)->first();
111
112 if (!$transaction) {
113 // We did not find the charge. So let's find the parent order ID and transactio reference
114 $parentIntentId = Arr::get($charge, 'supplementary_data.related_ids.order_id', '');
115 if ($parentIntentId) {
116 $paypalIntent = API::verifyPayment($parentIntentId);
117 if (is_wp_error($paypalIntent)) {
118 return;
119 }
120
121 $transactionHash = Arr::get($paypalIntent, 'purchase_units.0.reference_id', '');
122 if ($transactionHash) {
123 $transaction = OrderTransaction::query()
124 ->where('uuid', $transactionHash)
125 ->first();
126 }
127 }
128 }
129
130 if (!$transaction) {
131 // not our transaction!
132 return;
133 }
134
135 if ($transaction->status == Status::TRANSACTION_SUCCEEDED) {
136 if (!$transaction->vendor_charge_id) {
137 // We are just updating the vendor charge ID
138 $transaction->vendor_charge_id = $vendorChargeId;
139 $transaction->save();
140 }
141
142 // already processed
143 return;
144 }
145
146 // get full payment intent
147 $paypalOrderId = Arr::get($charge, 'supplementary_data.related_ids.order_id', '');
148 $paypalIntent = API::verifyPayment($paypalOrderId);
149
150 if (is_wp_error($paypalIntent)) {
151 fluent_cart_add_log(
152 __('PayPal Webhook Verification Failed', 'fluent-cart'),
153 __('Could not verify PayPal payment from webhook. Charge ID: ', 'fluent-cart') . $vendorChargeId,
154 'error',
155 [
156 'module_name' => 'order',
157 'module_id' => $transaction->order_id,
158 'log_type' => 'webhook'
159 ]
160 );
161 return;
162 }
163
164 // Verify that the paid amount and currency match the expected transaction
165 $paidAmount = 0;
166 $paidCurrency = '';
167 foreach (Arr::get($paypalIntent, 'purchase_units', []) as $unit) {
168 $paidAmount += Helper::toCent(Arr::get($unit, 'amount.value', 0));
169 if (!$paidCurrency) {
170 $paidCurrency = strtoupper(Arr::get($unit, 'amount.currency_code', ''));
171 }
172 }
173
174 if ($paidCurrency && $transaction->currency && strtoupper($transaction->currency) !== $paidCurrency) {
175 fluent_cart_add_log(
176 __('PayPal Webhook Currency Mismatch', 'fluent-cart'),
177 sprintf(
178 /* translators: %1$s: expected currency, %2$s: received currency, %3$s: transaction UUID */
179 __('Payment currency mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Order not confirmed.', 'fluent-cart'),
180 $transaction->currency,
181 $paidCurrency,
182 $transaction->uuid
183 ),
184 'error',
185 [
186 'module_name' => 'order',
187 'module_id' => $transaction->order_id,
188 'log_type' => 'webhook'
189 ]
190 );
191 return;
192 }
193
194 if ($transaction->total > 0 && $paidAmount != $transaction->total) {
195 fluent_cart_add_log(
196 __('PayPal Webhook Amount Mismatch', 'fluent-cart'),
197 sprintf(
198 /* translators: %1$s: expected amount, %2$s: received amount, %3$s: transaction UUID */
199 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Order not confirmed.', 'fluent-cart'),
200 Helper::toDecimal($transaction->total),
201 Helper::toDecimal($paidAmount),
202 $transaction->uuid
203 ),
204 'error',
205 [
206 'module_name' => 'order',
207 'module_id' => $transaction->order_id,
208 'log_type' => 'webhook'
209 ]
210 );
211 return;
212 }
213
214 // All Verified! Let's update the transaction and order
215 (new Processor())->confirmPaymentSuccessByCharge($transaction, [
216 'vendor_charge_id' => $vendorChargeId,
217 'payment_method_type' => 'PayPal',
218 'status' => Status::TRANSACTION_SUCCEEDED,
219 'total' => $paidAmount,
220 'payment_source' => Arr::get($paypalIntent, 'payment_source', []),
221 'meta' => [
222 'payer' => Arr::get($paypalIntent, 'payer', [])
223 ]
224 ]);
225
226 }
227
228
229 // called only when webhook/ipn hits
230 public function verifyAndProcess($data = []): void
231 {
232 $this->processWebhook();
233 }
234
235 /**
236 * Verify the webhook signature
237 *
238 * @param string $webhookId
239 * @return bool|\WP_Error
240 */
241 public function verifyWebhook($webhookId)
242 {
243 $disableWebhookVerification = apply_filters('fluent_cart/payments/paypal/disable_webhook_verification', 'no', []);
244 if ($disableWebhookVerification === 'yes') {
245 return true;
246 }
247
248 if (empty($webhookId)) {
249 return new \WP_Error('webhook_id_missing', __('Webhook ID is missing.', 'fluent-cart'));
250 }
251
252 $webhookId = trim($webhookId);
253 $header = getallheaders();
254
255 // make all headers lowercase
256 $header = array_change_key_case($header, CASE_LOWER);
257 if (!isset($header['paypal-auth-algo']) || !isset($header['paypal-cert-url']) ||
258 !isset($header['paypal-transmission-id']) || !isset($header['paypal-transmission-sig']) ||
259 !isset($header['paypal-transmission-time'])) {
260
261 return new \WP_Error('webhook_header_missing', __('Required PayPal webhook headers are missing.', 'fluent-cart'), [
262 'headers' => $header
263 ]);
264 }
265
266 $webhookEvent = json_decode(file_get_contents('php://input'));
267 $body = [
268 'auth_algo' => $header['paypal-auth-algo'],
269 'transmission_id' => $header['paypal-transmission-id'],
270 'transmission_time' => $header['paypal-transmission-time'],
271 'cert_url' => $header['paypal-cert-url'],
272 'transmission_sig' => $header['paypal-transmission-sig'],
273 'webhook_id' => $webhookId,
274 'webhook_event' => $webhookEvent
275 ];
276
277 $response = API::verifyWebhookSignature($body);
278
279 if (is_wp_error($response)) {
280 do_action('fluent_cart/dev_log', [
281 'raw_data' => $body,
282 'status' => 'failed',
283 'title' => __('Failed to verify PayPal webhook signature', 'fluent-cart'),
284 'log_type' => 'webhook',
285 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
286 'module_name' => 'PayPal'
287 ]);
288
289 return $response;
290 }
291
292 $http_code = wp_remote_retrieve_response_code($response);
293 $response_body = wp_remote_retrieve_body($response);
294 $response_data = json_decode($response_body, true);
295
296 if ($http_code !== 200 || empty($response_data['verification_status']) || $response_data['verification_status'] !== 'SUCCESS') {
297 return new \WP_Error('webhook_verification_failed', __('Webhook verification failed.', 'fluent-cart'), [
298 'http_code' => $http_code,
299 'response' => $response_data
300 ]);
301 }
302
303 return true;
304 }
305
306 public function processWebhook()
307 {
308 $post_data = file_get_contents('php://input');
309
310 $data = json_decode($post_data, true);
311
312 if (empty($data)) {
313 return;
314 }
315
316 $webhookType = Arr::get($data, 'event_type', '');
317
318 $webhookEvents = [
319 'PAYMENT.SALE.COMPLETED',
320 'PAYMENT.SALE.REFUNDED',
321 'PAYMENT.CAPTURE.REFUNDED',
322 'BILLING.SUBSCRIPTION.CREATED',
323 'BILLING.SUBSCRIPTION.ACTIVATED',
324 'BILLING.SUBSCRIPTION.CANCELLED',
325 'BILLING.SUBSCRIPTION.EXPIRED',
326 'BILLING.SUBSCRIPTION.SUSPENDED',
327 'BILLING.SUBSCRIPTION.RE-ACTIVATED',
328 'PAYMENT.CAPTURE.COMPLETED',
329 'CUSTOMER.DISPUTE.CREATED',
330 'CUSTOMER.DISPUTE.UPDATED',
331 'CUSTOMER.DISPUTE.RESOLVED',
332 'CHECKOUT.ORDER.APPROVED' // we don't need this
333 ];
334
335 if (!in_array($webhookType, $webhookEvents)) {
336 return;
337 }
338
339 do_action('fluent_cart/paypal_webhook_received', [
340 'data' => $data,
341 'raw' => $post_data
342 ]);
343
344 if (defined('FLUENT_CART_DEV_MODE')) {
345 do_action('fluent_cart/dev_log', [
346 'raw_data' => $post_data,
347 'status' => 'received',
348 'title' => __('PayPal Webhook Received', 'fluent-cart'),
349 'log_type' => 'webhook',
350 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
351 'module_name' => 'PayPal'
352 ]);
353 }
354
355 $paymentSettings = self::getPayPalSettings()->get();
356
357 $mode = (new StoreSettings)->get('order_mode');
358
359 // FCT_PAYPAL_LIVE_WEBHOOK_ID
360 if ($mode === 'test') {
361 $webhookId = defined('FCT_PAYPAL_TEST_WEBHOOK_ID') ? FCT_PAYPAL_TEST_WEBHOOK_ID : Arr::get($paymentSettings, $mode . '_webhook_id', '');
362 } else {
363 $webhookId = defined('FCT_PAYPAL_LIVE_WEBHOOK_ID') ? FCT_PAYPAL_LIVE_WEBHOOK_ID : Arr::get($paymentSettings, $mode . '_webhook_id', '');
364 }
365
366 $willVerify = apply_filters('fluent_cart/payments/paypal/verify_webhook', true, [
367 'data' => $data,
368 'mode' => $mode,
369 'type' => $webhookType
370 ]);
371
372 if ($willVerify) {
373
374 $verified = $this->verifyWebhook($webhookId);
375
376 if (is_wp_error($verified)) {
377 $data = json_encode($verified->get_error_data());
378 fluent_cart_add_log($verified->get_error_message() . ' Webhook: ' . $webhookType, $data, 'error', [
379 'log_type' => 'webhook',
380 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
381 'module_name' => 'PayPal',
382 ]);
383
384 exit(400);
385 }
386 }
387
388 $this->processPaypalWebhookEvents($data);
389 exit(200);
390 }
391
392 public function processSubscriptionActivated($data)
393 {
394 $paypalSubscription = Arr::get($data, 'paypal_subscription', []);
395 $vendorSubscriptionId = sanitize_text_field(Arr::get($paypalSubscription, 'id'));
396 if (empty($vendorSubscriptionId)) {
397 return;
398 }
399
400 $subscriptionModel = Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId)->first();
401 if (!$subscriptionModel) {
402 $subscriptionHash = Arr::get($paypalSubscription, 'custom_id', '');
403
404 if ($subscriptionHash) {
405 $subscriptionModel = Subscription::query()->where('uuid', $subscriptionHash)->first();
406 }
407 }
408
409 if (!$subscriptionModel || $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
410 return;
411 }
412
413 $transaction = $subscriptionModel->getLatestTransaction();
414 if (!$transaction) {
415 return;
416 }
417
418 (new Processor())->activateSubscription($paypalSubscription, $transaction, $subscriptionModel);
419 }
420
421 public function processRecurringPaymentReceived($data)
422 {
423 $charge = Arr::get($data, 'charge', []);
424 $vendorSubscriptionId = Arr::get($data, 'vendor_subscription_id', '');
425
426 $subscriptionModel = $vendorSubscriptionId ? Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId)->with('order')->first() : null;
427
428 if (!$subscriptionModel) {
429 $subscriptionHash = Arr::get($charge, 'custom', '');
430 if ($subscriptionHash) {
431 $subscriptionModel = Subscription::query()->where('uuid', $subscriptionHash)->first();
432 }
433 }
434
435 if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal') {
436 return false;
437 }
438
439 $amount = Helper::toCent(Arr::get($charge, 'amount.total', 0));
440 $chargeId = Arr::get($charge, 'id');
441 if (!$amount || !$chargeId) {
442 return false;
443 }
444
445 // find the OrderTransaction
446 $transaction = OrderTransaction::query()->where('vendor_charge_id', $chargeId)
447 ->where('subscription_id', $subscriptionModel->id)
448 ->where('payment_method', 'paypal')
449 ->first();
450
451 if ($transaction) {
452 return true;
453 }
454
455 // Fetch PayPal subscription data once — used for plan verification and renewal processing
456 $paypalSubscription = $vendorSubscriptionId ? API::getResource('billing/subscriptions/' . $vendorSubscriptionId) : null;
457
458 // Verify the PayPal subscription plan matches the expected plan
459 if ($subscriptionModel->vendor_plan_id && $paypalSubscription && !is_wp_error($paypalSubscription)) {
460 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
461 if ($paypalPlanId && $paypalPlanId !== $subscriptionModel->vendor_plan_id) {
462 fluent_cart_add_log(
463 __('PayPal Recurring Plan Mismatch', 'fluent-cart'),
464 sprintf(
465 /* translators: %1$s: expected plan ID, %2$s: received plan ID, %3$d: subscription ID */
466 __('Recurring payment plan mismatch. Expected: %1$s, Received: %2$s. Subscription ID: %3$d. Payment not recorded.', 'fluent-cart'),
467 $subscriptionModel->vendor_plan_id,
468 $paypalPlanId,
469 $subscriptionModel->id
470 ),
471 'error',
472 [
473 'module_type' => 'FluentCart\App\Models\Subscription',
474 'module_id' => $subscriptionModel->id,
475 'module_name' => 'subscription',
476 'log_type' => 'webhook'
477 ]
478 );
479 return false;
480 }
481 }
482
483 // Latest charge transaction = pending one for initial subscription OR for renewal
484 $latestTransaction = $subscriptionModel->getLatestTransaction();
485
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 ]);
493 return true;
494 }
495
496
497 // Now we are sure, we have a renewal payment for this subscription!
498
499 // we will just create the transaction here
500
501 $subscriptionUpdateData = [
502 'current_payment_method' => 'paypal',
503 'vendor_subscription_id' => $vendorSubscriptionId
504 ];
505
506 $payer = ($paypalSubscription && !is_wp_error($paypalSubscription)) ? Arr::get($paypalSubscription, 'subscriber', []) : [];
507 if ($paypalSubscription && !is_wp_error($paypalSubscription)) {
508 $nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time');
509 if ($nextBillingDate) {
510 $subscriptionUpdateData['next_billing_date'] = gmdate('Y-m-d H:i:s', strtotime($nextBillingDate));
511 }
512
513 $payerId = Arr::get($paypalSubscription, 'subscriber.payer_id');
514
515 if ($payerId) {
516 $subscriptionUpdateData['vendor_customer_id'] = $payerId;
517 }
518
519 if (!empty($paypalSubscription['plan_id'])) {
520 $subscriptionUpdateData['vendor_plan_id'] = $paypalSubscription['plan_id'];
521 }
522
523 if (Arr::get($paypalSubscription, 'status') === 'CANCELLED') {
524 $statusUpdateTime = Arr::get($paypalSubscription, 'status_update_time');
525 if ($statusUpdateTime) {
526 $subscriptionUpdateData['canceled_at'] = gmdate('Y-m-d H:i:s', strtotime($statusUpdateTime));
527 }
528 }
529
530 }
531
532 $transactionData = [
533 'payment_method' => 'paypal',
534 'total' => $amount,
535 'vendor_charge_id' => $chargeId,
536 'payment_method_type' => 'paypal',
537 'meta' => [
538 'payer' => $payer
539 ]
540 ];
541
542 return SubscriptionService::recordRenewalPayment($transactionData, $subscriptionModel, $subscriptionUpdateData);
543 }
544
545 public function handleSinglePaymentRefund($data)
546 {
547 $refundData = Arr::get($data, 'refund', []);
548 $paypalRefundId = Arr::get($refundData, 'id', '');
549 $paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.value', 0));
550
551 // Let's guess the transaction ID from links
552
553 $paypalTransactionId = '';
554
555 foreach (Arr::get($refundData, 'links', []) as $link) {
556 if (Arr::get($link, 'rel') !== 'up') {
557 continue;
558 }
559
560 $href = Arr::get($link, 'href', '');
561 $paypalTransactionId = basename($href);
562 if ($paypalTransactionId) {
563 break;
564 }
565 }
566
567 if (!$paypalTransactionId) {
568
569 do_action('fluent_cart/dev_log', [
570 'raw_data' => $refundData,
571 'status' => 'failed',
572 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
573 'log_type' => 'webhook',
574 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
575 'module_name' => 'PayPal'
576 ]);
577
578 return false; // We are really sorry that we could not get the transaction ID.
579 }
580
581 $parentTransaction = OrderTransaction::query()
582 ->where('vendor_charge_id', $paypalTransactionId)
583 ->where('status', Status::TRANSACTION_SUCCEEDED)
584 ->first();
585
586 if (!$parentTransaction) {
587
588 do_action('fluent_cart/dev_log', [
589 'raw_data' => $refundData,
590 'status' => 'failed',
591 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
592 'log_type' => 'webhook',
593 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
594 'module_name' => 'PayPal'
595 ]);
596
597 return false; // not our transaction, we are not handling this refund
598 }
599
600 return \FluentCart\App\Services\Payments\Refund::createOrRecordRefund([
601 'vendor_charge_id' => $paypalRefundId,
602 'payment_method' => 'paypal',
603 'total' => $paypalRefundAmount,
604 ], $parentTransaction);
605
606 }
607
608
609 public function handleWebhookRecurringPaymentRefunded($data)
610 {
611 $refundData = Arr::get($data, 'refund', []);
612
613 if (Arr::get($refundData, 'state') !== 'completed') {
614 return false;
615 }
616
617 $parentTxnId = Arr::get($refundData, 'sale_id', '');
618 if (!$parentTxnId) {
619 return false;
620 }
621
622 $subscriptionHash = sanitize_text_field(Arr::get($data, 'custom', ''));
623
624 $parentTransaction = OrderTransaction::query()->where('vendor_charge_id', $parentTxnId)
625 ->where('status', Status::TRANSACTION_SUCCEEDED)
626 ->first();
627
628 if (!$parentTransaction && $subscriptionHash) {
629 $parentSubscription = Subscription::query()->where('uuid', $subscriptionHash)->first();
630 $parentTransaction = $parentSubscription ? $parentSubscription->getLatestTransaction() : null;
631 }
632
633 if ($parentTransaction->transaction_type === Status::TRANSACTION_FAILED) {
634 return null;
635 }
636
637 if (!$parentTransaction) {
638 do_action('fluent_cart/dev_log', [
639 'raw_data' => $data,
640 'status' => 'failed',
641 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
642 'log_type' => 'webhook',
643 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
644 'module_name' => 'PayPal'
645 ]);
646
647 return null;
648 }
649
650 $paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.total', 0));
651
652 return \FluentCart\App\Services\Payments\Refund::createOrRecordRefund([
653 'vendor_charge_id' => Arr::get($refundData, 'id'),
654 'payment_method' => 'paypal',
655 'total' => $paypalRefundAmount,
656 'reason' => Arr::get($refundData, 'description'),
657 ], $parentTransaction);
658 }
659
660 public function handleWebhookRecurringProfileCancelled($data)
661 {
662 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
663 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
664
665 if (!$subscriptionModel) {
666 do_action('fluent_cart/dev_log', [
667 'raw_data' => $subscriptionInfo,
668 'status' => 'failed',
669 'title' => __('Failed to find Subscription for PayPal Cancel webhook', 'fluent-cart'),
670 'log_type' => 'webhook',
671 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
672 'module_name' => 'PayPal'
673 ]);
674 return;
675 }
676
677 if ($subscriptionModel->status === Status::SUBSCRIPTION_CANCELED || $subscriptionModel->current_payment_method !== 'paypal') {
678 return;
679 }
680
681 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
682 'status' => Status::SUBSCRIPTION_CANCELED,
683 'canceled_at' => DateTime::anyTimeToGmt(Arr::get($subscriptionInfo, 'status_update_time'))->format('Y-m-d H:i:s'),
684 'meta' => [
685 'cancellation_reason' => Arr::get($subscriptionInfo, 'status_change_note', ''),
686 ]
687 ]);
688 }
689
690 public function handleWebhookRecurringProfileExpired($data)
691 {
692 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
693 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
694
695 if (!$subscriptionModel) {
696 do_action('fluent_cart/dev_log', [
697 'raw_data' => $subscriptionInfo,
698 'status' => 'failed',
699 'title' => __('Failed to find Subscription for PayPal Subscription Expired webhook', 'fluent-cart'),
700 'log_type' => 'webhook',
701 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
702 'module_name' => 'PayPal'
703 ]);
704 return;
705 }
706
707 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
708 'status' => Status::SUBSCRIPTION_EXPIRED
709 ]);
710 }
711
712 public function handleWebhookRecurringProfileSuspended($data)
713 {
714 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
715 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
716
717 if (!$subscriptionModel) {
718 do_action('fluent_cart/dev_log', [
719 'raw_data' => $subscriptionInfo,
720 'status' => 'failed',
721 'title' => __('Failed to find Subscription for PayPal Subscription Suspended webhook', 'fluent-cart'),
722 'log_type' => 'webhook',
723 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
724 'module_name' => 'PayPal'
725 ]);
726 return;
727 }
728
729 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
730 'status' => Status::SUBSCRIPTION_PAUSED
731 ]);
732
733 }
734
735 public function handleWebhookRecurringProfileReactivated($data)
736 {
737 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
738 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
739
740 if (!$subscriptionModel) {
741 do_action('fluent_cart/dev_log', [
742 'raw_data' => $subscriptionInfo,
743 'status' => 'failed',
744 'title' => __('Failed to find Subscription for PayPal Subscription Reactive webhook', 'fluent-cart'),
745 'log_type' => 'webhook',
746 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
747 'module_name' => 'PayPal'
748 ]);
749 return;
750 }
751
752 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
753 'status' => Status::SUBSCRIPTION_ACTIVE
754 ]);
755 }
756
757 public function handleWebhookDisputeCreated($data)
758 {
759 $disputeInfo = Arr::get($data, 'dispute', []);
760 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
761 if (empty($disputeId)) {
762 return false;
763 }
764
765 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
766
767 if (count($disputedTransactions) > 1) {
768 return false;
769 }
770
771 $status = Arr::get($disputeInfo, 'status', '');
772 $stage = Arr::get($disputeInfo, 'dispute_life_cycle_stage', '');
773 $reason = Arr::get($disputeInfo, 'reason', '');
774
775 $fluentCartTransactions = [];
776 foreach ($disputedTransactions as $transaction) {
777 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
778 if ($transaction) {
779 $fluentCartTransactions[] = $transactionModel;
780 }
781 }
782 if (empty($fluentCartTransactions)) {
783 return false;
784 }
785
786 $isChargeRefundable = in_array($status, ['OPEN', 'WAITING_FOR_SELLER_RESPONSE']);
787
788 $transactionModel = $fluentCartTransactions[0];
789 $transactionModel->update([
790 'transaction_type' => Status::TRANSACTION_TYPE_DISPUTE,
791 'meta' => array_merge($transactionModel->meta, [
792 'dispute_id' => $disputeId,
793 'dispute_reason' => $reason,
794 'is_dispute_actionable' => in_array($stage, ['CHARGEBACK', 'REVIEW']),
795 'is_charge_refundable' => $isChargeRefundable,
796 'status' => $status
797 ])
798 ]);
799
800 return true;
801 }
802
803 public function handleWebhookDisputeUpdated($data)
804 {
805 $disputeInfo = Arr::get($data, 'dispute', []);
806 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
807 if (empty($disputeId)) {
808 return false;
809 }
810
811 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
812
813 if (count($disputedTransactions) > 1) {
814 return false;
815 }
816
817 $stage = Arr::get($disputeInfo, 'dispute_life_cycle_stage', '');
818
819 $fluentCartTransactions = [];
820 foreach ($disputedTransactions as $transaction) {
821 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
822 if (!$transactionModel) {
823 continue;
824 }
825 $fluentCartTransactions[] = $transactionModel;
826 }
827
828 if (empty($fluentCartTransactions)) {
829 return false;
830 }
831
832 $transactionModel = $fluentCartTransactions[0];
833 $status = Arr::get($disputeInfo, 'status', '');
834 $isChargeRefundable = in_array($status, ['OPEN', 'WAITING_FOR_SELLER_RESPONSE']) || in_array($stage, ['CHARGEBACK', 'INQUIRY']);
835
836 if ($stage === 'CHARGEBACK') {
837 $transactionModel->update([
838 'meta' => array_merge($transactionModel->meta, [
839 'is_dispute_actionable' => true,
840 'dispute_status' => Arr::get($disputeInfo, 'status', ''),
841 'is_charge_refundable' => $isChargeRefundable
842 ])
843 ]);
844 } else {
845 $transactionModel->update([
846 'meta' => array_merge($transactionModel->meta, [
847 'is_dispute_actionable' => false,
848 'dispute_status' => Arr::get($disputeInfo, 'status', ''),
849 'is_charge_refundable' => $isChargeRefundable
850 ])
851 ]);
852 }
853
854 return true;
855
856 }
857
858 public function handleWebhookDisputeResolved($data)
859 {
860 $disputeInfo = Arr::get($data, 'dispute', []);
861 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
862 if (empty($disputeId)) {
863 return false;
864 }
865
866 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
867 if (count($disputedTransactions) > 1) {
868 return false;
869 }
870
871 $status = Arr::get($disputeInfo, 'status', '');
872 if ($status !== 'RESOLVED') {
873 return false;
874 }
875
876 $fluentCartTransactions = [];
877 foreach ($disputedTransactions as $transaction) {
878 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
879 if (!$transactionModel) {
880 continue;
881 }
882 $fluentCartTransactions[] = $transactionModel;
883 }
884
885 if (empty($fluentCartTransactions)) {
886 return false;
887 }
888
889 // we are handling disputes only with one transaction - PayPal allow user to select multiple transactions on dispute creation
890 $transactionModel = $fluentCartTransactions[0];
891
892 if ($transactionModel->status === Status::TRANSACTION_DISPUTE_LOST) { // already dispute claim accepted via admin dashboard
893 return false;
894 }
895
896 // 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
897 $transactionModel->update([
898 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
899 'meta' => array_merge($transactionModel->meta, [
900 'is_dispute_actionable' => false,
901 'is_charge_refundable' => false,
902 'dispute_status' => $status
903 ])
904 ]);
905
906 return true;
907 }
908
909 private function getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo = [])
910 {
911 $id = Arr::get($subscriptionInfo, 'id', '');
912 if (empty($id)) {
913 return null;
914 }
915
916 $subscription = Subscription::query()->where('vendor_subscription_id', $id)->first();
917
918 if (!$subscription) {
919 $subscriptionHash = Arr::get($subscriptionInfo, 'custom_id', '');
920 if ($subscriptionHash) {
921 $subscription = Subscription::query()->where('uuid', $subscriptionHash)->first();
922 }
923 }
924
925 return $subscription;
926 }
927
928
929 private static function getPayPalSettings()
930 {
931 if (!self::$paypalSettings) {
932 self::$paypalSettings = new PayPalSettingsBase();
933 }
934 return self::$paypalSettings;
935 }
936 }
937