PluginProbe
GiveWP – Donation Plugin and Fundraising Platform / 4.17.0
GiveWP – Donation Plugin and Fundraising Platform v4.17.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 2.3.2 All 256 releases
← 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 }