PluginProbe
GiveWP – Donation Plugin and Fundraising Platform / 4.16.9
GiveWP – Donation Plugin and Fundraising Platform v4.16.9
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 2.30.0 All 255 releases
give / src / PaymentGateways / PayPalCommerce / AjaxRequestHandler.php

AjaxRequestHandler.php in GiveWP – Donation Plugin and Fundraising Platform 4.16.9, at src/PaymentGateways/PayPalCommerce/AjaxRequestHandler.php

533 lines 18.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Give\PaymentGateways\PayPalCommerce;
4
5 use Give\DonationForms\Actions\ValidateDonationFormRequest;
6 use Give\DonationForms\Exceptions\DonationFormFieldErrorsException;
7 use Give\DonationForms\Exceptions\DonationFormForbidden;
8 use Give\Log\Log;
9 use Give\PaymentGateways\PayPalCommerce\Models\MerchantDetail;
10 use Give\PaymentGateways\PayPalCommerce\Repositories\MerchantDetails;
11 use Give\PaymentGateways\PayPalCommerce\Repositories\PayPalAuth;
12 use Give\PaymentGateways\PayPalCommerce\Repositories\PayPalOrder;
13 use Give\PaymentGateways\PayPalCommerce\Repositories\Settings;
14 use Give\PaymentGateways\PayPalCommerce\Repositories\Webhooks;
15 use Give\Helpers\Form\Utils as FormUtils;
16
17 /**
18 * Class AjaxRequestHandler
19 * @package Give\PaymentGateways\PaypalCommerce
20 *
21 * @sicne 2.9.0
22 */
23 class AjaxRequestHandler
24 {
25 /**
26 * @since 2.9.0
27 *
28 * @var Webhooks
29 */
30 private $webhooksRepository;
31
32 /**
33 * @since 2.9.0
34 *
35 * @var MerchantDetail
36 */
37 private $merchantDetails;
38
39 /**
40 * @since 2.9.0
41 *
42 * @var PayPalAuth
43 */
44 private $payPalAuth;
45
46 /**
47 * @since 2.9.0
48 *
49 * @var MerchantDetails
50 */
51 private $merchantRepository;
52
53 /**
54 * @since 2.9.0
55 *
56 * @var RefreshToken
57 */
58 private $refreshToken;
59
60 /**
61 * @since 2.9.0
62 *
63 * @var Settings
64 */
65 private $settings;
66
67 /**
68 * AjaxRequestHandler constructor.
69 *
70 * @since 2.9.0
71 *
72 * @param Webhooks $webhooksRepository
73 * @param MerchantDetail $merchantDetails
74 * @param MerchantDetails $merchantRepository
75 * @param RefreshToken $refreshToken
76 * @param Settings $settings
77 * @param PayPalAuth $payPalAuth
78 */
79 public function __construct(
80 Webhooks $webhooksRepository,
81 MerchantDetail $merchantDetails,
82 MerchantDetails $merchantRepository,
83 RefreshToken $refreshToken,
84 Settings $settings,
85 PayPalAuth $payPalAuth
86 ) {
87 $this->webhooksRepository = $webhooksRepository;
88 $this->merchantDetails = $merchantDetails;
89 $this->merchantRepository = $merchantRepository;
90 $this->refreshToken = $refreshToken;
91 $this->settings = $settings;
92 $this->payPalAuth = $payPalAuth;
93 }
94
95 /**
96 * give_paypal_commerce_user_onboarded ajax action handler
97 *
98 * @since 2.32.0 Return error response on exception when fetch access token from authorization code.
99 * @since 2.9.0
100 */
101 public function onBoardedUserAjaxRequestHandler()
102 {
103 $this->validateAdminRequest();
104
105 if (empty($_GET['mode']) || ! in_array($_GET['mode'], ['sandbox', 'live'])) {
106 wp_send_json_error('Must include valid mode');
107 }
108
109 $mode = sanitize_text_field(wp_unslash($_GET['mode']));
110
111 // Set PayPal client mode.
112 give(PayPalClient::class)->setMode($mode);
113
114 $partnerLinkInfo = $this->settings->getPartnerLinkDetails();
115
116 try {
117 $payPalResponse = $this->payPalAuth->getTokenFromAuthorizationCode(
118 give_clean($_GET['authCode']),
119 give_clean($_GET['sharedId']),
120 $partnerLinkInfo['nonce']
121 );
122 } catch (\Exception $exception) {
123 wp_send_json_error();
124 }
125
126 $this->settings->updateAccessToken($payPalResponse);
127
128 // Set cron job to refresh token.
129 $refreshToken = give(RefreshToken::class);
130 $refreshToken->setMode($mode);
131 $refreshToken->registerCronJobToRefreshToken($payPalResponse['expiresIn']);
132
133 wp_send_json_success();
134 }
135
136 /**
137 * This function handle ajax request with give_paypal_commerce_get_partner_url action.
138 *
139 * @since 3.0.0 Add support for accountType. This param is required to get partner link.
140 * @since 2.30.0 Add support for mode param.
141 * @since 2.9.0
142 */
143 public function onGetPartnerUrlAjaxRequestHandler()
144 {
145 $this->validateAdminRequest();
146
147 if (empty($accountType = $_GET['accountType']) || ! in_array($accountType, ScriptLoader::$accountTypes, true)) {
148 wp_send_json_error('Must include valid account type');
149 }
150
151 if (empty($country = $_GET['countryCode']) || ! isset(give_get_country_list()[$country])) {
152 wp_send_json_error('Must include valid 2-character country code');
153 }
154
155 if (empty($_GET['mode']) || ! in_array($_GET['mode'], ['sandbox', 'live'])) {
156 wp_send_json_error('Must include valid mode');
157 }
158
159 $country = sanitize_text_field(wp_unslash($_GET['countryCode']));
160 $accountType = sanitize_text_field(wp_unslash($_GET['accountType']));
161 $mode = sanitize_text_field(wp_unslash($_GET['mode']));
162
163 // Generate a unique state token for CSRF protection on PayPal callback.
164 $stateToken = wp_generate_password(32, false);
165 set_transient('give_paypal_onboarding_state_' . $mode, $stateToken, HOUR_IN_SECONDS);
166
167 $redirectUrl = add_query_arg(
168 [
169 'tab' => 'gateways',
170 'section' => 'paypal',
171 'group' => 'paypal-commerce',
172 'mode' => $mode,
173 'give_paypal_state' => $stateToken,
174 ],
175 admin_url('edit.php?post_type=give_forms&page=give-settings')
176 );
177
178 // Set PayPal client mode.
179 give(PayPalClient::class)->setMode($mode);
180
181 $data = $this->payPalAuth->getSellerPartnerLink($redirectUrl, $accountType);
182
183 if (! $data) {
184 wp_send_json_error();
185 }
186
187 $this->settings->updateAccountCountry($country);
188 $this->settings->updatePartnerLinkDetails($data);
189
190 wp_send_json_success($data);
191 }
192
193 /**
194 * give_paypal_commerce_disconnect_account ajax request handler.
195 *
196 * @since 3.16.0 added security nonce check
197 * @since 3.13.0 Add new $keepWebhooks option
198 * @since 2.30.0 Add support for mode param.
199 * @since 2.25.0 Remove merchant seller token.
200 * @since 2.9.0
201 */
202 public function removePayPalAccount()
203 {
204 check_ajax_referer( 'give_paypal_commerce_disconnect_account');
205
206 if (! current_user_can('manage_give_settings')) {
207 wp_send_json_error(['error' => esc_html__('You are not allowed to perform this action.', 'give')]);
208 }
209
210 try {
211 $mode = give_clean($_POST['mode']);
212 $keepWebhooks = rest_sanitize_boolean($_POST['keep-webhooks']);
213 $this->webhooksRepository->setMode($mode);
214 $this->merchantRepository->setMode($mode);
215 $this->refreshToken->setMode($mode);
216 $this->settings->setMode($mode);
217
218 $this->validateAdminRequest();
219
220 // Remove the webhook from PayPal if there is one
221 if ( ! $keepWebhooks && $webhookConfig = $this->webhooksRepository->getWebhookConfig()) {
222 $this->webhooksRepository->deleteWebhook($this->merchantDetails->accessToken, $webhookConfig->id);
223 $this->webhooksRepository->deleteWebhookConfig();
224 }
225
226 $this->merchantRepository->delete();
227 $this->merchantRepository->deleteAccountErrors();
228 $this->merchantRepository->deleteClientToken();
229 $this->settings->deleteSellerAccessToken();
230 $this->refreshToken->deleteRefreshTokenCronJob();
231
232 wp_send_json_success();
233 } catch (\Exception $exception) {
234 wp_send_json_error(['error' => $exception->getMessage()]);
235 }
236 }
237
238 /**
239 * Create order.
240 *
241 * @todo: handle payment create error on frontend.
242 *
243 * @since 3.1.0 Remove unused variable from createOrder argument.
244 * @since 2.9.0
245 */
246 public function createOrder()
247 {
248 $this->validateFrontendRequest();
249 $data = $this->getOrderData();
250
251 try {
252 $result = give(PayPalOrder::class)->createOrder($data);
253
254 wp_send_json_success(
255 [
256 'id' => $result,
257 ]
258 );
259 } catch (\Exception $ex) {
260 wp_send_json_error(
261 [
262 'error' => json_decode($ex->getMessage(), true),
263 ]
264 );
265 }
266 }
267
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.
272 * @since 4.14.4 Validate donation amount before creating or updating an order.
273 * @since 4.2.1 Only filter amount for v2 forms.
274 * @since 3.4.2
275 */
276 private function getOrderData(): array
277 {
278 $postData = give_clean($_POST);
279 $formId = absint($postData['give-form-id']);
280 $donorAddress = $this->getDonorAddressFromPostedDataForPaypalOrder($postData);
281 $isV3Form = FormUtils::isV3Form($formId);
282
283 if (!$isV3Form) {
284 $this->skipLegacyCardFieldRequirements();
285 }
286
287 $this->validateDonationFormRequest($formId, $postData);
288
289 if ($isV3Form) {
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;
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 }
303 } else {
304 $amount = isset($postData['give-amount']) ?
305 (float)apply_filters(
306 'give_donation_total',
307 give_maybe_sanitize_amount(
308 $postData['give-amount'],
309 ['currency' => give_get_currency($formId)]
310 )
311 ) :
312 '0.00';
313
314 $this->validateDonationAmount($amount, $formId);
315 }
316
317 return [
318 'formId' => $formId,
319 'formTitle' => give_payment_gateway_item_title(['post_data' => $postData], 127),
320 'donationAmount' => $amount,
321 'payer' => [
322 'firstName' => $postData['give_first'],
323 'lastName' => $postData['give_last'],
324 'email' => $postData['give_email'],
325 'address' => $donorAddress,
326 ],
327 ];
328 }
329
330 /**
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.
335 *
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.
340 * @since 3.2.0 Discover error by checking capture status.
341 * @since 2.9.0
342 */
343 public function approveOrder()
344 {
345 wp_send_json_error(
346 ['error' => __('PayPal orders are captured when the donation is submitted.', 'give')]
347 );
348 }
349
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.
358 * @since 4.14.4 Validate donation amount before updating an order amount.
359 * @since 3.4.2
360 */
361 public function updateOrderAmount()
362 {
363 wp_send_json_error(
364 ['error' => __('PayPal order amounts are reconciled when the donation is submitted.', 'give')]
365 );
366 }
367
368 /**
369 * Return on boarding trouble notice.
370 *
371 * @since 2.9.6
372 */
373 public function onBoardingTroubleNotice()
374 {
375 if (! current_user_can('manage_give_settings')) {
376 wp_die();
377 }
378
379 /* @var AdminSettingFields $adminSettingFields */
380 $adminSettingFields = give(AdminSettingFields::class);
381
382 $actionList = sprintf(
383 '<ol><li>%1$s</li><li>%2$s</li><li>%3$s</li></ol>',
384 esc_html__(
385 'Make sure to complete the entire PayPal process. Do not close the window until you have finished the process.',
386 'give'
387 ),
388 esc_html__(
389 'The last screen of the PayPal connect process includes a button to be sent back to your site. It is important you click this and do not close the window yourself.',
390 'give'
391 ),
392 esc_html__(
393 'If you’re still having problems connecting: ',
394 'give'
395 ) . $adminSettingFields->getAdminGuidanceNotice(false)
396 );
397
398 $standardError = sprintf(
399 '<div id="give-paypal-onboarding-trouble-notice" class="give-hidden"><p class="error-message">%1$s</p><p>%2$s</p></div>',
400 esc_html__('Having trouble connecting to PayPal?', 'give'),
401 $actionList
402 );
403
404 wp_send_json_success($standardError);
405 }
406
407 /**
408 * Validate admin ajax request.
409 *
410 * @since 2.9.0
411 */
412 private function validateAdminRequest()
413 {
414 if (! current_user_can('manage_give_settings')) {
415 wp_die();
416 }
417 }
418
419 /**
420 * Validate frontend ajax request.
421 *
422 * @since 2.9.0
423 */
424 private function validateFrontendRequest()
425 {
426 $formId = absint($_POST['give-form-id']);
427
428 if (! $formId || ! give_verify_donation_form_nonce(give_clean($_POST['give-form-hash']), $formId)) {
429 wp_die();
430 }
431 }
432
433 /**
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.
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.
486 * @since 4.14.4
487 *
488 * @param float|string $amount
489 */
490 private function validateDonationAmount($amount, int $formId): void
491 {
492 $amount = (float)$amount;
493
494 if ($amount <= 0) {
495 wp_send_json_error(['error' => __('Invalid donation amount.', 'give')]);
496 }
497
498 $maxAmount = (float)give_get_form_maximum_price($formId);
499 if ($maxAmount > 0 && $amount > $maxAmount) {
500 wp_send_json_error([
501 'error' => sprintf(
502 /* translators: %s: maximum donation amount */
503 __('Donation amount must not exceed %s.', 'give'),
504 give_currency_filter(give_format_amount($maxAmount, ['sanitize' => false]))
505 ),
506 ]);
507 }
508 }
509
510 /**
511 * This function should return address array in PayPal rest api accepted format.
512 *
513 * @since 3.1.0 Return address only if setting enabled and has valida country in PayPal accepted formatted.
514 * @since 2.11.1
515 */
516 private function getDonorAddressFromPostedDataForPaypalOrder(array $postedData): array
517 {
518 if (empty($postedData['billing_country'])) {
519 return [];
520 }
521
522 $address['address_line_1'] = ! empty($postedData['card_address']) ? $postedData['card_address'] : '';
523 $address['address_line_2'] = ! empty($postedData['card_address_2']) ? $postedData['card_address_2'] : '';
524 $address['admin_area_2'] = ! empty($postedData['card_city']) ? $postedData['card_city'] : '';
525 $address['admin_area_1'] = ! empty($postedData['card_state']) ? $postedData['card_state'] : '';
526 $address['postal_code'] = ! empty($postedData['card_zip']) ? $postedData['card_zip'] : '';
527 $address['country_code'] = ! empty($postedData['billing_country']) ? $postedData['billing_country'] : '';
528
529 return $address;
530 }
531
532 }
533