PluginProbe
GiveWP – Donation Plugin and Fundraising Platform / 4.18.0
GiveWP – Donation Plugin and Fundraising Platform v4.18.0
4.18.0 4.17.0 4.16.9 4.16.8.1 4.16.8 4.16.7.2 4.16.7.1 4.16.7 4.16.6.1 4.16.6 4.16.5.1 4.16.5 4.16.4 4.16.3 4.16.2 4.16.1 4.16.0 4.15.5 4.15.4 4.15.3 4.15.2 4.15.1 4.15.0 2.3.0 2.3.1 All 257 releases
← 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 }