Merge pull request #2513 from woocommerce/PCP-3527-google-pay-shipping-callback-not-calculating-totals-correctly-on-single-product-page

Google Pay: Shipping callback not calculating totals correctly on Single Product page (3527)
This commit is contained in:
Philipp Stracker 2024-09-06 15:42:51 +02:00 committed by GitHub
commit c3d53853de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 274 additions and 43 deletions

View file

@ -1,5 +1,6 @@
import ErrorHandler from '../../../../ppcp-button/resources/js/modules/ErrorHandler';
import CartActionHandler from '../../../../ppcp-button/resources/js/modules/ActionHandler/CartActionHandler';
import TransactionInfo from '../Helper/TransactionInfo';
class BaseHandler {
constructor( buttonConfig, ppcpConfig, externalHandler ) {
@ -34,13 +35,14 @@ class BaseHandler {
// handle script reload
const data = result.data;
const transaction = new TransactionInfo(
data.total,
data.shipping_fee,
data.currency_code,
data.country_code
);
resolve( {
countryCode: data.country_code,
currencyCode: data.currency_code,
totalPriceStatus: 'FINAL',
totalPrice: data.total_str,
} );
resolve( transaction );
} );
} );
}

View file

@ -1,6 +1,7 @@
import Spinner from '../../../../ppcp-button/resources/js/modules/Helper/Spinner';
import BaseHandler from './BaseHandler';
import CheckoutActionHandler from '../../../../ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler';
import TransactionInfo from '../Helper/TransactionInfo';
class PayNowHandler extends BaseHandler {
validateContext() {
@ -14,12 +15,14 @@ class PayNowHandler extends BaseHandler {
return new Promise( async ( resolve, reject ) => {
const data = this.ppcpConfig.pay_now;
resolve( {
countryCode: data.country_code,
currencyCode: data.currency_code,
totalPriceStatus: 'FINAL',
totalPrice: data.total_str,
} );
const transaction = new TransactionInfo(
data.total,
data.shipping_fee,
data.currency_code,
data.country_code
);
resolve( transaction );
} );
}

View file

@ -3,6 +3,7 @@ import SimulateCart from '../../../../ppcp-button/resources/js/modules/Helper/Si
import ErrorHandler from '../../../../ppcp-button/resources/js/modules/ErrorHandler';
import UpdateCart from '../../../../ppcp-button/resources/js/modules/Helper/UpdateCart';
import BaseHandler from './BaseHandler';
import TransactionInfo from '../Helper/TransactionInfo';
class SingleProductHandler extends BaseHandler {
validateContext() {
@ -42,12 +43,14 @@ class SingleProductHandler extends BaseHandler {
this.ppcpConfig.ajax.simulate_cart.endpoint,
this.ppcpConfig.ajax.simulate_cart.nonce
).simulate( ( data ) => {
resolve( {
countryCode: data.country_code,
currencyCode: data.currency_code,
totalPriceStatus: 'FINAL',
totalPrice: data.total_str,
} );
const transaction = new TransactionInfo(
data.total,
data.shipping_fee,
data.currency_code,
data.country_code
);
resolve( transaction );
}, products );
} );
}

View file

@ -5,6 +5,7 @@ import {
import PaymentButton from '../../../ppcp-button/resources/js/modules/Renderer/PaymentButton';
import widgetBuilder from '../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder';
import UpdatePaymentData from './Helper/UpdatePaymentData';
import TransactionInfo from './Helper/TransactionInfo';
import { PaymentMethods } from '../../../ppcp-button/resources/js/modules/Helper/CheckoutMethodState';
import { setPayerData } from '../../../ppcp-button/resources/js/modules/Helper/PayerData';
import moduleStorage from './Helper/GooglePayStorage';
@ -480,7 +481,7 @@ class GooglepayButton extends PaymentButton {
return {
...baseRequest,
allowedPaymentMethods: this.googlePayConfig.allowedPaymentMethods,
transactionInfo: this.transactionInfo,
transactionInfo: this.transactionInfo.finalObject,
merchantInfo: this.googlePayConfig.merchantInfo,
callbackIntents,
emailRequired: true,
@ -513,6 +514,16 @@ class GooglepayButton extends PaymentButton {
).update( paymentData );
const transactionInfo = this.transactionInfo;
// Check, if the current context uses the WC cart.
const hasRealCart = [
'checkout-block',
'checkout',
'cart-block',
'cart',
'mini-cart',
'pay-now',
].includes( this.context );
this.log( 'onPaymentDataChanged:updatedData', updatedData );
this.log(
'onPaymentDataChanged:transactionInfo',
@ -521,7 +532,6 @@ class GooglepayButton extends PaymentButton {
updatedData.country_code = transactionInfo.countryCode;
updatedData.currency_code = transactionInfo.currencyCode;
updatedData.total_str = transactionInfo.totalPrice;
// Handle unserviceable address.
if ( ! updatedData.shipping_options?.shippingOptions?.length ) {
@ -531,20 +541,37 @@ class GooglepayButton extends PaymentButton {
return;
}
switch ( paymentData.callbackTrigger ) {
case 'INITIALIZE':
case 'SHIPPING_ADDRESS':
paymentDataRequestUpdate.newShippingOptionParameters =
updatedData.shipping_options;
paymentDataRequestUpdate.newTransactionInfo =
this.calculateNewTransactionInfo( updatedData );
break;
case 'SHIPPING_OPTION':
paymentDataRequestUpdate.newTransactionInfo =
this.calculateNewTransactionInfo( updatedData );
break;
if (
[ 'INITIALIZE', 'SHIPPING_ADDRESS' ].includes(
paymentData.callbackTrigger
)
) {
paymentDataRequestUpdate.newShippingOptionParameters =
this.sanitizeShippingOptions(
updatedData.shipping_options
);
}
if ( updatedData.total && hasRealCart ) {
transactionInfo.setTotal(
updatedData.total,
updatedData.shipping_fee
);
// This page contains a real cart and potentially a form for shipping options.
this.syncShippingOptionWithForm(
paymentData?.shippingOptionData?.id
);
} else {
transactionInfo.shippingFee = this.getShippingCosts(
paymentData?.shippingOptionData?.id,
updatedData.shipping_options
);
}
paymentDataRequestUpdate.newTransactionInfo =
this.calculateNewTransactionInfo( transactionInfo );
resolve( paymentDataRequestUpdate );
} catch ( error ) {
this.error( 'Error during onPaymentDataChanged:', error );
@ -553,6 +580,76 @@ class GooglepayButton extends PaymentButton {
} );
}
/**
* Google Pay throws an error, when the shippingOptions entries contain
* custom properties. This function strips unsupported properties from the
* provided ajax response.
*
* @param {Object} responseData Data returned from the ajax endpoint.
* @return {Object} Sanitized object.
*/
sanitizeShippingOptions( responseData ) {
// Sanitize the shipping options.
const cleanOptions = responseData.shippingOptions.map( ( item ) => ( {
id: item.id,
label: item.label,
description: item.description,
} ) );
// Ensure that the default option is valid.
let defaultOptionId = responseData.defaultSelectedOptionId;
if ( ! cleanOptions.some( ( item ) => item.id === defaultOptionId ) ) {
defaultOptionId = cleanOptions[ 0 ].id;
}
return {
defaultSelectedOptionId: defaultOptionId,
shippingOptions: cleanOptions,
};
}
/**
* Returns the shipping costs as numeric value.
*
* TODO - Move this to the PaymentButton base class
*
* @param {string} shippingId - The shipping method ID.
* @param {Object} shippingData - The PaymentDataRequest object that
* contains shipping options.
* @param {Array} shippingData.shippingOptions
* @param {string} shippingData.defaultSelectedOptionId
*
* @return {number} The shipping costs.
*/
getShippingCosts(
shippingId,
{ shippingOptions = [], defaultSelectedOptionId = '' } = {}
) {
if ( ! shippingOptions?.length ) {
this.log( 'Cannot calculate shipping cost: No Shipping Options' );
return 0;
}
const findOptionById = ( id ) =>
shippingOptions.find( ( option ) => option.id === id );
const getValidShippingId = () => {
if (
'shipping_option_unselected' === shippingId ||
! findOptionById( shippingId )
) {
// Entered on initial call, and when changing the shipping country.
return defaultSelectedOptionId;
}
return shippingId;
};
const currentOption = findOptionById( getValidShippingId() );
return Number( currentOption?.cost ) || 0;
}
unserviceableShippingAddressError() {
return {
reason: 'SHIPPING_ADDRESS_UNSERVICEABLE',
@ -561,13 +658,14 @@ class GooglepayButton extends PaymentButton {
};
}
calculateNewTransactionInfo( updatedData ) {
return {
countryCode: updatedData.country_code,
currencyCode: updatedData.currency_code,
totalPriceStatus: 'FINAL',
totalPrice: updatedData.total_str,
};
/**
* Recalculates and returns the plain transaction info object.
*
* @param {TransactionInfo} transactionInfo - Internal transactionInfo instance.
* @return {{totalPrice: string, countryCode: string, totalPriceStatus: string, currencyCode: string}} Updated details.
*/
calculateNewTransactionInfo( transactionInfo ) {
return transactionInfo.finalObject;
}
//------------------------
@ -699,6 +797,55 @@ class GooglepayButton extends PaymentButton {
return response;
}
/**
* Updates the shipping option in the checkout form, if a form with shipping options is
* detected.
*
* @param {string} shippingOption - The shipping option ID, e.g. "flat_rate:4".
* @return {boolean} - True if a shipping option was found and selected, false otherwise.
*/
syncShippingOptionWithForm( shippingOption ) {
const wrappers = [
// Classic checkout, Classic cart.
'.woocommerce-shipping-methods',
// Block checkout.
'.wc-block-components-shipping-rates-control',
// Block cart.
'.wc-block-components-totals-shipping',
];
const sanitizedShippingOption = shippingOption.replace( /"/g, '' );
// Check for radio buttons with shipping options.
for ( const wrapper of wrappers ) {
const selector = `${ wrapper } input[type="radio"][value="${ sanitizedShippingOption }"]`;
const radioInput = document.querySelector( selector );
if ( radioInput ) {
radioInput.click();
return true;
}
}
// Check for select list with shipping options.
for ( const wrapper of wrappers ) {
const selector = `${ wrapper } select option[value="${ sanitizedShippingOption }"]`;
const selectOption = document.querySelector( selector );
if ( selectOption ) {
const selectElement = selectOption.closest( 'select' );
if ( selectElement ) {
selectElement.value = sanitizedShippingOption;
selectElement.dispatchEvent( new Event( 'change' ) );
return true;
}
}
}
return false;
}
}
export default GooglepayButton;

View file

@ -0,0 +1,73 @@
export default class TransactionInfo {
#country = '';
#currency = '';
#amount = 0;
#shippingFee = 0;
constructor( total, shippingFee, currency, country ) {
this.#country = country;
this.#currency = currency;
this.shippingFee = shippingFee;
this.amount = total - shippingFee;
}
set amount( newAmount ) {
this.#amount = this.toAmount( newAmount );
}
get amount() {
return this.#amount;
}
set shippingFee( newCost ) {
this.#shippingFee = this.toAmount( newCost );
}
get shippingFee() {
return this.#shippingFee;
}
get currencyCode() {
return this.#currency;
}
get countryCode() {
return this.#country;
}
get totalPrice() {
const total = this.#amount + this.#shippingFee;
return total.toFixed( 2 );
}
get finalObject() {
return {
countryCode: this.countryCode,
currencyCode: this.currencyCode,
totalPriceStatus: 'FINAL',
totalPrice: this.totalPrice,
};
}
/**
* Converts the value to a number and rounds to a precision of 2 digits.
*
* @param {any} value - The value to sanitize.
* @return {number} Numeric value.
*/
toAmount( value ) {
value = Number( value ) || 0;
return Math.round( value * 100 ) / 100;
}
setTotal( totalPrice, shippingFee ) {
totalPrice = this.toAmount( totalPrice );
if ( totalPrice ) {
this.shippingFee = shippingFee;
this.amount = totalPrice - this.shippingFee;
}
}
}

View file

@ -90,7 +90,8 @@ class UpdatePaymentDataEndpoint {
WC()->cart->calculate_fees();
WC()->cart->calculate_totals();
$total = (float) WC()->cart->get_total( 'numeric' );
$total = (float) WC()->cart->get_total( 'numeric' );
$shipping_fee = (float) WC()->cart->get_shipping_total();
// Shop settings.
$base_location = wc_get_base_location();
@ -100,7 +101,7 @@ class UpdatePaymentDataEndpoint {
wp_send_json_success(
array(
'total' => $total,
'total_str' => ( new Money( $total, $currency_code ) )->value_str(),
'shipping_fee' => $shipping_fee,
'currency_code' => $currency_code,
'country_code' => $shop_country_code,
'shipping_options' => $this->get_shipping_options(),
@ -146,6 +147,7 @@ class UpdatePaymentDataEndpoint {
wc_price( (float) $rate->get_cost(), array( 'currency' => get_woocommerce_currency() ) )
)
),
'cost' => $rate->get_cost(),
);
}