| 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 |
return true; |
| 825 |
} |
| 826 |
|
| 827 |
$result = $paypalSubscriptions->reSyncSubscriptionFromRemote( |
| 828 |
$subscriptionModel, |
| 829 |
$paypalSubscription, |
| 830 |
$remoteTransactions |
| 831 |
); |
| 832 |
|
| 833 |
if (is_wp_error($result)) { |
| 834 |
if (self::isTerminalRenewalError($result, $subscriptionModel)) { |
| 835 |
return true; |
| 836 |
} |
| 837 |
|
| 838 |
self::$recurringPaymentError = $result; |
| 839 |
if (self::isRetryableRemoteError($result)) { |
| 840 |
self::scheduleResyncRetry($subscriptionModel, $chargeId); |
| 841 |
} |
| 842 |
return false; |
| 843 |
} |
| 844 |
|
| 845 |
return true; |
| 846 |
} |
| 847 |
|
| 848 |
|
| 849 |
// Now we are sure, we have a renewal payment for this subscription! |
| 850 |
|
| 851 |
// we will just create the transaction here |
| 852 |
|
| 853 |
$subscriptionUpdateData = [ |
| 854 |
'current_payment_method' => 'paypal', |
| 855 |
'vendor_subscription_id' => $vendorSubscriptionId |
| 856 |
]; |
| 857 |
|
| 858 |
$payer = ($paypalSubscription && !is_wp_error($paypalSubscription)) ? Arr::get($paypalSubscription, 'subscriber', []) : []; |
| 859 |
if ($paypalSubscription && !is_wp_error($paypalSubscription)) { |
| 860 |
$subscriptionUpdateData['status'] = (new SubscriptionManager)->getCorrectSubscriptionStatus(Arr::get($paypalSubscription, 'status')); |
| 861 |
$nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time'); |
| 862 |
if ($nextBillingDate) { |
| 863 |
$subscriptionUpdateData['next_billing_date'] = SubscriptionHelper::safeTimestampToDatetime($nextBillingDate); |
| 864 |
} |
| 865 |
|
| 866 |
$payerId = Arr::get($paypalSubscription, 'subscriber.payer_id'); |
| 867 |
|
| 868 |
if ($payerId) { |
| 869 |
$subscriptionUpdateData['vendor_customer_id'] = $payerId; |
| 870 |
} |
| 871 |
|
| 872 |
if (!empty($paypalSubscription['plan_id'])) { |
| 873 |
$subscriptionUpdateData['vendor_plan_id'] = $paypalSubscription['plan_id']; |
| 874 |
} |
| 875 |
|
| 876 |
if (Arr::get($paypalSubscription, 'status') === 'CANCELLED') { |
| 877 |
$statusUpdateTime = Arr::get($paypalSubscription, 'status_update_time'); |
| 878 |
if ($statusUpdateTime) { |
| 879 |
$subscriptionUpdateData['canceled_at'] = gmdate('Y-m-d H:i:s', strtotime($statusUpdateTime)); |
| 880 |
} |
| 881 |
} |
| 882 |
|
| 883 |
} |
| 884 |
|
| 885 |
$transactionData = [ |
| 886 |
'payment_method' => 'paypal', |
| 887 |
'total' => $amount, |
| 888 |
'vendor_charge_id' => $chargeId, |
| 889 |
'payment_method_type' => 'paypal', |
| 890 |
'meta' => [ |
| 891 |
'payer' => $payer |
| 892 |
] |
| 893 |
]; |
| 894 |
|
| 895 |
// Credited before recording: recordRenewalPayment() recomputes bill_count |
| 896 |
// and the installment end-of-term inside itself, so an outstanding-balance |
| 897 |
// collection's extra cycles must already be on the books when it runs. |
| 898 |
// Idempotent per sale id, so a failed record retried later credits once. |
| 899 |
$paypalSubscriptions = new PayPalSubscriptions(); |
| 900 |
$credited = $paypalSubscriptions->creditOutstandingCollection($subscriptionModel, $chargeId, $amount); |
| 901 |
|
| 902 |
// Credit undecided: record nothing. Once a transaction exists this |
| 903 |
// method returns early on every redelivery, so the missed cycles would |
| 904 |
// become unreachable — leaving the sale unrecorded keeps both recovery |
| 905 |
// channels (PayPal redelivery, the resync ladder) able to repair it. |
| 906 |
if (is_wp_error($credited)) { |
| 907 |
self::$recurringPaymentError = $credited; |
| 908 |
self::scheduleResyncRetry($subscriptionModel, $chargeId); |
| 909 |
return false; |
| 910 |
} |
| 911 |
|
| 912 |
$result = SubscriptionService::recordRenewalPayment($transactionData, $subscriptionModel, $subscriptionUpdateData); |
| 913 |
|
| 914 |
if (is_wp_error($result)) { |
| 915 |
if ($result->get_error_code() === 'transaction_exists') { |
| 916 |
return true; |
| 917 |
} |
| 918 |
|
| 919 |
if ($result->get_error_code() === 'lock_failed') { |
| 920 |
// Contention is not proof of a committed payment. Keep the credit |
| 921 |
// available to the lock holder, but retry until the sale is recorded. |
| 922 |
if ($paypalSubscriptions->hasRecordedSale($subscriptionModel, $chargeId)) { |
| 923 |
return true; |
| 924 |
} |
| 925 |
|
| 926 |
self::$recurringPaymentError = $result; |
| 927 |
self::scheduleResyncRetry($subscriptionModel, $chargeId); |
| 928 |
return false; |
| 929 |
} |
| 930 |
|
| 931 |
if ($credited) { |
| 932 |
$paypalSubscriptions->revokeOutstandingCollection($subscriptionModel, $chargeId); |
| 933 |
} |
| 934 |
|
| 935 |
if (self::isTerminalRenewalError($result, $subscriptionModel)) { |
| 936 |
return true; |
| 937 |
} |
| 938 |
|
| 939 |
self::$recurringPaymentError = $result; |
| 940 |
if (self::isRetryableRemoteError($result)) { |
| 941 |
self::scheduleResyncRetry($subscriptionModel, $chargeId); |
| 942 |
} |
| 943 |
return false; |
| 944 |
} |
| 945 |
|
| 946 |
return true; |
| 947 |
} |
| 948 |
|
| 949 |
/** |
| 950 |
* Queue a resync at +1h / +6h / +24h as a backstop to PayPal's finite redelivery |
| 951 |
* window. One ladder per subscription (args are [subscription, attempt]); the |
| 952 |
* sale ids it chases accumulate in PENDING_SALES_META. |
| 953 |
* |
| 954 |
* @param Subscription $subscriptionModel |
| 955 |
* @param string|null $saleId the sale this attempt is chasing, if any |
| 956 |
* @param int $attempt 1-based position on the delay ladder |
| 957 |
* @return void |
| 958 |
*/ |
| 959 |
private static function scheduleResyncRetry(Subscription $subscriptionModel, $saleId = null, $attempt = 1) |
| 960 |
{ |
| 961 |
// After the guard: without Action Scheduler no worker ever prunes the set. |
| 962 |
if (!function_exists('as_schedule_single_action') || !function_exists('as_next_scheduled_action')) { |
| 963 |
return; |
| 964 |
} |
| 965 |
|
| 966 |
if ($saleId) { |
| 967 |
$pending = self::getPendingSales($subscriptionModel); |
| 968 |
if (!in_array($saleId, $pending, true)) { |
| 969 |
$pending[] = $saleId; |
| 970 |
$subscriptionModel->updateMeta(self::PENDING_SALES_META, $pending); |
| 971 |
} |
| 972 |
} |
| 973 |
|
| 974 |
$delays = [HOUR_IN_SECONDS, 6 * HOUR_IN_SECONDS, DAY_IN_SECONDS]; |
| 975 |
|
| 976 |
if (!isset($delays[$attempt - 1])) { |
| 977 |
fluent_cart_add_log( |
| 978 |
__('PayPal renewal resync retries exhausted — needs manual review', 'fluent-cart'), |
| 979 |
sprintf( |
| 980 |
/* translators: 1: subscription ID, 2: comma separated PayPal sale IDs */ |
| 981 |
__('All scheduled resync retries failed. Subscription ID: %1$d. Unresolved PayPal sales: %2$s', 'fluent-cart'), |
| 982 |
$subscriptionModel->id, |
| 983 |
implode(', ', self::getPendingSales($subscriptionModel)) ?: __('none recorded', 'fluent-cart') |
| 984 |
), |
| 985 |
'error', |
| 986 |
[ |
| 987 |
'module_type' => 'FluentCart\App\Models\Subscription', |
| 988 |
'module_id' => $subscriptionModel->id, |
| 989 |
'module_name' => 'subscription', |
| 990 |
'log_type' => 'webhook' |
| 991 |
] |
| 992 |
); |
| 993 |
$subscriptionModel->deleteMeta(self::PENDING_SALES_META); |
| 994 |
return; |
| 995 |
} |
| 996 |
|
| 997 |
// Start at $attempt so the running attempt's own in-progress action |
| 998 |
// (which as_next_scheduled_action reports as scheduled) can't block |
| 999 |
// its follow-up. |
| 1000 |
for ($pending = $attempt; $pending <= count($delays); $pending++) { |
| 1001 |
if (as_next_scheduled_action(self::RESYNC_RETRY_HOOK, [$subscriptionModel->id, $pending], 'fluent-cart')) { |
| 1002 |
return; |
| 1003 |
} |
| 1004 |
} |
| 1005 |
|
| 1006 |
as_schedule_single_action( |
| 1007 |
time() + $delays[$attempt - 1], |
| 1008 |
self::RESYNC_RETRY_HOOK, |
| 1009 |
[$subscriptionModel->id, $attempt], |
| 1010 |
'fluent-cart' |
| 1011 |
); |
| 1012 |
} |
| 1013 |
|
| 1014 |
public function handleResyncRetry($subscriptionId, $attempt = 1) |
| 1015 |
{ |
| 1016 |
/** @var Subscription|null $subscriptionModel */ |
| 1017 |
$subscriptionModel = Subscription::query()->find($subscriptionId); |
| 1018 |
|
| 1019 |
if (!$subscriptionModel || $subscriptionModel->current_payment_method !== 'paypal' || !$subscriptionModel->vendor_subscription_id) { |
| 1020 |
return; |
| 1021 |
} |
| 1022 |
|
| 1023 |
$result = (new PayPalSubscriptions())->reSyncSubscriptionFromRemote($subscriptionModel); |
| 1024 |
|
| 1025 |
if (is_wp_error($result)) { |
| 1026 |
if (self::isTerminalRenewalError($result, $subscriptionModel) || !self::isRetryableRemoteError($result)) { |
| 1027 |
$subscriptionModel->deleteMeta(self::PENDING_SALES_META); |
| 1028 |
return; |
| 1029 |
} |
| 1030 |
|
| 1031 |
self::scheduleResyncRetry($subscriptionModel, null, (int) $attempt + 1); |
| 1032 |
return; |
| 1033 |
} |
| 1034 |
|
| 1035 |
// A successful resync only means PayPal answered. Its transaction list |
| 1036 |
// lags, so the sale that armed this ladder can still be absent (or |
| 1037 |
// listed as non-completed, which binds nothing). Local rows are the |
| 1038 |
// only proof the payment landed. |
| 1039 |
if (self::pruneResolvedSales($subscriptionModel)) { |
| 1040 |
self::scheduleResyncRetry($subscriptionModel, null, (int) $attempt + 1); |
| 1041 |
} |
| 1042 |
} |
| 1043 |
|
| 1044 |
/** |
| 1045 |
* @param Subscription $subscriptionModel |
| 1046 |
* @return string[] |
| 1047 |
*/ |
| 1048 |
private static function getPendingSales(Subscription $subscriptionModel) |
| 1049 |
{ |
| 1050 |
$pending = $subscriptionModel->getMeta(self::PENDING_SALES_META, []); |
| 1051 |
|
| 1052 |
return array_values(array_filter((array) $pending, 'is_string')); |
| 1053 |
} |
| 1054 |
|
| 1055 |
/** |
| 1056 |
* Drop the sales that now have a local transaction, keep the rest. |
| 1057 |
* A recorded-then-refunded row still counts as recorded. |
| 1058 |
* |
| 1059 |
* @param Subscription $subscriptionModel |
| 1060 |
* @return bool true while at least one sale is still unaccounted for |
| 1061 |
*/ |
| 1062 |
private static function pruneResolvedSales(Subscription $subscriptionModel) |
| 1063 |
{ |
| 1064 |
$pending = self::getPendingSales($subscriptionModel); |
| 1065 |
|
| 1066 |
if (!$pending) { |
| 1067 |
return false; |
| 1068 |
} |
| 1069 |
|
| 1070 |
$recorded = OrderTransaction::query() |
| 1071 |
->where('subscription_id', $subscriptionModel->id) |
| 1072 |
->whereIn('vendor_charge_id', $pending) |
| 1073 |
->get(['vendor_charge_id']) |
| 1074 |
->pluck('vendor_charge_id') |
| 1075 |
->toArray(); |
| 1076 |
|
| 1077 |
$unresolved = array_values(array_diff($pending, $recorded)); |
| 1078 |
|
| 1079 |
if ($unresolved === $pending) { |
| 1080 |
return true; |
| 1081 |
} |
| 1082 |
|
| 1083 |
// An empty array does not survive the meta cast round-trip, so drop the row. |
| 1084 |
if ($unresolved) { |
| 1085 |
$subscriptionModel->updateMeta(self::PENDING_SALES_META, $unresolved); |
| 1086 |
} else { |
| 1087 |
$subscriptionModel->deleteMeta(self::PENDING_SALES_META); |
| 1088 |
} |
| 1089 |
|
| 1090 |
return (bool) $unresolved; |
| 1091 |
} |
| 1092 |
|
| 1093 |
/** |
| 1094 |
* A retry only helps against transient remote failures. PayPal reports a |
| 1095 |
* missing/invalid resource in the error body's `name` field (the WP_Error |
| 1096 |
* code stays `general_error` for REST errors), so inspect the data. |
| 1097 |
* |
| 1098 |
* @param \WP_Error $error |
| 1099 |
* @return bool |
| 1100 |
*/ |
| 1101 |
private static function isRetryableRemoteError($error) |
| 1102 |
{ |
| 1103 |
$data = $error->get_error_data(); |
| 1104 |
$name = is_array($data) ? Arr::get($data, 'name', '') : ''; |
| 1105 |
|
| 1106 |
return !in_array($name, ['RESOURCE_NOT_FOUND', 'INVALID_RESOURCE_ID'], true); |
| 1107 |
} |
| 1108 |
|
| 1109 |
/** |
| 1110 |
* Errors redelivery can never fix (local records gone): log loudly and ack, |
| 1111 |
* since a 500 would make PayPal retry for days and can disable the endpoint. |
| 1112 |
* |
| 1113 |
* @param \WP_Error $error |
| 1114 |
* @param Subscription $subscriptionModel |
| 1115 |
* @return bool true when the error was terminal and has been logged |
| 1116 |
*/ |
| 1117 |
private static function isTerminalRenewalError($error, $subscriptionModel) |
| 1118 |
{ |
| 1119 |
if (!in_array($error->get_error_code(), ['subscription_not_found', 'parent_order_not_found'], true)) { |
| 1120 |
return false; |
| 1121 |
} |
| 1122 |
|
| 1123 |
fluent_cart_add_log( |
| 1124 |
__('PayPal renewal payment could not be recorded — needs manual review', 'fluent-cart'), |
| 1125 |
sprintf( |
| 1126 |
/* translators: %1$s: error message, %2$d: subscription ID */ |
| 1127 |
__('%1$s Subscription ID: %2$d', 'fluent-cart'), |
| 1128 |
$error->get_error_message(), |
| 1129 |
$subscriptionModel->id |
| 1130 |
), |
| 1131 |
'error', |
| 1132 |
[ |
| 1133 |
'module_type' => 'FluentCart\App\Models\Subscription', |
| 1134 |
'module_id' => $subscriptionModel->id, |
| 1135 |
'module_name' => 'subscription', |
| 1136 |
'log_type' => 'webhook' |
| 1137 |
] |
| 1138 |
); |
| 1139 |
|
| 1140 |
return true; |
| 1141 |
} |
| 1142 |
|
| 1143 |
public function handleSinglePaymentRefund($data) |
| 1144 |
{ |
| 1145 |
$refundData = Arr::get($data, 'refund', []); |
| 1146 |
$paypalRefundId = Arr::get($refundData, 'id', ''); |
| 1147 |
$paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.value', 0)); |
| 1148 |
|
| 1149 |
// Let's guess the transaction ID from links |
| 1150 |
|
| 1151 |
$paypalTransactionId = ''; |
| 1152 |
|
| 1153 |
foreach (Arr::get($refundData, 'links', []) as $link) { |
| 1154 |
if (Arr::get($link, 'rel') !== 'up') { |
| 1155 |
continue; |
| 1156 |
} |
| 1157 |
|
| 1158 |
$href = Arr::get($link, 'href', ''); |
| 1159 |
$paypalTransactionId = basename($href); |
| 1160 |
if ($paypalTransactionId) { |
| 1161 |
break; |
| 1162 |
} |
| 1163 |
} |
| 1164 |
|
| 1165 |
if (!$paypalTransactionId) { |
| 1166 |
|
| 1167 |
do_action('fluent_cart/dev_log', [ |
| 1168 |
'raw_data' => $refundData, |
| 1169 |
'status' => 'failed', |
| 1170 |
'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'), |
| 1171 |
'log_type' => 'webhook', |
| 1172 |
'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal', |
| 1173 |
'module_name' => 'PayPal' |
| 1174 |
]); |
| 1175 |
|
| 1176 |
return false; // We are really sorry that we could not get the transaction ID. |
| 1177 |
} |
| 1178 |
|
| 1179 |
$parentTransaction = OrderTransaction::query() |
| 1180 |
->where('vendor_charge_id', $paypalTransactionId) |
| 1181 |
->where('status', Status::TRANSACTION_SUCCEEDED) |
| 1182 |
->first(); |
| 1183 |
|
| 1184 |
if (!$parentTransaction) { |
| 1185 |
|
| 1186 |
do_action('fluent_cart/dev_log', [ |
| 1187 |
'raw_data' => $refundData, |
| 1188 |
'status' => 'failed', |
| 1189 |
'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'), |
| 1190 |
'log_type' => 'webhook', |
| 1191 |
'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal', |
| 1192 |
'module_name' => 'PayPal' |
| 1193 |
]); |
| 1194 |
|
| 1195 |
return false; // not our transaction, we are not handling this refund |
| 1196 |
} |
| 1197 |
|
| 1198 |
return \FluentCart\App\Services\Payments\Refund::createOrRecordRefund([ |
| 1199 |
'vendor_charge_id' => $paypalRefundId, |
| 1200 |
'payment_method' => 'paypal', |
| 1201 |
'total' => $paypalRefundAmount, |
| 1202 |
], $parentTransaction); |
| 1203 |
|
| 1204 |
} |
| 1205 |
|
| 1206 |
|
| 1207 |
public function handleWebhookRecurringPaymentRefunded($data) |
| 1208 |
{ |
| 1209 |
$refundData = Arr::get($data, 'refund', []); |
| 1210 |
|
| 1211 |
if (Arr::get($refundData, 'state') !== 'completed') { |
| 1212 |
return false; |
| 1213 |
} |
| 1214 |
|
| 1215 |
$parentTxnId = Arr::get($refundData, 'sale_id', ''); |
| 1216 |
if (!$parentTxnId) { |
| 1217 |
return false; |
| 1218 |
} |
| 1219 |
|
| 1220 |
$subscriptionHash = sanitize_text_field(Arr::get($data, 'custom', '')); |
| 1221 |
|
| 1222 |
$parentTransaction = OrderTransaction::query()->where('vendor_charge_id', $parentTxnId) |
| 1223 |
->where('status', Status::TRANSACTION_SUCCEEDED) |
| 1224 |
->first(); |
| 1225 |
|
| 1226 |
if (!$parentTransaction && $subscriptionHash) { |
| 1227 |
$parentSubscription = Subscription::query()->where('uuid', $subscriptionHash)->first(); |
| 1228 |
$parentTransaction = $parentSubscription ? $parentSubscription->getLatestTransaction() : null; |
| 1229 |
} |
| 1230 |
|
| 1231 |
if (!$parentTransaction) { |
| 1232 |
do_action('fluent_cart/dev_log', [ |
| 1233 |
'raw_data' => $data, |
| 1234 |
'status' => 'failed', |
| 1235 |
'title' => __('Failed to find parent transaction for PayPal Refund webhook', 'fluent-cart'), |
| 1236 |
'log_type' => 'webhook', |
| 1237 |
'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal', |
| 1238 |
'module_name' => 'PayPal' |
| 1239 |
]); |
| 1240 |
|
| 1241 |
return null; |
| 1242 |
} |
| 1243 |
|
| 1244 |
if ($parentTransaction->status === Status::TRANSACTION_FAILED) { |
| 1245 |
return null; |
| 1246 |
} |
| 1247 |
|
| 1248 |
$paypalRefundAmount = Helper::toCent(Arr::get($refundData, 'amount.total', 0)); |
| 1249 |
|
| 1250 |
return \FluentCart\App\Services\Payments\Refund::createOrRecordRefund([ |
| 1251 |
'vendor_charge_id' => Arr::get($refundData, 'id'), |
| 1252 |
'payment_method' => 'paypal', |
| 1253 |
'total' => $paypalRefundAmount, |
| 1254 |
'reason' => Arr::get($refundData, 'description'), |
| 1255 |
], $parentTransaction); |
| 1256 |
} |
| 1257 |
|
| 1258 |
public function handleWebhookRecurringProfileCancelled($data) |
| 1259 |
{ |
| 1260 |
$subscriptionInfo = Arr::get($data, 'paypal_subscription', []); |
| 1261 |
$subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo); |
| 1262 |
|
| 1263 |
if (!$subscriptionModel) { |
| 1264 |
do_action('fluent_cart/dev_log', [ |
| 1265 |
'raw_data' => $subscriptionInfo, |
| 1266 |
'status' => 'failed', |
| 1267 |
'title' => __('Failed to find Subscription for PayPal Cancel webhook', 'fluent-cart'), |
| 1268 |
'log_type' => 'webhook', |
| 1269 |
'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal', |
| 1270 |
'module_name' => 'PayPal' |
| 1271 |
]); |
| 1272 |
return; |
| 1273 |
} |
| 1274 |
|
| 1275 |
if ($subscriptionModel->status === Status::SUBSCRIPTION_CANCELED || $subscriptionModel->current_payment_method !== 'paypal') { |
| 1276 |
return; |
| 1277 |
} |
| 1278 |
|
| 1279 |
return SubscriptionService::syncSubscriptionStates($subscriptionModel, [ |
| 1280 |
'status' => Status::SUBSCRIPTION_CANCELED, |
| 1281 |
'canceled_at' => DateTime::anyTimeToGmt(Arr::get($subscriptionInfo, 'status_update_time'))->format('Y-m-d H:i:s'), |
| 1282 |
'meta' => [ |
| 1283 |
'cancellation_reason' => Arr::get($subscriptionInfo, 'status_change_note', ''), |
| 1284 |
] |
| 1285 |
]); |
| 1286 |
} |
| 1287 |
|
| 1288 |
public function handleWebhookRecurringProfileExpired($data) |
| 1289 |
{ |
| 1290 |
$subscriptionInfo = Arr::get($data, 'paypal_subscription', []); |
| 1291 |
$subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo); |
| 1292 |
|
| 1293 |
if (!$subscriptionModel) { |
| 1294 |
do_action('fluent_cart/dev_log', [ |
| 1295 |
'raw_data' => $subscriptionInfo, |
| 1296 |
'status' => 'failed', |
| 1297 |
'title' => __('Failed to find Subscription for PayPal Subscription Expired webhook', 'fluent-cart'), |
| 1298 |
'log_type' => 'webhook', |
| 1299 |
'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal', |
| 1300 |
'module_name' => 'PayPal' |
| 1301 |
]); |
| 1302 |
return; |
| 1303 |
} |
| 1304 |
|
| 1305 |
return SubscriptionService::syncSubscriptionStates($subscriptionModel, [ |
| 1306 |
'status' => Status::SUBSCRIPTION_EXPIRED |
| 1307 |
]); |
| 1308 |
} |
| 1309 |
|
| 1310 |
public function handleWebhookRecurringProfileSuspended($data) |
| 1311 |
{ |
| 1312 |
$subscriptionInfo = Arr::get($data, 'paypal_subscription', []); |
| 1313 |
$subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo); |
| 1314 |
|
| 1315 |
if (!$subscriptionModel) { |
| 1316 |
do_action('fluent_cart/dev_log', [ |
| 1317 |
'raw_data' => $subscriptionInfo, |
| 1318 |
'status' => 'failed', |
| 1319 |
'title' => __('Failed to find Subscription for PayPal Subscription Suspended webhook', 'fluent-cart'), |
| 1320 |
'log_type' => 'webhook', |
| 1321 |
'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal', |
| 1322 |
'module_name' => 'PayPal' |
| 1323 |
]); |
| 1324 |
return; |
| 1325 |
} |
| 1326 |
|
| 1327 |
return SubscriptionService::syncSubscriptionStates($subscriptionModel, [ |
| 1328 |
'status' => Status::SUBSCRIPTION_PAUSED |
| 1329 |
]); |
| 1330 |
|
| 1331 |
} |
| 1332 |
|
| 1333 |
public function handleWebhookRecurringProfileReactivated($data) |
| 1334 |
{ |
| 1335 |
$subscriptionInfo = Arr::get($data, 'paypal_subscription', []); |
| 1336 |
$subscriptionModel = $this->getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo); |
| 1337 |
|
| 1338 |
if (!$subscriptionModel) { |
| 1339 |
do_action('fluent_cart/dev_log', [ |
| 1340 |
'raw_data' => $subscriptionInfo, |
| 1341 |
'status' => 'failed', |
| 1342 |
'title' => __('Failed to find Subscription for PayPal Subscription Reactive webhook', 'fluent-cart'), |
| 1343 |
'log_type' => 'webhook', |
| 1344 |
'module_type' => 'FluentCart\App\Modules\PaymentMethods\PayPal', |
| 1345 |
'module_name' => 'PayPal' |
| 1346 |
]); |
| 1347 |
return; |
| 1348 |
} |
| 1349 |
|
| 1350 |
return SubscriptionService::syncSubscriptionStates($subscriptionModel, [ |
| 1351 |
'status' => Status::SUBSCRIPTION_ACTIVE |
| 1352 |
]); |
| 1353 |
} |
| 1354 |
|
| 1355 |
public function handleWebhookDisputeCreated($data) |
| 1356 |
{ |
| 1357 |
$disputeInfo = Arr::get($data, 'dispute', []); |
| 1358 |
$disputeId = Arr::get($disputeInfo, 'dispute_id', ''); |
| 1359 |
if (empty($disputeId)) { |
| 1360 |
return false; |
| 1361 |
} |
| 1362 |
|
| 1363 |
$disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []); |
| 1364 |
|
| 1365 |
if (count($disputedTransactions) > 1) { |
| 1366 |
return false; |
| 1367 |
} |
| 1368 |
|
| 1369 |
$status = Arr::get($disputeInfo, 'status', ''); |
| 1370 |
$stage = Arr::get($disputeInfo, 'dispute_life_cycle_stage', ''); |
| 1371 |
$reason = Arr::get($disputeInfo, 'reason', ''); |
| 1372 |
|
| 1373 |
$fluentCartTransactions = []; |
| 1374 |
foreach ($disputedTransactions as $transaction) { |
| 1375 |
$transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first(); |
| 1376 |
if ($transaction) { |
| 1377 |
$fluentCartTransactions[] = $transactionModel; |
| 1378 |
} |
| 1379 |
} |
| 1380 |
if (empty($fluentCartTransactions)) { |
| 1381 |
return false; |
| 1382 |
} |
| 1383 |
|
| 1384 |
$isChargeRefundable = in_array($status, ['OPEN', 'WAITING_FOR_SELLER_RESPONSE']); |
| 1385 |
|
| 1386 |
$transactionModel = $fluentCartTransactions[0]; |
| 1387 |
$transactionModel->update([ |
| 1388 |
'transaction_type' => Status::TRANSACTION_TYPE_DISPUTE, |
| 1389 |
'meta' => array_merge($transactionModel->meta, [ |
| 1390 |
'dispute_id' => $disputeId, |
| 1391 |
'dispute_reason' => $reason, |
| 1392 |
'is_dispute_actionable' => in_array($stage, ['CHARGEBACK', 'REVIEW']), |
| 1393 |
'is_charge_refundable' => $isChargeRefundable, |
| 1394 |
'status' => $status |
| 1395 |
]) |
| 1396 |
]); |
| 1397 |
|
| 1398 |
return true; |
| 1399 |
} |
| 1400 |
|
| 1401 |
public function handleWebhookDisputeUpdated($data) |
| 1402 |
{ |
| 1403 |
$disputeInfo = Arr::get($data, 'dispute', []); |
| 1404 |
$disputeId = Arr::get($disputeInfo, 'dispute_id', ''); |
| 1405 |
if (empty($disputeId)) { |
| 1406 |
return false; |
| 1407 |
} |
| 1408 |
|
| 1409 |
$disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []); |
| 1410 |
|
| 1411 |
if (count($disputedTransactions) > 1) { |
| 1412 |
return false; |
| 1413 |
} |
| 1414 |
|
| 1415 |
$stage = Arr::get($disputeInfo, 'dispute_life_cycle_stage', ''); |
| 1416 |
|
| 1417 |
$fluentCartTransactions = []; |
| 1418 |
foreach ($disputedTransactions as $transaction) { |
| 1419 |
$transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first(); |
| 1420 |
if (!$transactionModel) { |
| 1421 |
continue; |
| 1422 |
} |
| 1423 |
$fluentCartTransactions[] = $transactionModel; |
| 1424 |
} |
| 1425 |
|
| 1426 |
if (empty($fluentCartTransactions)) { |
| 1427 |
return false; |
| 1428 |
} |
| 1429 |
|
| 1430 |
$transactionModel = $fluentCartTransactions[0]; |
| 1431 |
$status = Arr::get($disputeInfo, 'status', ''); |
| 1432 |
$isChargeRefundable = in_array($status, ['OPEN', 'WAITING_FOR_SELLER_RESPONSE']) || in_array($stage, ['CHARGEBACK', 'INQUIRY']); |
| 1433 |
|
| 1434 |
if ($stage === 'CHARGEBACK') { |
| 1435 |
$transactionModel->update([ |
| 1436 |
'meta' => array_merge($transactionModel->meta, [ |
| 1437 |
'is_dispute_actionable' => true, |
| 1438 |
'dispute_status' => Arr::get($disputeInfo, 'status', ''), |
| 1439 |
'is_charge_refundable' => $isChargeRefundable |
| 1440 |
]) |
| 1441 |
]); |
| 1442 |
} else { |
| 1443 |
$transactionModel->update([ |
| 1444 |
'meta' => array_merge($transactionModel->meta, [ |
| 1445 |
'is_dispute_actionable' => false, |
| 1446 |
'dispute_status' => Arr::get($disputeInfo, 'status', ''), |
| 1447 |
'is_charge_refundable' => $isChargeRefundable |
| 1448 |
]) |
| 1449 |
]); |
| 1450 |
} |
| 1451 |
|
| 1452 |
return true; |
| 1453 |
|
| 1454 |
} |
| 1455 |
|
| 1456 |
public function handleWebhookDisputeResolved($data) |
| 1457 |
{ |
| 1458 |
$disputeInfo = Arr::get($data, 'dispute', []); |
| 1459 |
$disputeId = Arr::get($disputeInfo, 'dispute_id', ''); |
| 1460 |
if (empty($disputeId)) { |
| 1461 |
return false; |
| 1462 |
} |
| 1463 |
|
| 1464 |
$disputedTransactions = Arr::get($disputeInfo, 'disputed_transactions', []); |
| 1465 |
if (count($disputedTransactions) > 1) { |
| 1466 |
return false; |
| 1467 |
} |
| 1468 |
|
| 1469 |
$status = Arr::get($disputeInfo, 'status', ''); |
| 1470 |
if ($status !== 'RESOLVED') { |
| 1471 |
return false; |
| 1472 |
} |
| 1473 |
|
| 1474 |
$fluentCartTransactions = []; |
| 1475 |
foreach ($disputedTransactions as $transaction) { |
| 1476 |
$transactionModel = OrderTransaction::query()->where('vendor_charge_id', Arr::get($transaction, 'seller_transaction_id'))->first(); |
| 1477 |
if (!$transactionModel) { |
| 1478 |
continue; |
| 1479 |
} |
| 1480 |
$fluentCartTransactions[] = $transactionModel; |
| 1481 |
} |
| 1482 |
|
| 1483 |
if (empty($fluentCartTransactions)) { |
| 1484 |
return false; |
| 1485 |
} |
| 1486 |
|
| 1487 |
// we are handling disputes only with one transaction - PayPal allow user to select multiple transactions on dispute creation |
| 1488 |
$transactionModel = $fluentCartTransactions[0]; |
| 1489 |
|
| 1490 |
if ($transactionModel->status === Status::TRANSACTION_DISPUTE_LOST) { // already dispute claim accepted via admin dashboard |
| 1491 |
return false; |
| 1492 |
} |
| 1493 |
|
| 1494 |
// 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 |
| 1495 |
$transactionModel->update([ |
| 1496 |
'transaction_type' => Status::TRANSACTION_TYPE_CHARGE, |
| 1497 |
'meta' => array_merge($transactionModel->meta, [ |
| 1498 |
'is_dispute_actionable' => false, |
| 1499 |
'is_charge_refundable' => false, |
| 1500 |
'dispute_status' => $status |
| 1501 |
]) |
| 1502 |
]); |
| 1503 |
|
| 1504 |
return true; |
| 1505 |
} |
| 1506 |
|
| 1507 |
private function getSubscriptionByPaypalSubscriptionInfo($subscriptionInfo = []) |
| 1508 |
{ |
| 1509 |
$id = Arr::get($subscriptionInfo, 'id', ''); |
| 1510 |
if (empty($id)) { |
| 1511 |
return null; |
| 1512 |
} |
| 1513 |
|
| 1514 |
$subscription = Subscription::query()->where('vendor_subscription_id', $id)->first(); |
| 1515 |
|
| 1516 |
if (!$subscription) { |
| 1517 |
$subscriptionHash = Arr::get($subscriptionInfo, 'custom_id', ''); |
| 1518 |
if ($subscriptionHash) { |
| 1519 |
$subscription = Subscription::query()->where('uuid', $subscriptionHash)->first(); |
| 1520 |
} |
| 1521 |
} |
| 1522 |
|
| 1523 |
return $subscription; |
| 1524 |
} |
| 1525 |
|
| 1526 |
|
| 1527 |
private static function getPayPalSettings() |
| 1528 |
{ |
| 1529 |
if (!self::$paypalSettings) { |
| 1530 |
self::$paypalSettings = new PayPalSettingsBase(); |
| 1531 |
} |
| 1532 |
return self::$paypalSettings; |
| 1533 |
} |
| 1534 |
} |
| 1535 |
|