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

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