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() ); } }