diff --git a/assets/src/js/frontend/paypal-commerce/SmartButtons.js b/assets/src/js/frontend/paypal-commerce/SmartButtons.js index 0450a6cca6..9f0cbbfec9 100644 --- a/assets/src/js/frontend/paypal-commerce/SmartButtons.js +++ b/assets/src/js/frontend/paypal-commerce/SmartButtons.js @@ -8,16 +8,18 @@ import AdvancedCardFields from './AdvancedCardFields'; * PayPal Smart Buttons. */ class SmartButtons extends PaymentMethod { - /** - * Setup properties. - * - * @since 2.9.0 - */ - setupProperties() { - this.ccFieldsContainer = this.form.querySelector( '[id^="give_cc_fields-"]' ); - this.recurringChoiceHiddenField = this.form.querySelector( 'input[name="_give_is_donation_recurring"]' ); - this.smartButton = null; - } + /** + * Setup properties. + * + * @unreleased Add the updateOrderAmount property + * @since 2.9.0 + */ + setupProperties() { + this.ccFieldsContainer = this.form.querySelector('[id^="give_cc_fields-"]'); + this.recurringChoiceHiddenField = this.form.querySelector('input[name="_give_is_donation_recurring"]'); + this.smartButton = null; + this.updateOrderAmount = false; + } /** * Check if smart buttons can be shown. @@ -28,116 +30,142 @@ class SmartButtons extends PaymentMethod { return givePayPalCommerce.payPalSdkQueryParameters.components.indexOf('buttons') !== -1; } - /** - * Get smart button container. - * - * @since 2.9.0 - * - * @return {object} Smart button container selector. - */ - getButtonContainer() { - this.ccFieldsContainer = this.form.querySelector( '[id^="give_cc_fields-"]' ); // Refresh cc field container selector. - const oldSmartButtonWrap = this.ccFieldsContainer.querySelector( '#give-paypal-commerce-smart-buttons-wrap' ); - - if ( oldSmartButtonWrap ) { - return oldSmartButtonWrap; - } - - const smartButtonWrap = document.createElement( 'div' ); - const separator = this.ccFieldsContainer.querySelector( '.separator-with-text' ); - smartButtonWrap.setAttribute( 'id', 'give-paypal-commerce-smart-buttons-wrap' ); - const cardNumberWarp = this.ccFieldsContainer.querySelector( '[id^=give-card-number-wrap-]' ); - - return this.ccFieldsContainer.insertBefore( smartButtonWrap, separator ? separator : cardNumberWarp ); - } - - /** - * Render smart buttons. - * - * @since 2.9.0 - * @return {object} Return Promise - */ - renderPaymentMethodOption() { - this.smartButtonContainer = this.getButtonContainer(); - - if ( this.smartButton ) { - this.smartButton.close(); - } - - const options = { - onInit: this.onInitHandler.bind( this ), - onClick: this.onClickHandler.bind( this ), - createOrder: this.createOrderHandler.bind( this ), - onApprove: this.orderApproveHandler.bind( this ), - style: { - layout: 'vertical', - size: 'responsive', - shape: 'rect', - label: 'paypal', - color: 'gold', - tagline: false, - }, - onError: ( error ) =>{ - this.displayErrorMessage( error ); - }, - }; - - if ( DonationForm.isRecurringDonation( this.form ) ) { - options.createSubscription = this.creatSubscriptionHandler.bind( this ); - options.onApprove = this.subscriptionApproveHandler.bind( this ); - - delete options.createOrder; - } - - DonationForm.toggleDonateNowButton( this.form ); - - this.smartButton = paypal.Buttons( options ); - - return this.smartButton.render( this.smartButtonContainer ); - } - - /** - * On init event handler for smart buttons. - * - * @since 2.9.0 - * - * @param {object} data PayPal button data. - * @param {object} actions PayPal button actions. - */ - onInitHandler( data, actions ) { // eslint-disable-line - // Keeping this for future reference. - } - - /** - * On click event handler for smart buttons. - * - * @since 2.9.0 - * - * @param {object} data PayPal button data. - * @param {object} actions PayPal button actions. - * - * @return {Promise} Return whether or not open PayPal checkout window. - */ - async onClickHandler(data, actions) { // eslint-disable-line - const formData = new FormData( this.form ); - - if ( AdvancedCardFields.canShow() ) { - formData.delete( 'card_name' ); - formData.delete( 'card_cvc' ); - formData.delete( 'card_number' ); - formData.delete( 'card_expiry' ); - } - - Give.form.fn.removeErrors( this.jQueryForm ); - const result = await Give.form.fn.isDonorFilledValidData( this.form, formData ); - - if ( 'success' === result ) { - return actions.resolve(); - } - - this.showError( result ); - return actions.reject(); - } + /** + * Get smart button container. + * + * @since 2.9.0 + * + * @return {object} Smart button container selector. + */ + getButtonContainer() { + this.ccFieldsContainer = this.form.querySelector('[id^="give_cc_fields-"]'); // Refresh cc field container selector. + const oldSmartButtonWrap = this.ccFieldsContainer.querySelector('#give-paypal-commerce-smart-buttons-wrap'); + + if (oldSmartButtonWrap) { + return oldSmartButtonWrap; + } + + const smartButtonWrap = document.createElement('div'); + const separator = this.ccFieldsContainer.querySelector('.separator-with-text'); + smartButtonWrap.setAttribute('id', 'give-paypal-commerce-smart-buttons-wrap'); + const cardNumberWarp = this.ccFieldsContainer.querySelector('[id^=give-card-number-wrap-]'); + + return this.ccFieldsContainer.insertBefore(smartButtonWrap, separator ? separator : cardNumberWarp); + } + + /** + * Render smart buttons. + * + * @since 2.9.0 + * @return {object} Return Promise + */ + renderPaymentMethodOption() { + this.smartButtonContainer = this.getButtonContainer(); + + if (this.smartButton) { + this.smartButton.close(); + } + + const options = { + onInit: this.onInitHandler.bind(this), + onClick: this.onClickHandler.bind(this), + createOrder: this.createOrderHandler.bind(this), + onApprove: this.orderApproveHandler.bind(this), + style: { + layout: 'vertical', + size: 'responsive', + shape: 'rect', + label: 'paypal', + color: 'gold', + tagline: false, + }, + onError: (error) => { + this.displayErrorMessage(error); + }, + }; + + if (DonationForm.isRecurringDonation(this.form)) { + options.createSubscription = this.creatSubscriptionHandler.bind(this); + options.onApprove = this.subscriptionApproveHandler.bind(this); + + delete options.createOrder; + } + + DonationForm.toggleDonateNowButton(this.form); + + this.smartButton = paypal.Buttons(options); + + return this.smartButton.render(this.smartButtonContainer); + } + + /** + * On init event handler for smart buttons. + * + * @since 2.9.0 + * + * @param {object} data PayPal button data. + * @param {object} actions PayPal button actions. + */ + onInitHandler(data, actions) { + // eslint-disable-line + // Keeping this for future reference. + } + + /** + * On click event handler for smart buttons. + * + * @since 2.9.0 + * + * @param {object} data PayPal button data. + * @param {object} actions PayPal button actions. + * + * @return {Promise} Return whether or not open PayPal checkout window. + */ + async onClickHandler(data, actions) { + // eslint-disable-line + const formData = new FormData(this.form); + + if (AdvancedCardFields.canShow()) { + formData.delete('card_name'); + formData.delete('card_cvc'); + formData.delete('card_number'); + formData.delete('card_expiry'); + } + + Give.form.fn.removeErrors(this.jQueryForm); + const result = await Give.form.fn.isDonorFilledValidData(this.form, formData); + + if ('success' === result) { + this.observeAmount(); + return actions.resolve(); + } + + this.showError(result); + return actions.reject(); + } + + /** + * Watching for changes in the amount field input after the user has clicked on the smart buttons + * @unreleased + */ + observeAmount() { + const $this = this; + const giveAmount = $this.form.querySelector('#give-amount'); + + if (!!giveAmount) { + const observer = new MutationObserver(function (mutations) { + $this.updateOrderAmount = true; + }); + + const config = { + attributes: true, + childList: true, + characterData: true, + }; + + observer.observe(giveAmount, config); + } + } /** * Create subscription event handler for smart buttons. @@ -167,135 +195,140 @@ class SmartButtons extends PaymentMethod { const formData = new FormData(this.form); const subscriberData = { - "name": { - "given_name": formData.get('give_first'), - "surname": formData.get('give_last') + name: { + given_name: formData.get('give_first'), + surname: formData.get('give_last'), }, - "email_address": formData.get('give_email') + email_address: formData.get('give_email'), }; if (formData.get('billing_country')) { subscriberData.shipping_address = { name: { - "full_name": `${formData.get('give_first')} ${formData.get('give_last')}`.trim() + full_name: `${formData.get('give_first')} ${formData.get('give_last')}`.trim(), }, address: { - "address_line_1": formData.get('card_address'), - "address_line_2": formData.get('card_address_2'), - "admin_area_2": formData.get('card_city'), - "admin_area_1": formData.get('card_state'), - "postal_code": formData.get('card_zip'), - "country_code": formData.get('billing_country') - } + address_line_1: formData.get('card_address'), + address_line_2: formData.get('card_address_2'), + admin_area_2: formData.get('card_city'), + admin_area_1: formData.get('card_state'), + postal_code: formData.get('card_zip'), + country_code: formData.get('billing_country'), + }, }; } return actions.subscription.create({ - "plan_id": responseJson.data.id, - "subscriber": subscriberData - }); + plan_id: responseJson.data.id, + subscriber: subscriberData, + }); + } + + /** + * Subscription approve event handler for smart buttons. + * + * @since 2.9.0 + * + * @param {object} data PayPal button data. + * @param {object} actions PayPal button actions. + * + * @return {*} Return whether or not PayPal payment captured. + */ + async subscriptionApproveHandler(data, actions) { + // eslint-disable-line + Give.form.fn.showProcessingState(window.givePayPalCommerce.textForOverlayScreen); + await DonationForm.addFieldToForm(this.form, data.subscriptionID, 'payPalSubscriptionId'); + + this.submitDonationForm(); + } + + /** + * Order approve event handler for smart buttons. + * + * @unreleased Add 'update_amount' query string to the ajax URL + * @since 3.1.2 Handle custom error. + * @since 2.9.0 + * + * @param {object} data PayPal button data. + * @param {object} actions PayPal button actions. + * + * @return {*} Return whether or not PayPal payment captured. + */ + async orderApproveHandler(data, actions) { + Give.form.fn.showProcessingState(window.givePayPalCommerce.textForOverlayScreen); + Give.form.fn.disable(this.jQueryForm, true); + Give.form.fn.removeErrors(this.jQueryForm); + + // eslint-disable-next-line + const response = await fetch( + `${this.ajaxurl}?action=give_paypal_commerce_approve_order&order=${data.orderID}&update_amount=${this.updateOrderAmount}`, + { + method: 'post', + body: DonationForm.getFormDataWithoutGiveActionField(this.form), + } + ); + const responseJson = await response.json(); + + // Three cases to handle: + // (1) Recoverable INSTRUMENT_DECLINED -> call actions.restart() + // (2) Other non-recoverable errors -> Show a failure message + // (3) Successful transaction -> Show a success / thank you message + + let errorDetail = {}; + if (!responseJson.success) { + Give.form.fn.disable(this.jQueryForm, false); + Give.form.fn.hideProcessingState(); + + this.displayErrorMessage(responseJson.data.error, true); + + errorDetail = responseJson.data.error?.details?.[0]; + if (errorDetail && errorDetail.issue === 'INSTRUMENT_DECLINED') { + // Recoverable state, see: "Handle Funding Failures" + // https://developer.paypal.com/docs/checkout/integration-features/funding-failure/ + return actions.restart(); + } + + return; + } + + const orderData = responseJson.data.order; + await DonationForm.addFieldToForm(this.form, orderData.id, 'payPalOrderId'); + + this.submitDonationForm(); + } + + /** + * Submit donation form. + * + * @since 2.9.0 + */ + submitDonationForm() { + // Do not submit empty or filled Name credit card field with form. + // If we do that we will get `empty_card_name` error or other. + // We are removing this field before form submission because this donation processed with smart button. + this.jQueryForm.off('submit'); + this.removeCreditCardFields(); + this.form.submit(); } - /** - * Subscription approve event handler for smart buttons. - * - * @since 2.9.0 - * - * @param {object} data PayPal button data. - * @param {object} actions PayPal button actions. - * - * @return {*} Return whether or not PayPal payment captured. - */ - async subscriptionApproveHandler( data, actions ) { // eslint-disable-line - Give.form.fn.showProcessingState( window.givePayPalCommerce.textForOverlayScreen ); - await DonationForm.addFieldToForm( this.form, data.subscriptionID, 'payPalSubscriptionId' ); - - this.submitDonationForm(); - } - - /** - * Order approve event handler for smart buttons. - * - * @unrelease Handle custom error. - * @since 2.9.0 - * - * @param {object} data PayPal button data. - * @param {object} actions PayPal button actions. - * - * @return {*} Return whether or not PayPal payment captured. - */ - async orderApproveHandler( data, actions ) { - Give.form.fn.showProcessingState( window.givePayPalCommerce.textForOverlayScreen ); - Give.form.fn.disable( this.jQueryForm, true ); - Give.form.fn.removeErrors( this.jQueryForm ); - - // eslint-disable-next-line - const response = await fetch( `${ this.ajaxurl }?action=give_paypal_commerce_approve_order&order=` + data.orderID, { - method: 'post', - body: DonationForm.getFormDataWithoutGiveActionField( this.form ), - } ); - const responseJson = await response.json(); - - // Three cases to handle: - // (1) Recoverable INSTRUMENT_DECLINED -> call actions.restart() - // (2) Other non-recoverable errors -> Show a failure message - // (3) Successful transaction -> Show a success / thank you message - - let errorDetail = {}; - if ( ! responseJson.success ) { - Give.form.fn.disable( this.jQueryForm, false ); - Give.form.fn.hideProcessingState(); - - this.displayErrorMessage( responseJson.data.error, true ); - - errorDetail = responseJson.data.error?.details?.[0]; - if ( errorDetail && errorDetail.issue === 'INSTRUMENT_DECLINED' ) { - // Recoverable state, see: "Handle Funding Failures" - // https://developer.paypal.com/docs/checkout/integration-features/funding-failure/ - return actions.restart(); - } - - return; - } - - const orderData = responseJson.data.order; - await DonationForm.addFieldToForm( this.form, orderData.id, 'payPalOrderId' ); - - this.submitDonationForm(); - } - - /** - * Submit donation form. - * - * @since 2.9.0 - */ - submitDonationForm() { - // Do not submit empty or filled Name credit card field with form. - // If we do that we will get `empty_card_name` error or other. - // We are removing this field before form submission because this donation processed with smart button. - this.jQueryForm.off( 'submit' ); - this.removeCreditCardFields(); - this.form.submit(); - } - - /** - * Remove Card fields. - * - * @since 2.9.0 - */ - removeCreditCardFields() { - // Remove custom card fields. - if ( AdvancedCardFields.canShow() ) { - this.jQueryForm.find( 'input[name="card_name"]' ).parent().remove(); - this.ccFieldsContainer.querySelector( '.separator-with-text' ).remove(); // Remove separator. - - const $customCardFields = new CustomCardFields( this.form ); - - for ( const key in $customCardFields.cardFields ) { - $customCardFields.cardFields[ key ].el.parentElement.remove(); - } - } - } + /** + * Remove Card fields. + * + * @since 2.9.0 + */ + removeCreditCardFields() { + // Remove custom card fields. + if (AdvancedCardFields.canShow()) { + this.jQueryForm.find('input[name="card_name"]').parent().remove(); + this.ccFieldsContainer.querySelector('.separator-with-text').remove(); // Remove separator. + + const $customCardFields = new CustomCardFields(this.form); + + for (const key in $customCardFields.cardFields) { + $customCardFields.cardFields[key].el.parentElement.remove(); + } + } + } } export default SmartButtons; diff --git a/give.php b/give.php index b4be93b69e..28563d4655 100644 --- a/give.php +++ b/give.php @@ -6,7 +6,7 @@ * Description: The most robust, flexible, and intuitive way to accept donations on WordPress. * Author: GiveWP * Author URI: https://givewp.com/ - * Version: 3.4.1 + * Version: 3.4.2 * Requires at least: 6.0 * Requires PHP: 7.2 * Text Domain: give @@ -403,7 +403,7 @@ private function setup_constants() { // Plugin version. if (!defined('GIVE_VERSION')) { - define('GIVE_VERSION', '3.4.1'); + define('GIVE_VERSION', '3.4.2'); } // Plugin Root File. diff --git a/readme.txt b/readme.txt index 854052a85a..089c468ac5 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Tags: donation, donate, recurring donations, fundraising, crowdfunding Requires at least: 6.0 Tested up to: 6.4 Requires PHP: 7.2 -Stable tag: 3.4.1 +Stable tag: 3.4.2 License: GPLv3 License URI: http://www.gnu.org/licenses/gpl-3.0.html @@ -262,6 +262,9 @@ The 2% fee on Stripe donations only applies to donations taken via our free Stri 10. Use almost any payment gateway integration with GiveWP through our add-ons or by creating your own add-on. == Changelog == += 3.4.2: February 19th, 2024 = +* Fix: Resolved an issue with PayPal donations that ensures the correct donation amount will be used after filling out payment details and modifying the original amount. + = 3.4.1: February 13th, 2024 = * Fix: Resolved an issue with the default email block that ensures it is always a required field in the donation form. diff --git a/src/DonationForms/V2/DonationFormsAdminPage.php b/src/DonationForms/V2/DonationFormsAdminPage.php index 24f0b5de05..3fa41b3363 100644 --- a/src/DonationForms/V2/DonationFormsAdminPage.php +++ b/src/DonationForms/V2/DonationFormsAdminPage.php @@ -313,7 +313,7 @@ public static function getUrl(): string /** * Get an array of supported addons * - * @unreleased Added support for Gift Aid + * @since 3.4.2 Added support for Gift Aid * @since 3.3.0 Add support to the Funds and Designations addon * @since 3.0.0 * @return array diff --git a/src/PaymentGateways/Gateways/PayPalCommerce/payPalCommerceGateway.tsx b/src/PaymentGateways/Gateways/PayPalCommerce/payPalCommerceGateway.tsx index 6f7064093d..00465f7aed 100644 --- a/src/PaymentGateways/Gateways/PayPalCommerce/payPalCommerceGateway.tsx +++ b/src/PaymentGateways/Gateways/PayPalCommerce/payPalCommerceGateway.tsx @@ -11,7 +11,7 @@ import {__, sprintf} from '@wordpress/i18n'; import {debounce} from 'react-ace/lib/editorOptions'; import {Flex, TextControl} from '@wordpress/components'; import {CSSProperties, useEffect, useState} from 'react'; -import {PayPalSubscriber} from "./types"; +import {PayPalSubscriber} from './types'; (() => { /** @@ -45,6 +45,9 @@ import {PayPalSubscriber} from "./types"; let addressLine2; let postalCode; + let updateOrderAmount = false; + let orderCreated = false; + const buttonsStyle = { color: 'gold' as 'gold' | 'blue' | 'silver' | 'white' | 'black', label: 'paypal' as 'paypal' | 'checkout' | 'buynow' | 'pay' | 'installment' | 'subscribe' | 'donate', @@ -84,14 +87,15 @@ import {PayPalSubscriber} from "./types"; let paypalScriptOptions = {...payPalDonationsSettings.sdkOptions}; // Remove hosted fields from components if subscription. - if( isSubscription && -1 !== paypalScriptOptions.components.indexOf('hosted-fields') ){ - paypalScriptOptions.components = paypalScriptOptions.components.split(',') + if (isSubscription && -1 !== paypalScriptOptions.components.indexOf('hosted-fields')) { + paypalScriptOptions.components = paypalScriptOptions.components + .split(',') .filter((component) => component !== 'hosted-fields') .join(','); } return paypalScriptOptions; - } + }; /** * Get amount with fee (if any). @@ -101,11 +105,11 @@ import {PayPalSubscriber} from "./types"; */ const getAmount = () => { const feeAmount = feeRecovery ? feeRecovery : 0; - let amountWithFee = amount + feeAmount + let amountWithFee = amount + feeAmount; amountWithFee = Math.round(amountWithFee * 100) / 100; return amountWithFee; - } + }; const getFormData = () => { const formData = new FormData(); @@ -115,7 +119,7 @@ import {PayPalSubscriber} from "./types"; formData.append('give_payment_mode', 'paypal-commerce'); - formData.append('give-amount', getAmount() ); + formData.append('give-amount', getAmount()); formData.append('give-recurring-period', subscriptionPeriod); formData.append('period', subscriptionPeriod); @@ -126,7 +130,7 @@ import {PayPalSubscriber} from "./types"; formData.append('give_last', lastName); formData.append('give_email', email); - if( country ) { + if (country) { formData.append('card_address', addressLine1); formData.append('card_address_2', addressLine2); formData.append('card_city', city); @@ -172,35 +176,37 @@ import {PayPalSubscriber} from "./types"; } const subscriberData: PayPalSubscriber = { - "name": { - "given_name": firstName, - "surname": lastName + name: { + given_name: firstName, + surname: lastName, }, - "email_address": email, + email_address: email, }; if (country) { subscriberData.shipping_address = { name: { - "full_name": `${firstName} ${lastName}`.trim() + full_name: `${firstName} ${lastName}`.trim(), }, address: { - "address_line_1": addressLine1, - "address_line_2": addressLine2, - "admin_area_2": city, - "admin_area_1": state, - "postal_code": postalCode, - "country_code": country - } + address_line_1: addressLine1, + address_line_2: addressLine2, + admin_area_2: city, + admin_area_1: state, + postal_code: postalCode, + country_code: country, + }, }; } - return actions.subscription.create({ - "plan_id": responseJson.data.id, - "subscriber": subscriberData - }).then((orderId) => { - return payPalSubscriptionId = orderId; - }); + return actions.subscription + .create({ + plan_id: responseJson.data.id, + subscriber: subscriberData, + }) + .then((orderId) => { + return (payPalSubscriptionId = orderId); + }); }; const Divider = ({label, style = {}}) => { @@ -249,7 +255,6 @@ import {PayPalSubscriber} from "./types"; lastName = useWatch({name: 'lastName'}); email = useWatch({name: 'email'}); - subscriptionFrequency = useWatch({name: 'subscriptionFrequency'}); subscriptionInstallments = useWatch({name: 'subscriptionInstallments'}); subscriptionPeriod = useWatch({name: 'subscriptionPeriod'}); @@ -261,6 +266,12 @@ import {PayPalSubscriber} from "./types"; postalCode = useWatch({name: 'zip'}); country = useWatch({name: 'country'}); + useEffect(() => { + if (orderCreated) { + updateOrderAmount = true; + } + }, [amount]); + return children; }; @@ -270,7 +281,14 @@ import {PayPalSubscriber} from "./types"; const donationType = useWatch({name: 'donationType'}); const {isSubmitting, isSubmitSuccessful} = useFormState(); const {useFormContext} = window.givewp.form.hooks; - const {getFieldState, setFocus, getValues, formState: {errors}, trigger, setError} = useFormContext(); + const { + getFieldState, + setFocus, + getValues, + formState: {errors}, + trigger, + setError, + } = useFormContext(); const gateway = window.givewp.gateways.get('paypal-commerce'); const props = { @@ -280,61 +298,87 @@ import {PayPalSubscriber} from "./types"; onClick: async (data, actions) => { // Validate whether payment gateway support subscriptions. if (donationType === 'subscription' && !gateway.supportsSubscriptions) { - setError('FORM_ERROR', { - message: __( - 'This payment gateway does not support recurring payments, please try selecting another payment gateway.', - 'give' - ) - }, + setError( + 'FORM_ERROR', + { + message: __( + 'This payment gateway does not support recurring payments, please try selecting another payment gateway.', + 'give' + ), + }, {shouldFocus: true} ); // Scroll to the top of the form. // Add this moment we do not have a way to scroll to the error message. // In the future we can add a way to scroll to the error message and remove this code. - document.querySelector('#give-next-gen button[type="submit"]') - .scrollIntoView({behavior: 'smooth'}); + document.querySelector('#give-next-gen button[type="submit"]').scrollIntoView({behavior: 'smooth'}); return actions.reject(); - } // Validate the form values before proceeding. const result = await trigger(); - if(result === false){ + if (result === false) { // Set focus on first invalid field. - for (const fieldName in getValues()) { - if(getFieldState(fieldName).invalid){ + for (const fieldName in getValues()) { + if (getFieldState(fieldName).invalid) { setFocus(fieldName); } - } + } return actions.reject(); } + orderCreated = true; return actions.resolve(); }, onApprove: async (data, actions) => { const donationFormWithSubmitButton = Array.from(document.forms).pop(); - const submitButton = donationFormWithSubmitButton.querySelector('[type="submit"]'); + const submitButton: HTMLButtonElement = donationFormWithSubmitButton.querySelector('[type="submit"]'); + const submitButtonDefaultText = submitButton.textContent; + submitButton.textContent = __('Waiting for PayPal...', 'give'); + submitButton.disabled = true; + + if (payPalOrderId && updateOrderAmount) { + const response = await fetch( + `${payPalDonationsSettings.ajaxUrl}?action=give_paypal_commerce_update_order_amount&order=${payPalOrderId}`, + { + method: 'POST', + body: getFormData(), + } + ); + + const {data: ajaxResponseData} = await response.json(); - if(donationType === 'subscription') { - // @ts-ignore + if (ajaxResponseData.hasOwnProperty('error')) { + submitButton.disabled = false; + submitButton.textContent = submitButtonDefaultText; + throw new Error(ajaxResponseData.error); + } + } + + if (donationType === 'subscription') { + submitButton.disabled = false; + submitButton.textContent = submitButtonDefaultText; submitButton.click(); return; } return actions.order.capture().then((details) => { - // @ts-ignore + submitButton.disabled = false; + submitButton.textContent = submitButtonDefaultText; submitButton.click(); }); - } - } + }, + }; - return donationType === 'subscription' + return donationType === 'subscription' ? ( // @ts-ignore - ? + + ) : ( // @ts-ignore - : ; + + ); }; const HostedFieldsContainer = () => { @@ -350,9 +394,8 @@ import {PayPalSubscriber} from "./types"; }); return ( - - -
+ +
- -
-
- + + ); }; @@ -431,7 +472,7 @@ import {PayPalSubscriber} from "./types"; return ( <> - { -1 !== options.components.indexOf('hosted-fields') && } + {-1 !== options.components.indexOf('hosted-fields') && } ); } @@ -454,10 +495,10 @@ import {PayPalSubscriber} from "./types"; }; } - if(payPalSubscriptionId) { + if (payPalSubscriptionId) { return { payPalSubscriptionId: payPalSubscriptionId, - } + }; } if (!validateHostedFields()) { @@ -466,8 +507,7 @@ import {PayPalSubscriber} from "./types"; const approveOrderCallback = async (data) => { const response = await fetch( - `${payPalDonationsSettings.ajaxUrl}?action=give_paypal_commerce_approve_order&order=` + - data.orderId, + `${payPalDonationsSettings.ajaxUrl}?action=give_paypal_commerce_approve_order&order=${data.orderId}&update_amount=${updateOrderAmount}`, { method: 'POST', body: getFormData(), @@ -476,33 +516,31 @@ import {PayPalSubscriber} from "./types"; const {data: ajaxResponseData} = await response.json(); - if( ajaxResponseData.hasOwnProperty('error')){ + if (ajaxResponseData.hasOwnProperty('error')) { throw new Error(ajaxResponseData.error); } return {...data, payPalOrderId: data.orderId}; }; - try{ - const result = await hostedField.cardFields - .submit({ - // Trigger 3D Secure authentication - contingencies: [ 'SCA_WHEN_REQUIRED' ], - cardholderName: cardholderName - }); - + try { + const result = await hostedField.cardFields.submit({ + // Trigger 3D Secure authentication + contingencies: ['SCA_WHEN_REQUIRED'], + cardholderName: cardholderName, + }); if ( - ! result // Check whether get result from paypal gateway server. - || ( - [ 'NO', 'POSSIBLE' ].includes( result.liabilityShift ) // Check whether card required 3D secure validation. - && ! (result.liabilityShifted && 'POSSIBLE' === result.liabilityShift) // Check whether card passed 3D secure validation. - ) + !result || // Check whether get result from paypal gateway server. + (['NO', 'POSSIBLE'].includes(result.liabilityShift) && // Check whether card required 3D secure validation. + !(result.liabilityShifted && 'POSSIBLE' === result.liabilityShift)) // Check whether card passed 3D secure validation. ) { - throw new Error(__( - 'There was a problem authenticating your payment method. Please try again. If the problem persists, please try another payment method.', - 'give' - )); + throw new Error( + __( + 'There was a problem authenticating your payment method. Please try again. If the problem persists, please try another payment method.', + 'give' + ) + ); } return await approveOrderCallback(result); @@ -511,16 +549,11 @@ import {PayPalSubscriber} from "./types"; // Handle PayPal error. const isPayPalDonationError = err.hasOwnProperty('details'); - if( isPayPalDonationError ){ + if (isPayPalDonationError) { throw new Error(err.details[0].description); } - throw new Error( - sprintf( - __('Paypal Donations Error: %s', 'give'), - err.message - ) - ); + throw new Error(sprintf(__('Paypal Donations Error: %s', 'give'), err.message)); } }, Fields() { @@ -530,10 +563,7 @@ import {PayPalSubscriber} from "./types"; return ( - + diff --git a/src/PaymentGateways/PayPalCommerce/AjaxRequestHandler.php b/src/PaymentGateways/PayPalCommerce/AjaxRequestHandler.php index f059a0a49a..716a846947 100644 --- a/src/PaymentGateways/PayPalCommerce/AjaxRequestHandler.php +++ b/src/PaymentGateways/PayPalCommerce/AjaxRequestHandler.php @@ -161,7 +161,7 @@ public function onGetPartnerUrlAjaxRequestHandler() 'tab' => 'gateways', 'section' => 'paypal', 'group' => 'paypal-commerce', - 'mode' => $mode + 'mode' => $mode, ], admin_url('edit.php?post_type=give_forms&page=give-settings') ); @@ -232,12 +232,35 @@ public function removePayPalAccount() 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 3.4.2 + */ + private function getOrderData(): array + { $postData = give_clean($_POST); $formId = absint($postData['give-form-id']); $donorAddress = $this->getDonorAddressFromPostedDataForPaypalOrder($postData); - $data = [ + return [ 'formId' => $formId, 'formTitle' => give_payment_gateway_item_title(['post_data' => $postData], 127), 'donationAmount' => isset($postData['give-amount']) ? @@ -254,24 +277,8 @@ public function createOrder() 'lastName' => $postData['give_last'], 'email' => $postData['give_email'], 'address' => $donorAddress, - ] + ], ]; - - 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), - ] - ); - } } /** @@ -287,8 +294,13 @@ public function approveOrder() $this->validateFrontendRequest(); $orderId = give_clean($_GET['order']); + $updateAmount = filter_var(give_clean($_GET['update_amount']), FILTER_VALIDATE_BOOLEAN); try { + if ($updateAmount) { + give(PayPalOrder::class)->updateOrderAmount($orderId, $this->getOrderData()); + } + $result = give(PayPalOrder::class)->approveOrder($orderId); // PayPal does not return error in case of invalid cvv. So we need to check capture status and return error. // ref - https://feedback.givewp.com/bug-reports/p/paypal-credit-card-donations-can-generate-a-fatal-error @@ -299,6 +311,24 @@ public function approveOrder() } } + /** + * @since 3.4.2 + */ + public function updateOrderAmount() + { + $this->validateFrontendRequest(); + + $orderId = give_clean($_GET['order']); + + try { + give(PayPalOrder::class)->updateOrderAmount($orderId, $this->getOrderData()); + + wp_send_json_success(['order' => $orderId,]); + } catch (\Exception $ex) { + wp_send_json_error(['error' => json_decode($ex->getMessage(), true),]); + } + } + /** * Return on boarding trouble notice. * diff --git a/src/PaymentGateways/PayPalCommerce/Repositories/PayPalOrder.php b/src/PaymentGateways/PayPalCommerce/Repositories/PayPalOrder.php index a33dcf9b0b..1bd4e200a7 100644 --- a/src/PaymentGateways/PayPalCommerce/Repositories/PayPalOrder.php +++ b/src/PaymentGateways/PayPalCommerce/Repositories/PayPalOrder.php @@ -5,15 +5,14 @@ use Give\Framework\Exceptions\Primitives\Exception; use Give\Framework\Exceptions\Primitives\InvalidArgumentException; use Give\PaymentGateways\PayPalCommerce\Models\MerchantDetail; +use Give\PaymentGateways\PayPalCommerce\Models\PayPalOrder as PayPalOrderModel; use Give\PaymentGateways\PayPalCommerce\PayPalClient; use PayPalCheckoutSdk\Orders\OrdersCaptureRequest; use PayPalCheckoutSdk\Orders\OrdersCreateRequest; use PayPalCheckoutSdk\Orders\OrdersGetRequest; +use PayPalCheckoutSdk\Orders\OrdersPatchRequest; use PayPalCheckoutSdk\Payments\CapturesRefundRequest; -use Give\PaymentGateways\PayPalCommerce\Models\PayPalOrder as PayPalOrderModel; - use PayPalHttp\HttpException; - use PayPalHttp\IOException; use function give_record_gateway_error as logError; @@ -97,55 +96,45 @@ public function approveOrder($orderId) * * @see https://developer.paypal.com/docs/api/orders/v2 * + * @since 3.4.2 Extract the amount parameters to a separate method * @since 3.1.0 "payer" argument is deprecated, using payment_source/paypal. * @since 2.9.0 * @since 2.16.2 Conditionally set transaction as donation or standard transaction in PayPal. * - * @param array $array - * - * @return string - * @throws Exception + * @throws Exception|HttpException|IOException */ - public function createOrder($array) + public function createOrder(array $array): string { $this->validateCreateOrderArguments($array); $request = new OrdersCreateRequest(); $request->payPalPartnerAttributionId(give('PAYPAL_COMMERCE_ATTRIBUTION_ID')); - $formId = (int)$array['formId']; - $donationCurrency = give_get_currency($formId); - $donationAmount = give_maybe_sanitize_amount( - $array['donationAmount'], - ['currency' => give_get_currency($formId)] - ); $request->body = [ 'intent' => 'CAPTURE', 'payment_source' => [ "paypal" => [ 'name' => [ "given_name" => $array['payer']['firstName'], - "surname" => $array['payer']['lastName'] + "surname" => $array['payer']['lastName'], ], - "email_address" => $array['payer']['email'] - ] + "email_address" => $array['payer']['email'], + ], ], 'purchase_units' => [ - [ - 'reference_id' => get_post_field('post_name', $formId), - 'description' => $array['formTitle'], - 'amount' => [ - 'value' => $donationAmount, - 'currency_code' => $donationCurrency, - ], - 'payee' => [ - 'email_address' => $this->merchantDetails->merchantId, - 'merchant_id' => $this->merchantDetails->merchantIdInPayPal, - ], - 'payment_instruction' => [ - 'disbursement_mode' => 'INSTANT', - ], - ], + array_merge( + $this->getAmountParameters($array), + [ + 'description' => $array['formTitle'], + 'payee' => [ + 'email_address' => $this->merchantDetails->merchantId, + 'merchant_id' => $this->merchantDetails->merchantIdInPayPal, + ], + 'payment_instruction' => [ + 'disbursement_mode' => 'INSTANT', + ], + ] + ), ], 'application_context' => [ 'shipping_preference' => 'NO_SHIPPING', @@ -153,9 +142,56 @@ public function createOrder($array) ], ]; + if (! empty($array['payer']['address'])) { + $request->body['payment_source']['paypal']['address'] = $array['payer']['address']; + } + + try { + return $this->paypalClient->getHttpClient()->execute($request)->result->id; + } catch (Exception $ex) { + logError( + 'Create PayPal Commerce order failure', + sprintf( + 'Request
%1$s

Response
%2$s
', + print_r($request->body, true), + print_r(json_decode($ex->getMessage(), true), true) + ) + ); + + throw $ex; + } + } + + /** + * @since 3.4.2 + */ + private function getAmountParameters($array): array + { + $formId = (int)$array['formId']; + $donationCurrency = give_get_currency($formId); + $donationAmount = give_maybe_sanitize_amount( + $array['donationAmount'], + ['currency' => give_get_currency($formId)] + ); + + /** + * To make an update, you must provide a reference_id. If you OMIT THIS VALUE WITH AN ORDER THAT CONTAINS + * ONLY ONE PURCHASE UNIT, PayPal sets the value to default which enables you to use the path: + * "/purchase_units/@reference_id=='default'/{attribute-or-object}". + * + * @see https://developer.paypal.com/docs/api/orders/v2/#orders_patch + */ + $amountParameters = [ + //'reference_id' => get_post_field('post_name', $formId), + 'amount' => [ + 'value' => $donationAmount, + 'currency_code' => $donationCurrency, + ], + ]; + // Set PayPal transaction as donation. if ($this->settings->isTransactionTypeDonation()) { - $request->body['purchase_units'][0]['items'] = [ + $amountParameters['items'] = [ [ 'name' => get_post_field('post_name', $formId), 'unit_amount' => [ @@ -167,7 +203,7 @@ public function createOrder($array) ], ]; - $request->body['purchase_units'][0]['amount']['breakdown'] = [ + $amountParameters['amount']['breakdown'] = [ 'item_total' => [ 'currency_code' => $donationCurrency, 'value' => $donationAmount, @@ -175,18 +211,45 @@ public function createOrder($array) ]; } - if (! empty($array['payer']['address'])) { - $request->body['payment_source']['paypal']['address'] = $array['payer']['address']; - } + return $amountParameters; + } + + /** + * @since 3.4.2 + * + * @see https://github.com/paypal/Checkout-PHP-SDK/blob/develop/samples/PatchOrder.php + * + * @return mixed + * + * @throws Exception|HttpException|IOException + */ + public function updateOrderAmount($orderId, array $array) + { + $this->validateCreateOrderArguments($array); + + $patchRequest = new OrdersPatchRequest($orderId); + + $patchRequest->body = [ + 0 => [ + 'op' => 'replace', + 'path' => '/intent', + 'value' => 'CAPTURE', + ], + 1 => [ + 'op' => 'replace', + 'path' => "/purchase_units/@reference_id=='default'", + 'value' => $this->getAmountParameters($array), + ], + ]; try { - return $this->paypalClient->getHttpClient()->execute($request)->result->id; + return $this->paypalClient->getHttpClient()->execute($patchRequest)->result->id; } catch (Exception $ex) { logError( - 'Create PayPal Commerce order failure', + 'Update PayPal Commerce order failure', sprintf( 'Request
%1$s

Response
%2$s
', - print_r($request->body, true), + print_r($patchRequest->body, true), print_r(json_decode($ex->getMessage(), true), true) ) ); diff --git a/src/ServiceProviders/PaymentGateways.php b/src/ServiceProviders/PaymentGateways.php index a9f5cf4010..81cfc65742 100644 --- a/src/ServiceProviders/PaymentGateways.php +++ b/src/ServiceProviders/PaymentGateways.php @@ -5,6 +5,8 @@ use Give\Controller\PayPalWebhooks; use Give\Framework\Migrations\MigrationsRegister; use Give\Helpers\Hooks; +use Give\PaymentGateways\Gateways\PayPalStandard\Migrations\RemovePayPalIPNVerificationSetting; +use Give\PaymentGateways\Gateways\PayPalStandard\Migrations\SetPayPalStandardGatewayId; use Give\PaymentGateways\PayPalCommerce\AccountAdminNotices; use Give\PaymentGateways\PayPalCommerce\AdvancedCardFields; use Give\PaymentGateways\PayPalCommerce\AjaxRequestHandler; @@ -23,8 +25,6 @@ use Give\PaymentGateways\PayPalCommerce\Webhooks\WebhookChecker; use Give\PaymentGateways\PayPalCommerce\Webhooks\WebhookRegister; use Give\PaymentGateways\PaypalSettingPage; -use Give\PaymentGateways\Gateways\PayPalStandard\Migrations\RemovePayPalIPNVerificationSetting; -use Give\PaymentGateways\Gateways\PayPalStandard\Migrations\SetPayPalStandardGatewayId; use Give\PaymentGateways\Stripe\Admin\AccountManagerSettingField; use Give\PaymentGateways\Stripe\Admin\CreditCardSettingField; use Give\PaymentGateways\Stripe\ApplicationFee; @@ -243,6 +243,14 @@ private function registerPayPalCommerceHooks() 'approveOrder' ); + Hooks::addAction('wp_ajax_give_paypal_commerce_update_order_amount', AjaxRequestHandler::class, + 'updateOrderAmount'); + Hooks::addAction( + 'wp_ajax_nopriv_give_paypal_commerce_update_order_amount', + AjaxRequestHandler::class, + 'updateOrderAmount' + ); + Hooks::addAction('admin_enqueue_scripts', ScriptLoader::class, 'loadAdminScripts'); Hooks::addAction('wp_enqueue_scripts', ScriptLoader::class, 'loadPublicAssets'); Hooks::addAction('give_pre_form_output', DonationFormPaymentMethod::class, 'handle');