skyverge/wc-plugin-framework

View on GitHub
woocommerce/payment-gateway/assets/js/frontend/sv-wc-payment-gateway-google-pay.js

Summary

Maintainability
C
1 day
Test Coverage
jQuery( function( $ ) {

    "use strict"

    /**
     * Google Pay handler.
     *
     * @since 5.10.0
     *
     * @type {SV_WC_Google_Pay_Handler_v5_12_2} object
     */
    window.SV_WC_Google_Pay_Handler_v5_12_2 = class SV_WC_Google_Pay_Handler_v5_12_2 {

        /**
         * Handler constructor.
         *
         * @since 5.10.0
         *
         * @param {Object} params The plugin ID
         * @param {string} params.plugin_id The plugin ID
         * @param {string} params.merchant_id The merchant ID
         * @param {string} params.merchant_name The site name
         * @param {string} params.gateway_id The gateway ID
         * @param {string} params.gateway_id_dasherized The gateway ID dasherized
         * @param {string} params.environment The gateway environment (PRODUCTION or TEST)
         * @param {string} params.ajax_url The AJAX URL
         * @param {string} params.recalculate_totals_nonce Nonce for the recalculate_totals AJAX action
         * @param {string} params.process_nonce Nonce for the process AJAX action
         * @param {string} params.button_style The button style
         * @param {string[]} params.card_types The supported card types
         * @param {string[]} params.available_countries Array of two-letter country codes the gateway is available for
         * @param {string[]} params.currency_code WC configured currency
         * @param {boolean} params.needs_shipping Whether or not the cart or product needs shipping
         * @param {string} params.generic_error The generic error message
         * @param {string} params.product_id The product ID if we are on a Product page
         */
        constructor( params ) {

            let {
                plugin_id,
                merchant_id,
                merchant_name,
                gateway_id,
                gateway_id_dasherized,
                environment,
                ajax_url,
                recalculate_totals_nonce,
                process_nonce,
                button_style,
                card_types,
                available_countries,
                currency_code,
                needs_shipping,
                generic_error
            } = params;

            this.gatewayID              = gateway_id;
            this.merchantID             = merchant_id;
            this.merchantName           = merchant_name;
            this.environment            = environment;
            this.ajaxURL                = ajax_url;
            this.recalculateTotalsNonce = recalculate_totals_nonce;
            this.processNonce           = process_nonce;
            this.buttonStyle            = button_style;
            this.availableCountries     = available_countries;
            this.currencyCode           = currency_code;
            this.needsShipping          = needs_shipping;
            this.genericError           = generic_error;

            if ( params.product_id ) {
                this.productID = params.product_id;
            }

            /**
             * Card networks supported by your site and your gateway
             *
             * @see {@link https://developers.google.com/pay/api/web/reference/request-objects#CardParameters|CardParameters}
             */
            const allowedCardNetworks = card_types;

            /**
             * Define the version of the Google Pay API referenced when creating your configuration
             *
             * @see {@link https://developers.google.com/pay/api/web/reference/request-objects#PaymentDataRequest|apiVersion in PaymentDataRequest}
             */
            this.baseRequest = {
                apiVersion: 2,
                apiVersionMinor: 0
            };

            /**
             * Card authentication methods supported by your site and your gateway
             *
             * @see {@link https://developers.google.com/pay/api/web/reference/request-objects#CardParameters|CardParameters}
             *
             * @todo confirm your processor supports Android device tokens for your supported card networks
             */
            const allowedCardAuthMethods = [ 'PAN_ONLY', 'CRYPTOGRAM_3DS' ];

            /**
             * Identify your gateway and your site's gateway merchant identifier
             *
             * The Google Pay API response will return an encrypted payment method capable
             * of being charged by a supported gateway after payer authorization
             *
             * @see {@link https://developers.google.com/pay/api/web/reference/request-objects#gateway|PaymentMethodTokenizationSpecification}
             */
            const tokenizationSpecification = {
                type: 'PAYMENT_GATEWAY',
                parameters: {
                    'gateway': plugin_id,
                    'gatewayMerchantId': this.merchantID
                }
            };

            /**
             * Describe your site's support for the CARD payment method and its required fields
             *
             * @see {@link https://developers.google.com/pay/api/web/reference/request-objects#CardParameters|CardParameters}
             */
            this.baseCardPaymentMethod = {
                type: 'CARD',
                parameters: {
                    allowedAuthMethods: allowedCardAuthMethods,
                    allowedCardNetworks: allowedCardNetworks,
                    billingAddressRequired: true,
                    billingAddressParameters: {
                        format: 'FULL',
                        phoneNumberRequired: true
                    }
                }
            };

            /**
             * Describe your site's support for the CARD payment method including optional fields
             *
             * @see {@link https://developers.google.com/pay/api/web/reference/request-objects#CardParameters|CardParameters}
             */
            this.cardPaymentMethod = Object.assign(
                {},
                this.baseCardPaymentMethod,
                {
                    tokenizationSpecification: tokenizationSpecification
                }
            );

            /**
             * An initialized google.payments.api.PaymentsClient object or null if not yet set
             *
             * @see {@link getGooglePaymentsClient}
             */
            this.paymentsClient = null;
        }

        /**
         * Configure your site's support for payment methods supported by the Google Pay
         * API.
         *
         * Each member of allowedPaymentMethods should contain only the required fields,
         * allowing reuse of this base request when determining a viewer's ability
         * to pay and later requesting a supported payment method
         *
         * @returns {object} Google Pay API version, payment methods supported by the site
         */
        getGoogleIsReadyToPayRequest() {

            return Object.assign(
                {},
                this.baseRequest,
                {
                    allowedPaymentMethods: [ this.baseCardPaymentMethod ]
                }
            );
        }

        /**
         * Configure support for the Google Pay API
         *
         * @see {@link https://developers.google.com/pay/api/web/reference/request-objects#PaymentDataRequest|PaymentDataRequest}
         *
         * @param {function} resolve callback
         * @returns {object} PaymentDataRequest fields
         */
        getGooglePaymentDataRequest( resolve ) {

            return this.getGoogleTransactionInfo( ( transactionInfo ) => {

                const paymentDataRequest = Object.assign( {}, this.baseRequest );
                paymentDataRequest.allowedPaymentMethods = [ this.cardPaymentMethod ];
                paymentDataRequest.transactionInfo = transactionInfo;
                paymentDataRequest.merchantInfo = {
                    merchantId: this.merchantID,
                    merchantName: this.merchantName
                };

                paymentDataRequest.emailRequired = true;
                paymentDataRequest.callbackIntents = [ 'PAYMENT_AUTHORIZATION' ];

                if ( this.needsShipping ) {
                    paymentDataRequest.callbackIntents = [ 'SHIPPING_ADDRESS', 'SHIPPING_OPTION', 'PAYMENT_AUTHORIZATION' ];
                    paymentDataRequest.shippingAddressRequired = true;
                    paymentDataRequest.shippingAddressParameters = this.getGoogleShippingAddressParameters();
                    paymentDataRequest.shippingOptionRequired = true;
                }

                resolve( paymentDataRequest );
            } );
        }

        /**
         * Return an active PaymentsClient or initialize
         *
         * @see {@link https://developers.google.com/pay/api/web/reference/client#PaymentsClient|PaymentsClient constructor}
         * @returns {google.payments.api.PaymentsClient} Google Pay API client
         */
        getGooglePaymentsClient() {
            if ( this.paymentsClient === null ) {
                let args = {
                    environment: this.environment,
                    merchantInfo: {
                        merchantName: this.merchantName,
                        merchantId: this.merchantID
                    },
                    paymentDataCallbacks: {
                        onPaymentAuthorized: ( paymentData ) => this.onPaymentAuthorized( paymentData ),
                    }
                };

                if ( this.needsShipping ) {
                    args.paymentDataCallbacks.onPaymentDataChanged = ( paymentData ) => this.onPaymentDataChanged( paymentData );
                }

                this.paymentsClient = new google.payments.api.PaymentsClient( args );
            }
            return this.paymentsClient;
        }

        /**
         * Handles payment authorization callback intent.
         *
         * @param {object} paymentData response from Google Pay API after a payer approves payment.
         * @see {@link https://developers.google.com/pay/api/web/reference/response-objects#PaymentData|PaymentData object reference}
         *
         * @returns Promise<{object}> Promise object to complete or fail the transaction.
         */
        onPaymentAuthorized( paymentData ) {

            this.blockUI();

            return new Promise( (resolve, reject) => {

                // handle the response
                try {
                    this.processPayment( paymentData, resolve );
                }    catch( err ) {
                    reject( {
                        transactionState: 'ERROR',
                        error: {
                            intent: 'PAYMENT_AUTHORIZATION',
                            message: 'Payment could not be processed',
                            reason: 'PAYMENT_DATA_INVALID'
                        }
                    } );
                }

                this.unblockUI();
            } );
        }

        /**
         * Handles dynamic buy flow shipping address and shipping options callback intents.
         *
         * @param {object} intermediatePaymentData response from Google Pay API a shipping address or shipping option is selected in the payment sheet.
         * @see {@link https://developers.google.com/pay/api/web/reference/response-objects#IntermediatePaymentData|IntermediatePaymentData object reference}
         *
         * @see {@link https://developers.google.com/pay/api/web/reference/response-objects#PaymentDataRequestUpdate|PaymentDataRequestUpdate}
         * @returns Promise<{object}> Promise of PaymentDataRequestUpdate object to update the payment sheet.
         */
        onPaymentDataChanged( intermediatePaymentData ) {

            this.blockUI();

            return new Promise(( resolve, reject ) => {

                try {
                    let shippingAddress = intermediatePaymentData.shippingAddress;
                    let shippingOptionData = intermediatePaymentData.shippingOptionData;
                    let chosenShippingMethod = '';

                    if ( intermediatePaymentData.callbackTrigger == 'SHIPPING_OPTION' ) {
                        chosenShippingMethod = shippingOptionData.id;
                    }

                    this.getUpdatedTotals( shippingAddress, chosenShippingMethod, ( paymentDataRequestUpdate ) => {

                        if ( paymentDataRequestUpdate.newShippingOptionParameters.shippingOptions.length == 0 ) {
                            paymentDataRequestUpdate = {
                                error: this.getGoogleUnserviceableAddressError()
                            };
                        }

                        resolve( paymentDataRequestUpdate );
                    } );

                }    catch( err ) {
                    this.failPayment( 'Could not load updated totals or process payment data request update. ' + err );
                }

                this.unblockUI();
            } );
        }

        /**
         * Provide Google Pay API with a payment amount, currency, and amount status
         *
         * @see {@link https://developers.google.com/pay/api/web/reference/request-objects#TransactionInfo|TransactionInfo}
         *
         * @param {function} resolve callback
         * @returns {object} transaction info, suitable for use as transactionInfo property of PaymentDataRequest
         */
        getGoogleTransactionInfo( resolve ) {

            // get transaction info from cart
            const data = {
                action: `wc_${this.gatewayID}_google_pay_get_transaction_info`,
            }

            if ( this.productID ) {
                data.productID = this.productID;
            }

            $.post( this.ajaxURL, data, ( response ) => {

                if ( response.success ) {
                    resolve( JSON.parse( response.data ) )
                } else {
                    this.failPayment( 'Could not build transaction info. ' + response.data.message );
                }
            } );
        }

        /**
         * Get updated totals and shipping options via AJAX for use in the PaymentDataRequest
         *
         * @see {@link https://developers.google.com/pay/api/web/reference/response-objects#PaymentDataRequestUpdate|PaymentDataRequestUpdate}
         *
         * @param {object} shippingAddress shipping address
         * @param {object} shippingMethod chosen shipping method
         * @param {function} resolve callback
         */
        getUpdatedTotals( shippingAddress, shippingMethod, resolve ) {

            const data = {
                action: `wc_${this.gatewayID}_google_pay_recalculate_totals`,
                'nonce': this.recalculateTotalsNonce,
                shippingAddress,
                shippingMethod
            }

            if ( this.productID ) {
                data.productID = this.productID;
            }

            $.post( this.ajaxURL, data, ( response ) => {

                if ( response.success ) {
                    resolve( JSON.parse( response.data ) )
                } else {
                    this.failPayment( 'Could not recalculate totals. ' + response.data.message );
                }
            } );
        }

        /**
         * Provide Google Pay API with shipping address parameters when using dynamic buy flow.
         *
         * @see {@link https://developers.google.com/pay/api/web/reference/request-objects#ShippingAddressParameters|ShippingAddressParameters}
         * @returns {object} shipping address details, suitable for use as shippingAddressParameters property of PaymentDataRequest
         */
        getGoogleShippingAddressParameters() {

            return {
                allowedCountryCodes: this.availableCountries
            };
        }

        /**
         * Provide Google Pay API with a payment data error.
         *
         * @see {@link https://developers.google.com/pay/api/web/reference/response-objects#PaymentDataError|PaymentDataError}
         * @returns {object} payment data error, suitable for use as error property of PaymentDataRequestUpdate
         */
        getGoogleUnserviceableAddressError() {
            return {
                reason: 'SHIPPING_ADDRESS_UNSERVICEABLE',
                message: 'Cannot ship to the selected address',
                intent: 'SHIPPING_ADDRESS'
            };
        }

        /**
         * Add a Google Pay purchase button alongside an existing checkout button
         *
         * @see {@link https://developers.google.com/pay/api/web/reference/request-objects#ButtonOptions|Button options}
         * @see {@link https://developers.google.com/pay/api/web/guides/brand-guidelines|Google Pay brand guidelines}
         */
        addGooglePayButton() {

            const paymentsClient = this.getGooglePaymentsClient();
            const button = paymentsClient.createButton( {
                onClick: ( event ) => this.onGooglePaymentButtonClicked( event ),
                buttonColor: this.buttonStyle,
                buttonSizeMode: 'fill'
            } );
            document.getElementById( 'sv-wc-google-pay-button-container' ).appendChild( button );
        }

        /**
         * Prefetch payment data to improve performance
         *
         * @see {@link https://developers.google.com/pay/api/web/reference/client#prefetchPaymentData|prefetchPaymentData()}
         */
        prefetchGooglePaymentData() {

            this.getGooglePaymentDataRequest( ( paymentDataRequest ) => {

                // transactionInfo must be set but does not affect cache
                paymentDataRequest.transactionInfo = {
                    totalPriceStatus: 'NOT_CURRENTLY_KNOWN',
                    currencyCode: this.currencyCode
                };
                const paymentsClient = this.getGooglePaymentsClient();
                paymentsClient.prefetchPaymentData( paymentDataRequest );
            } );
        }

        /**
         * Process payment data returned by the Google Pay API
         *
         * @see {@link https://developers.google.com/pay/api/web/reference/response-objects#PaymentData|PaymentData object reference}
         *
         * @param {object} paymentData response from Google Pay API after user approves payment
         * @param {function} resolve callback
         */
        processPayment( paymentData, resolve ) {

            // pass payment token to your gateway to process payment
            const data = {
                action: `wc_${this.gatewayID}_google_pay_process_payment`,
                nonce: this.processNonce,
                paymentData: JSON.stringify( paymentData ),
            }

            if ( this.productID && ! this.needsShipping ) {
                data.productID = this.productID;
            }

            return $.post( this.ajaxURL, data, ( response ) => {
                if ( response.success ) {
                    resolve( {
                        transactionState: 'SUCCESS'
                    } );
                    window.location = response.data.redirect;
                } else {
                    resolve( {
                        transactionState: 'ERROR',
                        error: {
                            intent: 'SHIPPING_ADDRESS',
                            message: 'Invalid data',
                            reason: 'PAYMENT_DATA_INVALID'
                        }
                    } );
                    this.failPayment( 'Payment could not be processed. ' + response.data.message );
                }
            } );
        }

        /**
         * Show Google Pay payment sheet when Google Pay payment button is clicked
         */
        onGooglePaymentButtonClicked( event ) {

            event.preventDefault();

            this.blockUI();

            this.getGooglePaymentDataRequest( ( paymentDataRequest ) => {

                const paymentsClient = this.getGooglePaymentsClient();
                try {
                    paymentsClient.loadPaymentData( paymentDataRequest );
                } catch ( err ) {
                    this.failPayment( 'Could not load payment data. ' + err );
                }

                this.unblockUI();
            } );
        }

        /**
         * Initialize Google PaymentsClient after Google-hosted JavaScript has loaded
         *
         * Display a Google Pay payment button after confirmation of the viewer's
         * ability to pay.
         */
        init() {

            // initialize for the various pages
            if ( $( 'form.cart' ).length ) {
                this.initProductPage();
            } else if ( $( 'form.woocommerce-cart-form' ).length ) {
                this.initCartPage();
            } else if ( $( 'form.woocommerce-checkout' ).length) {
                this.initCheckoutPage()
            } else {
                return;
            }

            this.initGooglePay();
        }

        /**
         * Initializes Google Pay.
         */
        initGooglePay() {

            const paymentsClient = this.getGooglePaymentsClient();
            paymentsClient.isReadyToPay( this.getGoogleIsReadyToPayRequest() )
                .then( ( response ) => {
                    if ( response.result ) {
                        this.addGooglePayButton();
                        // prefetch payment data to improve performance
                        this.prefetchGooglePaymentData();
                    }
                } )
                .catch( ( err ) => {
                    this.failPayment( 'Google Pay is not ready. ' + err );
                } );
        }

        /**
         * Initializes the product page.
         */
        initProductPage() {
            this.uiElement = $( 'form.cart' );
        }

        /**
         * Initializes the cart page.
         */
        initCartPage() {
            this.uiElement = $( 'form.woocommerce-cart-form' ).parents( 'div.woocommerce' );

            // re-init if the cart totals are updated
            $( document.body ).on( 'updated_cart_totals', () => {
                this.initGooglePay();
            } );
        }

        /**
         * Initializes the checkout page.
         */
        initCheckoutPage() {
            this.uiElement = $( 'form.woocommerce-checkout' );
        }

        /**
         * Fails the purchase based on the gateway result.
         */
        failPayment( error ) {

            console.error( '[Google Pay] ' + error );

            this.unblockUI();

            this.renderErrors( [ this.genericError ] );
        }

        /**
         * Renders any new errors and bring them into the viewport.
          */
        renderErrors( errors ) {

            // hide and remove any previous errors
            $( '.woocommerce-error, .woocommerce-message' ).remove();

            // add errors
            this.uiElement.prepend( '<ul class="woocommerce-error"><li>' + errors.join( '</li><li>' ) + '</li></ul>' );

            // unblock UI
            this.uiElement.removeClass( 'processing' ).unblock();

            // scroll to top
            $( 'html, body' ).animate( { scrollTop: this.uiElement.offset().top - 100 }, 1000 );
        }

        /**
         * Blocks the payment form UI.
         */
        blockUI() {
            this.uiElement.block( { message: null, overlayCSS: { background: '#fff', opacity: 0.6 } } );
        }

        /**
         * Unblocks the payment form UI.
         */
        unblockUI() {
            this.uiElement.unblock();
        }
    }

    $( document.body ).trigger( 'sv_wc_google_pay_handler_v5_12_2_loaded' );

} );