← All changes
|
src/PaymentGateways/PayPalCommerce/PayPalCommerce.php
+115
-31
4.15.3
→
4.17.0
View file →
| @@ -13,8 +13,9 @@ | ||
| 13 | 13 | use Give\Framework\PaymentGateways\PaymentGateway; |
| 14 | 14 | use Give\Framework\Support\ValueObjects\Money; |
| 15 | 15 | use Give\Log\Log; |
| 16 | 16 | use Give\PaymentGateways\PayPalCommerce\Models\MerchantDetail; |
| 17 | +use Give\PaymentGateways\PayPalCommerce\PayPalCheckoutSdk\ProcessorResponseError; | |
| 17 | 18 | |
| 18 | 19 | /** |
| 19 | 20 | * Class PayPalCommerce |
| 20 | 21 | * |
| @@ -80,8 +81,10 @@ | ||
| 80 | 81 | return esc_html__('Credit Card', 'give'); |
| 81 | 82 | } |
| 82 | 83 | |
| 83 | 84 | /** |
| 85 | + * @since 4.16.9 Capture every order here, and validate the captured amount against the donation. | |
| 86 | + * @since 4.16.8.1 Reject a completed order whose amount doesn't match the donation, or whose capture is already recorded against a different donation. | |
| 84 | 87 | * @since 4.2.1 updated to use updateOrderFromDonation |
| 85 | 88 | * @since 4.1.0 updated to include 3D Secure validation |
| 86 | 89 | * @since 4.0.0 updated to update and capture payment |
| 87 | 90 | * @since 2.19.0 |
| @@ -97,29 +100,34 @@ | ||
| 97 | 100 | $payPalOrderRepository = give(Repositories\PayPalOrder::class); |
| 98 | 101 | |
| 99 | 102 | $payPalOrder = $payPalOrderRepository->getApprovedOrder($payPalOrderId); |
| 100 | 103 | |
| 101 | - if ($payPalOrder->status === 'COMPLETED') { | |
| 102 | - $this->validatePayPalOrder($payPalOrder); | |
| 104 | + /* | |
| 105 | + * An order is only ever captured here, so one that is already captured belongs to another | |
| 106 | + * donation and cannot pay for this one. PayPal captures an order once and answers any | |
| 107 | + * further attempt with an error, which is what limits a capture to a single donation. | |
| 108 | + */ | |
| 109 | + if ($payPalOrder->status !== 'APPROVED' && $payPalOrder->status !== 'CREATED') { | |
| 110 | + throw new PaymentGatewayException(__('PayPal Order is not ready to be captured.', 'give')); | |
| 111 | + } | |
| 103 | 112 | |
| 104 | - $transactionId = $payPalOrder->purchase_units[0]->payments->captures[0]->id; | |
| 113 | + $this->validate3dSecure($payPalOrder); | |
| 105 | 114 | |
| 106 | - } elseif ($payPalOrder->status === 'APPROVED' || $payPalOrder->status === 'CREATED') { | |
| 107 | - $this->validate3dSecure($payPalOrder); | |
| 115 | + if ($this->shouldUpdateOrder($donation, $payPalOrder)) { | |
| 116 | + $payPalOrderRepository->updateOrderFromDonation($payPalOrderId, $donation); | |
| 117 | + } | |
| 108 | 118 | |
| 109 | - if ($this->shouldUpdateOrder($donation, $payPalOrder)){ | |
| 110 | - $payPalOrderRepository->updateOrderFromDonation($payPalOrderId, $donation); | |
| 111 | - } | |
| 112 | - | |
| 113 | - // ready to capture order, response is the updated PayPal order. | |
| 119 | + // ready to capture order, response is the updated PayPal order. | |
| 120 | + try { | |
| 114 | 121 | $response = $payPalOrderRepository->approveOrder($payPalOrderId); |
| 122 | + } catch (Exception $exception) { | |
| 123 | + throw new PaymentGatewayException($this->getCaptureFailureMessage($exception)); | |
| 124 | + } | |
| 115 | 125 | |
| 116 | - $this->validatePayPalOrder($response); | |
| 126 | + $this->validatePayPalOrder($response); | |
| 127 | + $this->validateCapturedAmountMatchesDonation($response, $donation); | |
| 117 | 128 | |
| 118 | - $transactionId = $response->purchase_units[0]->payments->captures[0]->id; | |
| 119 | - } else { | |
| 120 | - throw new PaymentGatewayException('PayPal Order status is not found.'); | |
| 121 | - } | |
| 129 | + $transactionId = $response->purchase_units[0]->payments->captures[0]->id; | |
| 122 | 130 | |
| 123 | 131 | give()->payment_meta->update_meta( |
| 124 | 132 | $donation->id, |
| 125 | 133 | '_give_order_id', |
| @@ -290,14 +298,23 @@ | ||
| 290 | 298 | return apply_filters('give_get_settings_paypal_commerce', $settings); |
| 291 | 299 | } |
| 292 | 300 | |
| 293 | 301 | /** |
| 302 | + * @since 4.16.8.1 Guard against a truncated PayPal response that would otherwise fatal on property access. | |
| 294 | 303 | * @since 4.0.0 |
| 304 | + * | |
| 305 | + * @throws PaymentGatewayException | |
| 295 | 306 | */ |
| 296 | 307 | private function shouldUpdateOrder(Donation $donation, $payPalOrder): bool |
| 297 | 308 | { |
| 298 | - $orderAmount = $payPalOrder->purchase_units[0]->amount->value; | |
| 299 | - $orderCurrency = $payPalOrder->purchase_units[0]->amount->currency_code; | |
| 309 | + $purchaseUnit = $payPalOrder->purchase_units[0] ?? null; | |
| 310 | + | |
| 311 | + if (! isset($purchaseUnit->amount->value, $purchaseUnit->amount->currency_code)) { | |
| 312 | + throw new PaymentGatewayException('PayPal Order does not have an amount.'); | |
| 313 | + } | |
| 314 | + | |
| 315 | + $orderAmount = $purchaseUnit->amount->value; | |
| 316 | + $orderCurrency = $purchaseUnit->amount->currency_code; | |
| 300 | 317 | $currentOrderAmount = Money::fromDecimal($orderAmount, $orderCurrency); |
| 301 | 318 | |
| 302 | 319 | if (!$currentOrderAmount->equals($donation->amount)) { |
| 303 | 320 | Log::error( |
| @@ -314,36 +331,103 @@ | ||
| 314 | 331 | return false; |
| 315 | 332 | } |
| 316 | 333 | |
| 317 | 334 | /** |
| 335 | + * shouldUpdateOrder() patches the order to the donation amount before the capture, but PayPal | |
| 336 | + * is what finally decides how much was taken, so the captured amount is compared to the | |
| 337 | + * donation rather than assumed to match. | |
| 338 | + * | |
| 339 | + * @since 4.16.9 | |
| 340 | + * | |
| 318 | 341 | * @throws PaymentGatewayException |
| 319 | 342 | */ |
| 343 | + private function validateCapturedAmountMatchesDonation(object $payPalOrder, Donation $donation): void | |
| 344 | + { | |
| 345 | + $capture = $payPalOrder->purchase_units[0]->payments->captures[0] ?? null; | |
| 346 | + | |
| 347 | + if (! isset($capture->amount->value, $capture->amount->currency_code)) { | |
| 348 | + throw new PaymentGatewayException(__('PayPal capture does not have an amount.', 'give')); | |
| 349 | + } | |
| 350 | + | |
| 351 | + $capturedAmount = Money::fromDecimal($capture->amount->value, $capture->amount->currency_code); | |
| 352 | + | |
| 353 | + if (!$capturedAmount->equals($donation->amount)) { | |
| 354 | + Log::error( | |
| 355 | + sprintf( | |
| 356 | + 'Captured PayPal amount does not match donation amount. PayPal Order ID: %s, Donation ID: %s', | |
| 357 | + $payPalOrder->id, | |
| 358 | + $donation->id | |
| 359 | + ) | |
| 360 | + ); | |
| 361 | + | |
| 362 | + throw new PaymentGatewayException( | |
| 363 | + __('Captured PayPal amount does not match donation amount.', 'give') | |
| 364 | + ); | |
| 365 | + } | |
| 366 | + } | |
| 367 | + | |
| 368 | + /** | |
| 369 | + * PayPal refuses a capture — most often a declined card — with an HTTP error whose body carries | |
| 370 | + * the reason, which the SDK raises as a plain exception. A donor is only shown the message of a | |
| 371 | + * PaymentGatewayException, so the reason is read out here rather than left in the log. | |
| 372 | + * | |
| 373 | + * @since 4.16.9 | |
| 374 | + */ | |
| 375 | + private function getCaptureFailureMessage(Exception $exception): string | |
| 376 | + { | |
| 377 | + $response = json_decode($exception->getMessage()); | |
| 378 | + $issue = $response->details[0]->issue ?? ''; | |
| 379 | + $description = $response->details[0]->description ?? ''; | |
| 380 | + | |
| 381 | + if ($issue === 'INSTRUMENT_DECLINED') { | |
| 382 | + return __('The payment method was declined. Please try another card or payment method.', 'give'); | |
| 383 | + } | |
| 384 | + | |
| 385 | + return $description ?: __('PayPal was unable to complete the payment.', 'give'); | |
| 386 | + } | |
| 387 | + | |
| 388 | + /** | |
| 389 | + * @since 4.16.9 Read the capture defensively, and report a failed or declined capture's processor response. | |
| 390 | + * | |
| 391 | + * @throws PaymentGatewayException | |
| 392 | + */ | |
| 320 | 393 | private function validatePayPalOrder(object $payPalOrder): void |
| 321 | 394 | { |
| 322 | - $transaction = $payPalOrder->purchase_units[0]->payments->captures[0]; | |
| 395 | + $transaction = $payPalOrder->purchase_units[0]->payments->captures[0] ?? null; | |
| 323 | 396 | |
| 324 | - $errors = property_exists($payPalOrder, 'details') ? $payPalOrder->details[0] : []; | |
| 397 | + if (! $transaction) { | |
| 398 | + throw new PaymentGatewayException(__('PayPal Order does not have a transaction.', 'give')); | |
| 399 | + } | |
| 325 | 400 | |
| 326 | - if (!$transaction) { | |
| 327 | - throw new PaymentGatewayException('PayPal Order does not have a transaction.'); | |
| 328 | - } | |
| 401 | + /* | |
| 402 | + * An invalid CVV or a failed AVS check is reported in the capture's processor response | |
| 403 | + * rather than as a PayPal error, so the reason is read from there when there is one. The | |
| 404 | + * response is only passed on when PayPal sent an object, since it is typed as one. It is | |
| 405 | + * added to the refusal rather than used in its place, because the same code map spells out | |
| 406 | + * the checks that passed too — on its own it can read as though nothing went wrong. | |
| 407 | + */ | |
| 408 | + if (in_array($transaction->status, ['DECLINED', 'FAILED'], true)) { | |
| 409 | + $hasProcessorResponse = isset($transaction->processor_response) | |
| 410 | + && $transaction->processor_response instanceof \stdClass; | |
| 329 | 411 | |
| 330 | - if ($transaction->status === "DECLINED") { | |
| 331 | - $errorMessage = sprintf( | |
| 412 | + $processorError = $hasProcessorResponse | |
| 413 | + ? ProcessorResponseError::getError($transaction->processor_response) | |
| 414 | + : ''; | |
| 415 | + | |
| 416 | + $message = sprintf( | |
| 332 | 417 | __('PayPal Order has been declined. Transaction status:: %s', 'give'), |
| 333 | 418 | $transaction->status |
| 334 | 419 | ); |
| 335 | 420 | |
| 336 | - throw new PaymentGatewayException($errorMessage); | |
| 421 | + throw new PaymentGatewayException(trim($message . ' ' . $processorError)); | |
| 337 | 422 | } |
| 338 | 423 | |
| 339 | - if (!empty($errors)) { | |
| 340 | - $errorMessage = sprintf( | |
| 341 | - __('PayPal Order has an error: %s', 'give'), | |
| 342 | - $errors->issue[0]->description | |
| 424 | + $error = $payPalOrder->details[0]->description ?? ''; | |
| 425 | + | |
| 426 | + if ($error) { | |
| 427 | + throw new PaymentGatewayException( | |
| 428 | + sprintf(__('PayPal Order has an error: %s', 'give'), $error) | |
| 343 | 429 | ); |
| 344 | - | |
| 345 | - throw new PaymentGatewayException($errorMessage); | |
| 346 | 430 | } |
| 347 | 431 | |
| 348 | 432 | $this->validate3dSecure($payPalOrder); |
| 349 | 433 | } |