webhooksRepository = $webhooksRepository; $this->merchantDetails = $merchantDetails; $this->merchantRepository = $merchantRepository; $this->refreshToken = $refreshToken; $this->settings = $settings; $this->payPalAuth = $payPalAuth; } /** * give_paypal_commerce_user_onboarded ajax action handler * * @since 2.32.0 Return error response on exception when fetch access token from authorization code. * @since 2.9.0 */ public function onBoardedUserAjaxRequestHandler() { $this->validateAdminRequest(); if (empty($_GET['mode']) || ! in_array($_GET['mode'], ['sandbox', 'live'])) { wp_send_json_error('Must include valid mode'); } $mode = sanitize_text_field(wp_unslash($_GET['mode'])); // Set PayPal client mode. give(PayPalClient::class)->setMode($mode); $partnerLinkInfo = $this->settings->getPartnerLinkDetails(); try { $payPalResponse = $this->payPalAuth->getTokenFromAuthorizationCode( give_clean($_GET['authCode']), give_clean($_GET['sharedId']), $partnerLinkInfo['nonce'] ); } catch (\Exception $exception) { wp_send_json_error(); } $this->settings->updateAccessToken($payPalResponse); // Set cron job to refresh token. $refreshToken = give(RefreshToken::class); $refreshToken->setMode($mode); $refreshToken->registerCronJobToRefreshToken($payPalResponse['expiresIn']); wp_send_json_success(); } /** * This function handle ajax request with give_paypal_commerce_get_partner_url action. * * @since 3.0.0 Add support for accountType. This param is required to get partner link. * @since 2.30.0 Add support for mode param. * @since 2.9.0 */ public function onGetPartnerUrlAjaxRequestHandler() { $this->validateAdminRequest(); if (empty($accountType = $_GET['accountType']) || ! in_array($accountType, ScriptLoader::$accountTypes, true)) { wp_send_json_error('Must include valid account type'); } if (empty($country = $_GET['countryCode']) || ! isset(give_get_country_list()[$country])) { wp_send_json_error('Must include valid 2-character country code'); } if (empty($_GET['mode']) || ! in_array($_GET['mode'], ['sandbox', 'live'])) { wp_send_json_error('Must include valid mode'); } $country = sanitize_text_field(wp_unslash($_GET['countryCode'])); $accountType = sanitize_text_field(wp_unslash($_GET['accountType'])); $mode = sanitize_text_field(wp_unslash($_GET['mode'])); // Generate a unique state token for CSRF protection on PayPal callback. $stateToken = wp_generate_password(32, false); set_transient('give_paypal_onboarding_state_' . $mode, $stateToken, HOUR_IN_SECONDS); $redirectUrl = add_query_arg( [ 'tab' => 'gateways', 'section' => 'paypal', 'group' => 'paypal-commerce', 'mode' => $mode, 'give_paypal_state' => $stateToken, ], admin_url('edit.php?post_type=give_forms&page=give-settings') ); // Set PayPal client mode. give(PayPalClient::class)->setMode($mode); $data = $this->payPalAuth->getSellerPartnerLink($redirectUrl, $accountType); if (! $data) { wp_send_json_error(); } $this->settings->updateAccountCountry($country); $this->settings->updatePartnerLinkDetails($data); wp_send_json_success($data); } /** * give_paypal_commerce_disconnect_account ajax request handler. * * @since 3.16.0 added security nonce check * @since 3.13.0 Add new $keepWebhooks option * @since 2.30.0 Add support for mode param. * @since 2.25.0 Remove merchant seller token. * @since 2.9.0 */ public function removePayPalAccount() { check_ajax_referer( 'give_paypal_commerce_disconnect_account'); if (! current_user_can('manage_give_settings')) { wp_send_json_error(['error' => esc_html__('You are not allowed to perform this action.', 'give')]); } try { $mode = give_clean($_POST['mode']); $keepWebhooks = rest_sanitize_boolean($_POST['keep-webhooks']); $this->webhooksRepository->setMode($mode); $this->merchantRepository->setMode($mode); $this->refreshToken->setMode($mode); $this->settings->setMode($mode); $this->validateAdminRequest(); // Remove the webhook from PayPal if there is one if ( ! $keepWebhooks && $webhookConfig = $this->webhooksRepository->getWebhookConfig()) { $this->webhooksRepository->deleteWebhook($this->merchantDetails->accessToken, $webhookConfig->id); $this->webhooksRepository->deleteWebhookConfig(); } $this->merchantRepository->delete(); $this->merchantRepository->deleteAccountErrors(); $this->merchantRepository->deleteClientToken(); $this->settings->deleteSellerAccessToken(); $this->refreshToken->deleteRefreshTokenCronJob(); wp_send_json_success(); } catch (\Exception $exception) { wp_send_json_error(['error' => $exception->getMessage()]); } } /** * Create order. * * @todo: handle payment create error on frontend. * * @since 3.1.0 Remove unused variable from createOrder argument. * @since 2.9.0 */ public function createOrder() { $this->validateFrontendRequest(); $data = $this->getOrderData(); try { $result = give(PayPalOrder::class)->createOrder($data); wp_send_json_success( [ 'id' => $result, ] ); } catch (\Exception $ex) { wp_send_json_error( [ 'error' => json_decode($ex->getMessage(), true), ] ); } } /** * @since 4.16.7.1 Validate the request through the form layer before building order data. v3 forms must * also send a total at least as large as the amount the form validated; v2 forms are * checked on the final, post-filter amount. * @since 4.14.4 Validate donation amount before creating or updating an order. * @since 4.2.1 Only filter amount for v2 forms. * @since 3.4.2 */ private function getOrderData(): array { $postData = give_clean($_POST); $formId = absint($postData['give-form-id']); $donorAddress = $this->getDonorAddressFromPostedDataForPaypalOrder($postData); $isV3Form = FormUtils::isV3Form($formId); if (!$isV3Form) { $this->skipLegacyCardFieldRequirements(); } $this->validateDonationFormRequest($formId, $postData); if ($isV3Form) { /* * v3 forms send the form's own amount field as "amount" and the total, with fee recovery * already included, as "give-amount". The total is what the donor approves in the PayPal * popup; PayPalCommerce::createPayment() reconciles the order to the validated donation * before capturing, so all this has to guarantee is that the total never drops below the * amount the form just validated. */ $validatedAmount = isset($postData['amount']) ? (float)$postData['amount'] : 0.0; $amount = isset($postData['give-amount']) ? give_clean($postData['give-amount']) : '0.00'; if ($validatedAmount <= 0 || (float)$amount < $validatedAmount) { wp_send_json_error(['error' => __('Invalid donation amount.', 'give')]); } } else { $amount = isset($postData['give-amount']) ? (float)apply_filters( 'give_donation_total', give_maybe_sanitize_amount( $postData['give-amount'], ['currency' => give_get_currency($formId)] ) ) : '0.00'; $this->validateDonationAmount($amount, $formId); } return [ 'formId' => $formId, 'formTitle' => give_payment_gateway_item_title(['post_data' => $postData], 127), 'donationAmount' => $amount, 'payer' => [ 'firstName' => $postData['give_first'], 'lastName' => $postData['give_last'], 'email' => $postData['give_email'], 'address' => $donorAddress, ], ]; } /** * Refuses every request. Both form versions now send their order id with the donation and let * PayPalCommerce::createPayment() capture it, so nothing legitimate captures from the browser. * The endpoint stays registered so anything still calling it receives an error it can report, * rather than an empty response from a missing action. * * @since 4.16.9 Refuse every request; the capture for both form versions happens in PayPalCommerce::createPayment(). * @since 4.16.7.1 Refuse v3 forms; their capture happens in PayPalCommerce::createPayment(). Validate * the posted form before every capture, not only when the amount changed. * @since 4.14.4 Validate donation amount before approving an order. * @since 3.2.0 Discover error by checking capture status. * @since 2.9.0 */ public function approveOrder() { wp_send_json_error( ['error' => __('PayPal orders are captured when the donation is submitted.', 'give')] ); } /** * Refuses every request. The order amount is reconciled against the donation in * PayPalCommerce::createPayment() before the capture, so no form version needs the browser to * change an order's amount. The endpoint stays registered for the same reason approveOrder() * does: a caller gets an error it can report rather than an empty response. * * @since 4.16.9 Refuse every request; the order amount is reconciled in PayPalCommerce::createPayment(). * @since 4.16.7.1 Refuse v3 forms; PayPalCommerce::createPayment() reconciles their order amount. * @since 4.14.4 Validate donation amount before updating an order amount. * @since 3.4.2 */ public function updateOrderAmount() { wp_send_json_error( ['error' => __('PayPal order amounts are reconciled when the donation is submitted.', 'give')] ); } /** * Return on boarding trouble notice. * * @since 2.9.6 */ public function onBoardingTroubleNotice() { if (! current_user_can('manage_give_settings')) { wp_die(); } /* @var AdminSettingFields $adminSettingFields */ $adminSettingFields = give(AdminSettingFields::class); $actionList = sprintf( '
  1. %1$s
  2. %2$s
  3. %3$s
', esc_html__( 'Make sure to complete the entire PayPal process. Do not close the window until you have finished the process.', 'give' ), esc_html__( '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.', 'give' ), esc_html__( 'If you’re still having problems connecting: ', 'give' ) . $adminSettingFields->getAdminGuidanceNotice(false) ); $standardError = sprintf( '

%1$s

%2$s

', esc_html__('Having trouble connecting to PayPal?', 'give'), $actionList ); wp_send_json_success($standardError); } /** * Validate admin ajax request. * * @since 2.9.0 */ private function validateAdminRequest() { if (! current_user_can('manage_give_settings')) { wp_die(); } } /** * Validate frontend ajax request. * * @since 2.9.0 */ private function validateFrontendRequest() { $formId = absint($_POST['give-form-id']); if (! $formId || ! give_verify_donation_form_nonce(give_clean($_POST['give-form-hash']), $formId)) { wp_die(); } } /** * Hold the request to the form's own rules before anything reaches PayPal. The form layer owns * the rules: amount limits, required fields, and whatever else it validates for this form * version; this handler only acts on the verdict. * * @since 4.16.7.1 */ private function validateDonationFormRequest(int $formId, array $request): void { try { give(ValidateDonationFormRequest::class)($formId, $request); } catch (DonationFormFieldErrorsException $exception) { wp_send_json_error(['error' => implode(' ', $exception->getError()->get_error_messages())]); } catch (DonationFormForbidden $exception) { wp_send_json_error(['error' => $exception->getMessage()], 403); } catch (\Exception $exception) { /* * Anything else the form layer throws (a spam detection, for one) still means "do not * create this order". Same handling as the validate route, log entry included. */ Log::error('PayPal Commerce order request rejected', [ 'formId' => $formId, 'exception' => get_class($exception), 'message' => $exception->getMessage(), ]); wp_send_json_error(['error' => $exception->getMessage()]); } } /** * The v2 form posts its card inputs along with everything else, but for this gateway those inputs * are PayPal-hosted fields (SmartButtons.js strips them before its own validation call for the * same reason), so the legacy validator must not require them here. * * @since 4.16.7.1 */ private function skipLegacyCardFieldRequirements(): void { add_filter('give_donation_form_required_fields', static function ($requiredFields) { return array_diff_key( (array)$requiredFields, array_flip(['card_name', 'card_number', 'card_cvc', 'card_expiry']) ); }); } /** * The legacy validator checks the raw posted amount. The amount that actually reaches PayPal for * a v2 form has been through the give_donation_total filter (fee recovery), so it is checked * again here: positive and within the form's maximum. v3 amounts are validated by the form layer. * * @since 4.16.7.1 Applies to v2 forms only. * @since 4.14.4 * * @param float|string $amount */ private function validateDonationAmount($amount, int $formId): void { $amount = (float)$amount; if ($amount <= 0) { wp_send_json_error(['error' => __('Invalid donation amount.', 'give')]); } $maxAmount = (float)give_get_form_maximum_price($formId); if ($maxAmount > 0 && $amount > $maxAmount) { wp_send_json_error([ 'error' => sprintf( /* translators: %s: maximum donation amount */ __('Donation amount must not exceed %s.', 'give'), give_currency_filter(give_format_amount($maxAmount, ['sanitize' => false])) ), ]); } } /** * This function should return address array in PayPal rest api accepted format. * * @since 3.1.0 Return address only if setting enabled and has valida country in PayPal accepted formatted. * @since 2.11.1 */ private function getDonorAddressFromPostedDataForPaypalOrder(array $postedData): array { if (empty($postedData['billing_country'])) { return []; } $address['address_line_1'] = ! empty($postedData['card_address']) ? $postedData['card_address'] : ''; $address['address_line_2'] = ! empty($postedData['card_address_2']) ? $postedData['card_address_2'] : ''; $address['admin_area_2'] = ! empty($postedData['card_city']) ? $postedData['card_city'] : ''; $address['admin_area_1'] = ! empty($postedData['card_state']) ? $postedData['card_state'] : ''; $address['postal_code'] = ! empty($postedData['card_zip']) ? $postedData['card_zip'] : ''; $address['country_code'] = ! empty($postedData['billing_country']) ? $postedData['billing_country'] : ''; return $address; } }