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

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