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/AjaxRequestHandler.php +100 -89 4.15.1 → 4.17.0 View file →
@@ -1,10 +1,13 @@
1 1 <?php
2 2
3 3 namespace Give\PaymentGateways\PayPalCommerce;
4 4
5 +use Give\DonationForms\Actions\ValidateDonationFormRequest;
6 +use Give\DonationForms\Exceptions\DonationFormFieldErrorsException;
7 +use Give\DonationForms\Exceptions\DonationFormForbidden;
8 +use Give\Log\Log;
5 9 use Give\PaymentGateways\PayPalCommerce\Models\MerchantDetail;
6 -use Give\PaymentGateways\PayPalCommerce\PayPalCheckoutSdk\ProcessorResponseError;
7 10 use Give\PaymentGateways\PayPalCommerce\Repositories\MerchantDetails;
8 11 use Give\PaymentGateways\PayPalCommerce\Repositories\PayPalAuth;
9 12 use Give\PaymentGateways\PayPalCommerce\Repositories\PayPalOrder;
10 13 use Give\PaymentGateways\PayPalCommerce\Repositories\Settings;
@@ -262,10 +265,13 @@
262 265 }
263 266 }
264 267
265 268 /**
269 + * @since 4.16.7.1 Validate the request through the form layer before building order data. v3 forms must
270 + * also send a total at least as large as the amount the form validated; v2 forms are
271 + * checked on the final, post-filter amount.
266 272 * @since 4.14.4 Validate donation amount before creating or updating an order.
267 - * @since 4.2.1 Only filter amount for v2 forms.
273 + * @since 4.2.1 Only filter amount for v2 forms.
268 274 * @since 3.4.2
269 275 */
270 276 private function getOrderData(): array
271 277 {
@@ -273,11 +279,28 @@
273 279 $formId = absint($postData['give-form-id']);
274 280 $donorAddress = $this->getDonorAddressFromPostedDataForPaypalOrder($postData);
275 281 $isV3Form = FormUtils::isV3Form($formId);
276 282
283 + if (!$isV3Form) {
284 + $this->skipLegacyCardFieldRequirements();
285 + }
286 +
287 + $this->validateDonationFormRequest($formId, $postData);
288 +
277 289 if ($isV3Form) {
278 - // if coming from v3 forms, fee recovery is already included in the amount and should not be filtered.
290 + /*
291 + * v3 forms send the form's own amount field as "amount" and the total, with fee recovery
292 + * already included, as "give-amount". The total is what the donor approves in the PayPal
293 + * popup; PayPalCommerce::createPayment() reconciles the order to the validated donation
294 + * before capturing, so all this has to guarantee is that the total never drops below the
295 + * amount the form just validated.
296 + */
297 + $validatedAmount = isset($postData['amount']) ? (float)$postData['amount'] : 0.0;
279 298 $amount = isset($postData['give-amount']) ? give_clean($postData['give-amount']) : '0.00';
299 +
300 + if ($validatedAmount <= 0 || (float)$amount < $validatedAmount) {
301 + wp_send_json_error(['error' => __('Invalid donation amount.', 'give')]);
302 + }
280 303 } else {
281 304 $amount = isset($postData['give-amount']) ?
282 305 (float)apply_filters(
283 306 'give_donation_total',
@@ -286,12 +309,12 @@
286 309 ['currency' => give_get_currency($formId)]
287 310 )
288 311 ) :
289 312 '0.00';
313 +
314 + $this->validateDonationAmount($amount, $formId);
290 315 }
291 316
292 - $this->validateDonationAmount($amount, $formId);
293 -
294 317 return [
295 318 'formId' => $formId,
296 319 'formTitle' => give_payment_gateway_item_title(['post_data' => $postData], 127),
297 320 'donationAmount' => $amount,
@@ -304,59 +327,43 @@
304 327 ];
305 328 }
306 329
307 330 /**
308 - * Approve order.
331 + * Refuses every request. Both form versions now send their order id with the donation and let
332 + * PayPalCommerce::createPayment() capture it, so nothing legitimate captures from the browser.
333 + * The endpoint stays registered so anything still calling it receives an error it can report,
334 + * rather than an empty response from a missing action.
309 335 *
310 - * @todo: handle payment capture error on frontend.
311 - *
312 - * @since 4.14.4 Validate donation amount before approving an order.
336 + * @since 4.16.9 Refuse every request; the capture for both form versions happens in PayPalCommerce::createPayment().
337 + * @since 4.16.7.1 Refuse v3 forms; their capture happens in PayPalCommerce::createPayment(). Validate
338 + * the posted form before every capture, not only when the amount changed.
339 + * @since 4.14.4 Validate donation amount before approving an order.
313 340 * @since 3.2.0 Discover error by checking capture status.
314 341 * @since 2.9.0
315 342 */
316 343 public function approveOrder()
317 344 {
318 - $this->validateFrontendRequest();
319 -
320 - $orderId = give_clean($_GET['order']);
321 - $updateAmount = filter_var(give_clean($_GET['update_amount']), FILTER_VALIDATE_BOOLEAN);
322 -
323 - try {
324 - if ($updateAmount) {
325 - $orderData = $this->getOrderData();
326 - $this->validateOrderAmountNotDecreased($orderId, $orderData['donationAmount']);
327 - give(PayPalOrder::class)->updateOrderAmount($orderId, $orderData);
328 - }
329 -
330 - $result = give(PayPalOrder::class)->approveOrder($orderId);
331 - // PayPal does not return error in case of invalid cvv. So we need to check capture status and return error.
332 - // ref - https://feedback.givewp.com/bug-reports/p/paypal-credit-card-donations-can-generate-a-fatal-error
333 - $this->returnErrorOnFailedApproveOrderResponse($result);
334 - wp_send_json_success(['order' => $result,]);
335 - } catch (\Exception $ex) {
336 - wp_send_json_error(['error' => json_decode($ex->getMessage(), true),]);
337 - }
345 + wp_send_json_error(
346 + ['error' => __('PayPal orders are captured when the donation is submitted.', 'give')]
347 + );
338 348 }
339 349
340 350 /**
351 + * Refuses every request. The order amount is reconciled against the donation in
352 + * PayPalCommerce::createPayment() before the capture, so no form version needs the browser to
353 + * change an order's amount. The endpoint stays registered for the same reason approveOrder()
354 + * does: a caller gets an error it can report rather than an empty response.
355 + *
356 + * @since 4.16.9 Refuse every request; the order amount is reconciled in PayPalCommerce::createPayment().
357 + * @since 4.16.7.1 Refuse v3 forms; PayPalCommerce::createPayment() reconciles their order amount.
341 358 * @since 4.14.4 Validate donation amount before updating an order amount.
342 359 * @since 3.4.2
343 360 */
344 361 public function updateOrderAmount()
345 362 {
346 - $this->validateFrontendRequest();
347 -
348 - $orderId = give_clean($_GET['order']);
349 -
350 - try {
351 - $orderData = $this->getOrderData();
352 - $this->validateOrderAmountNotDecreased($orderId, $orderData['donationAmount']);
353 - give(PayPalOrder::class)->updateOrderAmount($orderId, $orderData);
354 -
355 - wp_send_json_success(['order' => $orderId,]);
356 - } catch (\Exception $ex) {
357 - wp_send_json_error(['error' => json_decode($ex->getMessage(), true),]);
358 - }
363 + wp_send_json_error(
364 + ['error' => __('PayPal order amounts are reconciled when the donation is submitted.', 'give')]
365 + );
359 366 }
360 367
361 368 /**
362 369 * Return on boarding trouble notice.
@@ -423,14 +430,63 @@
423 430 }
424 431 }
425 432
426 433 /**
427 - * Validate the donation amount against the form's configured maximum and that it is positive.
434 + * Hold the request to the form's own rules before anything reaches PayPal. The form layer owns
435 + * the rules: amount limits, required fields, and whatever else it validates for this form
436 + * version; this handler only acts on the verdict.
428 437 *
438 + * @since 4.16.7.1
439 + */
440 + private function validateDonationFormRequest(int $formId, array $request): void
441 + {
442 + try {
443 + give(ValidateDonationFormRequest::class)($formId, $request);
444 + } catch (DonationFormFieldErrorsException $exception) {
445 + wp_send_json_error(['error' => implode(' ', $exception->getError()->get_error_messages())]);
446 + } catch (DonationFormForbidden $exception) {
447 + wp_send_json_error(['error' => $exception->getMessage()], 403);
448 + } catch (\Exception $exception) {
449 + /*
450 + * Anything else the form layer throws (a spam detection, for one) still means "do not
451 + * create this order". Same handling as the validate route, log entry included.
452 + */
453 + Log::error('PayPal Commerce order request rejected', [
454 + 'formId' => $formId,
455 + 'exception' => get_class($exception),
456 + 'message' => $exception->getMessage(),
457 + ]);
458 +
459 + wp_send_json_error(['error' => $exception->getMessage()]);
460 + }
461 + }
462 +
463 + /**
464 + * The v2 form posts its card inputs along with everything else, but for this gateway those inputs
465 + * are PayPal-hosted fields (SmartButtons.js strips them before its own validation call for the
466 + * same reason), so the legacy validator must not require them here.
467 + *
468 + * @since 4.16.7.1
469 + */
470 + private function skipLegacyCardFieldRequirements(): void
471 + {
472 + add_filter('give_donation_form_required_fields', static function ($requiredFields) {
473 + return array_diff_key(
474 + (array)$requiredFields,
475 + array_flip(['card_name', 'card_number', 'card_cvc', 'card_expiry'])
476 + );
477 + });
478 + }
479 +
480 + /**
481 + * The legacy validator checks the raw posted amount. The amount that actually reaches PayPal for
482 + * a v2 form has been through the give_donation_total filter (fee recovery), so it is checked
483 + * again here: positive and within the form's maximum. v3 amounts are validated by the form layer.
484 + *
485 + * @since 4.16.7.1 Applies to v2 forms only.
429 486 * @since 4.14.4
430 487 *
431 488 * @param float|string $amount
432 - * @param int $formId
433 489 */
434 490 private function validateDonationAmount($amount, int $formId): void
435 491 {
436 492 $amount = (float)$amount;
@@ -451,30 +507,8 @@
451 507 }
452 508 }
453 509
454 510 /**
455 - * Validate that the new donation amount is not less than the original PayPal order amount.
456 - *
457 - * @since 4.14.4
458 - *
459 - * @param string $orderId
460 - * @param float|string $newAmount
461 - */
462 - private function validateOrderAmountNotDecreased(string $orderId, $newAmount): void
463 - {
464 - $newAmount = (float)$newAmount;
465 -
466 - $currentOrder = give(PayPalOrder::class)->getApprovedOrder($orderId);
467 - $currentAmount = (float)$currentOrder->purchase_units[0]->amount->value;
468 -
469 - if ($newAmount < $currentAmount) {
470 - wp_send_json_error([
471 - 'error' => __('Donation amount cannot be decreased.', 'give'),
472 - ]);
473 - }
474 - }
475 -
476 - /**
477 511 * This function should return address array in PayPal rest api accepted format.
478 512 *
479 513 * @since 3.1.0 Return address only if setting enabled and has valida country in PayPal accepted formatted.
480 514 * @since 2.11.1
@@ -494,28 +528,5 @@
494 528
495 529 return $address;
496 530 }
497 531
498 - /**
499 - * This function should validate PayPal ApproveOrder response and respond to ajax request on error.
500 - *
501 - * @since 3.2.0
502 - */
503 - private function returnErrorOnFailedApproveOrderResponse(\stdClass $response)
504 - {
505 - // Get capture.
506 - // ref - https://developer.paypal.com/docs/api/orders/v2/#orders_capture
507 - $capture = $response->purchase_units[0]->payments->captures[0];
508 -
509 - // Check if capture status is failed or declined.
510 - if (
511 - in_array($capture->status, ['FAILED', 'DECLINED'])
512 - && property_exists($capture, 'processor_response')
513 - ) {
514 - $error = ProcessorResponseError::getError($capture->processor_response);
515 -
516 - if ($error) {
517 - wp_send_json_error(['error' => $error]);
518 - }
519 - }
520 - }
521 532 }