← 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 | } |