← All changes
|
src/PaymentGateways/PayPalCommerce/PayPalCommerce.php
+75
-70
4.16.8.1
→
4.18.0
View file →
| @@ -4,9 +4,8 @@ | ||
| 4 | 4 | |
| 5 | 5 | use Exception; |
| 6 | 6 | use Give\Donations\Models\Donation; |
| 7 | 7 | use Give\Donations\Models\DonationNote; |
| 8 | -use Give\Donations\Repositories\DonationRepository; | |
| 9 | 8 | use Give\Framework\PaymentGateways\Commands\GatewayCommand; |
| 10 | 9 | use Give\Framework\PaymentGateways\Commands\PaymentComplete; |
| 11 | 10 | use Give\Framework\PaymentGateways\Commands\PaymentRefunded; |
| 12 | 11 | use Give\Framework\PaymentGateways\Contracts\PaymentGatewayRefundable; |
| @@ -14,8 +13,9 @@ | ||
| 14 | 13 | use Give\Framework\PaymentGateways\PaymentGateway; |
| 15 | 14 | use Give\Framework\Support\ValueObjects\Money; |
| 16 | 15 | use Give\Log\Log; |
| 17 | 16 | use Give\PaymentGateways\PayPalCommerce\Models\MerchantDetail; |
| 17 | +use Give\PaymentGateways\PayPalCommerce\PayPalCheckoutSdk\ProcessorResponseError; | |
| 18 | 18 | |
| 19 | 19 | /** |
| 20 | 20 | * Class PayPalCommerce |
| 21 | 21 | * |
| @@ -81,8 +81,9 @@ | ||
| 81 | 81 | return esc_html__('Credit Card', 'give'); |
| 82 | 82 | } |
| 83 | 83 | |
| 84 | 84 | /** |
| 85 | + * @since 4.16.9 Capture every order here, and validate the captured amount against the donation. | |
| 85 | 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. |
| 86 | 87 | * @since 4.2.1 updated to use updateOrderFromDonation |
| 87 | 88 | * @since 4.1.0 updated to include 3D Secure validation |
| 88 | 89 | * @since 4.0.0 updated to update and capture payment |
| @@ -99,32 +100,34 @@ | ||
| 99 | 100 | $payPalOrderRepository = give(Repositories\PayPalOrder::class); |
| 100 | 101 | |
| 101 | 102 | $payPalOrder = $payPalOrderRepository->getApprovedOrder($payPalOrderId); |
| 102 | 103 | |
| 103 | - if ($payPalOrder->status === 'COMPLETED') { | |
| 104 | - $this->validatePayPalOrder($payPalOrder); | |
| 105 | - $this->validateCompletedOrderAmountMatchesDonation($payPalOrder, $donation); | |
| 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 | + } | |
| 106 | 112 | |
| 107 | - $transactionId = $payPalOrder->purchase_units[0]->payments->captures[0]->id; | |
| 113 | + $this->validate3dSecure($payPalOrder); | |
| 108 | 114 | |
| 109 | - $this->validateCaptureNotAlreadyRecorded($transactionId, $donation); | |
| 115 | + if ($this->shouldUpdateOrder($donation, $payPalOrder)) { | |
| 116 | + $payPalOrderRepository->updateOrderFromDonation($payPalOrderId, $donation); | |
| 117 | + } | |
| 110 | 118 | |
| 111 | - } elseif ($payPalOrder->status === 'APPROVED' || $payPalOrder->status === 'CREATED') { | |
| 112 | - $this->validate3dSecure($payPalOrder); | |
| 113 | - | |
| 114 | - if ($this->shouldUpdateOrder($donation, $payPalOrder)){ | |
| 115 | - $payPalOrderRepository->updateOrderFromDonation($payPalOrderId, $donation); | |
| 116 | - } | |
| 117 | - | |
| 118 | - // ready to capture order, response is the updated PayPal order. | |
| 119 | + // ready to capture order, response is the updated PayPal order. | |
| 120 | + try { | |
| 119 | 121 | $response = $payPalOrderRepository->approveOrder($payPalOrderId); |
| 122 | + } catch (Exception $exception) { | |
| 123 | + throw new PaymentGatewayException($this->getCaptureFailureMessage($exception)); | |
| 124 | + } | |
| 120 | 125 | |
| 121 | - $this->validatePayPalOrder($response); | |
| 126 | + $this->validatePayPalOrder($response); | |
| 127 | + $this->validateCapturedAmountMatchesDonation($response, $donation); | |
| 122 | 128 | |
| 123 | - $transactionId = $response->purchase_units[0]->payments->captures[0]->id; | |
| 124 | - } else { | |
| 125 | - throw new PaymentGatewayException('PayPal Order status is not found.'); | |
| 126 | - } | |
| 129 | + $transactionId = $response->purchase_units[0]->payments->captures[0]->id; | |
| 127 | 130 | |
| 128 | 131 | give()->payment_meta->update_meta( |
| 129 | 132 | $donation->id, |
| 130 | 133 | '_give_order_id', |
| @@ -328,101 +331,103 @@ | ||
| 328 | 331 | return false; |
| 329 | 332 | } |
| 330 | 333 | |
| 331 | 334 | /** |
| 332 | - * A completed order's amount cannot be reconciled the way shouldUpdateOrder() does for an | |
| 333 | - * order still pending capture, so a mismatch here is rejected outright. | |
| 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. | |
| 334 | 338 | * |
| 335 | - * @since 4.16.8.1 | |
| 339 | + * @since 4.16.9 | |
| 336 | 340 | * |
| 337 | 341 | * @throws PaymentGatewayException |
| 338 | 342 | */ |
| 339 | - private function validateCompletedOrderAmountMatchesDonation(object $payPalOrder, Donation $donation): void | |
| 343 | + private function validateCapturedAmountMatchesDonation(object $payPalOrder, Donation $donation): void | |
| 340 | 344 | { |
| 341 | - $purchaseUnit = $payPalOrder->purchase_units[0] ?? null; | |
| 345 | + $capture = $payPalOrder->purchase_units[0]->payments->captures[0] ?? null; | |
| 342 | 346 | |
| 343 | - if (! isset($purchaseUnit->amount->value, $purchaseUnit->amount->currency_code)) { | |
| 344 | - throw new PaymentGatewayException('PayPal Order does not have an amount.'); | |
| 347 | + if (! isset($capture->amount->value, $capture->amount->currency_code)) { | |
| 348 | + throw new PaymentGatewayException(__('PayPal capture does not have an amount.', 'give')); | |
| 345 | 349 | } |
| 346 | 350 | |
| 347 | - $orderAmount = $purchaseUnit->amount->value; | |
| 348 | - $orderCurrency = $purchaseUnit->amount->currency_code; | |
| 349 | - $completedOrderAmount = Money::fromDecimal($orderAmount, $orderCurrency); | |
| 351 | + $capturedAmount = Money::fromDecimal($capture->amount->value, $capture->amount->currency_code); | |
| 350 | 352 | |
| 351 | - if (!$completedOrderAmount->equals($donation->amount)) { | |
| 353 | + if (!$capturedAmount->equals($donation->amount)) { | |
| 352 | 354 | Log::error( |
| 353 | 355 | sprintf( |
| 354 | - 'Completed PayPal Order amount does not match donation amount. PayPal Order ID: %s, Donation ID: %s', | |
| 356 | + 'Captured PayPal amount does not match donation amount. PayPal Order ID: %s, Donation ID: %s', | |
| 355 | 357 | $payPalOrder->id, |
| 356 | 358 | $donation->id |
| 357 | 359 | ) |
| 358 | 360 | ); |
| 359 | 361 | |
| 360 | - throw new PaymentGatewayException('PayPal Order amount does not match donation amount.'); | |
| 362 | + throw new PaymentGatewayException( | |
| 363 | + __('Captured PayPal amount does not match donation amount.', 'give') | |
| 364 | + ); | |
| 361 | 365 | } |
| 362 | 366 | } |
| 363 | 367 | |
| 364 | 368 | /** |
| 365 | - * @since 4.16.8.1 | |
| 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. | |
| 366 | 372 | * |
| 367 | - * @throws PaymentGatewayException | |
| 373 | + * @since 4.16.9 | |
| 368 | 374 | */ |
| 369 | - private function validateCaptureNotAlreadyRecorded(string $transactionId, Donation $donation): void | |
| 375 | + private function getCaptureFailureMessage(Exception $exception): string | |
| 370 | 376 | { |
| 371 | - /** | |
| 372 | - * Guard against replaying the same capture for another donation; allow | |
| 373 | - * retry of the same donation. Note: not atomic with PaymentComplete save | |
| 374 | - * — concurrent replays could both pass; a UNIQUE DB constraint is the | |
| 375 | - * future hardening for that race. | |
| 376 | - */ | |
| 377 | - $existingDonation = give(DonationRepository::class) | |
| 378 | - ->queryByGatewayTransactionId($transactionId) | |
| 379 | - ->where('ID', $donation->id, '!=') | |
| 380 | - ->get(); | |
| 377 | + $response = json_decode($exception->getMessage()); | |
| 378 | + $issue = $response->details[0]->issue ?? ''; | |
| 379 | + $description = $response->details[0]->description ?? ''; | |
| 381 | 380 | |
| 382 | - if ($existingDonation) { | |
| 383 | - Log::error( | |
| 384 | - sprintf( | |
| 385 | - 'PayPal capture is already recorded against a different donation. Capture ID: %s, Donation ID: %s, Existing Donation ID: %s', | |
| 386 | - $transactionId, | |
| 387 | - $donation->id, | |
| 388 | - $existingDonation->id | |
| 389 | - ) | |
| 390 | - ); | |
| 381 | + if ($issue === 'INSTRUMENT_DECLINED') { | |
| 382 | + return __('The payment method was declined. Please try another card or payment method.', 'give'); | |
| 383 | + } | |
| 391 | 384 | |
| 392 | - throw new PaymentGatewayException('This PayPal transaction has already been recorded for another donation.'); | |
| 393 | - } | |
| 385 | + return $description ?: __('PayPal was unable to complete the payment.', 'give'); | |
| 394 | 386 | } |
| 395 | 387 | |
| 396 | 388 | /** |
| 389 | + * @since 4.16.9 Read the capture defensively, and report a failed or declined capture's processor response. | |
| 390 | + * | |
| 397 | 391 | * @throws PaymentGatewayException |
| 398 | 392 | */ |
| 399 | 393 | private function validatePayPalOrder(object $payPalOrder): void |
| 400 | 394 | { |
| 401 | - $transaction = $payPalOrder->purchase_units[0]->payments->captures[0]; | |
| 395 | + $transaction = $payPalOrder->purchase_units[0]->payments->captures[0] ?? null; | |
| 402 | 396 | |
| 403 | - $errors = property_exists($payPalOrder, 'details') ? $payPalOrder->details[0] : []; | |
| 397 | + if (! $transaction) { | |
| 398 | + throw new PaymentGatewayException(__('PayPal Order does not have a transaction.', 'give')); | |
| 399 | + } | |
| 404 | 400 | |
| 405 | - if (!$transaction) { | |
| 406 | - throw new PaymentGatewayException('PayPal Order does not have a transaction.'); | |
| 407 | - } | |
| 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; | |
| 408 | 411 | |
| 409 | - if ($transaction->status === "DECLINED") { | |
| 410 | - $errorMessage = sprintf( | |
| 412 | + $processorError = $hasProcessorResponse | |
| 413 | + ? ProcessorResponseError::getError($transaction->processor_response) | |
| 414 | + : ''; | |
| 415 | + | |
| 416 | + $message = sprintf( | |
| 411 | 417 | __('PayPal Order has been declined. Transaction status:: %s', 'give'), |
| 412 | 418 | $transaction->status |
| 413 | 419 | ); |
| 414 | 420 | |
| 415 | - throw new PaymentGatewayException($errorMessage); | |
| 421 | + throw new PaymentGatewayException(trim($message . ' ' . $processorError)); | |
| 416 | 422 | } |
| 417 | 423 | |
| 418 | - if (!empty($errors)) { | |
| 419 | - $errorMessage = sprintf( | |
| 420 | - __('PayPal Order has an error: %s', 'give'), | |
| 421 | - $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) | |
| 422 | 429 | ); |
| 423 | - | |
| 424 | - throw new PaymentGatewayException($errorMessage); | |
| 425 | 430 | } |
| 426 | 431 | |
| 427 | 432 | $this->validate3dSecure($payPalOrder); |
| 428 | 433 | } |