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

1,059 lines 43.8 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 // System subscription: persist the vault token from the captured order
338 // (idempotent — the AJAX confirmation may have done it already).
339 (new Processor())->maybePersistVaultToken($transaction, $paypalIntent);
340
341 }
342
343
344 // called only when webhook/ipn hits
345 public function verifyAndProcess($data = []): void
346 {
347 $this->processWebhook();
348 }
349
350 /**
351 * Verify the webhook signature
352 *
353 * @param string $webhookId
354 * @return bool|\WP_Error
355 */
356 public function verifyWebhook($webhookId)
357 {
358 $disableWebhookVerification = apply_filters('fluent_cart/payments/paypal/disable_webhook_verification', 'no', []);
359 if ($disableWebhookVerification === 'yes') {
360 return true;
361 }
362
363 if (empty($webhookId)) {
364 return new \WP_Error('webhook_id_missing', __('Webhook ID is missing.', 'fluent-cart'));
365 }
366
367 $webhookId = trim($webhookId);
368 $header = getallheaders();
369
370 // make all headers lowercase
371 $header = array_change_key_case($header, CASE_LOWER);
372 if (!isset($header['paypal-auth-algo']) || !isset($header['paypal-cert-url']) ||
373 !isset($header['paypal-transmission-id']) || !isset($header['paypal-transmission-sig']) ||
374 !isset($header['paypal-transmission-time'])) {
375
376 return new \WP_Error('webhook_header_missing', __('Required PayPal webhook headers are missing.', 'fluent-cart'), [
377 'headers' => $header
378 ]);
379 }
380
381 $webhookEvent = json_decode(file_get_contents('php://input'));
382 $body = [
383 'auth_algo' => $header['paypal-auth-algo'],
384 'transmission_id' => $header['paypal-transmission-id'],
385 'transmission_time' => $header['paypal-transmission-time'],
386 'cert_url' => $header['paypal-cert-url'],
387 'transmission_sig' => $header['paypal-transmission-sig'],
388 'webhook_id' => $webhookId,
389 'webhook_event' => $webhookEvent
390 ];
391
392 $response = API::verifyWebhookSignature($body);
393
394 if (is_wp_error($response)) {
395 do_action('fluent_cart/dev_log', [
396 'raw_data' => $body,
397 'status' => 'failed',
398 'title' => __('Failed to verify PayPal webhook signature', 'fluent-cart'),
399 'log_type' => 'webhook',
400 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
401 'module_name' => 'PayPal'
402 ]);
403
404 return $response;
405 }
406
407 $http_code = wp_remote_retrieve_response_code($response);
408 $response_body = wp_remote_retrieve_body($response);
409 $response_data = json_decode($response_body, true);
410
411 if ($http_code !== 200 || empty($response_data['verification_status']) || $response_data['verification_status'] !== 'SUCCESS') {
412 return new \WP_Error('webhook_verification_failed', __('Webhook verification failed.', 'fluent-cart'), [
413 'http_code' => $http_code,
414 'response' => $response_data
415 ]);
416 }
417
418 return true;
419 }
420
421 public function processWebhook()
422 {
423 $post_data = file_get_contents('php://input');
424
425 $data = json_decode($post_data, true);
426
427 if (empty($data)) {
428 return;
429 }
430
431 $webhookType = Arr::get($data, 'event_type', '');
432
433 $webhookEvents = [
434 'PAYMENT.SALE.COMPLETED',
435 'PAYMENT.SALE.REFUNDED',
436 'PAYMENT.CAPTURE.REFUNDED',
437 'BILLING.SUBSCRIPTION.CREATED',
438 'BILLING.SUBSCRIPTION.ACTIVATED',
439 'BILLING.SUBSCRIPTION.CANCELLED',
440 'BILLING.SUBSCRIPTION.EXPIRED',
441 'BILLING.SUBSCRIPTION.SUSPENDED',
442 'BILLING.SUBSCRIPTION.RE-ACTIVATED',
443 'PAYMENT.CAPTURE.COMPLETED',
444 'CUSTOMER.DISPUTE.CREATED',
445 'CUSTOMER.DISPUTE.UPDATED',
446 'CUSTOMER.DISPUTE.RESOLVED',
447 'CHECKOUT.ORDER.APPROVED' // we don't need this
448 ];
449
450 if (!in_array($webhookType, $webhookEvents)) {
451 return;
452 }
453
454 do_action('fluent_cart/paypal_webhook_received', [
455 'data' => $data,
456 'raw' => $post_data
457 ]);
458
459 if (defined('FLUENT_CART_DEV_MODE')) {
460 do_action('fluent_cart/dev_log', [
461 'raw_data' => $post_data,
462 'status' => 'received',
463 'title' => __('PayPal Webhook Received', 'fluent-cart'),
464 'log_type' => 'webhook',
465 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
466 'module_name' => 'PayPal'
467 ]);
468 }
469
470 $paymentSettings = self::getPayPalSettings()->get();
471
472 $mode = (new StoreSettings)->get('order_mode');
473
474 // FCT_PAYPAL_LIVE_WEBHOOK_ID
475 if ($mode === 'test') {
476 $webhookId = defined('FCT_PAYPAL_TEST_WEBHOOK_ID') ? FCT_PAYPAL_TEST_WEBHOOK_ID : Arr::get($paymentSettings, $mode . '_webhook_id', '');
477 } else {
478 $webhookId = defined('FCT_PAYPAL_LIVE_WEBHOOK_ID') ? FCT_PAYPAL_LIVE_WEBHOOK_ID : Arr::get($paymentSettings, $mode . '_webhook_id', '');
479 }
480
481 $willVerify = apply_filters('fluent_cart/payments/paypal/verify_webhook', true, [
482 'data' => $data,
483 'mode' => $mode,
484 'type' => $webhookType
485 ]);
486
487 if ($willVerify) {
488
489 $verified = $this->verifyWebhook($webhookId);
490
491 if (is_wp_error($verified)) {
492 $data = json_encode($verified->get_error_data());
493 fluent_cart_add_log($verified->get_error_message() . ' Webhook: ' . $webhookType, $data, 'error', [
494 'log_type' => 'webhook',
495 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
496 'module_name' => 'PayPal',
497 ]);
498
499 exit(400);
500 }
501 }
502
503 $this->processPaypalWebhookEvents($data);
504 exit(200);
505 }
506
507 public function processSubscriptionActivated($data)
508 {
509 $paypalSubscription = Arr::get($data, 'paypal_subscription', []);
510 $vendorSubscriptionId = sanitize_text_field(Arr::get($paypalSubscription, 'id'));
511 if (empty($vendorSubscriptionId)) {
512 return;
513 }
514
515 $subscriptionModel = Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId)->first();
516 if (!$subscriptionModel) {
517 $subscriptionHash = Arr::get($paypalSubscription, 'custom_id', '');
518
519 if ($subscriptionHash) {
520 $subscriptionModel = Subscription::query()->where('uuid', $subscriptionHash)->first();
521 }
522 }
523
524 if (!$subscriptionModel || $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
525 return;
526 }
527
528 $transaction = $subscriptionModel->getLatestTransaction();
529 if (!$transaction) {
530 return;
531 }
532
533 (new Processor())->activateSubscription($paypalSubscription, $transaction, $subscriptionModel);
534 }
535
536 public function processRecurringPaymentReceived($data)
537 {
538 $charge = Arr::get($data, 'charge', []);
539 $vendorSubscriptionId = Arr::get($data, 'vendor_subscription_id', '');
540
541 $subscriptionModel = $vendorSubscriptionId ? Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId)->with('order')->first() : null;
542
543 if (!$subscriptionModel) {
544 $subscriptionHash = Arr::get($charge, 'custom', '');
545 if ($subscriptionHash) {
546 $subscriptionModel = Subscription::query()->where('uuid', $subscriptionHash)->first();
547 }
548 }
549
550 if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal') {
551 return false;
552 }
553
554 $amount = Helper::toCent(Arr::get($charge, 'amount.total', 0));
555 $chargeId = Arr::get($charge, 'id');
556 if (!$amount || !$chargeId) {
557 return false;
558 }
559
560 // find the OrderTransaction
561 $transaction = OrderTransaction::query()->where('vendor_charge_id', $chargeId)
562 ->where('subscription_id', $subscriptionModel->id)
563 ->where('payment_method', 'paypal')
564 ->first();
565
566 if ($transaction) {
567 return true;
568 }
569
570 // Fetch PayPal subscription data once — used for plan verification and renewal processing
571 $paypalSubscription = $vendorSubscriptionId ? API::getResource('billing/subscriptions/' . $vendorSubscriptionId) : null;
572
573 // Verify the PayPal subscription plan matches the expected plan
574 if ($subscriptionModel->vendor_plan_id && $paypalSubscription && !is_wp_error($paypalSubscription)) {
575 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
576 if ($paypalPlanId && $paypalPlanId !== $subscriptionModel->vendor_plan_id) {
577 fluent_cart_add_log(
578 __('PayPal Recurring Plan Mismatch', 'fluent-cart'),
579 sprintf(
580 /* translators: %1$s: expected plan ID, %2$s: received plan ID, %3$d: subscription ID */
581 __('Recurring payment plan mismatch. Expected: %1$s, Received: %2$s. Subscription ID: %3$d. Payment not recorded.', 'fluent-cart'),
582 $subscriptionModel->vendor_plan_id,
583 $paypalPlanId,
584 $subscriptionModel->id
585 ),
586 'error',
587 [
588 'module_type' => 'FluentCart\App\Models\Subscription',
589 'module_id' => $subscriptionModel->id,
590 'module_name' => 'subscription',
591 'log_type' => 'webhook'
592 ]
593 );
594 return false;
595 }
596 }
597
598 // Latest charge transaction = pending one for initial subscription OR for renewal
599 $latestTransaction = $subscriptionModel->getLatestTransaction();
600
601 if ($latestTransaction && !$latestTransaction->vendor_charge_id && $latestTransaction->total) {
602 if ($latestTransaction->status !== Status::TRANSACTION_SUCCEEDED) {
603 (new Processor())->confirmPaymentSuccessByCharge($latestTransaction, [
604 'vendor_charge_id' => $chargeId,
605 'status' => Status::TRANSACTION_SUCCEEDED,
606 'total' => $amount,
607 'payment_method_type' => 'PayPal',
608 ]);
609 } else {
610 // activateSubscription() already marked this succeeded (billing_info.last_payment matched),
611 // but vendor_charge_id was not available at that point — fill it in now.
612 $latestTransaction->update(['vendor_charge_id' => $chargeId]);
613 }
614 return true;
615 }
616
617
618 // Now we are sure, we have a renewal payment for this subscription!
619
620 // we will just create the transaction here
621
622 $subscriptionUpdateData = [
623 'current_payment_method' => 'paypal',
624 'vendor_subscription_id' => $vendorSubscriptionId
625 ];
626
627 $payer = ($paypalSubscription && !is_wp_error($paypalSubscription)) ? Arr::get($paypalSubscription, 'subscriber', []) : [];
628 if ($paypalSubscription && !is_wp_error($paypalSubscription)) {
629 $subscriptionUpdateData['status'] = (new SubscriptionManager)->getCorrectSubscriptionStatus(Arr::get($paypalSubscription, 'status'));
630 $nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time');
631 if ($nextBillingDate) {
632 $subscriptionUpdateData['next_billing_date'] = SubscriptionHelper::safeTimestampToDatetime($nextBillingDate);
633 }
634
635 $payerId = Arr::get($paypalSubscription, 'subscriber.payer_id');
636
637 if ($payerId) {
638 $subscriptionUpdateData['vendor_customer_id'] = $payerId;
639 }
640
641 if (!empty($paypalSubscription['plan_id'])) {
642 $subscriptionUpdateData['vendor_plan_id'] = $paypalSubscription['plan_id'];
643 }
644
645 if (Arr::get($paypalSubscription, 'status') === 'CANCELLED') {
646 $statusUpdateTime = Arr::get($paypalSubscription, 'status_update_time');
647 if ($statusUpdateTime) {
648 $subscriptionUpdateData['canceled_at'] = gmdate('Y-m-d H:i:s', strtotime($statusUpdateTime));
649 }
650 }
651
652 }
653
654 $transactionData = [
655 'payment_method' => 'paypal',
656 'total' => $amount,
657 'vendor_charge_id' => $chargeId,
658 'payment_method_type' => 'paypal',
659 'meta' => [
660 'payer' => $payer
661 ]
662 ];
663
664 return SubscriptionService::recordRenewalPayment($transactionData, $subscriptionModel, $subscriptionUpdateData);
665 }
666
667 public function handleSinglePaymentRefund($data)
668 {
669 $refundData = Arr::get($data, 'refund', []);
670 $paypalRefundId = Arr::get($refundData, 'id', '');
671 $paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.value', 0));
672
673 // Let's guess the transaction ID from links
674
675 $paypalTransactionId = '';
676
677 foreach (Arr::get($refundData, 'links', []) as $link) {
678 if (Arr::get($link, 'rel') !== 'up') {
679 continue;
680 }
681
682 $href = Arr::get($link, 'href', '');
683 $paypalTransactionId = basename($href);
684 if ($paypalTransactionId) {
685 break;
686 }
687 }
688
689 if (!$paypalTransactionId) {
690
691 do_action('fluent_cart/dev_log', [
692 'raw_data' => $refundData,
693 'status' => 'failed',
694 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
695 'log_type' => 'webhook',
696 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
697 'module_name' => 'PayPal'
698 ]);
699
700 return false; // We are really sorry that we could not get the transaction ID.
701 }
702
703 $parentTransaction = OrderTransaction::query()
704 ->where('vendor_charge_id', $paypalTransactionId)
705 ->where('status', Status::TRANSACTION_SUCCEEDED)
706 ->first();
707
708 if (!$parentTransaction) {
709
710 do_action('fluent_cart/dev_log', [
711 'raw_data' => $refundData,
712 'status' => 'failed',
713 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
714 'log_type' => 'webhook',
715 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
716 'module_name' => 'PayPal'
717 ]);
718
719 return false; // not our transaction, we are not handling this refund
720 }
721
722 return \FluentCart\App\Services\Payments\Refund::createOrRecordRefund([
723 'vendor_charge_id' => $paypalRefundId,
724 'payment_method' => 'paypal',
725 'total' => $paypalRefundAmount,
726 ], $parentTransaction);
727
728 }
729
730
731 public function handleWebhookRecurringPaymentRefunded($data)
732 {
733 $refundData = Arr::get($data, 'refund', []);
734
735 if (Arr::get($refundData, 'state') !== 'completed') {
736 return false;
737 }
738
739 $parentTxnId = Arr::get($refundData, 'sale_id', '');
740 if (!$parentTxnId) {
741 return false;
742 }
743
744 $subscriptionHash = sanitize_text_field(Arr::get($data, 'custom', ''));
745
746 $parentTransaction = OrderTransaction::query()->where('vendor_charge_id', $parentTxnId)
747 ->where('status', Status::TRANSACTION_SUCCEEDED)
748 ->first();
749
750 if (!$parentTransaction && $subscriptionHash) {
751 $parentSubscription = Subscription::query()->where('uuid', $subscriptionHash)->first();
752 $parentTransaction = $parentSubscription ? $parentSubscription->getLatestTransaction() : null;
753 }
754
755 if (!$parentTransaction) {
756 do_action('fluent_cart/dev_log', [
757 'raw_data' => $data,
758 'status' => 'failed',
759 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
760 'log_type' => 'webhook',
761 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
762 'module_name' => 'PayPal'
763 ]);
764
765 return null;
766 }
767
768 if ($parentTransaction->status === Status::TRANSACTION_FAILED) {
769 return null;
770 }
771
772 $paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.total', 0));
773
774 return \FluentCart\App\Services\Payments\Refund::createOrRecordRefund([
775 'vendor_charge_id' => Arr::get($refundData, 'id'),
776 'payment_method' => 'paypal',
777 'total' => $paypalRefundAmount,
778 'reason' => Arr::get($refundData, 'description'),
779 ], $parentTransaction);
780 }
781
782 public function handleWebhookRecurringProfileCancelled($data)
783 {
784 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
785 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
786
787 if (!$subscriptionModel) {
788 do_action('fluent_cart/dev_log', [
789 'raw_data' => $subscriptionInfo,
790 'status' => 'failed',
791 'title' => __('Failed to find Subscription for PayPal Cancel webhook', 'fluent-cart'),
792 'log_type' => 'webhook',
793 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
794 'module_name' => 'PayPal'
795 ]);
796 return;
797 }
798
799 if ($subscriptionModel->status === Status::SUBSCRIPTION_CANCELED || $subscriptionModel->current_payment_method !== 'paypal') {
800 return;
801 }
802
803 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
804 'status' => Status::SUBSCRIPTION_CANCELED,
805 'canceled_at' => DateTime::anyTimeToGmt(Arr::get($subscriptionInfo, 'status_update_time'))->format('Y-m-d H:i:s'),
806 'meta' => [
807 'cancellation_reason' => Arr::get($subscriptionInfo, 'status_change_note', ''),
808 ]
809 ]);
810 }
811
812 public function handleWebhookRecurringProfileExpired($data)
813 {
814 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
815 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
816
817 if (!$subscriptionModel) {
818 do_action('fluent_cart/dev_log', [
819 'raw_data' => $subscriptionInfo,
820 'status' => 'failed',
821 'title' => __('Failed to find Subscription for PayPal Subscription Expired webhook', 'fluent-cart'),
822 'log_type' => 'webhook',
823 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
824 'module_name' => 'PayPal'
825 ]);
826 return;
827 }
828
829 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
830 'status' => Status::SUBSCRIPTION_EXPIRED
831 ]);
832 }
833
834 public function handleWebhookRecurringProfileSuspended($data)
835 {
836 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
837 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
838
839 if (!$subscriptionModel) {
840 do_action('fluent_cart/dev_log', [
841 'raw_data' => $subscriptionInfo,
842 'status' => 'failed',
843 'title' => __('Failed to find Subscription for PayPal Subscription Suspended webhook', 'fluent-cart'),
844 'log_type' => 'webhook',
845 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
846 'module_name' => 'PayPal'
847 ]);
848 return;
849 }
850
851 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
852 'status' => Status::SUBSCRIPTION_PAUSED
853 ]);
854
855 }
856
857 public function handleWebhookRecurringProfileReactivated($data)
858 {
859 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
860 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
861
862 if (!$subscriptionModel) {
863 do_action('fluent_cart/dev_log', [
864 'raw_data' => $subscriptionInfo,
865 'status' => 'failed',
866 'title' => __('Failed to find Subscription for PayPal Subscription Reactive webhook', 'fluent-cart'),
867 'log_type' => 'webhook',
868 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
869 'module_name' => 'PayPal'
870 ]);
871 return;
872 }
873
874 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
875 'status' => Status::SUBSCRIPTION_ACTIVE
876 ]);
877 }
878
879 public function handleWebhookDisputeCreated($data)
880 {
881 $disputeInfo = Arr::get($data, 'dispute', []);
882 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
883 if (empty($disputeId)) {
884 return false;
885 }
886
887 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
888
889 if (count($disputedTransactions) > 1) {
890 return false;
891 }
892
893 $status = Arr::get($disputeInfo, 'status', '');
894 $stage = Arr::get($disputeInfo, 'dispute_life_cycle_stage', '');
895 $reason = Arr::get($disputeInfo, 'reason', '');
896
897 $fluentCartTransactions = [];
898 foreach ($disputedTransactions as $transaction) {
899 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
900 if ($transaction) {
901 $fluentCartTransactions[] = $transactionModel;
902 }
903 }
904 if (empty($fluentCartTransactions)) {
905 return false;
906 }
907
908 $isChargeRefundable = in_array($status, ['OPEN', 'WAITING_FOR_SELLER_RESPONSE']);
909
910 $transactionModel = $fluentCartTransactions[0];
911 $transactionModel->update([
912 'transaction_type' => Status::TRANSACTION_TYPE_DISPUTE,
913 'meta' => array_merge($transactionModel->meta, [
914 'dispute_id' => $disputeId,
915 'dispute_reason' => $reason,
916 'is_dispute_actionable' => in_array($stage, ['CHARGEBACK', 'REVIEW']),
917 'is_charge_refundable' => $isChargeRefundable,
918 'status' => $status
919 ])
920 ]);
921
922 return true;
923 }
924
925 public function handleWebhookDisputeUpdated($data)
926 {
927 $disputeInfo = Arr::get($data, 'dispute', []);
928 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
929 if (empty($disputeId)) {
930 return false;
931 }
932
933 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
934
935 if (count($disputedTransactions) > 1) {
936 return false;
937 }
938
939 $stage = Arr::get($disputeInfo, 'dispute_life_cycle_stage', '');
940
941 $fluentCartTransactions = [];
942 foreach ($disputedTransactions as $transaction) {
943 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
944 if (!$transactionModel) {
945 continue;
946 }
947 $fluentCartTransactions[] = $transactionModel;
948 }
949
950 if (empty($fluentCartTransactions)) {
951 return false;
952 }
953
954 $transactionModel = $fluentCartTransactions[0];
955 $status = Arr::get($disputeInfo, 'status', '');
956 $isChargeRefundable = in_array($status, ['OPEN', 'WAITING_FOR_SELLER_RESPONSE']) || in_array($stage, ['CHARGEBACK', 'INQUIRY']);
957
958 if ($stage === 'CHARGEBACK') {
959 $transactionModel->update([
960 'meta' => array_merge($transactionModel->meta, [
961 'is_dispute_actionable' => true,
962 'dispute_status' => Arr::get($disputeInfo, 'status', ''),
963 'is_charge_refundable' => $isChargeRefundable
964 ])
965 ]);
966 } else {
967 $transactionModel->update([
968 'meta' => array_merge($transactionModel->meta, [
969 'is_dispute_actionable' => false,
970 'dispute_status' => Arr::get($disputeInfo, 'status', ''),
971 'is_charge_refundable' => $isChargeRefundable
972 ])
973 ]);
974 }
975
976 return true;
977
978 }
979
980 public function handleWebhookDisputeResolved($data)
981 {
982 $disputeInfo = Arr::get($data, 'dispute', []);
983 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
984 if (empty($disputeId)) {
985 return false;
986 }
987
988 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
989 if (count($disputedTransactions) > 1) {
990 return false;
991 }
992
993 $status = Arr::get($disputeInfo, 'status', '');
994 if ($status !== 'RESOLVED') {
995 return false;
996 }
997
998 $fluentCartTransactions = [];
999 foreach ($disputedTransactions as $transaction) {
1000 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
1001 if (!$transactionModel) {
1002 continue;
1003 }
1004 $fluentCartTransactions[] = $transactionModel;
1005 }
1006
1007 if (empty($fluentCartTransactions)) {
1008 return false;
1009 }
1010
1011 // we are handling disputes only with one transaction - PayPal allow user to select multiple transactions on dispute creation
1012 $transactionModel = $fluentCartTransactions[0];
1013
1014 if ($transactionModel->status === Status::TRANSACTION_DISPUTE_LOST) { // already dispute claim accepted via admin dashboard
1015 return false;
1016 }
1017
1018 // 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
1019 $transactionModel->update([
1020 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
1021 'meta' => array_merge($transactionModel->meta, [
1022 'is_dispute_actionable' => false,
1023 'is_charge_refundable' => false,
1024 'dispute_status' => $status
1025 ])
1026 ]);
1027
1028 return true;
1029 }
1030
1031 private function getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo = [])
1032 {
1033 $id = Arr::get($subscriptionInfo, 'id', '');
1034 if (empty($id)) {
1035 return null;
1036 }
1037
1038 $subscription = Subscription::query()->where('vendor_subscription_id', $id)->first();
1039
1040 if (!$subscription) {
1041 $subscriptionHash = Arr::get($subscriptionInfo, 'custom_id', '');
1042 if ($subscriptionHash) {
1043 $subscription = Subscription::query()->where('uuid', $subscriptionHash)->first();
1044 }
1045 }
1046
1047 return $subscription;
1048 }
1049
1050
1051 private static function getPayPalSettings()
1052 {
1053 if (!self::$paypalSettings) {
1054 self::$paypalSettings = new PayPalSettingsBase();
1055 }
1056 return self::$paypalSettings;
1057 }
1058 }
1059