import { combineStyles, combineWrapperIds, } from '../../../ppcp-button/resources/js/modules/Helper/PaymentButtonHelpers'; 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 { 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. * * Note that most properties of this object do not apply to the Google Pay button. * * @typedef {Object} PPCPStyle * @property {string} shape - Outline shape. * @property {?number} height - Button height in pixel. */ /** * Style options that are defined by the Google Pay SDK and are required to render the button. * * @typedef {Object} GooglePayStyle * @property {string} type - Defines the button label. * @property {string} color - Button color * @property {string} language - The locale; an empty string will apply the user-agent's language. */ /** * Google Pay JS SDK * * @see https://developers.google.com/pay/api/web/reference/request-objects * @typedef {Object} GooglePaySDK * @property {typeof PaymentsClient} PaymentsClient - Main API client for payment actions. */ /** * The Payments Client class, generated by the Google Pay SDK. * * @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 {(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. */ /** * This object describes the transaction details. * * @see https://developers.google.com/pay/api/web/reference/request-objects#TransactionInfo * @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. */ 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 */ static methodId = PaymentMethods.GOOGLEPAY; /** * @inheritDoc */ static cssClass = 'google-pay'; /** * Client reference, provided by the Google Pay JS SDK. */ #paymentsClient = null; /** * Details about the processed transaction, provided to the Google SDK. * * @type {?TransactionInfo} */ #transactionInfo = null; /** * The currently visible payment button. * * @type {HTMLElement|null} */ #button = null; /** * The Google Pay configuration object. * * @type {Object|null} */ googlePayConfig = null; /** * The start time of the configuration process. * * @type {number} */ #configureStartTime = 0; /** * The maximum time to wait for buttonAttributes before proceeding with initialization. * @type {number} */ #maxWaitTime = 1000; /** * The stored button attributes. * * @type {null} */ #storedButtonAttributes = null; /** * @inheritDoc */ static getWrappers( buttonConfig, ppcpConfig ) { return combineWrapperIds( buttonConfig?.button?.wrapper || '', buttonConfig?.button?.mini_cart_wrapper || '', ppcpConfig?.button?.wrapper || '', 'ppc-button-googlepay-container', 'ppc-button-ppcp-googlepay' ); } /** * @inheritDoc */ static getStyles( buttonConfig, ppcpConfig ) { const styles = combineStyles( ppcpConfig?.button || {}, buttonConfig?.button || {} ); if ( 'buy' === styles.MiniCart.type ) { styles.MiniCart.type = 'pay'; } return styles; } constructor( context, externalHandler, buttonConfig, ppcpConfig, contextHandler, buttonAttributes ) { // Disable debug output in the browser console: // buttonConfig.is_debug = false; super( context, externalHandler, buttonConfig, ppcpConfig, contextHandler, buttonAttributes ); this.init = this.init.bind( this ); this.onPaymentAuthorized = this.onPaymentAuthorized.bind( this ); this.onPaymentDataChanged = this.onPaymentDataChanged.bind( this ); this.onButtonClick = this.onButtonClick.bind( this ); this.log( 'Create instance' ); } /** * @inheritDoc */ get requiresShipping() { return super.requiresShipping && this.buttonConfig.shipping?.enabled; } /** * The Google Pay API. * * @return {?GooglePaySDK} API for the Google Pay JS SDK, or null when SDK is not ready yet. */ get googlePayApi() { return window.google?.payments?.api; } /** * The Google Pay PaymentsClient instance created by this button. * @see https://developers.google.com/pay/api/web/reference/client * * @return {?PaymentsClient} The SDK object, or null when SDK is not ready yet. */ get paymentsClient() { return this.#paymentsClient; } /** * Details about the processed transaction. * * This object defines the price that is charged, and text that is displayed inside the * payment sheet. * * @return {?TransactionInfo} The TransactionInfo object. */ get transactionInfo() { return this.#transactionInfo; } /** * Assign the new transaction details to the payment button. * * @param {TransactionInfo} newTransactionInfo - Transaction details. */ set transactionInfo( newTransactionInfo ) { this.#transactionInfo = newTransactionInfo; this.refresh(); } /** * @inheritDoc */ registerValidationRules( invalidIf, validIf ) { invalidIf( () => ! [ 'TEST', 'PRODUCTION' ].includes( this.buttonConfig.environment ), `Invalid environment: ${ this.buttonConfig.environment }` ); validIf( () => this.isPreview ); invalidIf( () => ! this.googlePayConfig, 'No API configuration - missing configure() call?' ); invalidIf( () => ! this.transactionInfo, 'No transactionInfo - missing configure() call?' ); invalidIf( () => ! this.contextHandler?.validateContext(), `Invalid context handler.` ); invalidIf( () => this.buttonAttributes?.height && isNaN( parseInt( this.buttonAttributes.height ) ), 'Invalid height in buttonAttributes' ); invalidIf( () => this.buttonAttributes?.borderRadius && isNaN( parseInt( this.buttonAttributes.borderRadius ) ), 'Invalid borderRadius in buttonAttributes' ); return true; } /** * Configures the button instance. Must be called before the initial `init()`. * * @param {Object} apiConfig - API configuration. * @param {Object} transactionInfo - Transaction details; required before "init" call. * @param {Object} buttonAttributes - Button attributes. */ configure( apiConfig, transactionInfo, buttonAttributes = {} ) { // Start timing on first configure call if ( ! this.#configureStartTime ) { this.#configureStartTime = Date.now(); } // If valid buttonAttributes, store them if ( buttonAttributes?.height && buttonAttributes?.borderRadius ) { this.#storedButtonAttributes = { ...buttonAttributes }; } // Use stored attributes if current ones are missing const attributes = buttonAttributes?.height ? buttonAttributes : this.#storedButtonAttributes; // Check if we've exceeded wait time const timeWaited = Date.now() - this.#configureStartTime; if ( timeWaited > this.#maxWaitTime ) { this.log( 'GooglePay: Timeout waiting for buttonAttributes - proceeding with initialization' ); this.googlePayConfig = apiConfig; this.#transactionInfo = transactionInfo; this.buttonAttributes = attributes || buttonAttributes; this.allowedPaymentMethods = this.googlePayConfig.allowedPaymentMethods; this.baseCardPaymentMethod = this.allowedPaymentMethods[ 0 ]; this.init(); return; } // Block any initialization until we have valid buttonAttributes if ( ! attributes?.height || ! attributes?.borderRadius ) { setTimeout( () => this.configure( apiConfig, transactionInfo, buttonAttributes ), 100 ); return; } // Reset timer for future configure calls this.#configureStartTime = 0; this.googlePayConfig = apiConfig; this.#transactionInfo = transactionInfo; this.buttonAttributes = attributes; this.allowedPaymentMethods = this.googlePayConfig.allowedPaymentMethods; this.baseCardPaymentMethod = this.allowedPaymentMethods[ 0 ]; this.init(); } init() { // Skip if already initialized if ( this.isInitialized ) { return; } // Validate configuration if ( ! this.validateConfiguration() ) { return; } super.init(); this.#paymentsClient = this.createPaymentsClient(); this.paymentsClient .isReadyToPay( this.buildReadyToPayRequest( this.allowedPaymentMethods, this.googlePayConfig ) ) .then( ( response ) => { this.log( 'PaymentsClient.isReadyToPay response:', response ); this.isEligible = !! response.result; } ) .catch( ( err ) => { this.error( err ); this.isEligible = false; } ); } reinit() { // Missing (invalid) configuration indicates, that the first `init()` call did not happen yet. if ( ! this.validateConfiguration( true ) ) { return; } super.reinit(); this.init(); } /** * Provides an object with relevant paymentDataCallbacks for the current button instance. * * @return {Object} An object containing callbacks for the current scope & configuration. */ preparePaymentDataCallbacks() { const callbacks = {}; // We do not attach any callbacks to preview buttons. if ( this.isPreview ) { return callbacks; } callbacks.onPaymentAuthorized = this.onPaymentAuthorized; if ( this.requiresShipping ) { callbacks.onPaymentDataChanged = this.onPaymentDataChanged; } return callbacks; } createPaymentsClient() { if ( ! this.googlePayApi ) { return null; } const callbacks = this.preparePaymentDataCallbacks(); /** * Consider providing merchant info here: * * @see https://developers.google.com/pay/api/web/reference/request-objects#PaymentOptions */ return new this.googlePayApi.PaymentsClient( { environment: this.buttonConfig.environment, paymentDataCallbacks: callbacks, } ); } buildReadyToPayRequest( allowedPaymentMethods, baseRequest ) { this.log( 'Ready To Pay request', baseRequest, allowedPaymentMethods ); return Object.assign( {}, baseRequest, { allowedPaymentMethods, } ); } /** * Creates the payment button and calls `super.insertButton()` to make the button visible in the correct wrapper. */ addButton() { if ( ! this.paymentsClient ) { return; } // If current buttonAttributes are missing, try to use stored ones if ( ! this.buttonAttributes?.height && this.#storedButtonAttributes?.height ) { this.buttonAttributes = { ...this.#storedButtonAttributes }; } this.removeButton(); const baseCardPaymentMethod = this.baseCardPaymentMethod; const { color, type, language } = this.style; const buttonOptions = { buttonColor: color || 'black', buttonSizeMode: 'fill', buttonLocale: language || 'en', buttonType: type || 'pay', buttonRadius: parseInt( this.buttonAttributes?.borderRadius, 10 ), onClick: this.onButtonClick, allowedPaymentMethods: [ baseCardPaymentMethod ], }; const button = this.paymentsClient.createButton( buttonOptions ); this.#button = button; super.insertButton( button ); this.applyWrapperStyles(); } /** * Applies CSS classes and inline styling to the payment button wrapper. * Extends parent implementation to handle Google Pay specific styling. */ applyWrapperStyles() { super.applyWrapperStyles(); const wrapper = this.wrapperElement; if ( ! wrapper ) { return; } // Try stored attributes if current ones are missing const attributes = this.buttonAttributes?.height ? this.buttonAttributes : this.#storedButtonAttributes; if ( attributes?.height ) { const height = parseInt( attributes.height, 10 ); if ( ! isNaN( height ) ) { wrapper.style.height = `${ height }px`; wrapper.style.minHeight = `${ height }px`; } } } /** * Removes the payment button from the DOM. */ removeButton() { if ( ! this.isPresent || ! this.#button ) { return; } this.log( 'removeButton' ); try { this.wrapperElement.removeChild( this.#button ); } catch ( Exception ) { // Ignore this. } this.#button = null; } //------------------------ // Button click //------------------------ /** * Show Google Pay payment sheet when Google Pay payment button is clicked */ onButtonClick() { this.log( 'onButtonClick' ); const initiatePaymentRequest = () => { window.ppcpFundingSource = 'googlepay'; const paymentDataRequest = this.paymentDataRequest(); this.log( 'onButtonClick: paymentDataRequest', paymentDataRequest, this.context ); return this.paymentsClient.loadPaymentData( paymentDataRequest ); }; const validateForm = () => { if ( 'function' !== typeof this.contextHandler.validateForm ) { return Promise.resolve(); } return this.contextHandler.validateForm().catch( ( error ) => { this.error( 'Form validation failed:', error ); throw error; } ); }; const getTransactionInfo = () => { if ( 'function' !== typeof this.contextHandler.transactionInfo ) { return Promise.resolve(); } return this.contextHandler .transactionInfo() .then( ( transactionInfo ) => { this.transactionInfo = transactionInfo; } ) .catch( ( error ) => { this.error( 'Failed to get transaction info:', error ); throw error; } ); }; validateForm() .then( getTransactionInfo ) .then( initiatePaymentRequest ); } paymentDataRequest() { const baseRequest = { apiVersion: 2, apiVersionMinor: 0, }; const useShippingCallback = this.requiresShipping; const callbackIntents = [ 'PAYMENT_AUTHORIZATION' ]; if ( useShippingCallback ) { callbackIntents.push( 'SHIPPING_ADDRESS', 'SHIPPING_OPTION' ); } return { ...baseRequest, allowedPaymentMethods: this.googlePayConfig.allowedPaymentMethods, transactionInfo: this.transactionInfo.finalObject, merchantInfo: this.googlePayConfig.merchantInfo, callbackIntents, emailRequired: true, shippingAddressRequired: useShippingCallback, shippingOptionRequired: useShippingCallback, shippingAddressParameters: this.shippingAddressParameters(), }; } //------------------------ // Shipping processing //------------------------ shippingAddressParameters() { return { allowedCountryCodes: this.buttonConfig.shipping.countries, phoneNumberRequired: true, }; } onPaymentDataChanged( paymentData ) { this.log( 'onPaymentDataChanged', paymentData ); return new Promise( async ( resolve, reject ) => { try { const paymentDataRequestUpdate = {}; const updatedData = await new UpdatePaymentData( this.buttonConfig.ajax.update_payment_data ).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', transactionInfo ); updatedData.country_code = transactionInfo.countryCode; updatedData.currency_code = transactionInfo.currencyCode; // Handle unserviceable address. if ( ! updatedData.shipping_options?.shippingOptions?.length ) { paymentDataRequestUpdate.error = this.unserviceableShippingAddressError(); resolve( paymentDataRequestUpdate ); return; } 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 ); reject( error ); } } ); } /** * 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', message: 'Cannot ship to the selected address', intent: 'SHIPPING_ADDRESS', }; } /** * 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; } //------------------------ // Payment process //------------------------ onPaymentAuthorized( paymentData ) { this.log( 'onPaymentAuthorized', paymentData ); return this.processPayment( paymentData ); } async processPayment( paymentData ) { this.logGroup( 'processPayment' ); const payer = payerDataFromPaymentResponse( paymentData ); const paymentError = ( reason ) => { this.error( reason ); return this.processPaymentResponse( 'ERROR', 'PAYMENT_AUTHORIZATION', reason ); }; const checkPayPalApproval = async ( orderId ) => { const confirmationData = { orderId, paymentMethodData: paymentData.paymentMethodData, }; const confirmOrderResponse = await widgetBuilder.paypal .Googlepay() .confirmOrder( confirmationData ); 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 ); } ), }, } ); 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(); } ); } processPaymentResponse( state, intent = null, message = null ) { const response = { transactionState: state, }; if ( intent || message ) { response.error = { intent, message, }; } this.log( 'processPaymentResponse', response ); 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;