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

943 lines 36.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\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->vendor_charge_id && $latestTransaction->total) {
487 if ($latestTransaction->status !== Status::TRANSACTION_SUCCEEDED) {
488 (new Processor())->confirmPaymentSuccessByCharge($latestTransaction, [
489 'vendor_charge_id' => $chargeId,
490 'status' => Status::TRANSACTION_SUCCEEDED,
491 'total' => $amount,
492 'payment_method_type' => 'PayPal',
493 ]);
494 } else {
495 // activateSubscription() already marked this succeeded (billing_info.last_payment matched),
496 // but vendor_charge_id was not available at that point — fill it in now.
497 $latestTransaction->update(['vendor_charge_id' => $chargeId]);
498 }
499 return true;
500 }
501
502
503 // Now we are sure, we have a renewal payment for this subscription!
504
505 // we will just create the transaction here
506
507 $subscriptionUpdateData = [
508 'current_payment_method' => 'paypal',
509 'vendor_subscription_id' => $vendorSubscriptionId
510 ];
511
512 $payer = ($paypalSubscription && !is_wp_error($paypalSubscription)) ? Arr::get($paypalSubscription, 'subscriber', []) : [];
513 if ($paypalSubscription && !is_wp_error($paypalSubscription)) {
514 $nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time');
515 if ($nextBillingDate) {
516 $subscriptionUpdateData['next_billing_date'] = gmdate('Y-m-d H:i:s', strtotime($nextBillingDate));
517 }
518
519 $payerId = Arr::get($paypalSubscription, 'subscriber.payer_id');
520
521 if ($payerId) {
522 $subscriptionUpdateData['vendor_customer_id'] = $payerId;
523 }
524
525 if (!empty($paypalSubscription['plan_id'])) {
526 $subscriptionUpdateData['vendor_plan_id'] = $paypalSubscription['plan_id'];
527 }
528
529 if (Arr::get($paypalSubscription, 'status') === 'CANCELLED') {
530 $statusUpdateTime = Arr::get($paypalSubscription, 'status_update_time');
531 if ($statusUpdateTime) {
532 $subscriptionUpdateData['canceled_at'] = gmdate('Y-m-d H:i:s', strtotime($statusUpdateTime));
533 }
534 }
535
536 }
537
538 $transactionData = [
539 'payment_method' => 'paypal',
540 'total' => $amount,
541 'vendor_charge_id' => $chargeId,
542 'payment_method_type' => 'paypal',
543 'meta' => [
544 'payer' => $payer
545 ]
546 ];
547
548 return SubscriptionService::recordRenewalPayment($transactionData, $subscriptionModel, $subscriptionUpdateData);
549 }
550
551 public function handleSinglePaymentRefund($data)
552 {
553 $refundData = Arr::get($data, 'refund', []);
554 $paypalRefundId = Arr::get($refundData, 'id', '');
555 $paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.value', 0));
556
557 // Let's guess the transaction ID from links
558
559 $paypalTransactionId = '';
560
561 foreach (Arr::get($refundData, 'links', []) as $link) {
562 if (Arr::get($link, 'rel') !== 'up') {
563 continue;
564 }
565
566 $href = Arr::get($link, 'href', '');
567 $paypalTransactionId = basename($href);
568 if ($paypalTransactionId) {
569 break;
570 }
571 }
572
573 if (!$paypalTransactionId) {
574
575 do_action('fluent_cart/dev_log', [
576 'raw_data' => $refundData,
577 'status' => 'failed',
578 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
579 'log_type' => 'webhook',
580 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
581 'module_name' => 'PayPal'
582 ]);
583
584 return false; // We are really sorry that we could not get the transaction ID.
585 }
586
587 $parentTransaction = OrderTransaction::query()
588 ->where('vendor_charge_id', $paypalTransactionId)
589 ->where('status', Status::TRANSACTION_SUCCEEDED)
590 ->first();
591
592 if (!$parentTransaction) {
593
594 do_action('fluent_cart/dev_log', [
595 'raw_data' => $refundData,
596 'status' => 'failed',
597 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
598 'log_type' => 'webhook',
599 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
600 'module_name' => 'PayPal'
601 ]);
602
603 return false; // not our transaction, we are not handling this refund
604 }
605
606 return \FluentCart\App\Services\Payments\Refund::createOrRecordRefund([
607 'vendor_charge_id' => $paypalRefundId,
608 'payment_method' => 'paypal',
609 'total' => $paypalRefundAmount,
610 ], $parentTransaction);
611
612 }
613
614
615 public function handleWebhookRecurringPaymentRefunded($data)
616 {
617 $refundData = Arr::get($data, 'refund', []);
618
619 if (Arr::get($refundData, 'state') !== 'completed') {
620 return false;
621 }
622
623 $parentTxnId = Arr::get($refundData, 'sale_id', '');
624 if (!$parentTxnId) {
625 return false;
626 }
627
628 $subscriptionHash = sanitize_text_field(Arr::get($data, 'custom', ''));
629
630 $parentTransaction = OrderTransaction::query()->where('vendor_charge_id', $parentTxnId)
631 ->where('status', Status::TRANSACTION_SUCCEEDED)
632 ->first();
633
634 if (!$parentTransaction && $subscriptionHash) {
635 $parentSubscription = Subscription::query()->where('uuid', $subscriptionHash)->first();
636 $parentTransaction = $parentSubscription ? $parentSubscription->getLatestTransaction() : null;
637 }
638
639 if ($parentTransaction->transaction_type === Status::TRANSACTION_FAILED) {
640 return null;
641 }
642
643 if (!$parentTransaction) {
644 do_action('fluent_cart/dev_log', [
645 'raw_data' => $data,
646 'status' => 'failed',
647 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
648 'log_type' => 'webhook',
649 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
650 'module_name' => 'PayPal'
651 ]);
652
653 return null;
654 }
655
656 $paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.total', 0));
657
658 return \FluentCart\App\Services\Payments\Refund::createOrRecordRefund([
659 'vendor_charge_id' => Arr::get($refundData, 'id'),
660 'payment_method' => 'paypal',
661 'total' => $paypalRefundAmount,
662 'reason' => Arr::get($refundData, 'description'),
663 ], $parentTransaction);
664 }
665
666 public function handleWebhookRecurringProfileCancelled($data)
667 {
668 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
669 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
670
671 if (!$subscriptionModel) {
672 do_action('fluent_cart/dev_log', [
673 'raw_data' => $subscriptionInfo,
674 'status' => 'failed',
675 'title' => __('Failed to find Subscription for PayPal Cancel webhook', 'fluent-cart'),
676 'log_type' => 'webhook',
677 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
678 'module_name' => 'PayPal'
679 ]);
680 return;
681 }
682
683 if ($subscriptionModel->status === Status::SUBSCRIPTION_CANCELED || $subscriptionModel->current_payment_method !== 'paypal') {
684 return;
685 }
686
687 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
688 'status' => Status::SUBSCRIPTION_CANCELED,
689 'canceled_at' => DateTime::anyTimeToGmt(Arr::get($subscriptionInfo, 'status_update_time'))->format('Y-m-d H:i:s'),
690 'meta' => [
691 'cancellation_reason' => Arr::get($subscriptionInfo, 'status_change_note', ''),
692 ]
693 ]);
694 }
695
696 public function handleWebhookRecurringProfileExpired($data)
697 {
698 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
699 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
700
701 if (!$subscriptionModel) {
702 do_action('fluent_cart/dev_log', [
703 'raw_data' => $subscriptionInfo,
704 'status' => 'failed',
705 'title' => __('Failed to find Subscription for PayPal Subscription Expired webhook', 'fluent-cart'),
706 'log_type' => 'webhook',
707 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
708 'module_name' => 'PayPal'
709 ]);
710 return;
711 }
712
713 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
714 'status' => Status::SUBSCRIPTION_EXPIRED
715 ]);
716 }
717
718 public function handleWebhookRecurringProfileSuspended($data)
719 {
720 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
721 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
722
723 if (!$subscriptionModel) {
724 do_action('fluent_cart/dev_log', [
725 'raw_data' => $subscriptionInfo,
726 'status' => 'failed',
727 'title' => __('Failed to find Subscription for PayPal Subscription Suspended webhook', 'fluent-cart'),
728 'log_type' => 'webhook',
729 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
730 'module_name' => 'PayPal'
731 ]);
732 return;
733 }
734
735 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
736 'status' => Status::SUBSCRIPTION_PAUSED
737 ]);
738
739 }
740
741 public function handleWebhookRecurringProfileReactivated($data)
742 {
743 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
744 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
745
746 if (!$subscriptionModel) {
747 do_action('fluent_cart/dev_log', [
748 'raw_data' => $subscriptionInfo,
749 'status' => 'failed',
750 'title' => __('Failed to find Subscription for PayPal Subscription Reactive webhook', 'fluent-cart'),
751 'log_type' => 'webhook',
752 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
753 'module_name' => 'PayPal'
754 ]);
755 return;
756 }
757
758 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
759 'status' => Status::SUBSCRIPTION_ACTIVE
760 ]);
761 }
762
763 public function handleWebhookDisputeCreated($data)
764 {
765 $disputeInfo = Arr::get($data, 'dispute', []);
766 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
767 if (empty($disputeId)) {
768 return false;
769 }
770
771 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
772
773 if (count($disputedTransactions) > 1) {
774 return false;
775 }
776
777 $status = Arr::get($disputeInfo, 'status', '');
778 $stage = Arr::get($disputeInfo, 'dispute_life_cycle_stage', '');
779 $reason = Arr::get($disputeInfo, 'reason', '');
780
781 $fluentCartTransactions = [];
782 foreach ($disputedTransactions as $transaction) {
783 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
784 if ($transaction) {
785 $fluentCartTransactions[] = $transactionModel;
786 }
787 }
788 if (empty($fluentCartTransactions)) {
789 return false;
790 }
791
792 $isChargeRefundable = in_array($status, ['OPEN', 'WAITING_FOR_SELLER_RESPONSE']);
793
794 $transactionModel = $fluentCartTransactions[0];
795 $transactionModel->update([
796 'transaction_type' => Status::TRANSACTION_TYPE_DISPUTE,
797 'meta' => array_merge($transactionModel->meta, [
798 'dispute_id' => $disputeId,
799 'dispute_reason' => $reason,
800 'is_dispute_actionable' => in_array($stage, ['CHARGEBACK', 'REVIEW']),
801 'is_charge_refundable' => $isChargeRefundable,
802 'status' => $status
803 ])
804 ]);
805
806 return true;
807 }
808
809 public function handleWebhookDisputeUpdated($data)
810 {
811 $disputeInfo = Arr::get($data, 'dispute', []);
812 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
813 if (empty($disputeId)) {
814 return false;
815 }
816
817 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
818
819 if (count($disputedTransactions) > 1) {
820 return false;
821 }
822
823 $stage = Arr::get($disputeInfo, 'dispute_life_cycle_stage', '');
824
825 $fluentCartTransactions = [];
826 foreach ($disputedTransactions as $transaction) {
827 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
828 if (!$transactionModel) {
829 continue;
830 }
831 $fluentCartTransactions[] = $transactionModel;
832 }
833
834 if (empty($fluentCartTransactions)) {
835 return false;
836 }
837
838 $transactionModel = $fluentCartTransactions[0];
839 $status = Arr::get($disputeInfo, 'status', '');
840 $isChargeRefundable = in_array($status, ['OPEN', 'WAITING_FOR_SELLER_RESPONSE']) || in_array($stage, ['CHARGEBACK', 'INQUIRY']);
841
842 if ($stage === 'CHARGEBACK') {
843 $transactionModel->update([
844 'meta' => array_merge($transactionModel->meta, [
845 'is_dispute_actionable' => true,
846 'dispute_status' => Arr::get($disputeInfo, 'status', ''),
847 'is_charge_refundable' => $isChargeRefundable
848 ])
849 ]);
850 } else {
851 $transactionModel->update([
852 'meta' => array_merge($transactionModel->meta, [
853 'is_dispute_actionable' => false,
854 'dispute_status' => Arr::get($disputeInfo, 'status', ''),
855 'is_charge_refundable' => $isChargeRefundable
856 ])
857 ]);
858 }
859
860 return true;
861
862 }
863
864 public function handleWebhookDisputeResolved($data)
865 {
866 $disputeInfo = Arr::get($data, 'dispute', []);
867 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
868 if (empty($disputeId)) {
869 return false;
870 }
871
872 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
873 if (count($disputedTransactions) > 1) {
874 return false;
875 }
876
877 $status = Arr::get($disputeInfo, 'status', '');
878 if ($status !== 'RESOLVED') {
879 return false;
880 }
881
882 $fluentCartTransactions = [];
883 foreach ($disputedTransactions as $transaction) {
884 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
885 if (!$transactionModel) {
886 continue;
887 }
888 $fluentCartTransactions[] = $transactionModel;
889 }
890
891 if (empty($fluentCartTransactions)) {
892 return false;
893 }
894
895 // we are handling disputes only with one transaction - PayPal allow user to select multiple transactions on dispute creation
896 $transactionModel = $fluentCartTransactions[0];
897
898 if ($transactionModel->status === Status::TRANSACTION_DISPUTE_LOST) { // already dispute claim accepted via admin dashboard
899 return false;
900 }
901
902 // 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
903 $transactionModel->update([
904 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
905 'meta' => array_merge($transactionModel->meta, [
906 'is_dispute_actionable' => false,
907 'is_charge_refundable' => false,
908 'dispute_status' => $status
909 ])
910 ]);
911
912 return true;
913 }
914
915 private function getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo = [])
916 {
917 $id = Arr::get($subscriptionInfo, 'id', '');
918 if (empty($id)) {
919 return null;
920 }
921
922 $subscription = Subscription::query()->where('vendor_subscription_id', $id)->first();
923
924 if (!$subscription) {
925 $subscriptionHash = Arr::get($subscriptionInfo, 'custom_id', '');
926 if ($subscriptionHash) {
927 $subscription = Subscription::query()->where('uuid', $subscriptionHash)->first();
928 }
929 }
930
931 return $subscription;
932 }
933
934
935 private static function getPayPalSettings()
936 {
937 if (!self::$paypalSettings) {
938 self::$paypalSettings = new PayPalSettingsBase();
939 }
940 return self::$paypalSettings;
941 }
942 }
943