PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.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 All 51 releases
fluent-cart / app / Modules / PaymentMethods / PayPalGateway / IPN.php

IPN.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.7.1, at app/Modules/PaymentMethods/PayPalGateway/IPN.php

1,537 lines 63.0 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\Events\Subscription\SubscriptionRenewalFailed;
7 use FluentCart\App\Helpers\Helper;
8 use FluentCart\App\Helpers\Status;
9 use FluentCart\App\Models\OrderTransaction;
10 use FluentCart\App\Models\Subscription;
11 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API;
12 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
13 use FluentCart\App\Services\DateTime\DateTime;
14 use FluentCart\App\Services\Payments\SubscriptionHelper;
15 use FluentCart\Framework\Support\Arr;
16
17 class IPN
18 {
19 private const TEST_VERIFYING_URL = 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature';
20 private const LIVE_VERIFYING_URL = 'https://api-m.paypal.com/v1/notifications/verify-webhook-signature';
21 private const RESYNC_RETRY_HOOK = 'fluent_cart/paypal/subscription_resync_retry';
22 private const PENDING_SALES_META = 'paypal_pending_sales';
23 private static $paypalSettings = null;
24
25 /** @var \WP_Error|null unacknowledged failure from the recurring-payment handler */
26 private static $recurringPaymentError = null;
27
28 /**
29 * Indirection so PHPStan reads the declared property type instead of
30 * narrowing it to the literal null assigned right before processPaypalWebhookEvents().
31 *
32 * @return \WP_Error|null
33 */
34 private static function getRecurringPaymentError()
35 {
36 return self::$recurringPaymentError;
37 }
38
39 public function init()
40 {
41 // New
42 add_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [$this, 'processChargeCaptured'], 10, 1);
43
44 // reviewed.
45 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_activated', [$this, 'processSubscriptionActivated'], 10, 1);
46 add_action('fluent_cart/payments/paypal/webhook_subscription_payment_received', [$this, 'processRecurringPaymentReceived'], 10, 1);
47 add_action('fluent_cart/payments/paypal/webhook_payment_capture_refunded', [$this, 'handleSinglePaymentRefund']);
48 add_action('fluent_cart/payments/paypal/webhook_payment_sale_refunded', [$this, 'handleWebhookRecurringPaymentRefunded'], 10, 1);
49 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_cancelled', [$this, 'handleWebhookRecurringProfileCancelled'], 10, 1);
50 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_expired', [$this, 'handleWebhookRecurringProfileExpired'], 10, 1);
51 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_suspended', [$this, 'handleWebhookRecurringProfileSuspended'], 10, 1);
52 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_re-activated', [$this, 'handleWebhookRecurringProfileReactivated'], 10, 1);
53 add_action('fluent_cart/payments/paypal/webhook_billing_subscription_payment_failed', [$this, 'processSubscriptionPaymentFailed'], 10, 1);
54
55 // dispute
56 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_created', [$this, 'handleWebhookDisputeCreated'], 10, 1);
57 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_updated', [$this, 'handleWebhookDisputeUpdated'], 10, 1);
58 add_action('fluent_cart/payments/paypal/webhook_customer_dispute_resolved', [$this, 'handleWebhookDisputeResolved'], 10, 1);
59
60 add_action(self::RESYNC_RETRY_HOOK, [$this, 'handleResyncRetry'], 10, 2);
61 }
62
63 public function processPaypalWebhookEvents($event): void
64 {
65 $eventType = Arr::get($event, 'event_type', '');
66 $resource = Arr::get($event, 'resource', []);
67
68 if (empty($resource)) {
69 return;
70 }
71
72 // convert event to snake case ex: PAYMENT.SALE.COMPLETED to payment_sale_completed
73 $eventType = strtolower(str_replace('.', '_', $eventType));
74
75 if ($eventType === 'payment_sale_completed') {
76 $billingAgreementId = Arr::get($resource, 'billing_agreement_id', '');
77 if ($billingAgreementId) {
78 $subscriptionHash = Arr::get($resource, 'custom', '');
79 $subscription = $subscriptionHash ? Subscription::query()
80 ->where('uuid', $subscriptionHash)
81 ->where('current_payment_method', 'paypal')
82 ->first() : null;
83
84 if ($subscription && $subscription->status === Status::SUBSCRIPTION_INTENDED) {
85 // First payment - confirm initial order and activate subscription, rare case
86 do_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [
87 'charge' => $resource,
88 'vendor_subscription_id' => $billingAgreementId,
89 ]);
90 } else {
91 // Renewal payment
92 do_action('fluent_cart/payments/paypal/webhook_subscription_payment_received', [
93 'charge' => $resource,
94 'vendor_subscription_id' => $billingAgreementId,
95 ]);
96 }
97 } else {
98 // do not need webhook for one time payment
99 do_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [
100 'charge' => $resource
101 ]);
102 }
103 } else if ($eventType === 'payment_sale_refunded') {
104 // recurring payment refund
105 do_action('fluent_cart/payments/paypal/webhook_payment_sale_refunded', [
106 'refund' => $resource
107 ]);
108 } else if ($eventType === 'payment_capture_refunded') { // this is manly the refund for one time items
109 do_action('fluent_cart/payments/paypal/webhook_payment_capture_refunded', [
110 'refund' => $resource
111 ]);
112 } else if ($eventType === 'payment_capture_completed') {
113 do_action('fluent_cart/payments/paypal/webhook_payment_capture_completed', [
114 'charge' => $resource,
115 ]);
116 } else if ( $eventType === 'customer_dispute_created' ||$eventType == 'customer_dispute_updated' || $eventType === 'customer_dispute_resolved') {
117 do_action('fluent_cart/payments/paypal/webhook_' . $eventType, [
118 'dispute' => $resource
119 ]);
120 }
121 else {
122 /**
123 *
124 * fluent_cart/payments/paypal/webhook_billing_subscription_activated
125 * fluent_cart/payments/paypal/webhook_billing_subscription_created
126 * fluent_cart/payments/paypal/webhook_billing_subscription_cancelled
127 * fluent_cart/payments/paypal/webhook_billing_subscription_expired
128 * fluent_cart/payments/paypal/webhook_billing_subscription_suspended
129 * fluent_cart/payments/paypal/webhook_billing_subscription_re-activated
130 */
131 do_action('fluent_cart/payments/paypal/webhook_' . $eventType, [
132 'paypal_subscription' => $resource,
133 'webhook_event_id' => Arr::get($event, 'id', '')
134 ]);
135 }
136
137 }
138
139
140 public function processChargeCaptured($data)
141 {
142 $charge = Arr::get($data, 'charge', []);
143
144 $vendorChargeId = Arr::get($charge, 'id', '');
145 $vendorSubscriptionId = Arr::get($data, 'vendor_subscription_id', '');
146
147 // Handle first payment for intended subscriptions
148 if ($vendorSubscriptionId) {
149 // Same reasoning as processPaypalWebhookEvents(): match by uuid, not
150 // vendor_subscription_id, which isn't set yet for an intended subscription.
151 $subscriptionHash = Arr::get($charge, 'custom', '');
152 $subscription = $subscriptionHash ? Subscription::query()
153 ->where('uuid', $subscriptionHash)
154 ->where('current_payment_method', 'paypal')
155 ->first() : null;
156
157 if ($subscription && $subscription->status === Status::SUBSCRIPTION_INTENDED) {
158 $transaction = $subscription->getLatestTransaction();
159 if ($transaction) {
160 $mismatch = false;
161
162 if ($transaction->status !== Status::TRANSACTION_SUCCEEDED) {
163 $paidAmount = Helper::toCent(Arr::get($charge, 'amount.total', 0));
164 $paidCurrency = strtoupper(Arr::get($charge, 'amount.currency', ''));
165
166 if ($paidCurrency && $transaction->currency && strtoupper($transaction->currency) !== $paidCurrency) {
167 $mismatch = true;
168 fluent_cart_add_log(
169 __('PayPal Webhook Currency Mismatch', 'fluent-cart'),
170 sprintf(
171 /* translators: %1$s: expected currency, %2$s: received currency, %3$s: transaction UUID */
172 __('Payment currency mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Subscription not confirmed.', 'fluent-cart'),
173 $transaction->currency,
174 $paidCurrency,
175 $transaction->uuid
176 ),
177 'error',
178 [
179 'module_name' => 'order',
180 'module_id' => $transaction->order_id,
181 'log_type' => 'webhook'
182 ]
183 );
184 } else if ($transaction->total > 0 && $paidAmount != PayPalHelper::wireCents($transaction->total, $transaction->currency)) {
185 $mismatch = true;
186 fluent_cart_add_log(
187 __('PayPal Webhook Amount Mismatch', 'fluent-cart'),
188 sprintf(
189 /* translators: %1$s: expected amount, %2$s: received amount, %3$s: transaction UUID */
190 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Subscription not confirmed.', 'fluent-cart'),
191 Helper::toDecimal(PayPalHelper::wireCents($transaction->total, $transaction->currency)),
192 Helper::toDecimal($paidAmount),
193 $transaction->uuid
194 ),
195 'error',
196 [
197 'module_name' => 'order',
198 'module_id' => $transaction->order_id,
199 'log_type' => 'webhook'
200 ]
201 );
202 } else {
203 // Confirm transaction with actual charge amount from webhook
204 (new Processor())->confirmPaymentSuccessByCharge($transaction, [
205 'vendor_charge_id' => $vendorChargeId,
206 'status' => Status::TRANSACTION_SUCCEEDED,
207 'total' => $paidAmount,
208 'payment_method_type' => 'PayPal',
209 ]);
210 }
211 }
212
213 if (!$mismatch) {
214 // Activate even if the transaction was already confirmed elsewhere (e.g. AJAX return) — activateSubscription() guards against re-activating.
215 $paypalSubscription = API::getResource('billing/subscriptions/' . $vendorSubscriptionId);
216 if (!is_wp_error($paypalSubscription) && $paypalSubscription) {
217 (new Processor())->activateSubscription($paypalSubscription, $transaction, $subscription);
218 } else {
219 fluent_cart_add_log(
220 __('PayPal Subscription Activation Skipped', 'fluent-cart'),
221 sprintf(
222 /* translators: %1$s: subscription UUID, %2$s: vendor subscription ID */
223 __('Could not fetch PayPal subscription resource to activate. Subscription: %1$s, Vendor Subscription ID: %2$s.', 'fluent-cart'),
224 $subscription->uuid,
225 $vendorSubscriptionId
226 ),
227 'error',
228 [
229 'module_name' => 'order',
230 'module_id' => $transaction->order_id,
231 'log_type' => 'webhook'
232 ]
233 );
234 }
235 }
236 }
237 return;
238 }
239 }
240
241 $transaction = OrderTransaction::query()->where('vendor_charge_id', $vendorChargeId)->first();
242
243 if (!$transaction) {
244 // We did not find the charge. So let's find the parent order ID and transactio reference
245 $parentIntentId = Arr::get($charge, 'supplementary_data.related_ids.order_id', '');
246 if ($parentIntentId) {
247 $paypalIntent = API::verifyPayment($parentIntentId);
248 if (is_wp_error($paypalIntent)) {
249 return;
250 }
251
252 $transactionHash = Arr::get($paypalIntent, 'purchase_units.0.reference_id', '');
253 if ($transactionHash) {
254 $transaction = OrderTransaction::query()
255 ->where('uuid', $transactionHash)
256 ->first();
257 }
258 }
259 }
260
261 if (!$transaction) {
262 // not our transaction!
263 return;
264 }
265
266 if ($transaction->status == Status::TRANSACTION_SUCCEEDED) {
267 if (!$transaction->vendor_charge_id) {
268 // We are just updating the vendor charge ID
269 $transaction->vendor_charge_id = $vendorChargeId;
270 $transaction->save();
271 }
272
273 // already processed
274 return;
275 }
276
277 // get full payment intent
278 $paypalOrderId = Arr::get($charge, 'supplementary_data.related_ids.order_id', '');
279 $paypalIntent = API::verifyPayment($paypalOrderId);
280
281 if (is_wp_error($paypalIntent)) {
282 fluent_cart_add_log(
283 __('PayPal Webhook Verification Failed', 'fluent-cart'),
284 __('Could not verify PayPal payment from webhook. Charge ID: ', 'fluent-cart') . $vendorChargeId,
285 'error',
286 [
287 'module_name' => 'order',
288 'module_id' => $transaction->order_id,
289 'log_type' => 'webhook'
290 ]
291 );
292 return;
293 }
294
295 // Verify that the paid amount and currency match the expected transaction
296 $paidAmount = 0;
297 $paidCurrency = '';
298 foreach (Arr::get($paypalIntent, 'purchase_units', []) as $unit) {
299 $paidAmount += Helper::toCent(Arr::get($unit, 'amount.value', 0));
300 if (!$paidCurrency) {
301 $paidCurrency = strtoupper(Arr::get($unit, 'amount.currency_code', ''));
302 }
303 }
304
305 if ($paidCurrency && $transaction->currency && strtoupper($transaction->currency) !== $paidCurrency) {
306 fluent_cart_add_log(
307 __('PayPal Webhook Currency Mismatch', 'fluent-cart'),
308 sprintf(
309 /* translators: %1$s: expected currency, %2$s: received currency, %3$s: transaction UUID */
310 __('Payment currency mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Order not confirmed.', 'fluent-cart'),
311 $transaction->currency,
312 $paidCurrency,
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 $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
326
327 if ($transaction->total > 0 && $paidAmount != $expectedAmount) {
328 fluent_cart_add_log(
329 __('PayPal Webhook Amount Mismatch', 'fluent-cart'),
330 sprintf(
331 /* translators: %1$s: expected amount, %2$s: received amount, %3$s: transaction UUID */
332 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. Transaction: %3$s. Order not confirmed.', 'fluent-cart'),
333 Helper::toDecimal($expectedAmount),
334 Helper::toDecimal($paidAmount),
335 $transaction->uuid
336 ),
337 'error',
338 [
339 'module_name' => 'order',
340 'module_id' => $transaction->order_id,
341 'log_type' => 'webhook'
342 ]
343 );
344 return;
345 }
346
347 // All Verified! Let's update the transaction and order
348 (new Processor())->confirmPaymentSuccessByCharge($transaction, [
349 'vendor_charge_id' => $vendorChargeId,
350 'payment_method_type' => 'PayPal',
351 'status' => Status::TRANSACTION_SUCCEEDED,
352 'total' => $paidAmount,
353 'payment_source' => Arr::get($paypalIntent, 'payment_source', []),
354 'meta' => [
355 'payer' => Arr::get($paypalIntent, 'payer', [])
356 ]
357 ]);
358
359 // System subscription: persist the vault token from the captured order
360 // (idempotent — the AJAX confirmation may have done it already).
361 (new Processor())->maybePersistVaultToken($transaction, $paypalIntent);
362
363 }
364
365
366 // called only when webhook/ipn hits
367 public function verifyAndProcess($data = []): void
368 {
369 $this->processWebhook();
370 }
371
372 /**
373 * Verify the webhook signature
374 *
375 * @param string $webhookId PayPal webhook ID for the current mode.
376 * @param string|null $rawBody Raw request body; read from php://input when omitted.
377 * @return true|\WP_Error
378 */
379 public function verifyWebhook($webhookId, $rawBody = null)
380 {
381 $disableWebhookVerification = apply_filters('fluent_cart/payments/paypal/disable_webhook_verification', 'no', []);
382 if ($disableWebhookVerification === 'yes') {
383 return true;
384 }
385
386 if (empty($webhookId)) {
387 return new \WP_Error('webhook_id_missing', __('Webhook ID is missing.', 'fluent-cart'));
388 }
389
390 $webhookId = trim($webhookId);
391 $header = self::getRequestHeaders();
392
393 // make all headers lowercase
394 $header = array_change_key_case($header, CASE_LOWER);
395 if (!isset($header['paypal-auth-algo']) || !isset($header['paypal-cert-url']) ||
396 !isset($header['paypal-transmission-id']) || !isset($header['paypal-transmission-sig']) ||
397 !isset($header['paypal-transmission-time'])) {
398
399 return new \WP_Error('webhook_header_missing', __('Required PayPal webhook headers are missing.', 'fluent-cart'), [
400 'headers' => $header
401 ]);
402 }
403
404 if ($rawBody === null) {
405 $rawBody = file_get_contents('php://input');
406 }
407 $webhookEvent = json_decode($rawBody);
408 $body = [
409 'auth_algo' => $header['paypal-auth-algo'],
410 'transmission_id' => $header['paypal-transmission-id'],
411 'transmission_time' => $header['paypal-transmission-time'],
412 'cert_url' => $header['paypal-cert-url'],
413 'transmission_sig' => $header['paypal-transmission-sig'],
414 'webhook_id' => $webhookId,
415 'webhook_event' => $webhookEvent
416 ];
417
418 $response = API::verifyWebhookSignature($body);
419
420 if (is_wp_error($response)) {
421 do_action('fluent_cart/dev_log', [
422 'raw_data' => $body,
423 'status' => 'failed',
424 'title' => __('Failed to verify PayPal webhook signature', 'fluent-cart'),
425 'log_type' => 'webhook',
426 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
427 'module_name' => 'PayPal'
428 ]);
429
430 return $response;
431 }
432
433 $http_code = wp_remote_retrieve_response_code($response);
434 $response_body = wp_remote_retrieve_body($response);
435 $response_data = json_decode($response_body, true);
436
437 if ($http_code !== 200 || empty($response_data['verification_status']) || $response_data['verification_status'] !== 'SUCCESS') {
438 return new \WP_Error('webhook_verification_failed', __('Webhook verification failed.', 'fluent-cart'), [
439 'http_code' => $http_code,
440 'response' => $response_data
441 ]);
442 }
443
444 return true;
445 }
446
447 public function processWebhook()
448 {
449 $statusCode = $this->handleWebhookRequest(file_get_contents('php://input'));
450
451 // exit(int) only sets the process exit code; the HTTP status has to be
452 // sent explicitly or PayPal records every rejection as delivered.
453 status_header($statusCode);
454 exit;
455 }
456
457 /**
458 * Handle one PayPal webhook delivery and return the HTTP status to answer with.
459 *
460 * Separated from processWebhook() so the request body, headers and status
461 * can be exercised without php://input or exit().
462 *
463 * @param string $rawBody Raw JSON request body.
464 * @return int
465 */
466 public function handleWebhookRequest($rawBody): int
467 {
468 $post_data = (string) $rawBody;
469
470 $data = json_decode($post_data, true);
471
472 if (empty($data)) {
473 return 200;
474 }
475
476 $webhookType = Arr::get($data, 'event_type', '');
477
478 $webhookEvents = [
479 'PAYMENT.SALE.COMPLETED',
480 'PAYMENT.SALE.REFUNDED',
481 'PAYMENT.CAPTURE.REFUNDED',
482 'BILLING.SUBSCRIPTION.CREATED',
483 'BILLING.SUBSCRIPTION.ACTIVATED',
484 'BILLING.SUBSCRIPTION.CANCELLED',
485 'BILLING.SUBSCRIPTION.EXPIRED',
486 'BILLING.SUBSCRIPTION.SUSPENDED',
487 'BILLING.SUBSCRIPTION.RE-ACTIVATED',
488 'BILLING.SUBSCRIPTION.PAYMENT.FAILED',
489 'PAYMENT.CAPTURE.COMPLETED',
490 'CUSTOMER.DISPUTE.CREATED',
491 'CUSTOMER.DISPUTE.UPDATED',
492 'CUSTOMER.DISPUTE.RESOLVED',
493 'CHECKOUT.ORDER.APPROVED' // we don't need this
494 ];
495
496 if (!in_array($webhookType, $webhookEvents)) {
497 return 200;
498 }
499
500 if (defined('FLUENT_CART_DEV_MODE')) {
501 do_action('fluent_cart/dev_log', [
502 'raw_data' => $post_data,
503 'status' => 'received',
504 'title' => __('PayPal Webhook Received', 'fluent-cart'),
505 'log_type' => 'webhook',
506 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
507 'module_name' => 'PayPal'
508 ]);
509 }
510
511 $paymentSettings = self::getPayPalSettings()->get();
512
513 $mode = (new StoreSettings)->get('order_mode');
514
515 // FCT_PAYPAL_LIVE_WEBHOOK_ID
516 if ($mode === 'test') {
517 $webhookId = defined('FCT_PAYPAL_TEST_WEBHOOK_ID') ? FCT_PAYPAL_TEST_WEBHOOK_ID : Arr::get($paymentSettings, $mode . '_webhook_id', '');
518 } else {
519 $webhookId = defined('FCT_PAYPAL_LIVE_WEBHOOK_ID') ? FCT_PAYPAL_LIVE_WEBHOOK_ID : Arr::get($paymentSettings, $mode . '_webhook_id', '');
520 }
521
522 $willVerify = apply_filters('fluent_cart/payments/paypal/verify_webhook', true, [
523 'data' => $data,
524 'mode' => $mode,
525 'type' => $webhookType
526 ]);
527
528 if ($willVerify) {
529
530 $verified = $this->verifyWebhook($webhookId, $post_data);
531
532 if (is_wp_error($verified)) {
533 $data = json_encode($verified->get_error_data());
534 fluent_cart_add_log($verified->get_error_message() . ' Webhook: ' . $webhookType, $data, 'error', [
535 'log_type' => 'webhook',
536 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
537 'module_name' => 'PayPal',
538 ]);
539
540 return 400;
541 }
542 }
543
544 // Only a delivery that passed signature verification (or whose verification
545 // the site explicitly bypassed above) may reach extension listeners.
546 // Firing this earlier let an anonymous sender feed forged events to every
547 // listener even though the request was then rejected (FC-SEC-05).
548 do_action('fluent_cart/paypal_webhook_received', [
549 'data' => $data,
550 'raw' => $post_data
551 ]);
552
553 self::$recurringPaymentError = null;
554
555 $this->processPaypalWebhookEvents($data);
556
557 $recurringPaymentError = self::getRecurringPaymentError();
558
559 if (is_wp_error($recurringPaymentError)) {
560 fluent_cart_add_log(
561 'PayPal renewal processing failed: ' . $recurringPaymentError->get_error_message() . ' Webhook: ' . $webhookType,
562 json_encode($recurringPaymentError->get_error_data()),
563 'error',
564 [
565 'log_type' => 'webhook',
566 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
567 'module_name' => 'PayPal',
568 ]
569 );
570
571 // Non-2xx so PayPal redelivers; the payment is not recorded yet.
572 return 500;
573 }
574
575 return 200;
576 }
577
578 /**
579 * Request headers, falling back to $_SERVER for SAPIs without getallheaders().
580 *
581 * @return array<string, string>
582 */
583 private static function getRequestHeaders(): array
584 {
585 if (function_exists('getallheaders')) {
586 $headers = getallheaders();
587 return is_array($headers) ? $headers : [];
588 }
589
590 $headers = [];
591 foreach ($_SERVER as $key => $value) {
592 if (strpos($key, 'HTTP_') === 0) {
593 $name = str_replace('_', '-', substr($key, 5));
594 $headers[$name] = $value;
595 }
596 }
597
598 return $headers;
599 }
600
601 public function processSubscriptionActivated($data)
602 {
603 $paypalSubscription = Arr::get($data, 'paypal_subscription', []);
604 $vendorSubscriptionId = sanitize_text_field(Arr::get($paypalSubscription, 'id'));
605 if (empty($vendorSubscriptionId)) {
606 return;
607 }
608
609 $subscriptionModel = Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId)->first();
610 if (!$subscriptionModel) {
611 $subscriptionHash = Arr::get($paypalSubscription, 'custom_id', '');
612
613 if ($subscriptionHash) {
614 $subscriptionModel = Subscription::query()->where('uuid', $subscriptionHash)->first();
615 }
616 }
617
618 if (!$subscriptionModel || $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
619 return;
620 }
621
622 $transaction = $subscriptionModel->getLatestTransaction();
623 if (!$transaction) {
624 return;
625 }
626
627 (new Processor())->activateSubscription($paypalSubscription, $transaction, $subscriptionModel);
628 }
629
630 public function processSubscriptionPaymentFailed($data)
631 {
632 $paypalSubscription = Arr::get($data, 'paypal_subscription', []);
633 $vendorSubscriptionId = sanitize_text_field(Arr::get($paypalSubscription, 'id'));
634
635 $subscriptionModel = $vendorSubscriptionId ? Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId)->first() : null;
636
637 if (!$subscriptionModel) {
638 $subscriptionHash = Arr::get($paypalSubscription, 'custom_id', '');
639 if ($subscriptionHash) {
640 $subscriptionModel = Subscription::query()->where('uuid', $subscriptionHash)->first();
641 }
642 }
643
644 if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal') {
645 return false;
646 }
647
648 $order = $subscriptionModel->order;
649 if (!$order) {
650 return false;
651 }
652
653 $failedCount = Arr::get($paypalSubscription, 'billing_info.failed_payments_count');
654 $webhookEventId = sanitize_text_field(Arr::get($data, 'webhook_event_id', ''));
655
656 // One notification per failed attempt. The webhook event ID is the durable
657 // discriminator — unique per PayPal delivery, stable across a redelivery of
658 // that same event (mirrors the Stripe invoice-id claim in
659 // StripeGateway/Webhook/IPN.php). failed_payments_count is NOT usable for this:
660 // it resets to 0 on the next successful payment, so a later billing cycle that
661 // reaches the same failure count would reuse an old permanent claim key and
662 // silently suppress its own notification.
663 $claimDiscriminator = $webhookEventId !== '' ? $webhookEventId : ($failedCount !== null ? (string)(int)$failedCount : '');
664
665 if ($claimDiscriminator !== '') {
666 $claimKey = 'fct_sub_renewal_failed_' . $subscriptionModel->id . '_' . $claimDiscriminator;
667 if (!add_option($claimKey, '1', '', false)) {
668 return true; // already notified for this failed attempt
669 }
670 }
671
672 $error = $failedCount !== null
673 ? sprintf(
674 /* translators: %d: number of consecutive failed payments reported by PayPal */
675 __('PayPal reported a failed subscription payment (failed attempts: %d).', 'fluent-cart'),
676 (int)$failedCount
677 )
678 : __('PayPal reported a failed subscription payment.', 'fluent-cart');
679
680 try {
681 (new SubscriptionRenewalFailed($subscriptionModel, $order, $subscriptionModel->customer, $error))->dispatch();
682 } catch (\Throwable $e) {
683 if (isset($claimKey)) {
684 delete_option($claimKey);
685 }
686 throw $e;
687 }
688
689 return true;
690 }
691
692 public function processRecurringPaymentReceived($data)
693 {
694 $charge = Arr::get($data, 'charge', []);
695 $vendorSubscriptionId = Arr::get($data, 'vendor_subscription_id', '');
696
697 $subscriptionModel = $vendorSubscriptionId ? Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId)->with('order')->first() : null;
698
699 if (!$subscriptionModel) {
700 $subscriptionHash = Arr::get($charge, 'custom', '');
701 if ($subscriptionHash) {
702 $subscriptionModel = Subscription::query()->where('uuid', $subscriptionHash)->first();
703 }
704 }
705
706 if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal') {
707 return false;
708 }
709
710 if ($vendorSubscriptionId && !$subscriptionModel->vendor_subscription_id) {
711 $subscriptionModel->update(['vendor_subscription_id' => $vendorSubscriptionId]);
712 }
713
714 $amount = Helper::toCent(Arr::get($charge, 'amount.total', 0));
715 $chargeId = Arr::get($charge, 'id');
716 if (!$amount || !$chargeId) {
717 return false;
718 }
719
720 // find the OrderTransaction
721 $transaction = OrderTransaction::query()->where('vendor_charge_id', $chargeId)
722 ->where('subscription_id', $subscriptionModel->id)
723 ->where('payment_method', 'paypal')
724 ->first();
725
726 if ($transaction) {
727 return true;
728 }
729
730 // Fetch PayPal subscription data once — used for plan verification and renewal processing
731 $paypalSubscription = $vendorSubscriptionId ? API::getResource('billing/subscriptions/' . $vendorSubscriptionId) : null;
732
733 // Verify the PayPal subscription plan matches the expected plan
734 if ($subscriptionModel->vendor_plan_id && $paypalSubscription && !is_wp_error($paypalSubscription)) {
735 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
736 if ($paypalPlanId && $paypalPlanId !== $subscriptionModel->vendor_plan_id) {
737 fluent_cart_add_log(
738 __('PayPal Recurring Plan Mismatch', 'fluent-cart'),
739 sprintf(
740 /* translators: %1$s: expected plan ID, %2$s: received plan ID, %3$d: subscription ID */
741 __('Recurring payment plan mismatch. Expected: %1$s, Received: %2$s. Subscription ID: %3$d. Payment not recorded.', 'fluent-cart'),
742 $subscriptionModel->vendor_plan_id,
743 $paypalPlanId,
744 $subscriptionModel->id
745 ),
746 'error',
747 [
748 'module_type' => 'FluentCart\App\Models\Subscription',
749 'module_id' => $subscriptionModel->id,
750 'module_name' => 'subscription',
751 'log_type' => 'webhook'
752 ]
753 );
754 return false;
755 }
756 }
757
758 // Latest charge transaction = pending one for initial subscription OR for renewal
759 $latestTransaction = $subscriptionModel->getLatestTransaction();
760
761 if ($latestTransaction && !$latestTransaction->vendor_charge_id && $latestTransaction->total) {
762
763 if (!is_array($paypalSubscription)) {
764 self::$recurringPaymentError = is_wp_error($paypalSubscription)
765 ? $paypalSubscription
766 : new \WP_Error('paypal_subscription_fetch_failed', __('Could not fetch the PayPal subscription to confirm this payment.', 'fluent-cart'));
767 if (self::isRetryableRemoteError(self::$recurringPaymentError)) {
768 self::scheduleResyncRetry($subscriptionModel, $chargeId);
769 }
770 return false;
771 }
772
773 $paypalSubscriptions = new PayPalSubscriptions();
774
775 // One remote pull decides everything below — the sorted list answers
776 // the first-payment question and, on a mismatch, feeds the resync.
777 $remoteTransactions = $paypalSubscriptions->fetchSortedRemoteTransactions($subscriptionModel, $paypalSubscription);
778
779 if (is_wp_error($remoteTransactions)) {
780 if (self::isTerminalRenewalError($remoteTransactions, $subscriptionModel)) {
781 return true;
782 }
783
784 self::$recurringPaymentError = $remoteTransactions;
785 if (self::isRetryableRemoteError($remoteTransactions)) {
786 self::scheduleResyncRetry($subscriptionModel, $chargeId);
787 }
788 return false;
789 }
790
791 // The sale must be on the list AND completed there — a listed-but-pending
792 // entry binds nothing downstream (the resync loop only consumes completed
793 // sales), so it takes the same lag path as an absent one.
794 $saleInRemoteList = false;
795 foreach ($remoteTransactions as $remoteTransaction) {
796 if (Arr::get($remoteTransaction, 'id') === $chargeId
797 && strtolower((string) Arr::get($remoteTransaction, 'status')) === 'completed'
798 ) {
799 $saleInRemoteList = true;
800 break;
801 }
802 }
803
804 if (!$saleInRemoteList) {
805 self::$recurringPaymentError = new \WP_Error(
806 'paypal_transaction_list_lagging',
807 __('The incoming sale is not yet on the PayPal transaction list.', 'fluent-cart')
808 );
809 self::scheduleResyncRetry($subscriptionModel, $chargeId);
810 return false;
811 }
812
813 $earliestSale = $paypalSubscriptions->getEarliestCompletedRemoteSale($remoteTransactions);
814
815 if ($earliestSale && Arr::get($earliestSale, 'id') === $chargeId) {
816 $paypalSubscriptions->bindSaleToTransaction(
817 $latestTransaction,
818 $chargeId,
819 $amount,
820 Arr::get($paypalSubscription, 'subscriber', []),
821 DateTime::anyTimeToGmt(Arr::get($earliestSale, 'time'))->format('Y-m-d H:i:s')
822 );
823
824 $subscriptionModel->update(['bill_count' => $subscriptionModel->calculateBillCount()]);
825
826 return true;
827 }
828
829 $result = $paypalSubscriptions->reSyncSubscriptionFromRemote(
830 $subscriptionModel,
831 $paypalSubscription,
832 $remoteTransactions
833 );
834
835 if (is_wp_error($result)) {
836 if (self::isTerminalRenewalError($result, $subscriptionModel)) {
837 return true;
838 }
839
840 self::$recurringPaymentError = $result;
841 if (self::isRetryableRemoteError($result)) {
842 self::scheduleResyncRetry($subscriptionModel, $chargeId);
843 }
844 return false;
845 }
846
847 return true;
848 }
849
850
851 // Now we are sure, we have a renewal payment for this subscription!
852
853 // we will just create the transaction here
854
855 $subscriptionUpdateData = [
856 'current_payment_method' => 'paypal',
857 'vendor_subscription_id' => $vendorSubscriptionId
858 ];
859
860 $payer = ($paypalSubscription && !is_wp_error($paypalSubscription)) ? Arr::get($paypalSubscription, 'subscriber', []) : [];
861 if ($paypalSubscription && !is_wp_error($paypalSubscription)) {
862 $subscriptionUpdateData['status'] = (new SubscriptionManager)->getCorrectSubscriptionStatus(Arr::get($paypalSubscription, 'status'));
863 $nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time');
864 if ($nextBillingDate) {
865 $subscriptionUpdateData['next_billing_date'] = SubscriptionHelper::safeTimestampToDatetime($nextBillingDate);
866 }
867
868 $payerId = Arr::get($paypalSubscription, 'subscriber.payer_id');
869
870 if ($payerId) {
871 $subscriptionUpdateData['vendor_customer_id'] = $payerId;
872 }
873
874 if (!empty($paypalSubscription['plan_id'])) {
875 $subscriptionUpdateData['vendor_plan_id'] = $paypalSubscription['plan_id'];
876 }
877
878 if (Arr::get($paypalSubscription, 'status') === 'CANCELLED') {
879 $statusUpdateTime = Arr::get($paypalSubscription, 'status_update_time');
880 if ($statusUpdateTime) {
881 $subscriptionUpdateData['canceled_at'] = gmdate('Y-m-d H:i:s', strtotime($statusUpdateTime));
882 }
883 }
884
885 }
886
887 $transactionData = [
888 'payment_method' => 'paypal',
889 'total' => $amount,
890 'vendor_charge_id' => $chargeId,
891 'payment_method_type' => 'paypal',
892 'meta' => [
893 'payer' => $payer
894 ]
895 ];
896
897 // Credited before recording: recordRenewalPayment() recomputes bill_count
898 // and the installment end-of-term inside itself, so an outstanding-balance
899 // collection's extra cycles must already be on the books when it runs.
900 // Idempotent per sale id, so a failed record retried later credits once.
901 $paypalSubscriptions = new PayPalSubscriptions();
902 $credited = $paypalSubscriptions->creditOutstandingCollection($subscriptionModel, $chargeId, $amount);
903
904 // Credit undecided: record nothing. Once a transaction exists this
905 // method returns early on every redelivery, so the missed cycles would
906 // become unreachable — leaving the sale unrecorded keeps both recovery
907 // channels (PayPal redelivery, the resync ladder) able to repair it.
908 if (is_wp_error($credited)) {
909 self::$recurringPaymentError = $credited;
910 self::scheduleResyncRetry($subscriptionModel, $chargeId);
911 return false;
912 }
913
914 $result = SubscriptionService::recordRenewalPayment($transactionData, $subscriptionModel, $subscriptionUpdateData);
915
916 if (is_wp_error($result)) {
917 if ($result->get_error_code() === 'transaction_exists') {
918 return true;
919 }
920
921 if ($result->get_error_code() === 'lock_failed') {
922 // Contention is not proof of a committed payment. Keep the credit
923 // available to the lock holder, but retry until the sale is recorded.
924 if ($paypalSubscriptions->hasRecordedSale($subscriptionModel, $chargeId)) {
925 return true;
926 }
927
928 self::$recurringPaymentError = $result;
929 self::scheduleResyncRetry($subscriptionModel, $chargeId);
930 return false;
931 }
932
933 if ($credited) {
934 $paypalSubscriptions->revokeOutstandingCollection($subscriptionModel, $chargeId);
935 }
936
937 if (self::isTerminalRenewalError($result, $subscriptionModel)) {
938 return true;
939 }
940
941 self::$recurringPaymentError = $result;
942 if (self::isRetryableRemoteError($result)) {
943 self::scheduleResyncRetry($subscriptionModel, $chargeId);
944 }
945 return false;
946 }
947
948 return true;
949 }
950
951 /**
952 * Queue a resync at +1h / +6h / +24h as a backstop to PayPal's finite redelivery
953 * window. One ladder per subscription (args are [subscription, attempt]); the
954 * sale ids it chases accumulate in PENDING_SALES_META.
955 *
956 * @param Subscription $subscriptionModel
957 * @param string|null $saleId the sale this attempt is chasing, if any
958 * @param int $attempt 1-based position on the delay ladder
959 * @return void
960 */
961 private static function scheduleResyncRetry(Subscription $subscriptionModel, $saleId = null, $attempt = 1)
962 {
963 // After the guard: without Action Scheduler no worker ever prunes the set.
964 if (!function_exists('as_schedule_single_action') || !function_exists('as_next_scheduled_action')) {
965 return;
966 }
967
968 if ($saleId) {
969 $pending = self::getPendingSales($subscriptionModel);
970 if (!in_array($saleId, $pending, true)) {
971 $pending[] = $saleId;
972 $subscriptionModel->updateMeta(self::PENDING_SALES_META, $pending);
973 }
974 }
975
976 $delays = [HOUR_IN_SECONDS, 6 * HOUR_IN_SECONDS, DAY_IN_SECONDS];
977
978 if (!isset($delays[$attempt - 1])) {
979 fluent_cart_add_log(
980 __('PayPal renewal resync retries exhausted — needs manual review', 'fluent-cart'),
981 sprintf(
982 /* translators: 1: subscription ID, 2: comma separated PayPal sale IDs */
983 __('All scheduled resync retries failed. Subscription ID: %1$d. Unresolved PayPal sales: %2$s', 'fluent-cart'),
984 $subscriptionModel->id,
985 implode(', ', self::getPendingSales($subscriptionModel)) ?: __('none recorded', 'fluent-cart')
986 ),
987 'error',
988 [
989 'module_type' => 'FluentCart\App\Models\Subscription',
990 'module_id' => $subscriptionModel->id,
991 'module_name' => 'subscription',
992 'log_type' => 'webhook'
993 ]
994 );
995 $subscriptionModel->deleteMeta(self::PENDING_SALES_META);
996 return;
997 }
998
999 // Start at $attempt so the running attempt's own in-progress action
1000 // (which as_next_scheduled_action reports as scheduled) can't block
1001 // its follow-up.
1002 for ($pending = $attempt; $pending <= count($delays); $pending++) {
1003 if (as_next_scheduled_action(self::RESYNC_RETRY_HOOK, [$subscriptionModel->id, $pending], 'fluent-cart')) {
1004 return;
1005 }
1006 }
1007
1008 as_schedule_single_action(
1009 time() + $delays[$attempt - 1],
1010 self::RESYNC_RETRY_HOOK,
1011 [$subscriptionModel->id, $attempt],
1012 'fluent-cart'
1013 );
1014 }
1015
1016 public function handleResyncRetry($subscriptionId, $attempt = 1)
1017 {
1018 /** @var Subscription|null $subscriptionModel */
1019 $subscriptionModel = Subscription::query()->find($subscriptionId);
1020
1021 if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal' || !$subscriptionModel->vendor_subscription_id) {
1022 return;
1023 }
1024
1025 $result = (new PayPalSubscriptions())->reSyncSubscriptionFromRemote($subscriptionModel);
1026
1027 if (is_wp_error($result)) {
1028 if (self::isTerminalRenewalError($result, $subscriptionModel) || !self::isRetryableRemoteError($result)) {
1029 $subscriptionModel->deleteMeta(self::PENDING_SALES_META);
1030 return;
1031 }
1032
1033 self::scheduleResyncRetry($subscriptionModel, null, (int) $attempt + 1);
1034 return;
1035 }
1036
1037 // A successful resync only means PayPal answered. Its transaction list
1038 // lags, so the sale that armed this ladder can still be absent (or
1039 // listed as non-completed, which binds nothing). Local rows are the
1040 // only proof the payment landed.
1041 if (self::pruneResolvedSales($subscriptionModel)) {
1042 self::scheduleResyncRetry($subscriptionModel, null, (int) $attempt + 1);
1043 }
1044 }
1045
1046 /**
1047 * @param Subscription $subscriptionModel
1048 * @return string[]
1049 */
1050 private static function getPendingSales(Subscription $subscriptionModel)
1051 {
1052 $pending = $subscriptionModel->getMeta(self::PENDING_SALES_META, []);
1053
1054 return array_values(array_filter((array) $pending, 'is_string'));
1055 }
1056
1057 /**
1058 * Drop the sales that now have a local transaction, keep the rest.
1059 * A recorded-then-refunded row still counts as recorded.
1060 *
1061 * @param Subscription $subscriptionModel
1062 * @return bool true while at least one sale is still unaccounted for
1063 */
1064 private static function pruneResolvedSales(Subscription $subscriptionModel)
1065 {
1066 $pending = self::getPendingSales($subscriptionModel);
1067
1068 if (!$pending) {
1069 return false;
1070 }
1071
1072 $recorded = OrderTransaction::query()
1073 ->where('subscription_id', $subscriptionModel->id)
1074 ->whereIn('vendor_charge_id', $pending)
1075 ->get(['vendor_charge_id'])
1076 ->pluck('vendor_charge_id')
1077 ->toArray();
1078
1079 $unresolved = array_values(array_diff($pending, $recorded));
1080
1081 if ($unresolved === $pending) {
1082 return true;
1083 }
1084
1085 // An empty array does not survive the meta cast round-trip, so drop the row.
1086 if ($unresolved) {
1087 $subscriptionModel->updateMeta(self::PENDING_SALES_META, $unresolved);
1088 } else {
1089 $subscriptionModel->deleteMeta(self::PENDING_SALES_META);
1090 }
1091
1092 return (bool) $unresolved;
1093 }
1094
1095 /**
1096 * A retry only helps against transient remote failures. PayPal reports a
1097 * missing/invalid resource in the error body's `name` field (the WP_Error
1098 * code stays `general_error` for REST errors), so inspect the data.
1099 *
1100 * @param \WP_Error $error
1101 * @return bool
1102 */
1103 private static function isRetryableRemoteError($error)
1104 {
1105 $data = $error->get_error_data();
1106 $name = is_array($data) ? Arr::get($data, 'name', '') : '';
1107
1108 return !in_array($name, ['RESOURCE_NOT_FOUND', 'INVALID_RESOURCE_ID'], true);
1109 }
1110
1111 /**
1112 * Errors redelivery can never fix (local records gone): log loudly and ack,
1113 * since a 500 would make PayPal retry for days and can disable the endpoint.
1114 *
1115 * @param \WP_Error $error
1116 * @param Subscription $subscriptionModel
1117 * @return bool true when the error was terminal and has been logged
1118 */
1119 private static function isTerminalRenewalError($error, $subscriptionModel)
1120 {
1121 if (!in_array($error->get_error_code(), ['subscription_not_found', 'parent_order_not_found'], true)) {
1122 return false;
1123 }
1124
1125 fluent_cart_add_log(
1126 __('PayPal renewal payment could not be recorded — needs manual review', 'fluent-cart'),
1127 sprintf(
1128 /* translators: %1$s: error message, %2$d: subscription ID */
1129 __('%1$s Subscription ID: %2$d', 'fluent-cart'),
1130 $error->get_error_message(),
1131 $subscriptionModel->id
1132 ),
1133 'error',
1134 [
1135 'module_type' => 'FluentCart\App\Models\Subscription',
1136 'module_id' => $subscriptionModel->id,
1137 'module_name' => 'subscription',
1138 'log_type' => 'webhook'
1139 ]
1140 );
1141
1142 return true;
1143 }
1144
1145 public function handleSinglePaymentRefund($data)
1146 {
1147 $refundData = Arr::get($data, 'refund', []);
1148 $paypalRefundId = Arr::get($refundData, 'id', '');
1149 $paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.value', 0));
1150
1151 // Let's guess the transaction ID from links
1152
1153 $paypalTransactionId = '';
1154
1155 foreach (Arr::get($refundData, 'links', []) as $link) {
1156 if (Arr::get($link, 'rel') !== 'up') {
1157 continue;
1158 }
1159
1160 $href = Arr::get($link, 'href', '');
1161 $paypalTransactionId = basename($href);
1162 if ($paypalTransactionId) {
1163 break;
1164 }
1165 }
1166
1167 if (!$paypalTransactionId) {
1168
1169 do_action('fluent_cart/dev_log', [
1170 'raw_data' => $refundData,
1171 'status' => 'failed',
1172 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
1173 'log_type' => 'webhook',
1174 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1175 'module_name' => 'PayPal'
1176 ]);
1177
1178 return false; // We are really sorry that we could not get the transaction ID.
1179 }
1180
1181 $parentTransaction = OrderTransaction::query()
1182 ->where('vendor_charge_id', $paypalTransactionId)
1183 ->where('status', Status::TRANSACTION_SUCCEEDED)
1184 ->first();
1185
1186 if (!$parentTransaction) {
1187
1188 do_action('fluent_cart/dev_log', [
1189 'raw_data' => $refundData,
1190 'status' => 'failed',
1191 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
1192 'log_type' => 'webhook',
1193 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1194 'module_name' => 'PayPal'
1195 ]);
1196
1197 return false; // not our transaction, we are not handling this refund
1198 }
1199
1200 return \FluentCart\App\Services\Payments\Refund::createOrRecordRefund([
1201 'vendor_charge_id' => $paypalRefundId,
1202 'payment_method' => 'paypal',
1203 'total' => $paypalRefundAmount,
1204 ], $parentTransaction);
1205
1206 }
1207
1208
1209 public function handleWebhookRecurringPaymentRefunded($data)
1210 {
1211 $refundData = Arr::get($data, 'refund', []);
1212
1213 if (Arr::get($refundData, 'state') !== 'completed') {
1214 return false;
1215 }
1216
1217 $parentTxnId = Arr::get($refundData, 'sale_id', '');
1218 if (!$parentTxnId) {
1219 return false;
1220 }
1221
1222 $subscriptionHash = sanitize_text_field(Arr::get($data, 'custom', ''));
1223
1224 $parentTransaction = OrderTransaction::query()->where('vendor_charge_id', $parentTxnId)
1225 ->where('status', Status::TRANSACTION_SUCCEEDED)
1226 ->first();
1227
1228 if (!$parentTransaction && $subscriptionHash) {
1229 $parentSubscription = Subscription::query()->where('uuid', $subscriptionHash)->first();
1230 $parentTransaction = $parentSubscription ? $parentSubscription->getLatestTransaction() : null;
1231 }
1232
1233 if (!$parentTransaction) {
1234 do_action('fluent_cart/dev_log', [
1235 'raw_data' => $data,
1236 'status' => 'failed',
1237 'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'),
1238 'log_type' => 'webhook',
1239 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1240 'module_name' => 'PayPal'
1241 ]);
1242
1243 return null;
1244 }
1245
1246 if ($parentTransaction->status === Status::TRANSACTION_FAILED) {
1247 return null;
1248 }
1249
1250 $paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.total', 0));
1251
1252 return \FluentCart\App\Services\Payments\Refund::createOrRecordRefund([
1253 'vendor_charge_id' => Arr::get($refundData, 'id'),
1254 'payment_method' => 'paypal',
1255 'total' => $paypalRefundAmount,
1256 'reason' => Arr::get($refundData, 'description'),
1257 ], $parentTransaction);
1258 }
1259
1260 public function handleWebhookRecurringProfileCancelled($data)
1261 {
1262 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
1263 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
1264
1265 if (!$subscriptionModel) {
1266 do_action('fluent_cart/dev_log', [
1267 'raw_data' => $subscriptionInfo,
1268 'status' => 'failed',
1269 'title' => __('Failed to find Subscription for PayPal Cancel webhook', 'fluent-cart'),
1270 'log_type' => 'webhook',
1271 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1272 'module_name' => 'PayPal'
1273 ]);
1274 return;
1275 }
1276
1277 if ($subscriptionModel->status === Status::SUBSCRIPTION_CANCELED || $subscriptionModel->current_payment_method !== 'paypal') {
1278 return;
1279 }
1280
1281 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
1282 'status' => Status::SUBSCRIPTION_CANCELED,
1283 'canceled_at' => DateTime::anyTimeToGmt(Arr::get($subscriptionInfo, 'status_update_time'))->format('Y-m-d H:i:s'),
1284 'meta' => [
1285 'cancellation_reason' => Arr::get($subscriptionInfo, 'status_change_note', ''),
1286 ]
1287 ]);
1288 }
1289
1290 public function handleWebhookRecurringProfileExpired($data)
1291 {
1292 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
1293 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
1294
1295 if (!$subscriptionModel) {
1296 do_action('fluent_cart/dev_log', [
1297 'raw_data' => $subscriptionInfo,
1298 'status' => 'failed',
1299 'title' => __('Failed to find Subscription for PayPal Subscription Expired webhook', 'fluent-cart'),
1300 'log_type' => 'webhook',
1301 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1302 'module_name' => 'PayPal'
1303 ]);
1304 return;
1305 }
1306
1307 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
1308 'status' => Status::SUBSCRIPTION_EXPIRED
1309 ]);
1310 }
1311
1312 public function handleWebhookRecurringProfileSuspended($data)
1313 {
1314 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
1315 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
1316
1317 if (!$subscriptionModel) {
1318 do_action('fluent_cart/dev_log', [
1319 'raw_data' => $subscriptionInfo,
1320 'status' => 'failed',
1321 'title' => __('Failed to find Subscription for PayPal Subscription Suspended webhook', 'fluent-cart'),
1322 'log_type' => 'webhook',
1323 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1324 'module_name' => 'PayPal'
1325 ]);
1326 return;
1327 }
1328
1329 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
1330 'status' => Status::SUBSCRIPTION_PAUSED
1331 ]);
1332
1333 }
1334
1335 public function handleWebhookRecurringProfileReactivated($data)
1336 {
1337 $subscriptionInfo = Arr::get($data, 'paypal_subscription', []);
1338 $subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo);
1339
1340 if (!$subscriptionModel) {
1341 do_action('fluent_cart/dev_log', [
1342 'raw_data' => $subscriptionInfo,
1343 'status' => 'failed',
1344 'title' => __('Failed to find Subscription for PayPal Subscription Reactive webhook', 'fluent-cart'),
1345 'log_type' => 'webhook',
1346 'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal',
1347 'module_name' => 'PayPal'
1348 ]);
1349 return;
1350 }
1351
1352 return SubscriptionService::syncSubscriptionStates($subscriptionModel, [
1353 'status' => Status::SUBSCRIPTION_ACTIVE
1354 ]);
1355 }
1356
1357 public function handleWebhookDisputeCreated($data)
1358 {
1359 $disputeInfo = Arr::get($data, 'dispute', []);
1360 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
1361 if (empty($disputeId)) {
1362 return false;
1363 }
1364
1365 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
1366
1367 if (count($disputedTransactions) > 1) {
1368 return false;
1369 }
1370
1371 $status = Arr::get($disputeInfo, 'status', '');
1372 $stage = Arr::get($disputeInfo, 'dispute_life_cycle_stage', '');
1373 $reason = Arr::get($disputeInfo, 'reason', '');
1374
1375 $fluentCartTransactions = [];
1376 foreach ($disputedTransactions as $transaction) {
1377 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
1378 if ($transaction) {
1379 $fluentCartTransactions[] = $transactionModel;
1380 }
1381 }
1382 if (empty($fluentCartTransactions)) {
1383 return false;
1384 }
1385
1386 $isChargeRefundable = in_array($status, ['OPEN', 'WAITING_FOR_SELLER_RESPONSE']);
1387
1388 $transactionModel = $fluentCartTransactions[0];
1389 $transactionModel->update([
1390 'transaction_type' => Status::TRANSACTION_TYPE_DISPUTE,
1391 'meta' => array_merge($transactionModel->meta, [
1392 'dispute_id' => $disputeId,
1393 'dispute_reason' => $reason,
1394 'is_dispute_actionable' => in_array($stage, ['CHARGEBACK', 'REVIEW']),
1395 'is_charge_refundable' => $isChargeRefundable,
1396 'status' => $status
1397 ])
1398 ]);
1399
1400 return true;
1401 }
1402
1403 public function handleWebhookDisputeUpdated($data)
1404 {
1405 $disputeInfo = Arr::get($data, 'dispute', []);
1406 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
1407 if (empty($disputeId)) {
1408 return false;
1409 }
1410
1411 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
1412
1413 if (count($disputedTransactions) > 1) {
1414 return false;
1415 }
1416
1417 $stage = Arr::get($disputeInfo, 'dispute_life_cycle_stage', '');
1418
1419 $fluentCartTransactions = [];
1420 foreach ($disputedTransactions as $transaction) {
1421 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
1422 if (!$transactionModel) {
1423 continue;
1424 }
1425 $fluentCartTransactions[] = $transactionModel;
1426 }
1427
1428 if (empty($fluentCartTransactions)) {
1429 return false;
1430 }
1431
1432 $transactionModel = $fluentCartTransactions[0];
1433 $status = Arr::get($disputeInfo, 'status', '');
1434 $isChargeRefundable = in_array($status, ['OPEN', 'WAITING_FOR_SELLER_RESPONSE']) || in_array($stage, ['CHARGEBACK', 'INQUIRY']);
1435
1436 if ($stage === 'CHARGEBACK') {
1437 $transactionModel->update([
1438 'meta' => array_merge($transactionModel->meta, [
1439 'is_dispute_actionable' => true,
1440 'dispute_status' => Arr::get($disputeInfo, 'status', ''),
1441 'is_charge_refundable' => $isChargeRefundable
1442 ])
1443 ]);
1444 } else {
1445 $transactionModel->update([
1446 'meta' => array_merge($transactionModel->meta, [
1447 'is_dispute_actionable' => false,
1448 'dispute_status' => Arr::get($disputeInfo, 'status', ''),
1449 'is_charge_refundable' => $isChargeRefundable
1450 ])
1451 ]);
1452 }
1453
1454 return true;
1455
1456 }
1457
1458 public function handleWebhookDisputeResolved($data)
1459 {
1460 $disputeInfo = Arr::get($data, 'dispute', []);
1461 $disputeId = Arr::get($disputeInfo, 'dispute_id', '');
1462 if (empty($disputeId)) {
1463 return false;
1464 }
1465
1466 $disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []);
1467 if (count($disputedTransactions) > 1) {
1468 return false;
1469 }
1470
1471 $status = Arr::get($disputeInfo, 'status', '');
1472 if ($status !== 'RESOLVED') {
1473 return false;
1474 }
1475
1476 $fluentCartTransactions = [];
1477 foreach ($disputedTransactions as $transaction) {
1478 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first();
1479 if (!$transactionModel) {
1480 continue;
1481 }
1482 $fluentCartTransactions[] = $transactionModel;
1483 }
1484
1485 if (empty($fluentCartTransactions)) {
1486 return false;
1487 }
1488
1489 // we are handling disputes only with one transaction - PayPal allow user to select multiple transactions on dispute creation
1490 $transactionModel = $fluentCartTransactions[0];
1491
1492 if ($transactionModel->status === Status::TRANSACTION_DISPUTE_LOST) { // already dispute claim accepted via admin dashboard
1493 return false;
1494 }
1495
1496 // 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
1497 $transactionModel->update([
1498 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
1499 'meta' => array_merge($transactionModel->meta, [
1500 'is_dispute_actionable' => false,
1501 'is_charge_refundable' => false,
1502 'dispute_status' => $status
1503 ])
1504 ]);
1505
1506 return true;
1507 }
1508
1509 private function getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo = [])
1510 {
1511 $id = Arr::get($subscriptionInfo, 'id', '');
1512 if (empty($id)) {
1513 return null;
1514 }
1515
1516 $subscription = Subscription::query()->where('vendor_subscription_id', $id)->first();
1517
1518 if (!$subscription) {
1519 $subscriptionHash = Arr::get($subscriptionInfo, 'custom_id', '');
1520 if ($subscriptionHash) {
1521 $subscription = Subscription::query()->where('uuid', $subscriptionHash)->first();
1522 }
1523 }
1524
1525 return $subscription;
1526 }
1527
1528
1529 private static function getPayPalSettings()
1530 {
1531 if (!self::$paypalSettings) {
1532 self::$paypalSettings = new PayPalSettingsBase();
1533 }
1534 return self::$paypalSettings;
1535 }
1536 }
1537