File "GooglepayButton.js"

Full Path: /home/siazco/grocery.siazco.se/wp-content/plugins/woocommerce-paypal-payments/modules/ppcp-googlepay/resources/js/GooglepayButton.js
File size: 21.96 KB
MIME-type: text/x-java
Charset: utf-8

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
	 */
	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.`
		);
	}

	/**
	 * 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.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;