diff --git a/assets/blocks/cash-app-pay/component-cash-app-pay.js b/assets/blocks/cash-app-pay/component-cash-app-pay.js
new file mode 100644
index 00000000..2e0c62ee
--- /dev/null
+++ b/assets/blocks/cash-app-pay/component-cash-app-pay.js
@@ -0,0 +1,184 @@
+/**
+ * External dependencies
+ */
+import { useEffect, useState } from '@wordpress/element';
+import { decodeEntities } from '@wordpress/html-entities';
+
+/**
+ * Internal dependencies
+ */
+import {
+ getSquareCashAppPayServerData,
+ createPaymentRequest,
+ setContinuationSession,
+ log,
+} from './utils';
+import { PAYMENT_METHOD_ID } from './constants';
+
+const buttonId = 'wc-square-cash-app-pay';
+
+/**
+ * Square's credit card component
+ *
+ * @param {Object} props Incoming props
+ */
+export const ComponentCashAppPay = (props) => {
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [isLoaded, setIsLoaded] = useState(false);
+ const [paymentNonce, setPaymentNonce] = useState('');
+ const {
+ applicationId,
+ locationId,
+ buttonStyles,
+ referenceId,
+ generalError,
+ gatewayIdDasherized,
+ description,
+ } = getSquareCashAppPayServerData();
+ const {
+ onSubmit,
+ emitResponse,
+ eventRegistration,
+ billing: { cartTotal, currency },
+ components: { LoadingMask },
+ activePaymentMethod,
+ } = props;
+ const { onPaymentSetup } = eventRegistration;
+
+ // Checkout handler.
+ useEffect(() => {
+ const unsubscribe = onPaymentSetup(() => {
+ if (!paymentNonce) {
+ return {
+ type: emitResponse.responseTypes.ERROR,
+ message: generalError,
+ };
+ }
+ const paymentMethodData = {
+ [`wc-${gatewayIdDasherized}-payment-nonce`]: paymentNonce || '',
+ };
+ return {
+ type: emitResponse.responseTypes.SUCCESS,
+ meta: {
+ paymentMethodData,
+ },
+ };
+ });
+ return unsubscribe;
+ }, [
+ emitResponse.responseTypes.SUCCESS,
+ emitResponse.responseTypes.ERROR,
+ onPaymentSetup,
+ paymentNonce,
+ ]);
+
+ // Initialize the Square Cash App Pay Button.
+ useEffect(() => {
+ setIsLoaded(false);
+ setErrorMessage(null);
+ // Bail if Square is not loaded.
+ if (!window.Square) {
+ return;
+ }
+
+ log('[Square Cash App Pay] Initializing Square Cash App Pay Button');
+ const payments = window.Square.payments(applicationId, locationId);
+ if (!payments) {
+ return;
+ }
+
+ async function setupIntegration(){
+ setIsLoaded(false);
+ try {
+ const paymentRequest = await createPaymentRequest(payments);
+ if (window.wcSquareCashAppPay) {
+ await window.wcSquareCashAppPay.destroy();
+ window.wcSquareCashAppPay = null;
+ }
+
+ const cashAppPay = await payments.cashAppPay(paymentRequest, {
+ redirectURL: window.location.href,
+ referenceId: referenceId,
+ });
+ await cashAppPay.attach(`#${buttonId}`, buttonStyles);
+
+ // Handle the payment response.
+ cashAppPay.addEventListener('ontokenization', (event) => {
+ const { tokenResult, error } = event.detail;
+ if (error) {
+ setPaymentNonce('');
+ setErrorMessage(error.message);
+ } else if (tokenResult.status === 'OK') {
+ const nonce = tokenResult.token;
+ if (!nonce) {
+ setPaymentNonce('');
+ setErrorMessage(generalError);
+ }
+
+ // Set the nonce.
+ setPaymentNonce(nonce);
+
+ // Place an Order.
+ onSubmit();
+ } else {
+ // Declined. Reset the nonce and re-initialize the Square Cash App Pay Button.
+ setPaymentNonce(null);
+ setupIntegration();
+ }
+ });
+
+ // Handle the customer interaction. set continuation session to select the Cash App Pay payment method after the redirect back from the Cash App.
+ cashAppPay.addEventListener('customerInteraction', (event) => {
+ if (event.detail && event.detail.isMobile) {
+ return setContinuationSession();
+ }
+ });
+
+ window.wcSquareCashAppPay = cashAppPay;
+ log('[Square Cash App Pay] Square Cash App Pay Button Loaded');
+ } catch (e) {
+ setErrorMessage(generalError);
+ console.error(e);
+ }
+ setIsLoaded(true);
+ }
+ setupIntegration();
+
+ return () =>
+ (async () => {
+ if (window.wcSquareCashAppPay) {
+ await window.wcSquareCashAppPay.destroy();
+ window.wcSquareCashAppPay = null;
+ }
+ })();
+ }, [cartTotal.value, currency.code]);
+
+ // Disable the place order button when Cash App Pay is active. TODO: find a better way to do this.
+ useEffect(() => {
+ const button = document.querySelector(
+ 'button.wc-block-components-checkout-place-order-button'
+ );
+ if (button) {
+ if (activePaymentMethod === PAYMENT_METHOD_ID && !paymentNonce) {
+ button.setAttribute('disabled', 'disabled');
+ }
+ return () => {
+ button.removeAttribute('disabled');
+ };
+ }
+ }, [activePaymentMethod, paymentNonce]);
+
+ return (
+ <>
+
{decodeEntities(description || '')}
+ {errorMessage && (
+ {errorMessage}
+ )}
+ {!errorMessage && (
+
+
+
+ )}
+ >
+ );
+};
diff --git a/assets/blocks/cash-app-pay/constants.js b/assets/blocks/cash-app-pay/constants.js
new file mode 100644
index 00000000..38d7b40e
--- /dev/null
+++ b/assets/blocks/cash-app-pay/constants.js
@@ -0,0 +1 @@
+export const PAYMENT_METHOD_ID = 'square_cash_app_pay';
diff --git a/assets/blocks/cash-app-pay/index.js b/assets/blocks/cash-app-pay/index.js
new file mode 100644
index 00000000..8832e3b7
--- /dev/null
+++ b/assets/blocks/cash-app-pay/index.js
@@ -0,0 +1,81 @@
+/**
+ * External dependencies
+ */
+import { decodeEntities } from '@wordpress/html-entities';
+import { registerPaymentMethod } from '@woocommerce/blocks-registry';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { ComponentCashAppPay } from './component-cash-app-pay';
+import { PAYMENT_METHOD_ID } from './constants';
+import { getSquareCashAppPayServerData, selectCashAppPaymentMethod } from './utils';
+const { title, applicationId, locationId } = getSquareCashAppPayServerData();
+
+/**
+ * Label component
+ *
+ * @param {Object} props
+ */
+const SquareCashAppPayLabel = (props) => {
+ const { PaymentMethodLabel } = props.components;
+ return ;
+};
+
+/**
+ * Payment method content component
+ *
+ * @param {Object} props Incoming props for component (including props from Payments API)
+ * @param {ComponentCashAppPay} props.RenderedComponent Component to render
+ */
+const SquareComponent = ({ RenderedComponent, isEdit, ...props }) => {
+ // Don't render anything if we're in the block editor.
+ if (isEdit) {
+ return null;
+ }
+ return ;
+};
+
+/**
+ * Square Cash App Pay payment method.
+ */
+const squareCashAppPayMethod = {
+ name: PAYMENT_METHOD_ID,
+ label: ,
+ paymentMethodId: PAYMENT_METHOD_ID,
+ ariaLabel: __(
+ 'Cash App Pay payment method',
+ 'woocommerce-square'
+ ),
+ content: ,
+ edit: ,
+ canMakePayment: ({ billingData, cartTotals }) => {
+ const isSquareConnected = applicationId && locationId;
+ const isCountrySupported = billingData.country === 'US';
+ const isCurrencySupported = cartTotals.currency_code === 'USD';
+ const isEnabled = isSquareConnected && isCountrySupported && isCurrencySupported;
+
+ /**
+ * Set the Cash App Pay payment method as active when the checkout form is rendered.
+ *
+ * TODO: Find a better way to do this.
+ * Didn't find a suitable action to activate the cash app pay payment method when customer returns from the cash app.
+ *
+ * Initially tried to use the experimental__woocommerce_blocks-checkout-render-checkout-form action but it doesn't work when stripe payment gateway is enabled.
+ */
+ if ( isEnabled ) {
+ selectCashAppPaymentMethod();
+ }
+
+ return isEnabled;
+ },
+ supports: {
+ features: getSquareCashAppPayServerData().supports || [],
+ showSavedCards: getSquareCashAppPayServerData().showSavedCards || false,
+ showSaveOption: getSquareCashAppPayServerData().showSaveOption || false,
+ }
+};
+
+// Register Square Cash App.
+registerPaymentMethod( squareCashAppPayMethod );
diff --git a/assets/blocks/cash-app-pay/utils.js b/assets/blocks/cash-app-pay/utils.js
new file mode 100644
index 00000000..f8864362
--- /dev/null
+++ b/assets/blocks/cash-app-pay/utils.js
@@ -0,0 +1,177 @@
+/**
+ * External dependencies
+ */
+import { getSetting } from '@woocommerce/settings';
+import { dispatch } from '@wordpress/data';
+
+
+const { PAYMENT_STORE_KEY } = window.wc.wcBlocksData;
+import { PAYMENT_METHOD_ID } from './constants';
+let cachedSquareCashAppData = null;
+
+/**
+ * Square settings that comes from the server
+ *
+ * @return {SquareServerData} Square server data.
+ */
+export const getSquareCashAppPayServerData = () => {
+ if (cachedSquareCashAppData !== null) {
+ return cachedSquareCashAppData;
+ }
+
+ const squareData = getSetting('square_cash_app_pay_data', null);
+
+ if (!squareData) {
+ throw new Error(
+ 'Square Cash App Pay initialization data is not available'
+ );
+ }
+
+ cachedSquareCashAppData = {
+ title: squareData.title || '',
+ description: squareData.description || '',
+ applicationId: squareData.application_id || '',
+ locationId: squareData.location_id || '',
+ isSandbox: squareData.is_sandbox || false,
+ loggingEnabled: squareData.logging_enabled || false,
+ generalError: squareData.general_error || '',
+ showSavedCards: squareData.show_saved_cards || false,
+ showSaveOption: squareData.show_save_option || false,
+ supports: squareData.supports || {},
+ isPayForOrderPage: squareData.is_pay_for_order_page || false,
+ orderId: squareData.order_id || '',
+ ajaxUrl: squareData.ajax_url || '',
+ paymentRequestNonce: squareData.payment_request_nonce || '',
+ continuationSessionNonce: squareData.continuation_session_nonce || '',
+ gatewayIdDasherized: squareData.gateway_id_dasherized || '',
+ buttonStyles: squareData.button_styles || {},
+ isContinuation: squareData.is_continuation || false,
+ refereneceId: squareData.reference_id || '',
+ };
+
+ return cachedSquareCashAppData;
+};
+
+/**
+ * Returns the AJAX URL for a given action.
+ *
+ * @param {string} action Corresponding action name for the AJAX endpoint.
+ * @return {string} AJAX URL
+ */
+const getAjaxUrl = (action) => {
+ return getSquareCashAppPayServerData().ajaxUrl.replace(
+ '%%endpoint%%',
+ `square_cash_app_pay_${action}`
+ );
+};
+
+/**
+ * Returns the payment request object to create
+ * Square payment request object.
+ *
+ * @return {Object} data to create Square payment request.
+ */
+const getPaymentRequest = () => {
+ return new Promise((resolve, reject) => {
+ const data = {
+ security: getSquareCashAppPayServerData().paymentRequestNonce,
+ is_pay_for_order_page:
+ getSquareCashAppPayServerData().isPayForOrderPage || false,
+ order_id: getSquareCashAppPayServerData().orderId || 0,
+ };
+
+ jQuery.post(getAjaxUrl('get_payment_request'), data, (response) => {
+ if (response.success) {
+ return resolve(response.data);
+ }
+
+ return reject(response.data);
+ });
+ });
+};
+
+/**
+ * Returns the Square payment request.
+ *
+ * @param {Object} payments Square payment object.
+ * @return {Object} The payment request object.
+ */
+export const createPaymentRequest = async (payments) => {
+ const __paymentRequestJson = await getPaymentRequest();
+ const __paymentRequestObject = JSON.parse(__paymentRequestJson);
+ const paymentRequest = payments.paymentRequest(__paymentRequestObject);
+
+ return paymentRequest;
+};
+
+/**
+ * Set continuation session to select the cash app payment method after the redirect back from the cash app.
+ */
+export const setContinuationSession = (clear = false) => {
+ return new Promise((resolve, reject) => {
+ const data = {
+ security: getSquareCashAppPayServerData().continuationSessionNonce,
+ clear: clear,
+ };
+
+ jQuery.post(
+ getAjaxUrl('set_continuation_session'),
+ data,
+ (response) => {
+ if (response.success) {
+ return resolve(response.data);
+ }
+
+ return reject(response.data);
+ }
+ );
+ });
+};
+
+/**
+ * Clear continuation session.
+ */
+export const clearContinuationSession = () => {
+ return setContinuationSession(true);
+};
+
+/**
+ * Log to the console if logging is enabled
+ *
+ * @param {*} data Data to log to console
+ * @param {string} type Type of log, 'error' will log as an error
+ */
+export const log = (data, type = 'notice') => {
+ if (!getSquareCashAppPayServerData().loggingEnabled) {
+ return;
+ }
+
+ if (type === 'error') {
+ console.error(data);
+ } else {
+ console.log(data);
+ }
+};
+
+/**
+ * Select the Cash App Pay payment method if the continuation session is set.
+ */
+export const selectCashAppPaymentMethod = () => {
+ const payMethodInput =
+ document &&
+ document.getElementById(
+ 'radio-control-wc-payment-method-options-square_cash_app_pay'
+ );
+ if (
+ getSquareCashAppPayServerData().isContinuation &&
+ !window.wcSquareCashAppPaySelected &&
+ payMethodInput
+ ) {
+ log('[Square Cash App Pay] Selecting Cash App Pay payment method');
+ dispatch(PAYMENT_STORE_KEY).__internalSetActivePaymentMethod(
+ PAYMENT_METHOD_ID
+ );
+ window.wcSquareCashAppPaySelected = true;
+ clearContinuationSession();
+ }
+};
diff --git a/assets/css/frontend/wc-square-cash-app-pay.scss b/assets/css/frontend/wc-square-cash-app-pay.scss
new file mode 100644
index 00000000..5d5e844b
--- /dev/null
+++ b/assets/css/frontend/wc-square-cash-app-pay.scss
@@ -0,0 +1,3 @@
+img.wc-square-cash-app-pay-payment-gateway-icon{
+ max-width: 26px;
+}
\ No newline at end of file
diff --git a/assets/images/cash-app.png b/assets/images/cash-app.png
new file mode 100644
index 00000000..d6058b24
Binary files /dev/null and b/assets/images/cash-app.png differ
diff --git a/assets/images/cash-app.svg b/assets/images/cash-app.svg
new file mode 100644
index 00000000..3e2ee39e
--- /dev/null
+++ b/assets/images/cash-app.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/js/frontend/wc-square-cash-app-pay.js b/assets/js/frontend/wc-square-cash-app-pay.js
new file mode 100644
index 00000000..688154b3
--- /dev/null
+++ b/assets/js/frontend/wc-square-cash-app-pay.js
@@ -0,0 +1,400 @@
+/**
+ * Square Cash App Pay Handler class.
+ *
+ * @since x.x.x
+ */
+jQuery( document ).ready( ( $ ) => {
+ /**
+ * Square Cash App Pay Handler class.
+ *
+ * @since x.x.x
+ */
+ class WC_Square_Cash_App_Pay_Handler {
+ /**
+ * Setup handler
+ *
+ * @param {Array} args
+ * @since x.x.x
+ */
+ constructor( args ) {
+ this.args = args;
+ this.payment_request = args.payment_request || {};
+ this.isPayForOrderPage = args.is_pay_for_order_page;
+ this.orderId = args.order_id;
+ this.id_dasherized = args.gateway_id_dasherized;
+ this.buttonStyles = args.button_styles;
+ this.referenceId = this.reference_id;
+ this.cashAppButton = '#wc-square-cash-app';
+ this.settingUp = false;
+
+ this.build_cash_app();
+ this.attach_page_events();
+ }
+
+ /**
+ * Fetch a new payment request object and reload the Square Payments
+ *
+ * @since x.x.x
+ */
+ build_cash_app() {
+ // if we are already setting up or no cash app button, bail.
+ if ( this.settingUp || $( document ).find( this.cashAppButton ).length === 0 ) {
+ return;
+ }
+
+ this.settingUp = true;
+ this.block_ui();
+ return this.get_payment_request().then(
+ ( response ) => {
+ const oldPaymentRequest = JSON.stringify( this.payment_request );
+ this.payment_request = JSON.parse( response );
+ this.total_amount = this.payment_request.total.amount;
+
+ // If we have a nonce, loaded button and no updated payment request, bail.
+ if (
+ this.has_payment_nonce() &&
+ $( '#wc-square-cash-app #cash_app_pay_v1_element' ).length &&
+ JSON.stringify( this.payment_request ) === oldPaymentRequest
+ ) {
+ this.settingUp = false;
+ this.unblock_ui();
+ return;
+ }
+ $( this.cashAppButton ).hide();
+ // Clear the nonce.
+ $( `input[name=wc-${ this.id_dasherized }-payment-nonce]` ).val( '' );
+ return this.load_cash_app_form();
+ },
+ ( message ) => {
+ this.log( '[Square Cash App Pay] Could not build payment request. ' + message, 'error' );
+ $( this.cashAppButton ).hide();
+ this.unblock_ui();
+ this.settingUp = false;
+ }
+ );
+ }
+
+ /**
+ * Add page event listeners
+ *
+ * @since x.x.x
+ */
+ attach_page_events() {
+ $( document.body ).on( 'updated_checkout', () => this.build_cash_app() );
+ $( document.body ).on( 'payment_method_selected', () => this.toggle_order_button() );
+ }
+
+ /**
+ * Load the Cash App payment form
+ *
+ * @since x.x.x
+ */
+ async load_cash_app_form() {
+ this.log( '[Square Cash App Pay] Building Cash App Pay' );
+ const { applicationId, locationId } = this.get_form_params();
+ this.payments = window.Square.payments( applicationId, locationId );
+ await this.initializeCashAppPay();
+ this.unblock_ui();
+ this.log('[Square Cash App Pay] Square Cash App Pay Button Loaded');
+ this.settingUp = false;
+ }
+
+ /**
+ * Initializes the Cash App Pay payment methods.
+ *
+ * @returns void
+ */
+ async initializeCashAppPay() {
+ if ( ! this.payments ) {
+ return;
+ }
+
+ /**
+ * Create a payment request.
+ */
+ const paymentRequest = this.payments.paymentRequest( this.create_payment_request() );
+
+ // Destroy the existing Cash App Pay instance.
+ if ( this.cashAppPay ) {
+ await this.cashAppPay.destroy();
+ }
+
+ this.cashAppPay = await this.payments.cashAppPay( paymentRequest, {
+ redirectURL: window.location.href,
+ referenceId: this.referenceId,
+ });
+ await this.cashAppPay.attach( '#wc-square-cash-app', this.buttonStyles );
+
+ this.cashAppPay.addEventListener('ontokenization', (event) => this.handleCashAppPaymentResponse( event ) );
+
+ // Toggle the place order button.
+ this.toggle_order_button();
+
+ /**
+ * Display the button after successful initialize of Cash App Pay.
+ */
+ if ( this.cashAppPay ) {
+ $( this.cashAppButton ).show();
+ }
+ }
+
+ /**
+ * Handles the Cash App payment response.
+ *
+ * @param {Object} event The event object.
+ * @returns void
+ */
+ handleCashAppPaymentResponse( event ) {
+ this.blockedForm = this.blockForm();
+
+ const { tokenResult, error } = event.detail;
+ this.log_data(event.detail, 'response');
+ if ( error ) {
+ this.render_errors( [error.message] );
+ // unblock UI
+ if ( this.blockedForm ) {
+ this.blockedForm.unblock();
+ }
+ } else if ( tokenResult.status === 'OK' ) {
+ const nonce = tokenResult.token;
+ if ( ! nonce ) {
+ // unblock UI
+ if ( this.blockedForm ) {
+ this.blockedForm.unblock();
+ }
+ return this.render_errors( this.args.general_error );
+ }
+ $( `input[name=wc-${ this.id_dasherized }-payment-nonce]` ).val( nonce );
+
+ // Submit the form.
+ if ( ! $( 'input#payment_method_square_cash_app_pay' ).is( ':checked' ) ) {
+ $( 'input#payment_method_square_cash_app_pay' ).trigger( 'click' );
+ $( 'input#payment_method_square_cash_app_pay' ).attr( 'checked', true );
+ }
+
+ this.toggle_order_button();
+ if ( $( '#order_review' ).length ) {
+ $( '#order_review' ).trigger('submit');
+ } else {
+ $( 'form.checkout' ).trigger('submit');
+ }
+ } else {
+ // Declined transaction. Unblock UI and re-build Cash App Pay.
+ if ( this.blockedForm ) {
+ this.blockedForm.unblock();
+ }
+ this.build_cash_app();
+ }
+ }
+
+ /**
+ * Blocks a form when a payment is under process.
+ *
+ * @returns {Object} Returns the input jQuery object.
+ */
+ blockForm() {
+ const checkoutForm = $( 'form.checkout, form#order_review' );
+ checkoutForm.block( {
+ message: null,
+ overlayCSS: {
+ background: '#fff',
+ opacity: 0.6,
+ },
+ } );
+
+ return checkoutForm;
+ }
+
+ /**
+ * Gets the Square payment form params.
+ *
+ * @since x.x.x
+ */
+ get_form_params() {
+ const params = {
+ applicationId: this.args.application_id,
+ locationId: this.args.location_id,
+ };
+
+ return params;
+ }
+
+ /**
+ * Sets the a payment request object for the Square Payment Form
+ *
+ * @since x.x.x
+ */
+ create_payment_request() {
+ return this.payment_request;
+ }
+
+ /*
+ * Get the payment request on a product page
+ *
+ * @since x.x.x
+ */
+ get_payment_request() {
+ return new Promise( ( resolve, reject ) => {
+ const data = {
+ security: this.args.payment_request_nonce,
+ is_pay_for_order_page: this.isPayForOrderPage,
+ order_id: this.orderId,
+ };
+
+ // retrieve a payment request object.
+ $.post( this.get_ajax_url( 'get_payment_request' ), data, ( response ) => {
+ if ( response.success ) {
+ return resolve( response.data );
+ }
+
+ return reject( response.data );
+ } );
+ } );
+ }
+
+ /*
+ * Helper function to return the ajax URL for the given request/action
+ *
+ * @since x.x.x
+ */
+ get_ajax_url( request ) {
+ return this.args.ajax_url.replace( '%%endpoint%%', 'square_cash_app_pay_' + request );
+ }
+
+ /*
+ * Renders errors given the error message HTML
+ *
+ * @since x.x.x
+ */
+ render_errors_html( errors_html ) {
+ // hide and remove any previous errors.
+ $( '.woocommerce-error, .woocommerce-message' ).remove();
+
+ const element = $( 'form[name="checkout"]' );
+
+ // add errors
+ element.before( errors_html );
+
+ // unblock UI
+ if ( this.blockedForm ) {
+ this.blockedForm.unblock();
+ }
+
+ // scroll to top
+ $( 'html, body' ).animate( {
+ scrollTop: element.offset().top - 100,
+ }, 1000 );
+ }
+
+ /*
+ * Renders errors
+ *
+ * @since x.x.x
+ */
+ render_errors( errors ) {
+ const error_message_html = '- ' + errors.join( '
- ' ) + '
';
+ this.render_errors_html( error_message_html );
+ }
+
+ /*
+ * Block the payment buttons being clicked which processing certain actions
+ *
+ * @since x.x.x
+ */
+ block_ui() {
+ $( '.woocommerce-checkout-payment, #payment' ).block( {
+ message: null,
+ overlayCSS: {
+ background: '#fff',
+ opacity: 0.6,
+ },
+ } );
+ }
+
+ /*
+ * Unblocks the payment buttons
+ *
+ * @since x.x.x
+ */
+ unblock_ui() {
+ $( '.woocommerce-checkout-payment, #payment' ).unblock();
+ }
+
+ /**
+ * Logs data to the debug log via AJAX.
+ *
+ * @since x.x.x
+ *
+ * @param {Object} data Request data.
+ * @param {string} type Data type.
+ */
+ log_data( data, type ) {
+ // if logging is disabled, bail.
+ if ( ! this.args.logging_enabled ) {
+ return;
+ }
+
+ const ajax_data = {
+ security: this.args.ajax_log_nonce,
+ type,
+ data,
+ };
+
+ $.ajax( {
+ url: this.get_ajax_url( 'log_js_data' ),
+ data: ajax_data,
+ } );
+ }
+
+ /*
+ * Logs messages to the console when logging is turned on in the settings
+ *
+ * @since x.x.x
+ */
+ log( message, type = 'notice' ) {
+ // if logging is disabled, bail.
+ if ( ! this.args.checkout_logging ) {
+ return;
+ }
+
+ if ( type === 'error' ) {
+ return console.error( message );
+ }
+
+ return console.log( message );
+ }
+
+ /*
+ * Returns the payment nonce
+ *
+ * @since x.x.x
+ */
+ has_payment_nonce() {
+ return $( `input[name=wc-${ this.id_dasherized }-payment-nonce]` ).val();
+ }
+
+ /*
+ * Returns the selected payment gateway id
+ *
+ * @since x.x.x
+ */
+ get_selected_gateway_id() {
+ return $( 'form.checkout, form#order_review' ).find( 'input[name=payment_method]:checked' ).val();
+ }
+
+ /*
+ * Toggles the order button
+ *
+ * @since x.x.x
+ */
+ toggle_order_button() {
+ if ( this.get_selected_gateway_id() === this.args.gateway_id && ! this.has_payment_nonce() ) {
+ $( '#place_order' ).hide();
+ } else {
+ $( '#place_order' ).show();
+ }
+ }
+ }
+
+ window.WC_Square_Cash_App_Pay_Handler = WC_Square_Cash_App_Pay_Handler;
+} );
diff --git a/includes/Framework/PaymentGateway/Payment_Gateway.php b/includes/Framework/PaymentGateway/Payment_Gateway.php
index 5108eae6..444e449a 100644
--- a/includes/Framework/PaymentGateway/Payment_Gateway.php
+++ b/includes/Framework/PaymentGateway/Payment_Gateway.php
@@ -1616,7 +1616,7 @@ public function get_payment_method_image_url( $type ) {
*
* @since 3.0.0
* @param int|WC_Order_Square $order the order or order ID being processed
- * @return \WC_Order object with payment and transaction information attached
+ * @return WC_Order_Square object with payment and transaction information attached
*/
public function get_order( $order ) {
if ( is_numeric( $order ) ) {
@@ -3626,7 +3626,7 @@ public function get_id_dasherized() {
*
* @since 3.0.0
*
- * @return Payment_Gateway_Plugin the parent plugin object
+ * @return Plugin the parent plugin object
*/
public function get_plugin() {
@@ -4142,4 +4142,68 @@ public function get_icon() {
return apply_filters( 'woocommerce_gateway_icon', $icon, $this->get_id() );
}
+ /**
+ * Restores refunded Square inventory.
+ *
+ * @internal
+ *
+ * @since 2.0.0
+ *
+ * @param int $order_id order ID
+ * @param int $refund_id refund ID
+ */
+ public function restore_refunded_inventory( $order_id, $refund_id ) {
+ $inventory_adjustments = array();
+
+ // no handling if inventory sync is disabled
+ if ( ! $this->get_plugin()->get_settings_handler()->is_inventory_sync_enabled() ) {
+ return;
+ }
+
+ $order = wc_get_order( $order_id );
+
+ // check that the order was paid using our gateway
+ if ( ! $order instanceof \WC_Order || $order->get_payment_method() !== $this->get_id() ) {
+ return;
+ }
+
+ // don't refund items if the "Restock refunded items" option is unchecked - maintains backwards compatibility if this function is called outside of the `woocommerce_order_refunded` do_action
+ if ( isset( $_POST['restock_refunded_items'] ) ) {
+ // Validate the user has permissions to process this request.
+ if ( ! check_ajax_referer( 'order-item', 'security', false ) || ! current_user_can( 'edit_shop_orders' ) ) { // phpcs:ignore WordPress.WP.Capabilities.Unknown
+ return;
+ }
+
+ if ( 'false' === $_POST['restock_refunded_items'] ) {
+ return;
+ }
+ }
+
+ $refund = wc_get_order( $refund_id );
+
+ if ( $refund instanceof \WC_Order_Refund ) {
+
+ foreach ( $refund->get_items() as $item ) {
+ if ( $item->is_type( 'line_item' ) ) {
+ $product = $item->get_product();
+
+ if ( $product ) {
+ $inventory_adjustment = Product::get_inventory_change_adjustment_type( $product, absint( $item->get_quantity() ) );
+
+ if ( ! empty( $inventory_adjustment ) ) {
+ $inventory_adjustments[] = $inventory_adjustment;
+ }
+ }
+ }
+ }
+ }
+
+ if ( ! empty( $inventory_adjustments ) ) {
+ wc_square()->get_api()->batch_change_inventory(
+ wc_square()->get_idempotency_key( $refund_id . '_' . time() . '_change_inventory' ),
+ $inventory_adjustments
+ );
+ }
+ }
+
}
diff --git a/includes/Gateway.php b/includes/Gateway.php
index c5d4c426..872ae382 100644
--- a/includes/Gateway.php
+++ b/includes/Gateway.php
@@ -579,72 +579,6 @@ protected function get_order_for_refund( $order, $amount, $reason ) {
return $order;
}
-
- /**
- * Restores refunded Square inventory.
- *
- * @internal
- *
- * @since 2.0.0
- *
- * @param int $order_id order ID
- * @param int $refund_id refund ID
- */
- public function restore_refunded_inventory( $order_id, $refund_id ) {
- $inventory_adjustments = array();
-
- // no handling if inventory sync is disabled
- if ( ! $this->get_plugin()->get_settings_handler()->is_inventory_sync_enabled() ) {
- return;
- }
-
- $order = wc_get_order( $order_id );
-
- // check that the order was paid using our gateway
- if ( ! $order instanceof \WC_Order || $order->get_payment_method() !== $this->get_id() ) {
- return;
- }
-
- // don't refund items if the "Restock refunded items" option is unchecked - maintains backwards compatibility if this function is called outside of the `woocommerce_order_refunded` do_action
- if ( isset( $_POST['restock_refunded_items'] ) ) {
- // Validate the user has permissions to process this request.
- if ( ! check_ajax_referer( 'order-item', 'security', false ) || ! current_user_can( 'edit_shop_orders' ) ) {
- return;
- }
-
- if ( 'false' === $_POST['restock_refunded_items'] ) {
- return;
- }
- }
-
- $refund = wc_get_order( $refund_id );
-
- if ( $refund instanceof \WC_Order_Refund ) {
-
- foreach ( $refund->get_items() as $item ) {
- if ( $item->is_type( 'line_item' ) ) {
- $product = $item->get_product();
-
- if ( $product ) {
- $inventory_adjustment = Product::get_inventory_change_adjustment_type( $product, absint( $item->get_quantity() ) );
-
- if ( ! empty( $inventory_adjustment ) ) {
- $inventory_adjustments[] = $inventory_adjustment;
- }
- }
- }
- }
- }
-
- if ( ! empty( $inventory_adjustments ) ) {
- wc_square()->get_api()->batch_change_inventory(
- wc_square()->get_idempotency_key( $refund_id . '_' . time() . '_change_inventory' ),
- $inventory_adjustments
- );
- }
- }
-
-
/**
* Gets a mock order for adding a new payment method.
*
diff --git a/includes/Gateway/API.php b/includes/Gateway/API.php
index 0e946f8b..d657382c 100644
--- a/includes/Gateway/API.php
+++ b/includes/Gateway/API.php
@@ -42,6 +42,8 @@ class API extends \WooCommerce\Square\API {
/** @var \WC_Order order object associated with a request, if any */
protected $order;
+ /** @var string API ID */
+ protected $api_id;
/**
* Constructs the class.
@@ -148,6 +150,26 @@ public function gift_card_charge( \WC_Order $order ) {
return $this->perform_request( $request );
}
+ /**
+ * Performs a Cash App Pay charge for the given order.
+ *
+ * @since x.x.x
+ *
+ * @param \WC_Order $order order object
+ * @return \WooCommerce\Square\Gateway\API\Responses\Create_Payment
+ * @throws \Exception
+ */
+ public function cash_app_pay_charge( \WC_Order $order ) {
+
+ $request = new API\Requests\Payments( $this->get_location_id(), $this->client );
+
+ $request->set_charge_data( $order, true, true );
+
+ $this->set_response_handler( API\Responses\Create_Payment::class );
+
+ return $this->perform_request( $request );
+ }
+
/**
* Performs a refund for the given order.
@@ -748,7 +770,7 @@ public function get_order() {
*/
protected function get_api_id() {
- return $this->get_plugin()->get_gateway()->get_id();
+ return $this->api_id ? $this->api_id : $this->get_plugin()->get_gateway()->get_id();
}
@@ -777,5 +799,13 @@ public function check_debit( \WC_Order $order ) {}
*/
public function update_tokenized_payment_method( \WC_Order $order ) {}
-
+ /**
+ * Set API ID to used for logging.
+ *
+ * @param string $api_id
+ * @return void
+ */
+ public function set_api_id( $api_id ) {
+ $this->api_id = $api_id;
+ }
}
diff --git a/includes/Gateway/API/Requests/Payments.php b/includes/Gateway/API/Requests/Payments.php
index 402536c6..f8cbde92 100644
--- a/includes/Gateway/API/Requests/Payments.php
+++ b/includes/Gateway/API/Requests/Payments.php
@@ -71,13 +71,23 @@ public function set_authorization_data( \WC_Order $order ) {
* @param \WC_Order $order
* @param bool $capture whether to immediately capture the charge
*/
- public function set_charge_data( \WC_Order $order, $capture = true ) {
- $payment_total = isset( $order->payment->partial_total ) ? $order->payment->partial_total->credit_card : $order->payment_total;
+ public function set_charge_data( \WC_Order $order, $capture = true, $is_cash_app_pay = false ) {
$this->square_api_method = 'createPayment';
- $this->square_request = new \Square\Models\CreatePaymentRequest(
- ! empty( $order->payment->token ) ? $order->payment->token : $order->payment->nonce->credit_card,
- wc_square()->get_idempotency_key( $order->unique_transaction_ref, false )
- );
+
+ // Cash App Pay payment.
+ if ( $is_cash_app_pay ) {
+ $payment_total = $order->payment_total;
+ $this->square_request = new \Square\Models\CreatePaymentRequest(
+ $order->payment->nonce->cash_app_pay,
+ wc_square()->get_idempotency_key( $order->unique_transaction_ref, false )
+ );
+ } else {
+ $payment_total = isset( $order->payment->partial_total ) ? $order->payment->partial_total->credit_card : $order->payment_total;
+ $this->square_request = new \Square\Models\CreatePaymentRequest(
+ ! empty( $order->payment->token ) ? $order->payment->token : $order->payment->nonce->credit_card,
+ wc_square()->get_idempotency_key( $order->unique_transaction_ref, false )
+ );
+ }
$this->square_request->setReferenceId( $order->get_order_number() );
$this->square_request->setAmountMoney(
@@ -96,22 +106,30 @@ public function set_charge_data( \WC_Order $order, $capture = true ) {
$this->square_request->setNote( Square_Helper::str_truncate( $description, 500 ) );
- if ( isset( $order->payment->partial_total ) ) {
- $this->square_request->setAutocomplete( false );
- } else {
- $this->square_request->setAutocomplete( $capture );
- }
-
if ( ! empty( $order->square_customer_id ) ) {
$this->square_request->setCustomerId( $order->square_customer_id );
}
- // payment token (card ID) or card nonce (from JS)
- $this->square_request->setSourceId( ! empty( $order->payment->token ) ? $order->payment->token : $order->payment->nonce->credit_card );
+ // Cash App Pay payment.
+ if ( $is_cash_app_pay ) {
+ $this->square_request->setAutocomplete( $capture );
- // 3DS / SCA verification token (from JS)
- if ( ! empty( $order->payment->verification_token ) ) {
- $this->square_request->setVerificationToken( $order->payment->verification_token );
+ // Payment nonce (from JS)
+ $this->square_request->setSourceId( $order->payment->nonce->cash_app_pay );
+ } else {
+ if ( isset( $order->payment->partial_total ) ) {
+ $this->square_request->setAutocomplete( false );
+ } else {
+ $this->square_request->setAutocomplete( $capture );
+ }
+
+ // payment token (card ID) or card nonce (from JS)
+ $this->square_request->setSourceId( ! empty( $order->payment->token ) ? $order->payment->token : $order->payment->nonce->credit_card );
+
+ // 3DS / SCA verification token (from JS)
+ if ( ! empty( $order->payment->verification_token ) ) {
+ $this->square_request->setVerificationToken( $order->payment->verification_token );
+ }
}
if ( ! empty( $this->location_id ) ) {
diff --git a/includes/Gateway/API/Responses/Create_Payment.php b/includes/Gateway/API/Responses/Create_Payment.php
index 2703f619..7fe42c4c 100644
--- a/includes/Gateway/API/Responses/Create_Payment.php
+++ b/includes/Gateway/API/Responses/Create_Payment.php
@@ -134,6 +134,16 @@ public function is_gift_card_payment() {
return 'SQUARE_GIFT_CARD' === $card->getCardBrand();
}
+ /**
+ * Returns true if the payment status is completed.
+ *
+ * @since x.x.x
+ * @return boolean
+ */
+ public function is_cash_app_payment_completed() {
+ return $this->get_payment() && 'COMPLETED' === $this->get_payment()->getStatus();
+ }
+
/** No-op methods *************************************************************************************************/
diff --git a/includes/Gateway/Blocks_Handler.php b/includes/Gateway/Blocks_Handler.php
index a97455d2..3c6eab8a 100644
--- a/includes/Gateway/Blocks_Handler.php
+++ b/includes/Gateway/Blocks_Handler.php
@@ -249,7 +249,7 @@ public function display_compatible_version_notice() {
private function get_gateway() {
if ( empty( $this->gateway ) ) {
$gateways = $this->plugin->get_gateways();
- $this->gateway = ! empty( $gateways ) ? array_pop( $gateways ) : null;
+ $this->gateway = ! empty( $gateways ) ? $gateways[0] : null;
}
return $this->gateway;
diff --git a/includes/Gateway/Cash_App_Pay_Blocks_Handler.php b/includes/Gateway/Cash_App_Pay_Blocks_Handler.php
new file mode 100644
index 00000000..1319400a
--- /dev/null
+++ b/includes/Gateway/Cash_App_Pay_Blocks_Handler.php
@@ -0,0 +1,158 @@
+plugin = wc_square();
+ }
+
+
+ /**
+ * Initializes the payment method type.
+ */
+ public function initialize() {
+ $this->settings = get_option( 'woocommerce_square_cash_app_pay_settings', array() );
+ }
+
+ /**
+ * Returns if this payment method should be active. If false, the scripts will not be enqueued.
+ *
+ * @return boolean
+ */
+ public function is_active() {
+ return ! empty( $this->get_gateway() ) ? $this->get_gateway()->is_configured() : false;
+ }
+
+ /**
+ * Register scripts
+ *
+ * @return array
+ */
+ public function get_payment_method_script_handles() {
+ $asset_path = $this->plugin->get_plugin_path() . '/build/cash-app-pay.asset.php';
+ $version = Plugin::VERSION;
+ $dependencies = array();
+
+ if ( file_exists( $asset_path ) ) {
+ $asset = require $asset_path;
+ $version = is_array( $asset ) && isset( $asset['version'] ) ? $asset['version'] : $version;
+ $dependencies = is_array( $asset ) && isset( $asset['dependencies'] ) ? $asset['dependencies'] : $dependencies;
+ }
+
+ wp_register_script(
+ 'wc-square-cash-app-pay-blocks-integration',
+ $this->plugin->get_plugin_url() . '/build/cash-app-pay.js',
+ $dependencies,
+ $version,
+ true
+ );
+
+ wp_set_script_translations( 'wc-square-cash-app-pay-blocks-integration', 'woocommerce-square' );
+
+ return array( 'wc-square-cash-app-pay-blocks-integration' );
+ }
+
+ /**
+ * Returns an array of key=>value pairs of data made available to the payment methods script.
+ *
+ * @since x.x.x
+ * @return array
+ */
+ public function get_payment_method_data() {
+ if ( ! $this->get_gateway() ) {
+ return array();
+ }
+ return array(
+ 'title' => $this->get_setting( 'title' ),
+ 'description' => $this->get_setting( 'description' ),
+ 'application_id' => $this->get_gateway()->get_application_id(),
+ 'location_id' => $this->plugin->get_settings_handler()->get_location_id(),
+ 'is_sandbox' => $this->plugin->get_settings_handler()->is_sandbox(),
+ 'logging_enabled' => $this->get_gateway()->debug_checkout(),
+ 'general_error' => __( 'An error occurred, please try again or try an alternate form of payment.', 'woocommerce-square' ),
+ 'supports' => $this->get_supported_features(),
+ 'show_saved_cards' => false,
+ 'show_save_option' => false,
+ 'is_pay_for_order_page' => is_wc_endpoint_url( 'order-pay' ),
+ 'ajax_url' => \WC_AJAX::get_endpoint( '%%endpoint%%' ),
+ 'payment_request_nonce' => wp_create_nonce( 'wc-cash-app-get-payment-request' ),
+ 'continuation_session_nonce' => wp_create_nonce( 'wc-cash-app-set-continuation-session' ),
+ 'checkout_logging' => $this->get_gateway()->debug_checkout(),
+ 'order_id' => absint( get_query_var( 'order-pay' ) ),
+ 'gateway_id_dasherized' => $this->get_gateway()->get_id_dasherized(),
+ 'button_styles' => $this->get_gateway()->get_button_styles(),
+ 'is_continuation' => $this->get_gateway()->is_cash_app_pay_continuation(),
+ 'reference_id' => WC()->cart ? WC()->cart->get_cart_hash() : '',
+ );
+ }
+
+ /**
+ * Get a list of features supported by Square
+ *
+ * @since x.x.x
+ * @return array
+ */
+ public function get_supported_features() {
+ $gateway = $this->get_gateway();
+ return ! empty( $gateway ) ? array_filter( $gateway->supports, array( $gateway, 'supports' ) ) : array();
+ }
+
+ /**
+ * Helper function to get and store an instance of the Square gateway
+ *
+ * @since x.x.x
+ * @return Cash_App_Pay_Gateway|null
+ */
+ private function get_gateway() {
+ if ( empty( $this->gateway ) ) {
+ $this->gateway = $this->plugin->get_gateway( Plugin::CASH_APP_PAY_GATEWAY_ID );
+ }
+
+ return $this->gateway;
+ }
+}
diff --git a/includes/Gateway/Cash_App_Pay_Gateway.php b/includes/Gateway/Cash_App_Pay_Gateway.php
new file mode 100644
index 00000000..e02af982
--- /dev/null
+++ b/includes/Gateway/Cash_App_Pay_Gateway.php
@@ -0,0 +1,1234 @@
+ __( 'Cash App Pay (Square)', 'woocommerce-square' ),
+ 'method_description' => __( 'Allow customers to securely pay with Cash App', 'woocommerce-square' ),
+ 'payment_type' => 'cash_app_pay',
+ 'supports' => array(
+ self::FEATURE_PRODUCTS,
+ self::FEATURE_REFUNDS,
+ ),
+ 'countries' => array( 'US' ),
+ 'currencies' => array( 'USD' ),
+ )
+ );
+
+ // Payment method image.
+ $this->icon = $this->get_payment_method_image_url();
+
+ // Transaction URL format.
+ $this->view_transaction_url = $this->get_transaction_url_format();
+
+ // Ajax hooks
+ add_action( 'wc_ajax_square_cash_app_pay_get_payment_request', array( $this, 'ajax_get_payment_request' ) );
+ add_action( 'wc_ajax_square_cash_app_pay_set_continuation_session', array( $this, 'ajax_set_continuation_session' ) );
+ add_action( 'wc_ajax_square_cash_app_log_js_data', array( $this, 'log_js_data' ) );
+
+ // restore refunded Square inventory
+ add_action( 'woocommerce_order_refunded', array( $this, 'restore_refunded_inventory' ), 10, 2 );
+
+ // Admin hooks.
+ add_action( 'admin_notices', array( $this, 'add_admin_notices' ) );
+ }
+
+ /**
+ * Enqueue the necessary scripts & styles for the gateway.
+ *
+ * @since x.x.x
+ */
+ public function enqueue_scripts() {
+ if ( ! $this->is_configured() ) {
+ return;
+ }
+
+ // Enqueue payment gateway assets.
+ $this->enqueue_gateway_assets();
+ }
+
+ /**
+ * Payment form on checkout page.
+ *
+ * @since x.x.x
+ */
+ public function payment_fields() {
+ parent::payment_fields();
+ ?>
+
+
+
+
+
+ get_id_dasherized();
+ }
+
+ /**
+ * Enqueue the gateway-specific assets if present, including JS, CSS, and
+ * localized script params
+ *
+ * @since x.x.x
+ */
+ protected function enqueue_gateway_assets() {
+ $is_checkout = is_checkout() || ( function_exists( 'has_block' ) && has_block( 'woocommerce/checkout' ) );
+
+ // bail if not a checkout page or cash app pay is not enabled
+ if ( ! $is_checkout || ! $this->is_configured() ) {
+ return;
+ }
+
+ if ( $this->get_plugin()->get_settings_handler()->is_sandbox() ) {
+ $url = 'https://sandbox.web.squarecdn.com/v1/square.js';
+ } else {
+ $url = 'https://web.squarecdn.com/v1/square.js';
+ }
+
+ wp_enqueue_script( 'wc-' . $this->get_plugin()->get_id_dasherized() . '-payment-form', $url, array(), Plugin::VERSION, true );
+
+ parent::enqueue_gateway_assets();
+
+ // Render Payment JS
+ $this->render_js();
+ }
+
+ /**
+ * Validates the entered payment fields.
+ *
+ * @since x.x.x
+ *
+ * @return bool
+ */
+ public function validate_fields() {
+ $is_valid = true;
+
+ try {
+ if ( ! Square_Helper::get_post( 'wc-' . $this->get_id_dasherized() . '-payment-nonce' ) ) {
+ throw new \Exception( 'Payment nonce is missing.' );
+ }
+ } catch ( \Exception $exception ) {
+
+ $is_valid = false;
+
+ Square_Helper::wc_add_notice( __( 'An error occurred, please try again or try an alternate form of payment.', 'woocommerce-square' ), 'error' );
+
+ $this->add_debug_message( $exception->getMessage(), 'error' );
+ }
+
+ return $is_valid;
+ }
+
+ /** Admin methods *************************************************************************************************/
+ /**
+ * Adds admin notices.
+ *
+ * @since x.x.x
+ */
+ public function add_admin_notices() {
+ $base_location = wc_get_base_location();
+ $is_plugin_settings = $this->get_plugin()->is_payment_gateway_configuration_page( $this->get_id() );
+ $is_connected = $this->get_plugin()->get_settings_handler()->is_connected() && $this->get_plugin()->get_settings_handler()->get_location_id();
+ $is_enabled = $this->is_enabled() && $is_connected;
+
+ // Add a notice for cash app pay if the base location is not the US.
+ if ( ( $is_enabled || $is_plugin_settings ) && isset( $base_location['country'] ) && 'US' !== $base_location['country'] ) {
+
+ $this->get_plugin()->get_admin_notice_handler()->add_admin_notice(
+ sprintf(
+ /* translators: Placeholders: %1$s - tag, %2$s - tag, %3$s - 2-character country code, %4$s - comma separated list of 2-character country codes */
+ __( '%1$sCash App Pay (Square):%2$s Your base country is %3$s, but Cash App Pay can’t accept transactions from merchants outside of US.', 'woocommerce-square' ),
+ '',
+ '',
+ esc_html( $base_location['country'] )
+ ),
+ 'wc-square-cash-app-pay-base-location',
+ array(
+ 'notice_class' => 'notice-error',
+ )
+ );
+ }
+
+ // Add a notice to enable cash app pay and start accept payments using cash app pay.
+ if ( $is_connected && ! $this->is_enabled() && ! $is_plugin_settings && isset( $base_location['country'] ) && 'US' === $base_location['country'] ) {
+ $this->get_plugin()->get_admin_notice_handler()->add_admin_notice(
+ sprintf(
+ /* translators: Placeholders: %1$s - tag, %2$s - tag */
+ __( 'You are ready to accept payments using Cash App Pay (Square)! %1$sEnable it%2$s now to start accepting payments.', 'woocommerce-square' ),
+ '',
+ ''
+ ),
+ 'wc-square-enable-cash-app-pay',
+ );
+ }
+ }
+
+ /**
+ * Get the default payment method title, which is configurable within the
+ * admin and displayed on checkout
+ *
+ * @since x.x.x
+ * @return string payment method title to show on checkout
+ */
+ protected function get_default_title() {
+ return esc_html__( 'Cash App Pay', 'woocommerce-square' );
+ }
+
+
+ /**
+ * Get the default payment method description, which is configurable
+ * within the admin and displayed on checkout
+ *
+ * @since x.x.x
+ * @return string payment method description to show on checkout
+ */
+ protected function get_default_description() {
+ return esc_html__( 'Pay securely using Cash App Pay.', 'woocommerce-square' );
+ }
+
+ /**
+ * Get transaction URL format.
+ *
+ * @since x.x.x
+ *
+ * @return string URL format
+ */
+ public function get_transaction_url_format() {
+ return $this->get_plugin()->get_settings_handler()->is_sandbox() ? 'https://squareupsandbox.com/dashboard/sales/transactions/%s' : 'https://squareup.com/dashboard/sales/transactions/%s';
+ }
+
+ /**
+ * Initialize payment gateway settings fields
+ *
+ * @since x.x.x
+ * @see WC_Settings_API::init_form_fields()
+ */
+ public function init_form_fields() {
+
+ // common top form fields
+ $this->form_fields = array(
+ 'enabled' => array(
+ 'title' => esc_html__( 'Enable / Disable', 'woocommerce-square' ),
+ 'label' => esc_html__( 'Enable this gateway', 'woocommerce-square' ),
+ 'type' => 'checkbox',
+ 'default' => 'no',
+ ),
+
+ 'title' => array(
+ 'title' => esc_html__( 'Title', 'woocommerce-square' ),
+ 'type' => 'text',
+ 'desc_tip' => esc_html__( 'Payment method title that the customer will see during checkout.', 'woocommerce-square' ),
+ 'default' => $this->get_default_title(),
+ ),
+
+ 'description' => array(
+ 'title' => esc_html__( 'Description', 'woocommerce-square' ),
+ 'type' => 'textarea',
+ 'desc_tip' => esc_html__( 'Payment method description that the customer will see during checkout.', 'woocommerce-square' ),
+ 'default' => $this->get_default_description(),
+ ),
+
+ 'button_theme' => array(
+ 'title' => esc_html__( 'Cash App Pay Button Theme', 'woocommerce-square' ),
+ 'desc_tip' => esc_html__( 'Select the theme of the Cash App Pay button.', 'woocommerce-square' ),
+ 'type' => 'select',
+ 'default' => 'dark',
+ 'class' => 'wc-enhanced-select wc-square-cash-app-pay-options',
+ 'options' => array(
+ 'dark' => esc_html__( 'Dark', 'woocommerce-square' ),
+ 'light' => esc_html__( 'Light', 'woocommerce-square' ),
+ ),
+ ),
+
+ 'button_shape' => array(
+ 'title' => esc_html__( 'Cash App Pay Button Shape', 'woocommerce-square' ),
+ 'desc_tip' => esc_html__( 'Select the shape of the Cash App Pay button.', 'woocommerce-square' ),
+ 'type' => 'select',
+ 'default' => 'semiround',
+ 'class' => 'wc-enhanced-select wc-square-cash-app-pay-options',
+ 'options' => array(
+ 'semiround' => esc_html__( 'Semiround', 'woocommerce-square' ),
+ 'round' => esc_html__( 'Round', 'woocommerce-square' ),
+ ),
+ ),
+ );
+
+ $this->form_fields['advanced_settings_title'] = array(
+ 'title' => esc_html__( 'Advanced Settings', 'woocommerce-square' ),
+ 'type' => 'title',
+ );
+
+ // debug mode
+ $this->form_fields['debug_mode'] = array(
+ 'title' => esc_html__( 'Debug Mode', 'woocommerce-square' ),
+ 'type' => 'select',
+ 'class' => 'wc-enhanced-select',
+ /* translators: Placeholders: %1$s - tag, %2$s - tag */
+ 'desc' => sprintf( esc_html__( 'Show detailed error messages and API requests/responses on the checkout page and/or save them to the %1$sdebug log%2$s', 'woocommerce-square' ), '', '' ),
+ 'default' => self::DEBUG_MODE_OFF,
+ 'options' => array(
+ self::DEBUG_MODE_OFF => esc_html__( 'Off', 'woocommerce-square' ),
+ self::DEBUG_MODE_CHECKOUT => esc_html__( 'Show on Checkout Page', 'woocommerce-square' ),
+ self::DEBUG_MODE_LOG => esc_html__( 'Save to Log', 'woocommerce-square' ),
+ /* translators: show debugging information on both checkout page and in the log */
+ self::DEBUG_MODE_BOTH => esc_html__( 'Both', 'woocommerce-square' ),
+ ),
+ );
+
+ // if there is more than just the production environment available
+ if ( count( $this->get_environments() ) > 1 ) {
+ $this->form_fields = $this->add_environment_form_fields( $this->form_fields );
+ }
+
+ /**
+ * Payment Gateway Form Fields Filter.
+ *
+ * Actors can use this to add, remove, or tweak gateway form fields
+ *
+ * @since x.x.x
+ * @param array $form_fields array of form fields in format required by WC_Settings_API
+ * @param Payment_Gateway $this gateway instance
+ */
+ $this->form_fields = apply_filters( 'wc_payment_gateway_' . $this->get_id() . '_form_fields', $this->form_fields, $this );
+ }
+
+ /** Conditional methods *******************************************************************************************/
+
+ /**
+ * Determines if the gateway is available.
+ *
+ * @since x.x.x
+ *
+ * @return bool
+ */
+ public function is_available() {
+
+ return parent::is_available() && $this->get_plugin()->get_settings_handler()->is_connected() && $this->get_plugin()->get_settings_handler()->get_location_id();
+ }
+
+ /**
+ * Returns true if the gateway is properly configured to perform transactions
+ *
+ * @since x.x.x
+ * @return boolean true if the gateway is properly configured
+ */
+ public function is_configured() {
+ // Only available in the US and USD currency.
+ $base_location = wc_get_base_location();
+ $us_only = isset( $base_location['country'] ) && 'US' === $base_location['country'];
+
+ return $this->is_enabled() && $us_only && $this->get_plugin()->get_settings_handler()->is_connected() && $this->get_plugin()->get_settings_handler()->get_location_id();
+ }
+
+ /** Getter methods ************************************************************************************************/
+
+ /**
+ * Gets the API instance.
+ *
+ * @since x.x.x
+ *
+ * @return Gateway\API
+ */
+ public function get_api() {
+
+ if ( ! $this->api ) {
+ $settings = $this->get_plugin()->get_settings_handler();
+ $this->api = new Gateway\API( $settings->get_access_token(), $settings->get_location_id(), $settings->is_sandbox() );
+ $this->api->set_api_id( $this->get_id() );
+ }
+
+ return $this->api;
+ }
+
+
+ /**
+ * Gets the gateway settings fields.
+ *
+ * @since x.x.x
+ *
+ * @return array
+ */
+ protected function get_method_form_fields() {
+ return array();
+ }
+
+
+ /**
+ * Gets a user's stored customer ID.
+ *
+ * Overridden to avoid auto-creating customer IDs, as Square generates them.
+ *
+ * @since x.x.x
+ *
+ * @param int $user_id user ID
+ * @param array $args arguments
+ * @return string
+ */
+ public function get_customer_id( $user_id, $args = array() ) {
+
+ // Square generates customer IDs
+ $args['autocreate'] = false;
+
+ return parent::get_customer_id( $user_id, $args );
+ }
+
+
+ /**
+ * Gets a guest's customer ID.
+ *
+ * @since x.x.x
+ *
+ * @param \WC_Order $order order object
+ * @return string|bool
+ */
+ public function get_guest_customer_id( \WC_Order $order ) {
+
+ // is there a customer id already tied to this order?
+ $customer_id = $this->get_order_meta( $order, 'customer_id' );
+
+ if ( $customer_id ) {
+ return $customer_id;
+ }
+
+ return false;
+ }
+
+ /**
+ * Gets the order object with payment information added.
+ *
+ * @since x.x.x
+ *
+ * @param int|\WC_Order $order_id order ID or object
+ * @return \WC_Order
+ */
+ public function get_order( $order_id ) {
+ $order = parent::get_order( $order_id );
+
+ $order->payment->nonce = new \stdClass();
+ $order->payment->nonce->cash_app_pay = Square_Helper::get_post( 'wc-' . $this->get_id_dasherized() . '-payment-nonce' );
+
+ $order->square_customer_id = $order->customer_id;
+ $order->square_order_id = $this->get_order_meta( $order, 'square_order_id' );
+ $order->square_version = $this->get_order_meta( $order, 'square_version' );
+
+ // look up in the index for guest customers
+ if ( ! $order->get_user_id() ) {
+
+ $indexed_customers = Customer_Helper::get_customers_by_email( $order->get_billing_email() );
+
+ // only use an indexed customer ID if there was a single one returned, otherwise we can't know which to use
+ if ( ! empty( $indexed_customers ) && count( $indexed_customers ) === 1 ) {
+ $order->square_customer_id = $order->customer_id = $indexed_customers[0];
+ }
+ }
+
+ // if no previous customer could be found, always create a new customer
+ if ( empty( $order->square_customer_id ) ) {
+
+ try {
+
+ $response = $this->get_api()->create_customer( $order );
+
+ $order->square_customer_id = $order->customer_id = $response->get_customer_id(); // set $customer_id since we know this customer can be associated with this user
+
+ // store the guests customers in our index to avoid future duplicates
+ if ( ! $order->get_user_id() ) {
+ Customer_Helper::add_customer( $order->square_customer_id, $order->get_billing_email() );
+ }
+ } catch ( \Exception $exception ) {
+
+ // log the error, but continue with payment
+ if ( $this->debug_log() ) {
+ $this->get_plugin()->log( $exception->getMessage(), $this->get_id() );
+ }
+ }
+ }
+
+ return $order;
+ }
+
+ /**
+ * Gets an order with refund data attached.
+ *
+ * @since x.x.x
+ *
+ * @param int|\WC_Order $order order object
+ * @param float $amount amount to refund
+ * @param string $reason response for the refund
+ *
+ * @return \WC_Order|\WP_Error
+ */
+ protected function get_order_for_refund( $order, $amount, $reason ) {
+
+ $order = parent::get_order_for_refund( $order, $amount, $reason );
+ $order->square_version = $this->get_order_meta( $order, 'square_version' );
+ $transaction_date = $this->get_order_meta( $order, 'trans_date' );
+
+ if ( $transaction_date ) {
+ // refunds with the Refunds API can be made up to 1 year after payment and up to 120 days with the Transactions API
+ $max_refund_time = version_compare( $order->square_version, '2.2', '>=' ) ? '+1 year' : '+120 days';
+
+ // throw an error if the payment cannot be refunded
+ if ( time() >= strtotime( $max_refund_time, strtotime( $transaction_date ) ) ) {
+ /* translators: %s maximum refund date. */
+ return new \WP_Error( 'wc_square_refund_age_exceeded', sprintf( __( 'Refunds must be made within %s of the original payment date.', 'woocommerce-square' ), '+1 year' === $max_refund_time ? 'a year' : '120 days' ) );
+ }
+ }
+
+ $order->refund->location_id = $this->get_order_meta( $order, 'square_location_id' );
+ $order->refund->tender_id = $this->get_order_meta( $order, 'authorization_code' );
+
+ if ( ! $order->refund->tender_id ) {
+
+ try {
+ $response = version_compare( $order->square_version, '2.2', '>=' ) ? $this->get_api()->get_payment( $order->refund->trans_id ) : $this->get_api()->get_transaction( $order->refund->trans_id, $order->refund->location_id );
+
+ if ( ! $response->get_authorization_code() ) {
+ throw new \Exception( 'Tender missing' );
+ }
+
+ $this->update_order_meta( $order, 'authorization_code', $response->get_authorization_code() );
+ $this->update_order_meta( $order, 'square_location_id', $response->get_location_id() );
+
+ $order->refund->location_id = $response->get_location_id();
+ $order->refund->tender_id = $response->get_authorization_code();
+
+ } catch ( \Exception $exception ) {
+
+ return new \WP_Error( 'wc_square_refund_tender_missing', __( 'Could not find original transaction tender. Please refund this transaction from your Square dashboard.', 'woocommerce-square' ) );
+ }
+ }
+
+ return $order;
+ }
+
+ /**
+ * Gets the configured environment ID.
+ *
+ * @since x.x.x
+ *
+ * @return string
+ */
+ public function get_environment() {
+ return self::ENVIRONMENT_PRODUCTION;
+ }
+
+ /**
+ * Gets the configured application ID.
+ *
+ * @since x.x.x
+ *
+ * @return string
+ */
+ public function get_application_id() {
+ $square_application_id = 'sq0idp-wGVapF8sNt9PLrdj5znuKA';
+
+ if ( $this->get_plugin()->get_settings_handler()->is_sandbox() ) {
+ $square_application_id = $this->get_plugin()->get_settings_handler()->get_option( 'sandbox_application_id' );
+ }
+
+ /**
+ * Filters the configured application ID.
+ *
+ * @since x.x.x
+ *
+ * @param string $application_id application ID
+ */
+ return apply_filters( 'wc_square_application_id', $square_application_id );
+ }
+
+ /**
+ * Returns the $order object with a unique transaction ref member added
+ *
+ * @since x.x.x
+ * @param WC_Order_Square $order the order object
+ * @return WC_Order_Square order object with member named unique_transaction_ref
+ */
+ protected function get_order_with_unique_transaction_ref( $order ) {
+ $order_id = $order->get_id();
+
+ // generate a unique retry count
+ if ( is_numeric( $this->get_order_meta( $order_id, 'retry_count' ) ) ) {
+ $retry_count = $this->get_order_meta( $order_id, 'retry_count' );
+ ++$retry_count;
+ } else {
+ $retry_count = 0;
+ }
+
+ // keep track of the retry count
+ $this->update_order_meta( $order, 'retry_count', $retry_count );
+
+ $order->unique_transaction_ref = time() . '-' . $order_id . ( $retry_count >= 0 ? '-' . $retry_count : '' );
+ return $order;
+ }
+
+ /**
+ * Returns the payment method image URL.
+ *
+ * @since x.x.x
+ * @param string $type the payment method type or name
+ * @return string the image URL or null
+ */
+ public function get_payment_method_image_url( $type = '' ) {
+ /**
+ * Payment Gateway Fallback to PNG Filter.
+ *
+ * Allow actors to enable the use of PNGs over SVGs for payment icon images.
+ *
+ * @since x.x.x
+ * @param bool $use_svg true by default, false to use PNGs
+ */
+ $image_extension = apply_filters( 'wc_payment_gateway_' . $this->get_id() . '_use_svg', true ) ? '.svg' : '.png';
+
+ // first, is the image available within the plugin?
+ if ( is_readable( $this->get_plugin()->get_plugin_path() . '/assets/images/cash-app' . $image_extension ) ) {
+ return \WC_HTTPS::force_https_url( $this->get_plugin()->get_plugin_url() . '/assets/images/cash-app' . $image_extension );
+ }
+
+ // Fall back to framework image URL.
+ return parent::get_payment_method_image_url( $type );
+ }
+
+ /**
+ * Get the Cash App Pay button styles.
+ *
+ * @return array Button styles.
+ */
+ public function get_button_styles() {
+ $button_styles = array(
+ 'theme' => $this->settings['button_theme'] ?? 'dark',
+ 'shape' => $this->settings['button_shape'] ?? 'semiround',
+ 'size' => 'medium',
+ 'width' => 'full',
+ );
+
+ /**
+ * Filters the Cash App Pay button styles.
+ *
+ * @since x.x.x
+ * @param array $button_styles Button styles.
+ * @return array Button styles.
+ */
+ return apply_filters( 'wc_' . $this->get_id() . '_button_styles', $button_styles );
+ }
+
+
+ /**
+ * Mark an order as refunded. This should only be used when the full order
+ * amount has been refunded.
+ *
+ * @since x.x.x
+ *
+ * @param \WC_Order $order order object
+ */
+ public function mark_order_as_refunded( $order ) {
+
+ /* translators: Placeholders: %s - payment gateway title (such as Authorize.net, Braintree, etc) */
+ $order_note = sprintf( esc_html__( '%s Order completely refunded.', 'woocommerce-square' ), $this->get_method_title() );
+
+ // Add order note and continue with WC refund process.
+ $order->add_order_note( $order_note );
+ }
+
+ /**
+ * Build the payment request object for the cash app pay payment form.
+ *
+ * Payment request objects are used by the Payments and need to be in a specific format.
+ * Reference: https://developer.squareup.com/docs/api/paymentform#paymentform-paymentrequestobjects
+ *
+ * @since x.x.x
+ * @return array
+ */
+ public function get_payment_request() {
+ // Ignoring nonce verification checks as it is already handled in the parent function.
+ $payment_request = array();
+ $is_pay_for_order_page = isset( $_POST['is_pay_for_order_page'] ) ? 'true' === sanitize_text_field( wp_unslash( $_POST['is_pay_for_order_page'] ) ) : is_wc_endpoint_url( 'order-pay' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $order_id = isset( $_POST['order_id'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['order_id'] ) ) : absint( get_query_var( 'order-pay' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
+
+ if ( is_wc_endpoint_url( 'order-pay' ) || $is_pay_for_order_page ) {
+ $order = wc_get_order( $order_id );
+ $payment_request = $this->build_payment_request(
+ $order->get_total(),
+ array(
+ 'order_id' => $order_id,
+ 'is_pay_for_order_page' => $is_pay_for_order_page,
+ )
+ );
+ } elseif ( isset( WC()->cart ) ) {
+ WC()->cart->calculate_totals();
+ $payment_request = $this->build_payment_request( WC()->cart->total );
+ }
+
+ return $payment_request;
+ }
+
+ /**
+ * Build a payment request object to be sent to Payments.
+ *
+ * Documentation: https://developer.squareup.com/docs/api/paymentform#paymentform-paymentrequestobjects
+ *
+ * @since x.x.x
+ * @param string $amount - format '100.00'
+ * @param array $data
+ * @return array
+ */
+ public function build_payment_request( $amount, $data = array() ) {
+ $is_pay_for_order_page = isset( $data['is_pay_for_order_page'] ) ? $data['is_pay_for_order_page'] : false;
+ $order_id = isset( $data['order_id'] ) ? $data['order_id'] : 0;
+
+ $order_data = array();
+ $data = wp_parse_args(
+ $data,
+ array(
+ 'countryCode' => substr( get_option( 'woocommerce_default_country' ), 0, 2 ),
+ 'currencyCode' => get_woocommerce_currency(),
+ )
+ );
+
+ if ( $is_pay_for_order_page ) {
+ $order = wc_get_order( $order_id );
+ $order_data = array(
+ 'subtotal' => $order->get_subtotal(),
+ 'discount' => $order->get_discount_total(),
+ 'shipping' => $order->get_shipping_total(),
+ 'fees' => $order->get_total_fees(),
+ 'taxes' => $order->get_total_tax(),
+ );
+
+ // Set currency of order if order-pay page.
+ if ( $order && $order->get_currency() ) {
+ $data['currencyCode'] = $order->get_currency();
+ }
+
+ unset( $data['is_pay_for_order_page'], $data['order_id'] );
+ }
+
+ if ( ! isset( $data['lineItems'] ) ) {
+ $data['lineItems'] = $this->build_payment_request_line_items( $order_data );
+ }
+
+ /**
+ * Filters the payment request Total Label Suffix.
+ *
+ * @since x.x.x
+ * @param string $total_label_suffix
+ * @return string
+ */
+ $total_label_suffix = apply_filters( 'woocommerce_square_payment_request_total_label_suffix', __( 'via WooCommerce', 'woocommerce-square' ) );
+ $total_label_suffix = $total_label_suffix ? " ($total_label_suffix)" : '';
+
+ $data['total'] = array(
+ 'label' => get_bloginfo( 'name', 'display' ) . esc_html( $total_label_suffix ),
+ 'amount' => number_format( $amount, 2, '.', '' ),
+ 'pending' => false,
+ );
+
+ return $data;
+ }
+
+ /**
+ * Builds an array of line items/totals to be sent back to Square in the lineItems array.
+ *
+ * @since x.x.x
+ * @param array $totals
+ * @return array
+ */
+ public function build_payment_request_line_items( $totals = array() ) {
+ // Ignoring nonce verification checks as it is already handled in the parent function.
+ $totals = empty( $totals ) ? $this->get_cart_totals() : $totals;
+ $line_items = array();
+ $order_id = isset( $_POST['order_id'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['order_id'] ) ) : absint( get_query_var( 'order-pay' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
+
+ if ( $order_id ) {
+ $order = wc_get_order( $order_id );
+ $iterable = $order->get_items();
+ } else {
+ $iterable = WC()->cart->get_cart();
+ }
+
+ foreach ( $iterable as $item ) {
+ $amount = number_format( $order_id ? $order->get_subtotal() : $item['line_subtotal'], 2, '.', '' );
+
+ if ( $order_id ) {
+ $quantity_label = 1 < $item->get_quantity() ? ' x ' . $item->get_quantity() : '';
+ } else {
+ $quantity_label = 1 < $item['quantity'] ? ' x ' . $item['quantity'] : '';
+ }
+
+ $item = array(
+ 'label' => $order_id ? $item->get_name() . $quantity_label : $item['data']->get_name() . $quantity_label,
+ 'amount' => $amount,
+ 'pending' => false,
+ );
+
+ $line_items[] = $item;
+ }
+
+ if ( $totals['shipping'] > 0 ) {
+ $line_items[] = array(
+ 'label' => __( 'Shipping', 'woocommerce-square' ),
+ 'amount' => number_format( $totals['shipping'], 2, '.', '' ),
+ 'pending' => false,
+ );
+ }
+
+ if ( $totals['taxes'] > 0 ) {
+ $line_items[] = array(
+ 'label' => __( 'Tax', 'woocommerce-square' ),
+ 'amount' => number_format( $totals['taxes'], 2, '.', '' ),
+ 'pending' => false,
+ );
+ }
+
+ if ( $totals['discount'] > 0 ) {
+ $line_items[] = array(
+ 'label' => __( 'Discount', 'woocommerce-square' ),
+ 'amount' => number_format( $totals['discount'], 2, '.', '' ),
+ 'pending' => false,
+ );
+ }
+
+ if ( $totals['fees'] > 0 ) {
+ $line_items[] = array(
+ 'label' => __( 'Fees', 'woocommerce-square' ),
+ 'amount' => number_format( $totals['fees'], 2, '.', '' ),
+ 'pending' => false,
+ );
+ }
+
+ return $line_items;
+ }
+
+ /**
+ * Get the payment request object in an ajax request
+ *
+ * @since x.x.x
+ * @return void
+ */
+ public function ajax_get_payment_request() {
+ check_ajax_referer( 'wc-cash-app-get-payment-request', 'security' );
+
+ $payment_request = array();
+
+ try {
+ $payment_request = $this->get_payment_request();
+
+ if ( empty( $payment_request ) ) {
+ /* translators: Context (product, cart, checkout or page) */
+ throw new \Exception( esc_html__( 'Empty payment request data for page.', 'woocommerce-square' ) );
+ }
+ } catch ( \Exception $e ) {
+ wp_send_json_error( $e->getMessage() );
+ }
+
+ wp_send_json_success( wp_json_encode( $payment_request ) );
+ }
+
+ /**
+ * Set continuation session to select the cash app payment method after the redirect back from the cash app
+ *
+ * @since x.x.x
+ * @return void
+ */
+ public function ajax_set_continuation_session() {
+ check_ajax_referer( 'wc-cash-app-set-continuation-session', 'security' );
+ $clear_session = ( isset( $_POST['clear'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['clear'] ) ) );
+
+ try {
+ if ( $clear_session ) {
+ WC()->session->set( 'wc_square_cash_app_pay_continuation', null );
+ } else {
+ WC()->session->set( 'wc_square_cash_app_pay_continuation', 'yes' );
+ }
+ } catch ( \Exception $e ) {
+ wp_send_json_error( $e->getMessage() );
+ }
+
+ wp_send_json_success( wp_json_encode( array( 'success' => true ) ) );
+ }
+
+ /**
+ * Determines if the current request is a continuation of a cash app pay payment.
+ *
+ * @return boolean
+ */
+ public function is_cash_app_pay_continuation() {
+ return WC()->session && 'yes' === WC()->session->get( 'wc_square_cash_app_pay_continuation' );
+ }
+
+ /**
+ * Returns cart totals in an array format
+ *
+ * @since x.x.x
+ * @throws \Exception if no cart is found
+ * @return array
+ */
+ public function get_cart_totals() {
+ if ( ! isset( WC()->cart ) ) {
+ throw new \Exception( 'Cart data cannot be found.' );
+ }
+
+ return array(
+ 'subtotal' => WC()->cart->subtotal_ex_tax,
+ 'discount' => WC()->cart->get_cart_discount_total(),
+ 'shipping' => WC()->cart->shipping_total,
+ 'fees' => WC()->cart->fee_total,
+ 'taxes' => WC()->cart->tax_total + WC()->cart->shipping_tax_total,
+ );
+ }
+
+ /**
+ * Handles payment processing.
+ *
+ * @see WC_Payment_Gateway::process_payment()
+ *
+ * @since x.x.x
+ *
+ * @param int|string $order_id
+ * @return array associative array with members 'result' and 'redirect'
+ */
+ public function process_payment( $order_id ) {
+
+ $default = parent::process_payment( $order_id );
+
+ /**
+ * Direct Gateway Process Payment Filter.
+ *
+ * Allow actors to intercept and implement the process_payment() call for
+ * this transaction. Return an array value from this filter will return it
+ * directly to the checkout processing code and skip this method entirely.
+ *
+ * @since x.x.x
+ * @param bool $result default true
+ * @param int|string $order_id order ID for the payment
+ * @param Cash_App_Pay_Gateway $this instance
+ */
+ $result = apply_filters( 'wc_payment_gateway_' . $this->get_id() . '_process_payment', true, $order_id, $this );
+
+ if ( is_array( $result ) ) {
+ return $result;
+ }
+
+ // add payment information to order
+ $order = $this->get_order( $order_id );
+
+ try {
+ // Charge the order.
+ $transcation_result = $this->do_transaction( $order );
+
+ if ( $transcation_result ) {
+
+ /**
+ * Filters the order status that's considered to be "held".
+ *
+ * @since x.x.x
+ *
+ * @param string $status held order status
+ * @param \WC_Order $order order object
+ * @param \WooCommerce\Square\Gateway\API\Response|null $response API response object, if any
+ */
+ $held_order_status = apply_filters( 'wc_' . $this->get_id() . '_held_order_status', 'on-hold', $order, null );
+
+ if ( $order->has_status( $held_order_status ) ) {
+
+ /**
+ * Although `wc_reduce_stock_levels` accepts $order, it's necessary to pass
+ * the order ID instead as `wc_reduce_stock_levels` reloads the order from the DB.
+ *
+ * Refer to the following PR link for more details:
+ * @see https://github.com/woocommerce/woocommerce-square/pull/728
+ */
+ wc_reduce_stock_levels( $order->get_id() ); // reduce stock for held orders, but don't complete payment
+ } else {
+ $order->payment_complete(); // mark order as having received payment
+ }
+
+ // process_payment() can sometimes be called in an admin-context
+ if ( isset( WC()->cart ) ) {
+ WC()->cart->empty_cart();
+ }
+
+ /**
+ * Payment Gateway Payment Processed Action.
+ *
+ * Fired when a payment is processed for an order.
+ *
+ * @since x.x.x
+ * @param \WC_Order $order order object
+ * @param Payment_Gateway $this instance
+ */
+ do_action( 'wc_payment_gateway_' . $this->get_id() . '_payment_processed', $order, $this );
+
+ return array(
+ 'result' => 'success',
+ 'redirect' => $this->get_return_url( $order ),
+ );
+ }
+ } catch ( \Exception $e ) {
+
+ $this->mark_order_as_failed( $order, $e->getMessage() );
+
+ return array(
+ 'result' => 'failure',
+ 'message' => $e->getMessage(),
+ );
+ }
+
+ return $default;
+ }
+
+
+ /**
+ * Do the transaction.
+ *
+ * @since x.x.x
+ *
+ * @param WC_Order_Square $order
+ * @return bool
+ * @throws \Exception
+ */
+ protected function do_transaction( $order ) {
+ // if there is no associated Square order ID, create one
+ if ( empty( $order->square_order_id ) ) {
+
+ try {
+ $location_id = $this->get_plugin()->get_settings_handler()->get_location_id();
+ $response = $this->get_api()->create_order( $location_id, $order );
+
+ $order->square_order_id = $response->getId();
+
+ // adjust order by difference between WooCommerce and Square order totals
+ $wc_total = Money_Utility::amount_to_cents( $order->get_total() );
+ $square_total = $response->getTotalMoney()->getAmount();
+ $delta_total = $wc_total - $square_total;
+
+ if ( abs( $delta_total ) > 0 ) {
+ $response = $this->get_api()->adjust_order( $location_id, $order, $response->getVersion(), $delta_total );
+
+ // since a downward adjustment causes (downward) tax recomputation, perform an additional (untaxed) upward adjustment if necessary
+ $square_total = $response->getTotalMoney()->getAmount();
+ $delta_total = $wc_total - $square_total;
+
+ if ( $delta_total > 0 ) {
+ $response = $this->get_api()->adjust_order( $location_id, $order, $response->getVersion(), $delta_total );
+ }
+ }
+
+ // reset the payment total to the total calculated by Square to prevent errors
+ $order->payment_total = Square_Helper::number_format( Money_Utility::cents_to_float( $response->getTotalMoney()->getAmount() ) );
+
+ } catch ( \Exception $exception ) {
+
+ // log the error, but continue with payment
+ if ( $this->debug_log() ) {
+ $this->get_plugin()->log( $exception->getMessage(), $this->get_id() );
+ }
+ }
+ }
+
+ // Charge the order.
+ $response = $this->get_api()->cash_app_pay_charge( $order );
+
+ // success! update order record
+ if ( $response->transaction_approved() && $response->is_cash_app_payment_completed() ) {
+
+ $payment_response = $response->get_data();
+ $payment = $payment_response->getPayment();
+
+ // credit card order note
+ $message = sprintf(
+ /* translators: Placeholders: %1$s - payment method title, %2$s - environment ("Test"), %3$s - transaction type (authorization/charge), %4$s - card type (mastercard, visa, ...), %5$s - last four digits of the card */
+ esc_html__( '%1$s %2$s %3$s Approved for an amount of %4$s', 'woocommerce-square' ),
+ $this->get_method_title(),
+ wc_square()->get_settings_handler()->is_sandbox() ? esc_html_x( 'Test', 'noun, software environment', 'woocommerce-square' ) : '',
+ 'APPROVED' === $response->get_payment()->getStatus() ? esc_html_x( 'Authorization', 'Cash App transaction type', 'woocommerce-square' ) : esc_html_x( 'Charge', 'noun, Cash App transaction type', 'woocommerce-square' ),
+ wc_price( Money_Utility::cents_to_float( $payment->getTotalMoney()->getAmount(), $order->get_currency() ) )
+ );
+
+ // adds the transaction id (if any) to the order note
+ if ( $response->get_transaction_id() ) {
+ /* translators: Placeholders: %s - transaction ID */
+ $message .= ' ' . sprintf( esc_html__( '(Transaction ID %s)', 'woocommerce-square' ), $response->get_transaction_id() );
+ }
+
+ /**
+ * Direct Gateway Credit Card Transaction Approved Order Note Filter.
+ *
+ * Allow actors to modify the order note added when a Credit Card transaction
+ * is approved.
+ *
+ * @since x.x.x
+ *
+ * @param string $message order note
+ * @param \WC_Order $order order object
+ * @param \WooCommerce\Square\Gateway\API\Response $response transaction response
+ * @param Cash_App_Pay_Gateway $this instance
+ */
+ $message = apply_filters( 'wc_payment_gateway_' . $this->get_id() . '_transaction_approved_order_note', $message, $order, $response, $this );
+
+ $order->add_order_note( $message );
+
+ // add the standard transaction data
+ $this->add_transaction_data( $order, $response );
+
+ // allow the concrete class to add any gateway-specific transaction data to the order
+ $this->add_payment_gateway_transaction_data( $order, $response );
+
+ return true;
+ } else {
+ return $this->do_transaction_failed_result( $order, $response );
+ }
+ }
+
+ /**
+ * Adds transaction data to the order.
+ *
+ * @since x.x.x
+ *
+ * @param \WC_Order $order order object
+ * @param \WooCommerce\Square\Gateway\API\Responses\Create_Payment $response API response object
+ */
+ public function add_payment_gateway_transaction_data( $order, $response ) {
+
+ $location_id = $response->get_location_id() ? $response->get_location_id() : $this->get_plugin()->get_settings_handler()->get_location_id();
+
+ if ( $location_id ) {
+ $this->update_order_meta( $order, 'square_location_id', $location_id );
+ }
+
+ if ( $response->get_square_order_id() ) {
+ $this->update_order_meta( $order, 'square_order_id', $response->get_square_order_id() );
+ }
+
+ // store the plugin version on the order
+ $this->update_order_meta( $order, 'square_version', Plugin::VERSION );
+ }
+
+ /**
+ * Renders the payment form JS.
+ *
+ * @since x.x.x
+ */
+ public function render_js() {
+
+ try {
+ $payment_request = $this->get_payment_request();
+ } catch ( \Exception $e ) {
+ $this->get_plugin()->log( 'Error: ' . $e->getMessage() );
+ }
+
+ $args = array(
+ 'application_id' => $this->get_application_id(),
+ 'ajax_log_nonce' => wp_create_nonce( 'wc_' . $this->get_id() . '_log_js_data' ),
+ 'location_id' => wc_square()->get_settings_handler()->get_location_id(),
+ 'gateway_id' => $this->get_id(),
+ 'gateway_id_dasherized' => $this->get_id_dasherized(),
+ 'payment_request' => $payment_request,
+ 'general_error' => __( 'An error occurred, please try again or try an alternate form of payment.', 'woocommerce-square' ),
+ 'ajax_url' => \WC_AJAX::get_endpoint( '%%endpoint%%' ),
+ 'payment_request_nonce' => wp_create_nonce( 'wc-cash-app-get-payment-request' ),
+ 'checkout_logging' => $this->debug_checkout(),
+ 'logging_enabled' => $this->debug_log(),
+ 'is_pay_for_order_page' => is_checkout() && is_wc_endpoint_url( 'order-pay' ),
+ 'order_id' => absint( get_query_var( 'order-pay' ) ),
+ 'button_styles' => $this->get_button_styles(),
+ 'reference_id' => WC()->cart ? WC()->cart->get_cart_hash() : '',
+ );
+
+ /**
+ * Payment Gateway Payment JS Arguments Filter.
+ *
+ * Filter the arguments passed to the Payment handler JS class
+ *
+ * @since x.x.x
+ *
+ * @param array $args arguments passed to the Payment Gateway handler JS class
+ * @param Payment_Gateway $this payment gateway instance
+ */
+ $args = apply_filters( 'wc_' . $this->get_id() . '_payment_js_args', $args, $this );
+
+ wc_enqueue_js( sprintf( 'window.wc_%s_payment_handler = new WC_Square_Cash_App_Pay_Handler( %s );', esc_js( $this->get_id() ), wp_json_encode( $args ) ) );
+ }
+
+ /**
+ * Logs any data sent by the payment form JS via AJAX.
+ *
+ * @since x.x.x
+ */
+ public function log_js_data() {
+ check_ajax_referer( 'wc_' . $this->get_id() . '_log_js_data', 'security' );
+
+ $message = sprintf( "wc-square-cash-app-pay.js %1\$s:\n ", ! empty( $_REQUEST['type'] ) ? ucfirst( wc_clean( wp_unslash( $_REQUEST['type'] ) ) ) : 'Request' );
+
+ // add the data
+ if ( ! empty( $_REQUEST['data'] ) ) {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
+ $message .= print_r( wc_clean( wp_unslash( $_REQUEST['data'] ) ), true );
+ }
+
+ $this->get_plugin()->log( $message, $this->get_id() );
+ }
+}
diff --git a/includes/Handlers/Order.php b/includes/Handlers/Order.php
index 8998569b..8892ed31 100644
--- a/includes/Handlers/Order.php
+++ b/includes/Handlers/Order.php
@@ -51,6 +51,16 @@ class Order {
*/
private $products_to_sync = array();
+ /**
+ * Array of payment gateways that are Square payment gateways.
+ *
+ * @var array
+ */
+ private $square_payment_gateways = array(
+ Plugin::GATEWAY_ID,
+ Plugin::CASH_APP_PAY_GATEWAY_ID,
+ );
+
/**
* Sets up Square order handler.
*
@@ -158,7 +168,7 @@ public function maybe_sync_stock_for_order_via_paypal( $posted ) {
public function maybe_sync_stock_for_order_via_other_gateway( $order_id, $posted_data, $order ) {
// Confirm we are not processing the order through the Square gateway.
- if ( ! $order instanceof \WC_Order || Plugin::GATEWAY_ID === $order->get_payment_method() ) {
+ if ( ! $order instanceof \WC_Order || in_array( $order->get_payment_method(), $this->square_payment_gateways, true ) ) {
return;
}
@@ -178,7 +188,7 @@ public function maybe_sync_stock_for_order_via_other_gateway( $order_id, $posted
public function maybe_sync_stock_for_store_api_order_via_other_gateway( $order ) {
// Confirm we are not processing the order through the Square gateway.
- if ( ! $order instanceof \WC_Order || Plugin::GATEWAY_ID === $order->get_payment_method() ) {
+ if ( ! $order instanceof \WC_Order || in_array( $order->get_payment_method(), $this->square_payment_gateways, true ) ) {
return;
}
@@ -286,7 +296,7 @@ public function maybe_stage_stock_updates_for_product( $item, $change, $order )
*/
if (
! $order instanceof \WC_Order ||
- Plugin::GATEWAY_ID === $order->get_payment_method() ||
+ in_array( $order->get_payment_method(), $this->square_payment_gateways, true ) ||
! wc_square()->get_settings_handler()->is_inventory_sync_enabled() ||
defined( 'DOING_SQUARE_SYNC' ) ||
! $product ||
@@ -324,7 +334,7 @@ public function maybe_sync_inventory_for_stock_increase( $order_id ) {
// Confirm we are not processing the order through the Square gateway.
$order = wc_get_order( $order_id );
- if ( ! $order instanceof \WC_Order || Plugin::GATEWAY_ID === $order->get_payment_method() ) {
+ if ( ! $order instanceof \WC_Order || in_array( $order->get_payment_method(), $this->square_payment_gateways, true ) ) {
return;
}
@@ -413,7 +423,7 @@ public function maybe_sync_stock_for_refund_from_other_gateway( $order_id, $refu
// Confirm we are not processing the order through the Square gateway.
$order = wc_get_order( $order_id );
- if ( ! $order instanceof \WC_Order || Plugin::GATEWAY_ID === $order->get_payment_method() ) {
+ if ( ! $order instanceof \WC_Order || in_array( $order->get_payment_method(), $this->square_payment_gateways, true ) ) {
return;
}
diff --git a/includes/Plugin.php b/includes/Plugin.php
index 4d2ca8ce..e818c89b 100644
--- a/includes/Plugin.php
+++ b/includes/Plugin.php
@@ -27,6 +27,7 @@
use WooCommerce\Square\Framework\PaymentGateway\Payment_Gateway_Plugin;
use WooCommerce\Square\Framework\Square_Helper;
+use WooCommerce\Square\Gateway\Cash_App_Pay_Gateway;
use WooCommerce\Square\Handlers\Background_Job;
use WooCommerce\Square\Handlers\Async_Request;
use WooCommerce\Square\Handlers\Email;
@@ -52,6 +53,8 @@ class Plugin extends Payment_Gateway_Plugin {
/** string gateway ID */
const GATEWAY_ID = 'square_credit_card';
+ /** string Cash App Pay gateway ID */
+ const CASH_APP_PAY_GATEWAY_ID = 'square_cash_app_pay';
/** @var Plugin plugin instance */
protected static $instance;
@@ -98,7 +101,10 @@ public function __construct() {
self::VERSION,
array(
'text_domain' => 'woocommerce-square',
- 'gateways' => array( self::GATEWAY_ID => Gateway::class ),
+ 'gateways' => array(
+ self::GATEWAY_ID => Gateway::class,
+ self::CASH_APP_PAY_GATEWAY_ID => Cash_App_Pay_Gateway::class,
+ ),
'require_ssl' => true,
'supports' => array(
self::FEATURE_CAPTURE_CHARGE,
diff --git a/package.json b/package.json
index 71f1c637..46cbc627 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,9 @@
"scripts": {
"build": "composer install --no-dev && NODE_ENV=production grunt && NODE_ENV=production npm run build:webpack && npm run archive",
"build:dev": "composer install && grunt && npm run build:webpack",
- "build:webpack": "rimraf build/* && wp-scripts build assets/blocks/index.js",
+ "build:webpack": "rimraf build/* && wp-scripts build",
"build-watch:grunt": "grunt watch",
- "build-watch:webpack": "rimraf build/* && wp-scripts start assets/blocks/index.js",
+ "build-watch:webpack": "rimraf build/* && wp-scripts start",
"lint:js": "wp-scripts lint-js assets --ext js --format table",
"lint:js:fix": "wp-scripts lint-js assets --ext js --fix",
"phpcompat": "./vendor/bin/phpcs --standard=phpcs-compat.xml.dist -p .",
diff --git a/tests/e2e/specs/d1.cash-app-pay.spec.js b/tests/e2e/specs/d1.cash-app-pay.spec.js
new file mode 100644
index 00000000..090ed895
--- /dev/null
+++ b/tests/e2e/specs/d1.cash-app-pay.spec.js
@@ -0,0 +1,403 @@
+import { test, expect, devices, chromium } from '@playwright/test';
+import {
+ clearCart,
+ createProduct,
+ doSquareRefund,
+ doesProductExist,
+ fillAddressFields,
+ gotoOrderEditPage,
+ placeCashAppPayOrder,
+ saveCashAppPaySettings,
+ selectPaymentMethod,
+ visitCheckout,
+ waitForUnBlock,
+} from '../utils/helper';
+const iPhone = devices['iPhone 14 Pro Max'];
+
+test.describe('Cash App Pay Tests', () => {
+ test.beforeAll('Setup', async ({ baseURL }) => {
+ const browser = await chromium.launch();
+ const page = await browser.newPage();
+
+ // Create a product if it doesn't exist.
+ if (!(await doesProductExist(baseURL, 'simple-product'))) {
+ await createProduct(page, {
+ name: 'Simple Product',
+ regularPrice: '14.99',
+ sku: 'simple-product',
+ });
+
+ await expect(
+ await page.getByText('Product published')
+ ).toBeVisible();
+ }
+ await browser.close();
+ });
+
+ test('Store owner can see Cash App Pay in payment methods list - @foundational', async ({
+ page,
+ }) => {
+ await page.goto('/wp-admin/admin.php?page=wc-settings&tab=checkout');
+ const creditCard = await page.locator(
+ 'table.wc_gateways tr[data-gateway_id="square_cash_app_pay"]'
+ );
+ await expect(creditCard).toBeVisible();
+ await expect(creditCard.locator('td.name a')).toContainText(
+ 'Cash App Pay (Square)'
+ );
+ });
+
+ test('Store owner can configure Cash App Pay payment gateway - @foundational', async ({
+ page,
+ }) => {
+ await saveCashAppPaySettings(page, {
+ enabled: false,
+ });
+
+ await page.goto('/product/simple-product');
+ await page.locator('.single_add_to_cart_button').click();
+
+ // Confirm that the Cash App Pay is not visible on checkout page.
+ await visitCheckout(page, false);
+ await fillAddressFields(page, false);
+ await expect(
+ await page.locator(
+ 'ul.wc_payment_methods li.payment_method_square_cash_app_pay'
+ )
+ ).not.toBeVisible();
+ // Confirm that the Cash App Pay is not visible on block-checkout page.
+ await visitCheckout(page, true);
+ await expect(
+ await page.locator(
+ 'label[for="radio-control-wc-payment-method-options-square_cash_app_pay"]'
+ )
+ ).not.toBeVisible();
+
+ const cashAppTitle = 'Cash App Pay TEST';
+ const cashAppDescription = 'Cash App Pay TEST Description';
+ await saveCashAppPaySettings(page, {
+ enabled: true,
+ title: cashAppTitle,
+ description: cashAppDescription,
+ });
+
+ // Confirm that the Cash App Pay is visible on checkout page.
+ await visitCheckout(page, false);
+ const paymentMethod = await page.locator(
+ 'ul.wc_payment_methods li.payment_method_square_cash_app_pay'
+ );
+ await expect(paymentMethod).toBeVisible();
+ await selectPaymentMethod(page, 'square_cash_app_pay', false);
+ await expect(paymentMethod.locator('label').first()).toContainText(
+ cashAppTitle
+ );
+ await expect(
+ paymentMethod
+ .locator('.payment_method_square_cash_app_pay p', {
+ hasText: cashAppDescription,
+ })
+ .first()
+ ).toBeVisible();
+
+ // Confirm that the Cash App Pay is visible on block-checkout page.
+ await visitCheckout(page, true);
+ const cashAppMethod = await page.locator(
+ 'label[for="radio-control-wc-payment-method-options-square_cash_app_pay"]'
+ );
+ await expect(cashAppMethod).toBeVisible();
+ await expect(cashAppMethod).toContainText(cashAppTitle);
+ await selectPaymentMethod(page, 'square_cash_app_pay', true);
+ await expect(
+ page
+ .locator(
+ '.wc-block-components-radio-control-accordion-content p',
+ {
+ hasText: cashAppDescription,
+ }
+ )
+ .first()
+ ).toBeVisible();
+ });
+
+ test('Store owner can configure Cash App Pay Button Appearance - @foundational', async ({
+ page,
+ }) => {
+ await saveCashAppPaySettings(page, {
+ enabled: true,
+ buttonTheme: 'light',
+ buttonShape: 'round',
+ });
+
+ // Confirm button styles on checkout page.
+ await visitCheckout(page, false);
+ const paymentMethod = await page.locator(
+ 'ul.wc_payment_methods li.payment_method_square_cash_app_pay'
+ );
+ await expect(paymentMethod).toBeVisible();
+ await selectPaymentMethod(page, 'square_cash_app_pay', false);
+ const cashAppPayButton = await page
+ .locator('#wc-square-cash-app')
+ .getByTestId('cap-btn');
+ await expect(cashAppPayButton).toBeVisible();
+ await expect(cashAppPayButton).toHaveClass(/rounded-3xl/);
+ await expect(cashAppPayButton).toHaveClass(/bg-white/);
+
+ // Confirm button styles on block-checkout page.
+ await visitCheckout(page, true);
+ const cashAppMethod = await page.locator(
+ 'label[for="radio-control-wc-payment-method-options-square_cash_app_pay"]'
+ );
+ await expect(cashAppMethod).toBeVisible();
+ await selectPaymentMethod(page, 'square_cash_app_pay', true);
+ const cashAppButton = await page
+ .locator('#wc-square-cash-app-pay')
+ .getByTestId('cap-btn');
+ await expect(cashAppButton).toBeVisible();
+ await expect(cashAppButton).toHaveClass(/rounded-3xl/);
+ await expect(cashAppButton).toHaveClass(/bg-white/);
+
+ await saveCashAppPaySettings(page, {
+ enabled: true,
+ buttonTheme: 'dark',
+ buttonShape: 'semiround',
+ });
+
+ // Confirm button styles on checkout page.
+ await visitCheckout(page, false);
+ const payMethod = await page.locator(
+ 'ul.wc_payment_methods li.payment_method_square_cash_app_pay'
+ );
+ await expect(payMethod).toBeVisible();
+ await selectPaymentMethod(page, 'square_cash_app_pay', false);
+ const button = await page
+ .locator('#wc-square-cash-app')
+ .getByTestId('cap-btn');
+ await expect(button).toBeVisible();
+ await expect(button).toHaveClass(/rounded-md/);
+ await expect(button).toHaveClass(/bg-black/);
+
+ // Confirm button styles on block-checkout page.
+ await visitCheckout(page, true);
+ const blockPayMethod = await page.locator(
+ 'label[for="radio-control-wc-payment-method-options-square_cash_app_pay"]'
+ );
+ await expect(blockPayMethod).toBeVisible();
+ await selectPaymentMethod(page, 'square_cash_app_pay', true);
+ const buttonBlock = await page
+ .locator('#wc-square-cash-app-pay')
+ .getByTestId('cap-btn');
+ await expect(buttonBlock).toBeVisible();
+ await expect(buttonBlock).toHaveClass(/rounded-md/);
+ await expect(buttonBlock).toHaveClass(/bg-black/);
+ });
+
+ test('Cash App Pay should be only available for US based sellers - @foundational', async ({
+ page,
+ }) => {
+ await page.goto('/wp-admin/admin.php?page=wc-settings&tab=general');
+ await page
+ .locator('select[name="woocommerce_default_country"]')
+ .selectOption('IN:GJ');
+ await page.locator('.woocommerce-save-button').click();
+
+ await expect(
+ page
+ .locator('.notice.notice-error p', {
+ hasText: /Cash App Pay/,
+ })
+ .first()
+ ).toContainText(
+ 'Your base country is IN, but Cash App Pay can’t accept transactions from merchants outside of US.'
+ );
+
+ // Confirm that the Cash App Pay is not visible on block-checkout page.
+ await visitCheckout(page, true);
+ await expect(
+ await page.locator(
+ 'label[for="radio-control-wc-payment-method-options-square_cash_app_pay"]'
+ )
+ ).not.toBeVisible();
+
+ await page.goto('/wp-admin/admin.php?page=wc-settings&tab=general');
+ await page
+ .locator('select[name="woocommerce_default_country"]')
+ .selectOption('US:CA');
+ await page.locator('.woocommerce-save-button').click();
+ });
+
+ test('Cash App Pay should be only available for US based buyers - @foundational', async ({
+ page,
+ }) => {
+ // Confirm that the Cash App Pay is not visible on checkout page.
+ await visitCheckout(page, false);
+ await fillAddressFields(page, false);
+
+ // non-US buyer.
+ await page.locator('#billing_country').selectOption('IN');
+ await page.locator('#billing_country').blur();
+ await waitForUnBlock(page);
+ const payMethod = await page.locator(
+ 'ul.wc_payment_methods li.payment_method_square_cash_app_pay'
+ );
+ await expect(payMethod).not.toBeVisible();
+
+ // US based buyer.
+ await fillAddressFields(page, false);
+ await expect(payMethod).toBeVisible();
+
+ // Confirm that the Cash App Pay is not visible on block-checkout page.
+ await visitCheckout(page, true);
+ await fillAddressFields(page, true);
+ await page.locator('#billing-country').locator('input').fill('India');
+ await page.waitForTimeout(1500);
+ const blockPayMethod = await page.locator(
+ 'label[for="radio-control-wc-payment-method-options-square_cash_app_pay"]'
+ );
+ await expect(blockPayMethod).not.toBeVisible();
+
+ // US based buyer.
+ await fillAddressFields(page, true);
+ await expect(blockPayMethod).toBeVisible();
+ });
+
+ test('Cash App Pay should be only available for USD currency - @foundational', async ({
+ page,
+ }) => {
+ await page.goto('/wp-admin/admin.php?page=wc-settings&tab=general');
+ await page
+ .locator('select[name="woocommerce_currency"]')
+ .selectOption('INR');
+ await page.locator('.woocommerce-save-button').click();
+
+ // Confirm that the Cash App Pay is not visible on checkout page.
+ await visitCheckout(page, false);
+ await fillAddressFields(page, false);
+ await expect(
+ await page.locator(
+ 'ul.wc_payment_methods li.payment_method_square_cash_app_pay'
+ )
+ ).not.toBeVisible();
+ // Confirm that the Cash App Pay is not visible on block-checkout page.
+ await visitCheckout(page, true);
+ await expect(
+ await page.locator(
+ 'label[for="radio-control-wc-payment-method-options-square_cash_app_pay"]'
+ )
+ ).not.toBeVisible();
+
+ await page.goto('/wp-admin/admin.php?page=wc-settings&tab=general');
+ await page
+ .locator('select[name="woocommerce_currency"]')
+ .selectOption('USD');
+ await page.locator('.woocommerce-save-button').click();
+ });
+
+ const isBlockCheckout = [true, false];
+
+ for (const isBlock of isBlockCheckout) {
+ const title = isBlock ? '[Block]:' : '[non-Block]:';
+
+ test(
+ title + 'Customers can pay using Cash App Pay - @foundational',
+ async ({ browser }) => {
+ const context = await browser.newContext({
+ ...iPhone,
+ });
+ const page = await context.newPage();
+ await page.goto('/product/simple-product');
+ await page.locator('.single_add_to_cart_button').click();
+ await visitCheckout(page, isBlock);
+ await fillAddressFields(page, isBlock);
+ await selectPaymentMethod(page, 'square_cash_app_pay', isBlock);
+ const orderId = await placeCashAppPayOrder(page, isBlock);
+
+ await gotoOrderEditPage(page, orderId);
+ await expect(page.locator('#order_status')).toHaveValue(
+ 'wc-processing'
+ );
+ await expect(
+ page.getByText(
+ 'Cash App Pay (Square) Test Charge Approved for an amount of'
+ )
+ ).toBeVisible();
+ }
+ );
+ }
+
+ test( '[Block]: Customers can pay using Cash App Pay after decline transcation once - @foundational',
+ async ({ browser }) => {
+ test.slow();
+ const context = await browser.newContext({
+ ...iPhone,
+ });
+ const page = await context.newPage();
+ await page.goto('/product/simple-product');
+ await page.locator('.single_add_to_cart_button').click();
+ await visitCheckout(page, true);
+ await fillAddressFields(page, true);
+ await selectPaymentMethod(page, 'square_cash_app_pay', true);
+ // Decline transcation once.
+ await placeCashAppPayOrder(page, true, true);
+ await page.waitForLoadState('networkidle');
+ const orderId = await placeCashAppPayOrder(page, true);
+
+ await gotoOrderEditPage(page, orderId);
+ await expect(page.locator('#order_status')).toHaveValue(
+ 'wc-processing'
+ );
+ await expect(
+ page.getByText(
+ 'Cash App Pay (Square) Test Charge Approved for an amount of'
+ )
+ ).toBeVisible();
+ }
+ );
+
+ test('Store owners can fully refund Cash App Pay orders - @foundational', async ({
+ browser,
+ }) => {
+ const isBlock = true;
+ const context = await browser.newContext({
+ ...iPhone,
+ });
+ const page = await context.newPage();
+ await clearCart( page );
+ await page.goto('/product/simple-product');
+ page.on('dialog', dialog => dialog.accept());
+ await page.locator('.single_add_to_cart_button').click();
+ await visitCheckout(page, isBlock);
+ await fillAddressFields(page, isBlock);
+ await selectPaymentMethod(page, 'square_cash_app_pay', isBlock);
+ const orderId = await placeCashAppPayOrder(page, isBlock);
+ await gotoOrderEditPage(page, orderId);
+ await doSquareRefund( page, '14.99' );
+ await expect( page.locator( '#order_status' ) ).toHaveValue( 'wc-refunded' );
+ await expect( await page.getByText( 'Cash App Pay (Square) Refund in the amount of $14.99 approved' ) ).toBeVisible();
+ await expect( await page.getByText( 'Cash App Pay (Square) Order completely refunded.' ) ).toBeVisible();
+ });
+
+ test('Store owners can partially refund Cash App Pay orders - @foundational', async ({
+ browser,
+ }) => {
+ const isBlock = true;
+ const context = await browser.newContext({
+ ...iPhone,
+ });
+ const page = await context.newPage();
+ await page.goto('/product/simple-product');
+ page.on('dialog', dialog => dialog.accept());
+ await page.locator('.single_add_to_cart_button').click();
+ await visitCheckout(page, isBlock);
+ await fillAddressFields(page, isBlock);
+ await selectPaymentMethod(page, 'square_cash_app_pay', isBlock);
+ const orderId = await placeCashAppPayOrder(page, isBlock);
+ await gotoOrderEditPage(page, orderId);
+ await doSquareRefund( page, '1' );
+ await expect( await page.getByText( 'Cash App Pay (Square) Refund in the amount of $1.00 approved' ) ).toBeVisible();
+ await expect( page.locator( '#order_status' ) ).toHaveValue( 'wc-processing' );
+
+ await doSquareRefund( page, '13.99' );
+ await expect( page.locator( '#order_status' ) ).toHaveValue( 'wc-refunded' );
+ await expect( await page.getByText( 'Cash App Pay (Square) Order completely refunded.' ) ).toBeVisible();
+ });
+});
diff --git a/tests/e2e/specs/d2.cash-app-pay-inventory-sync.spec.js b/tests/e2e/specs/d2.cash-app-pay-inventory-sync.spec.js
new file mode 100644
index 00000000..3484ff39
--- /dev/null
+++ b/tests/e2e/specs/d2.cash-app-pay-inventory-sync.spec.js
@@ -0,0 +1,134 @@
+import { test, expect, devices, chromium } from '@playwright/test';
+import {
+ deleteAllProducts,
+ doSquareRefund,
+ doesProductExist,
+ fillAddressFields,
+ gotoOrderEditPage,
+ placeCashAppPayOrder,
+ selectPaymentMethod,
+ visitCheckout,
+} from '../utils/helper';
+import {
+ clearSync,
+ createCatalogObject,
+ deleteAllCatalogItems,
+ importProducts,
+ retrieveInventoryCount,
+ updateCatalogItemInventory,
+} from '../utils/square-sandbox';
+const iPhone = devices['iPhone 14 Pro Max'];
+
+/**
+ * Marked test skip because:
+ * 1. It is flaky and flakiness depends on the Square sandbox environment.
+ * 2. It takes a long time to run. (more than 1 minute)
+ *
+ * We can run this test locally by removing the skip during the smoke testing or support WP/WC version bumps.
+ */
+test.describe('Cash App Pay Inventory Sync Tests', () => {
+ let itemId;
+ const quantity = 100;
+ test.beforeAll('Setup', async ({ baseURL }) => {
+ const browser = await chromium.launch();
+ const page = await browser.newPage();
+
+ await deleteAllProducts(page);
+ await deleteAllCatalogItems();
+ const response = await createCatalogObject(
+ 'Sample product with inventory',
+ 'sample-product-with-inventory',
+ 1900,
+ 'This is a sample product with inventory.'
+ );
+ itemId = response.catalog_object.item_data.variations[0].id;
+
+ await updateCatalogItemInventory(itemId, `${quantity}`);
+ await clearSync(page);
+
+ await page.goto('/wp-admin/admin.php?page=wc-settings&tab=square');
+ await page
+ .locator('#wc_square_system_of_record')
+ .selectOption({ label: 'Square' });
+ await page.locator('.woocommerce-save-button').click();
+ await expect(
+ await page.getByText('Your settings have been saved.')
+ ).toBeVisible();
+
+ await browser.close();
+ });
+
+ test.skip('Product inventory should be sync for order placed by Cash App Pay', async ({
+ browser,
+ page,
+ baseURL,
+ }) => {
+ // Sync product with inventory to Square first.
+ test.slow();
+
+ page.on('dialog', (dialog) => dialog.accept());
+ await importProducts(page);
+
+ const nRetries = 8;
+ let isProductExist = false;
+ for (let i = 0; i < nRetries; i++) {
+ isProductExist = await doesProductExist(
+ baseURL,
+ 'sample-product-with-inventory/ '
+ );
+ if ( isProductExist ) {
+ break;
+ } else {
+ await page.waitForTimeout(4000); // wait for import inventory to be completed.
+ }
+ }
+
+ // Skip the test if the product is not imported.
+ if ( !isProductExist ) {
+ test.skip();
+ }
+
+ // Place order with Cash App Pay.
+ const isBlock = true;
+ const context = await browser.newContext({
+ ...iPhone,
+ });
+ const mobilePage = await context.newPage();
+ await mobilePage.goto('/product/sample-product-with-inventory/');
+ mobilePage.on('dialog', (dialog) => dialog.accept());
+ await mobilePage.locator('.single_add_to_cart_button').click();
+ await visitCheckout(mobilePage, isBlock);
+ await fillAddressFields(mobilePage, isBlock);
+ await selectPaymentMethod(mobilePage, 'square_cash_app_pay', isBlock);
+ const orderId = await placeCashAppPayOrder(mobilePage, isBlock);
+
+ // Confirm that the inventory is deducted.
+ await mobilePage.waitForTimeout(6000);
+ const updatedInventory = await retrieveInventoryCount(itemId);
+ const updatedQty =
+ updatedInventory.counts &&
+ updatedInventory.counts[0] &&
+ updatedInventory.counts[0].quantity;
+ await expect(updatedQty).toBe(`${quantity - 1}`);
+
+ await gotoOrderEditPage(mobilePage, orderId);
+ await doSquareRefund(mobilePage, '19');
+ await expect(
+ await mobilePage.getByText(
+ 'Cash App Pay (Square) Order completely refunded.'
+ )
+ ).toBeVisible();
+ await expect(mobilePage.locator('#order_status')).toHaveValue(
+ 'wc-refunded'
+ );
+
+ // Confirm that the inventory is restored after refund.
+ await mobilePage.waitForTimeout(6000);
+ const updatedInventory2 = await retrieveInventoryCount(itemId);
+ const updatedQty2 =
+ updatedInventory2.counts &&
+ updatedInventory2.counts[0] &&
+ updatedInventory2.counts[0].quantity;
+ await expect(updatedQty2).toBe(`${quantity}`);
+ });
+});
diff --git a/tests/e2e/utils/helper.js b/tests/e2e/utils/helper.js
index 2a746080..5e40a0e5 100644
--- a/tests/e2e/utils/helper.js
+++ b/tests/e2e/utils/helper.js
@@ -322,6 +322,9 @@ export async function doSquareRefund( page, amount = '' ) {
*/
export async function deleteAllProducts( page, permanent = true ) {
await page.goto( '/wp-admin/edit.php?post_type=product' );
+ if ( ! await page.locator( '#cb-select-all-1' ).isVisible() ) {
+ return;
+ }
await page.locator( '#cb-select-all-1' ).check();
await page.locator( '#bulk-action-selector-top' ).selectOption( { value: 'trash' } );
await page.locator( '#doaction' ).click();
@@ -331,3 +334,141 @@ export async function deleteAllProducts( page, permanent = true ) {
await page.locator( '#delete_all' ).first().click();
}
}
+
+
+/**
+ * Save Cash App Pay payment settings
+ *
+ * @param {Page} page Playwright page object
+ * @param {Object} options Cash App Pay payment settings
+ */
+export async function saveCashAppPaySettings(page, options) {
+ const settings = {
+ enabled: true,
+ title: 'Cash App Pay',
+ description: 'Pay securely using Cash App Pay.',
+ debugMode: 'off',
+ buttonTheme: 'dark',
+ buttonShape: 'semiround',
+ ...options,
+ };
+
+ await page.goto(
+ '/wp-admin/admin.php?page=wc-settings&tab=checkout§ion=square_cash_app_pay'
+ );
+
+ // Enable/Disable
+ if (!settings.enabled) {
+ await page.locator('#woocommerce_square_cash_app_pay_enabled').uncheck();
+ } else {
+ await page.locator('#woocommerce_square_cash_app_pay_enabled').check();
+ }
+
+ // Title and Description
+ await page
+ .locator('#woocommerce_square_cash_app_pay_title')
+ .fill(settings.title);
+ await page
+ .locator('#woocommerce_square_cash_app_pay_description')
+ .fill(settings.description);
+
+ // Debug Mode and Environment
+ await page
+ .locator('#woocommerce_square_cash_app_pay_debug_mode')
+ .selectOption(settings.debugMode);
+
+ // Button customization
+ await page
+ .locator('#woocommerce_square_cash_app_pay_button_theme')
+ .selectOption(settings.buttonTheme);
+ await page
+ .locator('#woocommerce_square_cash_app_pay_button_shape')
+ .selectOption(settings.buttonShape);
+
+ await page.getByRole('button', { name: 'Save changes' }).click();
+ await expect(page.locator('#message.updated.inline').last()).toContainText(
+ 'Your settings have been saved.'
+ );
+}
+
+/**
+ * Select payment method
+ *
+ * @param {Page} page Playwright page object
+ * @param {string} paymentMethod Payment method name
+ * @param {boolean} isBlockCheckout Is block checkout?
+ */
+export async function selectPaymentMethod(
+ page,
+ paymentMethod,
+ isBlockCheckout
+) {
+ if (isBlockCheckout) {
+ await page
+ .locator(
+ `label[for="radio-control-wc-payment-method-options-${paymentMethod}"]`
+ )
+ .click();
+ await expect(
+ page.locator('.wc-block-components-loading-mask')
+ ).not.toBeVisible();
+ return;
+ }
+ // Wait for overlay to disappear
+ await page
+ .locator('.blockUI.blockOverlay')
+ .last()
+ .waitFor({ state: 'detached' });
+
+ // Wait for payment method to appear
+ const payMethod = await page
+ .locator(
+ `ul.wc_payment_methods li.payment_method_${paymentMethod} label`
+ )
+ .first();
+ await expect(payMethod).toBeVisible();
+
+ // Select payment method
+ await page
+ .locator(`label[for="payment_method_${paymentMethod}"]`)
+ .waitFor();
+ await payMethod.click();
+}
+
+/**
+ * Pay using Cash App Pay
+ *
+ * @param {Object} page Playwright page object.
+ * @param {Boolean} isBlock Indicates if is block checkout.
+ * @param {Boolean} decline Indicates if payment should be declined.
+ */
+export async function placeCashAppPayOrder( page, isBlock = true, decline = false ) {
+ // Wait for overlay to disappear
+ await waitForUnBlock(page);
+ if ( isBlock ) {
+ await page.locator('#wc-square-cash-app-pay').getByTestId('cap-btn').click();
+ } else {
+ await page.locator('#wc-square-cash-app').getByTestId('cap-btn').click();
+ }
+ await page.waitForLoadState('networkidle');
+ if ( decline ) {
+ await page.getByRole('button', { name: 'Decline' }).click();
+ } else {
+ await page.getByRole('button', { name: 'Approve' }).click();
+ }
+ await page.waitForLoadState('networkidle');
+ await page.getByRole('button', { name: 'Done' }).click();
+ // Early return if declined.
+ if ( decline ) {
+ return;
+ }
+
+ await page.waitForLoadState('networkidle');
+ await expect(
+ await page.locator( '.entry-title' )
+ ).toHaveText( 'Order received' );
+ const orderId = await page
+ .locator( '.woocommerce-order-overview__order strong' )
+ .innerText();
+ return orderId;
+}
diff --git a/webpack.config.js b/webpack.config.js
index a6c19277..87a5676c 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,5 +1,6 @@
const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
const DependencyExtractionWebpackPlugin = require( '@woocommerce/dependency-extraction-webpack-plugin' );
+const path = require('path');
module.exports = {
...defaultConfig,
@@ -10,4 +11,8 @@ module.exports = {
),
new DependencyExtractionWebpackPlugin(),
],
+ entry: {
+ 'index': path.resolve(process.cwd(), 'assets/blocks', 'index.js'),
+ 'cash-app-pay': path.resolve(process.cwd(), 'assets/blocks/cash-app-pay', 'index.js'),
+ }
};
\ No newline at end of file
diff --git a/woocommerce-square.php b/woocommerce-square.php
index 45230e61..d7ea9f58 100644
--- a/woocommerce-square.php
+++ b/woocommerce-square.php
@@ -426,6 +426,7 @@ protected function is_php_version_valid() {
public function register_payment_method_block_integrations( $payment_method_registry ) {
if ( class_exists( '\Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType' ) ) {
$payment_method_registry->register( new WooCommerce\Square\Gateway\Blocks_Handler() );
+ $payment_method_registry->register( new WooCommerce\Square\Gateway\Cash_App_Pay_Blocks_Handler() );
}
}