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

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