Merge pull request #2525 from woocommerce/PCP-3525-google-pay-shipping-callback-active-for-virtual-product

Google Pay billing data without shipping callback (3525)
This commit is contained in:
Philipp Stracker 2024-09-06 14:50:29 +02:00 committed by GitHub
commit 87e8aed779
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 790 additions and 180 deletions

View file

@ -0,0 +1,102 @@
import { GooglePayStorage } from '../Helper/GooglePayStorage';
import {
getWooCommerceCustomerDetails,
setPayerData,
} from '../../../../ppcp-button/resources/js/modules/Helper/PayerData';
const CHECKOUT_FORM_SELECTOR = 'form.woocommerce-checkout';
export class CheckoutBootstrap {
/**
* @type {GooglePayStorage}
*/
#storage;
/**
* @type {HTMLFormElement|null}
*/
#checkoutForm;
/**
* @param {GooglePayStorage} storage
*/
constructor( storage ) {
this.#storage = storage;
this.#checkoutForm = CheckoutBootstrap.getCheckoutForm();
}
/**
* Indicates if the current page contains a checkout form.
*
* @return {boolean} True if a checkout form is present.
*/
static isPageWithCheckoutForm() {
return null !== CheckoutBootstrap.getCheckoutForm();
}
/**
* Retrieves the WooCommerce checkout form element.
*
* @return {HTMLFormElement|null} The form, or null if not a checkout page.
*/
static getCheckoutForm() {
return document.querySelector( CHECKOUT_FORM_SELECTOR );
}
/**
* Returns the WooCommerce checkout form element.
*
* @return {HTMLFormElement|null} The form, or null if not a checkout page.
*/
get checkoutForm() {
return this.#checkoutForm;
}
/**
* Initializes the checkout process.
*
* @throws {Error} If called on a page without a checkout form.
*/
init() {
if ( ! this.#checkoutForm ) {
throw new Error(
'Checkout form not found. Cannot initialize CheckoutBootstrap.'
);
}
this.#populateCheckoutFields();
}
/**
* Populates checkout fields with stored or customer data.
*/
#populateCheckoutFields() {
const loggedInData = getWooCommerceCustomerDetails();
if ( loggedInData ) {
// If customer is logged in, we use the details from the customer profile.
return;
}
const billingData = this.#storage.getPayer();
if ( ! billingData ) {
return;
}
setPayerData( billingData, true );
this.checkoutForm.addEventListener(
'submit',
this.#onFormSubmit.bind( this )
);
}
/**
* Clean-up when checkout form is submitted.
*
* Immediately removes the payer details from the localStorage.
*/
#onFormSubmit() {
this.#storage.clearPayer();
}
}

View file

@ -6,6 +6,8 @@ import PaymentButton from '../../../ppcp-button/resources/js/modules/Renderer/Pa
import widgetBuilder from '../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder';
import UpdatePaymentData from './Helper/UpdatePaymentData';
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';
/**
* Plugin-specific styling.
@ -39,11 +41,17 @@ import { PaymentMethods } from '../../../ppcp-button/resources/js/modules/Helper
*
* @see https://developers.google.com/pay/api/web/reference/client
* @typedef {Object} PaymentsClient
* @property {Function} createButton - The convenience method is used to generate a Google Pay payment button styled with the latest Google Pay branding for insertion into a webpage.
* @property {Function} isReadyToPay - Use the isReadyToPay(isReadyToPayRequest) method to determine a user's ability to return a form of payment from the Google Pay API.
* @property {Function} loadPaymentData - This method presents a Google Pay payment sheet that allows selection of a payment method and optionally configured parameters
* @property {Function} onPaymentAuthorized - This method is called when a payment is authorized in the payment sheet.
* @property {Function} onPaymentDataChanged - This method handles payment data changes in the payment sheet such as shipping address and shipping options.
* @property {Function} createButton - The convenience method is used to
* generate a Google Pay payment button styled with the latest Google Pay branding for
* insertion into a webpage.
* @property {Function} isReadyToPay - Use the isReadyToPay(isReadyToPayRequest)
* method to determine a user's ability to return a form of payment from the Google Pay API.
* @property {(Object) => Promise} loadPaymentData - This method presents a Google Pay payment
* sheet that allows selection of a payment method and optionally configured parameters
* @property {Function} onPaymentAuthorized - This method is called when a payment is
* authorized in the payment sheet.
* @property {Function} onPaymentDataChanged - This method handles payment data changes
* in the payment sheet such as shipping address and shipping options.
*/
/**
@ -53,14 +61,40 @@ import { PaymentMethods } from '../../../ppcp-button/resources/js/modules/Helper
* @typedef {Object} TransactionInfo
* @property {string} currencyCode - Required. The ISO 4217 alphabetic currency code.
* @property {string} countryCode - Optional. required for EEA countries,
* @property {string} transactionId - Optional. A unique ID that identifies a facilitation attempt. Highly encouraged for troubleshooting.
* @property {string} totalPriceStatus - Required. [ESTIMATED|FINAL] The status of the total price used:
* @property {string} totalPrice - Required. Total monetary value of the transaction with an optional decimal precision of two decimal places.
* @property {Array} displayItems - Optional. A list of cart items shown in the payment sheet (e.g. subtotals, sales taxes, shipping charges, discounts etc.).
* @property {string} totalPriceLabel - Optional. Custom label for the total price within the display items.
* @property {string} checkoutOption - Optional. Affects the submit button text displayed in the Google Pay payment sheet.
* @property {string} transactionId - Optional. A unique ID that identifies a facilitation
* attempt. Highly encouraged for troubleshooting.
* @property {string} totalPriceStatus - Required. [ESTIMATED|FINAL] The status of the total price
* used:
* @property {string} totalPrice - Required. Total monetary value of the transaction with an
* optional decimal precision of two decimal places.
* @property {Array} displayItems - Optional. A list of cart items shown in the payment sheet
* (e.g. subtotals, sales taxes, shipping charges, discounts etc.).
* @property {string} totalPriceLabel - Optional. Custom label for the total price within the
* display items.
* @property {string} checkoutOption - Optional. Affects the submit button text displayed in the
* Google Pay payment sheet.
*/
function payerDataFromPaymentResponse( response ) {
const raw = response?.paymentMethodData?.info?.billingAddress;
return {
email_address: response?.email,
name: {
given_name: raw.name.split( ' ' )[ 0 ], // Assuming first name is the first part
surname: raw.name.split( ' ' ).slice( 1 ).join( ' ' ), // Assuming last name is the rest
},
address: {
country_code: raw.countryCode,
address_line_1: raw.address1,
address_line_2: raw.address2,
admin_area_1: raw.administrativeArea,
admin_area_2: raw.locality,
postal_code: raw.postalCode,
},
};
}
class GooglepayButton extends PaymentButton {
/**
* @inheritDoc
@ -78,7 +112,7 @@ class GooglepayButton extends PaymentButton {
#paymentsClient = null;
/**
* Details about the processed transaction.
* Details about the processed transaction, provided to the Google SDK.
*
* @type {?TransactionInfo}
*/
@ -388,12 +422,14 @@ class GooglepayButton extends PaymentButton {
const initiatePaymentRequest = () => {
window.ppcpFundingSource = 'googlepay';
const paymentDataRequest = this.paymentDataRequest();
this.log(
'onButtonClick: paymentDataRequest',
paymentDataRequest,
this.context
);
this.paymentsClient.loadPaymentData( paymentDataRequest );
return this.paymentsClient.loadPaymentData( paymentDataRequest );
};
const validateForm = () => {
@ -434,28 +470,24 @@ class GooglepayButton extends PaymentButton {
apiVersionMinor: 0,
};
const googlePayConfig = this.googlePayConfig;
const paymentDataRequest = Object.assign( {}, baseRequest );
paymentDataRequest.allowedPaymentMethods =
googlePayConfig.allowedPaymentMethods;
paymentDataRequest.transactionInfo = this.transactionInfo;
paymentDataRequest.merchantInfo = googlePayConfig.merchantInfo;
const useShippingCallback = this.requiresShipping;
const callbackIntents = [ 'PAYMENT_AUTHORIZATION' ];
if ( this.requiresShipping ) {
paymentDataRequest.callbackIntents = [
'SHIPPING_ADDRESS',
'SHIPPING_OPTION',
'PAYMENT_AUTHORIZATION',
];
paymentDataRequest.shippingAddressRequired = true;
paymentDataRequest.shippingAddressParameters =
this.shippingAddressParameters();
paymentDataRequest.shippingOptionRequired = true;
} else {
paymentDataRequest.callbackIntents = [ 'PAYMENT_AUTHORIZATION' ];
if ( useShippingCallback ) {
callbackIntents.push( 'SHIPPING_ADDRESS', 'SHIPPING_OPTION' );
}
return paymentDataRequest;
return {
...baseRequest,
allowedPaymentMethods: this.googlePayConfig.allowedPaymentMethods,
transactionInfo: this.transactionInfo,
merchantInfo: this.googlePayConfig.merchantInfo,
callbackIntents,
emailRequired: true,
shippingAddressRequired: useShippingCallback,
shippingOptionRequired: useShippingCallback,
shippingAddressParameters: this.shippingAddressParameters(),
};
}
//------------------------
@ -543,83 +575,111 @@ class GooglepayButton extends PaymentButton {
//------------------------
onPaymentAuthorized( paymentData ) {
this.log( 'onPaymentAuthorized' );
this.log( 'onPaymentAuthorized', paymentData );
return this.processPayment( paymentData );
}
async processPayment( paymentData ) {
this.log( 'processPayment' );
this.logGroup( 'processPayment' );
return new Promise( async ( resolve, reject ) => {
try {
const id = await this.contextHandler.createOrder();
const payer = payerDataFromPaymentResponse( paymentData );
this.log( 'processPayment: createOrder', id );
const paymentError = ( reason ) => {
this.error( reason );
const confirmOrderResponse = await widgetBuilder.paypal
.Googlepay()
.confirmOrder( {
orderId: id,
paymentMethodData: paymentData.paymentMethodData,
} );
return this.processPaymentResponse(
'ERROR',
'PAYMENT_AUTHORIZATION',
reason
);
};
this.log(
'processPayment: confirmOrder',
confirmOrderResponse
);
const checkPayPalApproval = async ( orderId ) => {
const confirmationData = {
orderId,
paymentMethodData: paymentData.paymentMethodData,
};
/** Capture the Order on the Server */
if ( confirmOrderResponse.status === 'APPROVED' ) {
let approveFailed = false;
await this.contextHandler.approveOrder(
{
orderID: id,
},
{
// actions mock object.
restart: () =>
new Promise( ( resolve, reject ) => {
approveFailed = true;
resolve();
} ),
order: {
get: () =>
new Promise( ( resolve, reject ) => {
resolve( null );
} ),
},
}
);
const confirmOrderResponse = await widgetBuilder.paypal
.Googlepay()
.confirmOrder( confirmationData );
if ( ! approveFailed ) {
resolve( this.processPaymentResponse( 'SUCCESS' ) );
} else {
resolve(
this.processPaymentResponse(
'ERROR',
'PAYMENT_AUTHORIZATION',
'FAILED TO APPROVE'
)
);
}
} else {
resolve(
this.processPaymentResponse(
'ERROR',
'PAYMENT_AUTHORIZATION',
'TRANSACTION FAILED'
)
);
this.log( 'confirmOrder', confirmOrderResponse );
return 'APPROVED' === confirmOrderResponse?.status;
};
/**
* This approval mainly confirms that the orderID is valid.
*
* It's still needed because this handler redirects to the checkout page if the server-side
* approval was successful.
*
* @param {string} orderID
*/
const approveOrderServerSide = async ( orderID ) => {
let isApproved = true;
this.log( 'approveOrder', orderID );
await this.contextHandler.approveOrder(
{ orderID, payer },
{
restart: () =>
new Promise( ( resolve ) => {
isApproved = false;
resolve();
} ),
order: {
get: () =>
new Promise( ( resolve ) => {
resolve( null );
} ),
},
}
} catch ( err ) {
resolve(
this.processPaymentResponse(
'ERROR',
'PAYMENT_AUTHORIZATION',
err.message
)
);
);
return isApproved;
};
const processPaymentPromise = async ( resolve ) => {
const id = await this.contextHandler.createOrder();
this.log( 'createOrder', id );
const isApprovedByPayPal = await checkPayPalApproval( id );
if ( ! isApprovedByPayPal ) {
resolve( paymentError( 'TRANSACTION FAILED' ) );
return;
}
// This must be the last step in the process, as it initiates a redirect.
const success = await approveOrderServerSide( id );
if ( success ) {
resolve( this.processPaymentResponse( 'SUCCESS' ) );
} else {
resolve( paymentError( 'FAILED TO APPROVE' ) );
}
};
const addBillingDataToSession = () => {
moduleStorage.setPayer( payer );
setPayerData( payer );
};
return new Promise( async ( resolve ) => {
try {
addBillingDataToSession();
await processPaymentPromise( resolve );
} catch ( err ) {
resolve( paymentError( err.message ) );
}
this.logGroup();
} );
}

View file

@ -0,0 +1,31 @@
import { LocalStorage } from '../../../../ppcp-button/resources/js/modules/Helper/LocalStorage';
export class GooglePayStorage extends LocalStorage {
static PAYER = 'payer';
static PAYER_TTL = 900; // 15 minutes in seconds
constructor() {
super( 'ppcp-googlepay' );
}
getPayer() {
return this.get( GooglePayStorage.PAYER );
}
setPayer( data ) {
/*
* The payer details are deleted on successful checkout, or after the TTL is reached.
* This helps to remove stale data from the browser, in case the customer chooses to
* use a different method to complete the purchase.
*/
this.set( GooglePayStorage.PAYER, data, GooglePayStorage.PAYER_TTL );
}
clearPayer() {
this.clear( GooglePayStorage.PAYER );
}
}
const moduleStorage = new GooglePayStorage();
export default moduleStorage;

View file

@ -1,28 +1,62 @@
/**
* Initialize the GooglePay module in the front end.
* In some cases, this module is loaded when the `window.PayPalCommerceGateway` object is not
* present. In that case, the page does not contain a Google Pay button, but some other logic
* that is related to Google Pay (e.g., the CheckoutBootstrap module)
*
* @file
*/
import { loadCustomScript } from '@paypal/paypal-js';
import { loadPaypalScript } from '../../../ppcp-button/resources/js/modules/Helper/ScriptLoading';
import GooglepayManager from './GooglepayManager';
import { setupButtonEvents } from '../../../ppcp-button/resources/js/modules/Helper/ButtonRefreshHelper';
import { CheckoutBootstrap } from './ContextBootstrap/CheckoutBootstrap';
import moduleStorage from './Helper/GooglePayStorage';
( function ( { buttonConfig, ppcpConfig, jQuery } ) {
let manager;
( function ( { buttonConfig, ppcpConfig = {} } ) {
const context = ppcpConfig.context;
const bootstrap = function () {
manager = new GooglepayManager( buttonConfig, ppcpConfig );
manager.init();
};
setupButtonEvents( function () {
if ( manager ) {
manager.reinit();
function bootstrapPayButton() {
if ( ! buttonConfig || ! ppcpConfig ) {
return;
}
} );
const manager = new GooglepayManager( buttonConfig, ppcpConfig );
manager.init();
setupButtonEvents( function () {
manager.reinit();
} );
}
function bootstrapCheckout() {
if ( context && ! [ 'continuation', 'checkout' ].includes( context ) ) {
// Context must be missing/empty, or "continuation"/"checkout" to proceed.
return;
}
if ( ! CheckoutBootstrap.isPageWithCheckoutForm() ) {
return;
}
const checkoutBootstrap = new CheckoutBootstrap( moduleStorage );
checkoutBootstrap.init();
}
function bootstrap() {
bootstrapPayButton();
bootstrapCheckout();
}
document.addEventListener( 'DOMContentLoaded', () => {
if (
typeof buttonConfig === 'undefined' ||
typeof ppcpConfig === 'undefined'
) {
// No PayPal buttons present on this page.
if ( ! buttonConfig || ! ppcpConfig ) {
/*
* No PayPal buttons present on this page, but maybe a bootstrap module needs to be
* initialized. Skip loading the SDK or gateway configuration, and directly initialize
* the module.
*/
bootstrap();
return;
}
@ -52,5 +86,4 @@ import { setupButtonEvents } from '../../../ppcp-button/resources/js/modules/Hel
} )( {
buttonConfig: window.wc_ppcp_googlepay,
ppcpConfig: window.PayPalCommerceGateway,
jQuery: window.jQuery,
} );

View file

@ -100,11 +100,21 @@ class GooglepayModule implements ServiceModule, ExtendingModule, ExecutableModul
static function () use ( $c, $button ) {
$smart_button = $c->get( 'button.smart-button' );
assert( $smart_button instanceof SmartButtonInterface );
if ( $smart_button->should_load_ppcp_script() ) {
$button->enqueue();
return;
}
/*
* Checkout page, but no PPCP scripts were loaded. Most likely in continuation mode.
* Need to enqueue some Google Pay scripts to populate the billing form with details
* provided by Google Pay.
*/
if ( is_checkout() ) {
$button->enqueue();
}
if ( has_block( 'woocommerce/checkout' ) || has_block( 'woocommerce/cart' ) ) {
/**
* Should add this to the ButtonInterface.