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; googlePayConfig = 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 ) { // Disable debug output in the browser console: // buttonConfig.is_debug = false; super( context, externalHandler, buttonConfig, ppcpConfig, contextHandler ); 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 */ validateConfiguration( silent = false ) { const validEnvs = [ 'PRODUCTION', 'TEST' ]; const isInvalid = ( ...args ) => { if ( ! silent ) { this.error( ...args ); } return false; }; if ( ! validEnvs.includes( this.buttonConfig.environment ) ) { return isInvalid( 'Invalid environment:', this.buttonConfig.environment ); } // Preview buttons only need a valid environment. if ( this.isPreview ) { return true; } if ( ! this.googlePayConfig ) { return isInvalid( 'No API configuration - missing configure() call?' ); } if ( ! this.transactionInfo ) { return isInvalid( 'No transactionInfo - missing configure() call?' ); } if ( ! typeof this.contextHandler?.validateContext() ) { return isInvalid( 'Invalid context handler.', this.contextHandler ); } 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. */ configure( apiConfig, transactionInfo ) { this.googlePayConfig = apiConfig; this.#transactionInfo = transactionInfo; this.allowedPaymentMethods = this.googlePayConfig.allowedPaymentMethods; this.baseCardPaymentMethod = this.allowedPaymentMethods[ 0 ]; } init() { // Use `reinit()` to force a full refresh of an initialized button. if ( this.isInitialized ) { return; } // Stop, if configuration is invalid. if ( ! this.validateConfiguration() ) { return; } super.init(); this.#paymentsClient = this.createPaymentsClient(); if ( ! this.isPresent ) { this.log( 'Payment wrapper not found', this.wrapperId ); return; } if ( ! this.paymentsClient ) { this.log( 'Could not initialize the payments client' ); return; } 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 `this.insertButton()` to make the button visible in the * correct wrapper. */ addButton() { if ( ! this.paymentsClient ) { return; } const baseCardPaymentMethod = this.baseCardPaymentMethod; const { color, type, language } = this.style; /** * @see https://developers.google.com/pay/api/web/reference/client#createButton */ const button = this.paymentsClient.createButton( { onClick: this.onButtonClick, allowedPaymentMethods: [ baseCardPaymentMethod ], buttonColor: color || 'black', buttonType: type || 'pay', buttonLocale: language || 'en', buttonSizeMode: 'fill', } ); this.insertButton( button ); } //------------------------ // 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, 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; this.log( 'onPaymentDataChanged:updatedData', updatedData ); this.log( 'onPaymentDataChanged:transactionInfo', transactionInfo ); updatedData.country_code = transactionInfo.countryCode; updatedData.currency_code = transactionInfo.currencyCode; updatedData.total_str = transactionInfo.totalPrice; // Handle unserviceable address. if ( ! updatedData.shipping_options?.shippingOptions?.length ) { paymentDataRequestUpdate.error = this.unserviceableShippingAddressError(); resolve( paymentDataRequestUpdate ); 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; } resolve( paymentDataRequestUpdate ); } catch ( error ) { this.error( 'Error during onPaymentDataChanged:', error ); reject( error ); } } ); } unserviceableShippingAddressError() { return { reason: 'SHIPPING_ADDRESS_UNSERVICEABLE', message: 'Cannot ship to the selected address', intent: 'SHIPPING_ADDRESS', }; } calculateNewTransactionInfo( updatedData ) { return { countryCode: updatedData.country_code, currencyCode: updatedData.currency_code, totalPriceStatus: 'FINAL', totalPrice: updatedData.total_str, }; } //------------------------ // Payment process //------------------------ onPaymentAuthorized( paymentData ) { this.log( 'onPaymentAuthorized', paymentData ); return this.processPayment( paymentData ); } async processPayment( paymentData ) { this.logGroup( 'processPayment' ); 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 }, { 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 = () => { const payer = payerDataFromPaymentResponse( paymentData ); 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; } } export default GooglepayButton;