diff --git a/client/classic/upe/deferred-intent.js b/client/classic/upe/deferred-intent.js new file mode 100644 index 0000000000..1b69b4486c --- /dev/null +++ b/client/classic/upe/deferred-intent.js @@ -0,0 +1,59 @@ +import jQuery from 'jquery'; +import WCStripeAPI from '../../api'; +import { + generateCheckoutEventNames, + getSelectedUPEGatewayPaymentMethod, + getStripeServerData, + isUsingSavedPaymentMethod, +} from '../../stripe-utils'; +import './style.scss'; +import { + processPayment, + mountStripePaymentElement, +} from './payment-processing'; + +jQuery( function ( $ ) { + // Create an API object, which will be used throughout the checkout. + const api = new WCStripeAPI( + getStripeServerData(), + // A promise-based interface to jQuery.post. + ( url, args ) => { + return new Promise( ( resolve, reject ) => { + jQuery.post( url, args ).then( resolve ).fail( reject ); + } ); + } + ); + + // Only attempt to mount the card element once that section of the page has loaded. + // We can use the updated_checkout event for this. + $( document.body ).on( 'updated_checkout', () => { + maybeMountStripePaymentElement(); + } ); + + $( 'form.checkout' ).on( generateCheckoutEventNames(), function () { + return processPaymentIfNotUsingSavedMethod( $( this ) ); + } ); + + function processPaymentIfNotUsingSavedMethod( $form ) { + const paymentMethodType = getSelectedUPEGatewayPaymentMethod(); + if ( ! isUsingSavedPaymentMethod( paymentMethodType ) ) { + return processPayment( api, $form, paymentMethodType ); + } + } + + // If the card element selector doesn't exist, then do nothing. + // For example, when a 100% discount coupon is applied). + // We also don't re-mount if already mounted in DOM. + async function maybeMountStripePaymentElement() { + if ( + $( '.wc-stripe-upe-element' ).length && + ! $( '.wc-stripe-upe-element' ).children().length + ) { + for ( const upeElement of $( + '.wc-stripe-upe-element' + ).toArray() ) { + await mountStripePaymentElement( api, upeElement ); + } + } + } +} ); diff --git a/client/classic/upe/index.js b/client/classic/upe/index.js index aa991af156..ffde593bf1 100644 --- a/client/classic/upe/index.js +++ b/client/classic/upe/index.js @@ -11,6 +11,7 @@ import { getFontRulesFromPage, getAppearance } from '../../styles/upe'; import enableStripeLinkPaymentMethod from '../../stripe-link'; import { legacyHashchangeHandler } from './legacy-support'; import './style.scss'; +import './deferred-intent.js'; jQuery( function ( $ ) { const key = getStripeServerData()?.key; @@ -200,7 +201,7 @@ jQuery( function ( $ ) { // Do not recreate UPE element unnecessarily. if ( upeElement ) { upeElement.unmount(); - upeElement.mount( '#wc-stripe-upe-element' ); + upeElement.mount( '.wc-stripe-upe-element' ); return; } @@ -221,7 +222,7 @@ jQuery( function ( $ ) { // I repeat, do NOT recreate UPE element unnecessarily. if ( upeElement || paymentIntentId ) { upeElement.unmount(); - upeElement.mount( '#wc-stripe-upe-element' ); + upeElement.mount( '.wc-stripe-upe-element' ); return; } @@ -302,7 +303,7 @@ jQuery( function ( $ ) { } upeElement = elements.create( 'payment', upeSettings ); - upeElement.mount( '#wc-stripe-upe-element' ); + upeElement.mount( '.wc-stripe-upe-element' ); upeElement.on( 'ready', () => { unblockUI( $( upeLoadingSelector ) ); @@ -339,31 +340,13 @@ jQuery( function ( $ ) { } ); }; - // Only attempt to mount the card element once that section of the page has loaded. We can use the updated_checkout - // event for this. This part of the page can also reload based on changes to checkout details, so we call unmount - // first to ensure the card element is re-mounted correctly. - $( document.body ).on( 'updated_checkout', () => { - // If the card element selector doesn't exist, then do nothing (for example, when a 100% discount coupon is applied). - // We also don't re-mount if already mounted in DOM. - if ( - $( '#wc-stripe-upe-element' ).length && - ! $( '#wc-stripe-upe-element' ).children().length && - isUPEEnabled - ) { - const isSetupIntent = ! ( - getStripeServerData()?.isPaymentNeeded ?? true - ); - mountUPEElement( isSetupIntent ); - } - } ); - if ( $( 'form#add_payment_method' ).length || $( 'form#order_review' ).length ) { if ( - $( '#wc-stripe-upe-element' ).length && - ! $( '#wc-stripe-upe-element' ).children().length && + $( '.wc-stripe-upe-element' ).length && + ! $( '.wc-stripe-upe-element' ).children().length && isUPEEnabled && ! upeElement ) { diff --git a/client/classic/upe/payment-processing.js b/client/classic/upe/payment-processing.js new file mode 100644 index 0000000000..916c33c1a1 --- /dev/null +++ b/client/classic/upe/payment-processing.js @@ -0,0 +1,326 @@ +import { + appendIsUsingDeferredIntentToForm, + appendPaymentMethodIdToForm, + getPaymentMethodTypes, + getStorageWithExpiration, + getStripeServerData, + getUpeSettings, + setStorageWithExpiration, + showErrorCheckout, + storageKeys, +} from '../../stripe-utils'; +import { getFontRulesFromPage, getAppearance } from '../../styles/upe'; + +const gatewayUPEComponents = {}; + +const paymentMethodsConfig = getStripeServerData()?.paymentMethodsConfig; + +for ( const paymentMethodType in paymentMethodsConfig ) { + gatewayUPEComponents[ paymentMethodType ] = { + elements: null, + upeElement: null, + }; +} + +/** + * Initializes the appearance of the payment element by retrieving the UPE configuration + * from the API and saving the appearance if it doesn't exist. If the appearance already exists, + * it is simply returned. + * + * @return {Object} The appearance object for the UPE. + */ +function initializeAppearance() { + const themeName = getStripeServerData()?.theme_name; + const storageKey = `${ storageKeys.UPE_APPEARANCE }_${ themeName }`; + let appearance = getStorageWithExpiration( storageKey ); + + if ( ! appearance ) { + appearance = getAppearance(); + const oneDayDuration = 24 * 60 * 60 * 1000; + setStorageWithExpiration( storageKey, appearance, oneDayDuration ); + } + + return appearance; +} + +/** + * Block UI to indicate processing and avoid duplicate submission. + * + * @param {Object} jQueryForm The jQuery object for the form. + */ +function blockUI( jQueryForm ) { + jQueryForm.addClass( 'processing' ).block( { + message: null, + overlayCSS: { + background: '#fff', + opacity: 0.6, + }, + } ); +} + +/** + * Validates the Stripe elements by submitting them and handling any errors that occur during submission. + * If an error occurs, the function removes loading effect from the provided jQuery form and thus unblocks it, + * and shows an error message in the checkout. + * + * @param {Object} elements The Stripe elements object to be validated. + * @return {Promise} Promise for the checkout submission. + */ +export function validateElements( elements ) { + return elements.submit().then( ( result ) => { + if ( result.error ) { + throw new Error( result.error.message ); + } + } ); +} + +/** + * Creates a Stripe payment element with the specified payment method type and options. The function + * retrieves the necessary data from the UPE configuration and initializes the appearance. It then creates the + * Stripe elements and the Stripe payment element, which is attached to the gatewayUPEComponents object afterward. + * + * @todo Make paymentMethodType required when Split is implemented. + * + * @param {Object} api The API object used to create the Stripe payment element. + * @param {string} paymentMethodType The type of Stripe payment method to create. + * @return {Object} A promise that resolves with the created Stripe payment element. + */ +function createStripePaymentElement( api, paymentMethodType = null ) { + const amount = Number( getStripeServerData()?.cartTotal ); + const paymentMethodTypes = getPaymentMethodTypes( paymentMethodType ); + const options = { + mode: amount < 1 ? 'setup' : 'payment', + currency: getStripeServerData()?.currency.toLowerCase(), + amount, + paymentMethodCreation: 'manual', + paymentMethodTypes, + appearance: initializeAppearance(), + fonts: getFontRulesFromPage(), + }; + + const elements = api.getStripe().elements( options ); + const createdStripePaymentElement = elements.create( 'payment', { + ...getUpeSettings(), + wallets: { + applePay: 'never', + googlePay: 'never', + }, + } ); + + // To be removed with Split PE. + if ( paymentMethodType === null ) { + paymentMethodType = 'stripe'; + gatewayUPEComponents.stripe = { + elements: null, + upeElement: null, + }; + } + + gatewayUPEComponents[ paymentMethodType ].elements = elements; + gatewayUPEComponents[ + paymentMethodType + ].upeElement = createdStripePaymentElement; + return createdStripePaymentElement; +} + +/** + * Submits the provided jQuery form and removes the 'processing' class from it. + * + * @param {Object} jQueryForm The jQuery object for the form being submitted. + */ +function submitForm( jQueryForm ) { + jQueryForm.removeClass( 'processing' ).trigger( 'submit' ); +} + +/** + * Creates a Stripe payment method by calling the Stripe API's createPaymentMethod with the provided elements + * and billing details. The billing details are obtained from various form elements on the page. + * + * @param {Object} api The API object used to call the Stripe API's createPaymentMethod method. + * @param {Object} elements The Stripe elements object used to create a Stripe payment method. + * @param {Object} jQueryForm The jQuery object for the form being submitted. + * @param {string} paymentMethodType The type of Stripe payment method to create. + * @return {Promise} A promise that resolves with the created Stripe payment method. + */ +function createStripePaymentMethod( + api, + elements, + jQueryForm, + paymentMethodType +) { + let params = {}; + if ( jQueryForm.attr( 'name' ) === 'checkout' ) { + params = { + billing_details: { + name: document.querySelector( '#billing_first_name' ) + ? ( + document.querySelector( '#billing_first_name' ) + ?.value + + ' ' + + document.querySelector( '#billing_last_name' ) + ?.value + ).trim() + : undefined, + email: document.querySelector( '#billing_email' )?.value, + phone: document.querySelector( '#billing_phone' )?.value, + address: { + city: document.querySelector( '#billing_city' )?.value, + country: document.querySelector( '#billing_country' ) + ?.value, + line1: document.querySelector( '#billing_address_1' ) + ?.value, + line2: document.querySelector( '#billing_address_2' ) + ?.value, + postal_code: document.querySelector( '#billing_postcode' ) + ?.value, + state: document.querySelector( '#billing_state' )?.value, + }, + }, + }; + } + + return api + .getStripe( paymentMethodType ) + .createPaymentMethod( { elements, params } ); +} + +/** + * Mounts the existing Stripe Payment Element to the DOM element. + * Creates the Stripe Payment Element instance if it doesn't exist and mounts it to the DOM element. + * + * @todo Make it only Split when implemented. + * + * @param {Object} api The API object. + * @param {string} domElement The selector of the DOM element of particular payment method to mount the UPE element to. + **/ +export async function mountStripePaymentElement( api, domElement ) { + /* + * Trigger this event to ensure the tokenization-form.js init + * is executed. + * + * This script handles the radio input interaction when toggling + * between the user's saved card / entering new card details. + * + * Ref: https://github.com/woocommerce/woocommerce/blob/2429498/assets/js/frontend/tokenization-form.js#L109 + */ + const event = new Event( 'wc-credit-card-form-init' ); + document.body.dispatchEvent( event ); + + const paymentMethodType = domElement.dataset.paymentMethodType; + let upeElement; + + // Non-split PE. To be removed. + if ( typeof paymentMethodType === 'undefined' ) { + upeElement = await createStripePaymentElement( api ); + + upeElement.on( 'change', ( e ) => { + const selectedUPEPaymentType = e.value.type; + const isPaymentMethodReusable = + paymentMethodsConfig[ selectedUPEPaymentType ].isReusable; + showNewPaymentMethodCheckbox( isPaymentMethodReusable ); + setSelectedUPEPaymentType( selectedUPEPaymentType ); + } ); + } else { + // Split PE. + if ( ! gatewayUPEComponents[ paymentMethodType ] ) { + return; + } + + upeElement = + gatewayUPEComponents[ paymentMethodType ].upeElement || + ( await createStripePaymentElement( api, paymentMethodType ) ); + } + + upeElement.mount( domElement ); +} + +// Set the selected UPE payment type field +function setSelectedUPEPaymentType( paymentType ) { + document.querySelector( + '#wc_stripe_selected_upe_payment_type' + ).value = paymentType; +} + +// Show or hide save payment information checkbox +function showNewPaymentMethodCheckbox( show = true ) { + const saveCardElement = document.querySelector( + '.woocommerce-SavedPaymentMethods-saveNew' + ); + + if ( saveCardElement ) { + saveCardElement.style.visibility = show ? 'visible' : 'hidden'; + } + + const stripeSaveCardCheckbox = document.querySelector( + 'input#wc-stripe-new-payment-method' + ); + + if ( ! show && stripeSaveCardCheckbox ) { + stripeSaveCardCheckbox.setAttribute( 'checked', false ); + stripeSaveCardCheckbox.dispatchEvent( new Event( 'change' ) ); + } +} + +/** + * Handles the checkout process for the provided jQuery form and Stripe payment method type. The function blocks the + * form UI to prevent duplicate submission and validates the Stripe elements. It then creates a Stripe payment method + * object and appends the necessary data to the form for checkout completion. Finally, it submits the form and prevents + * the default form submission from WC Core. + * + * @param {Object} api The API object used to create the Stripe payment method. + * @param {Object} jQueryForm The jQuery object for the form being submitted. + * @param {string} paymentMethodType The type of Stripe payment method being used. + * @return {boolean} return false to prevent the default form submission from WC Core. + */ +let hasCheckoutCompleted; +export const processPayment = ( + api, + jQueryForm, + paymentMethodType, + additionalActionsHandler = () => {} +) => { + if ( hasCheckoutCompleted ) { + hasCheckoutCompleted = false; + return; + } + + blockUI( jQueryForm ); + + // Non split. To be removed. + if ( paymentMethodType === null ) { + paymentMethodType = 'stripe'; + } + + const elements = gatewayUPEComponents[ paymentMethodType ].elements; + + ( async () => { + try { + await validateElements( elements ); + const paymentMethodObject = await createStripePaymentMethod( + api, + elements, + jQueryForm, + paymentMethodType + ); + appendIsUsingDeferredIntentToForm( jQueryForm ); + appendPaymentMethodIdToForm( + jQueryForm, + paymentMethodObject.paymentMethod.id + ); + await additionalActionsHandler( + paymentMethodObject.paymentMethod, + jQueryForm, + api + ); + hasCheckoutCompleted = true; + submitForm( jQueryForm ); + } catch ( err ) { + hasCheckoutCompleted = false; + jQueryForm.removeClass( 'processing' ).unblock(); + showErrorCheckout( err.message ); + } + } )(); + + // Prevent WC Core default form submission (see woocommerce/assets/js/frontend/checkout.js) from happening. + return false; +}; diff --git a/client/classic/upe/style.scss b/client/classic/upe/style.scss index 76e0163edb..7c85e6fc6d 100644 --- a/client/classic/upe/style.scss +++ b/client/classic/upe/style.scss @@ -1,3 +1,3 @@ -#wc-stripe-upe-element { +.wc-stripe-upe-element { margin-bottom: 4px; } diff --git a/client/stripe-utils/utils.js b/client/stripe-utils/utils.js index 4d6aaff5d8..34813bb464 100644 --- a/client/stripe-utils/utils.js +++ b/client/stripe-utils/utils.js @@ -194,3 +194,239 @@ export const getStorageWithExpiration = ( key ) => { }; export { getStripeServerData, getErrorMessageForTypeAndCode }; + +// Used by dPE. + +/** + * Check whether Stripe Link is enabled. + * + * @param {Object} paymentMethodsConfig Checkout payment methods configuration settings object. + * @return {boolean} True, if enabled; false otherwise. + */ +export const isLinkEnabled = ( paymentMethodsConfig ) => { + return ( + paymentMethodsConfig.link !== undefined && + paymentMethodsConfig.card !== undefined + ); +}; + +/** + * Get array of payment method types to use with intent. + * + * @todo Make paymentMethodType required when Split is implemented. + * + * @param {string} paymentMethodType Payment method type Stripe ID. + * @return {Array} Array of payment method types to use with intent. + */ +export const getPaymentMethodTypes = ( paymentMethodType = null ) => { + const paymentMethodsConfig = getStripeServerData()?.paymentMethodsConfig; + + if ( paymentMethodType === null ) { + return Object.keys( paymentMethodsConfig || {} ); + } + + const paymentMethodTypes = [ paymentMethodType ]; + if ( + paymentMethodType === 'card' && + isLinkEnabled( paymentMethodsConfig ) + ) { + paymentMethodTypes.push( 'link' ); + } + return paymentMethodTypes; +}; + +function shouldIncludeTerms() { + if ( getStripeServerData()?.cartContainsSubscription ) { + return true; + } + + const savePaymentMethodCheckbox = document.getElementById( + 'wc-stripe-new-payment-method' + ); + if ( + savePaymentMethodCheckbox !== null && + savePaymentMethodCheckbox.checked + ) { + return true; + } + + return false; +} + +export const generateCheckoutEventNames = () => { + const paymentMethods = [ 'stripe' ]; + + return paymentMethods + .map( ( method ) => `checkout_place_order_${ method }` ) + .join( ' ' ); +}; + +// To be removed when we fully switch to dPE. +export const appendIsUsingDeferredIntentToForm = ( form ) => { + form.append( + '' + ); +}; + +export const appendPaymentMethodIdToForm = ( form, paymentMethodId ) => { + form.append( + `` + ); +}; + +/** + * Checks if the customer is using a saved payment method. + * + * @return {boolean} Boolean indicating whether or not a saved payment method is being used. + */ +export const isUsingSavedPaymentMethod = () => { + return ( + document.querySelector( '#wc-stripe-new-payment-method' )?.length && + ! document + .querySelector( '#wc-stripe-new-payment-method' ) + .is( ':checked' ) + ); +}; + +/** + * Finds selected payment gateway and returns matching Stripe payment method for gateway. + * + * @return {string} Stripe payment method type + */ +export const getSelectedUPEGatewayPaymentMethod = () => { + const paymentMethodsConfig = getStripeServerData()?.paymentMethodsConfig; + const gatewayCardId = getStripeServerData()?.gatewayId; + let selectedGatewayId = null; + + // Handle payment method selection on the Checkout page or Add Payment Method page where class names differ. + const radio = document.querySelector( + 'li.wc_payment_method input.input-radio:checked, li.woocommerce-PaymentMethod input.input-radio:checked' + ); + if ( radio !== null ) { + selectedGatewayId = radio.id; + } + + if ( selectedGatewayId === 'payment_method_stripe' ) { + selectedGatewayId = 'payment_method_stripe_card'; + } + + let selectedPaymentMethod = null; + + for ( const paymentMethodType in paymentMethodsConfig ) { + if ( + `payment_method_${ gatewayCardId }_${ paymentMethodType }` === + selectedGatewayId + ) { + selectedPaymentMethod = paymentMethodType; + break; + } + } + + return selectedPaymentMethod; +}; + +export const getHiddenBillingFields = ( enabledBillingFields ) => { + return { + name: + enabledBillingFields.includes( 'billing_first_name' ) || + enabledBillingFields.includes( 'billing_last_name' ) + ? 'never' + : 'auto', + email: enabledBillingFields.includes( 'billing_email' ) + ? 'never' + : 'auto', + phone: enabledBillingFields.includes( 'billing_phone' ) + ? 'never' + : 'auto', + address: { + country: enabledBillingFields.includes( 'billing_country' ) + ? 'never' + : 'auto', + line1: enabledBillingFields.includes( 'billing_address_1' ) + ? 'never' + : 'auto', + line2: enabledBillingFields.includes( 'billing_address_2' ) + ? 'never' + : 'auto', + city: enabledBillingFields.includes( 'billing_city' ) + ? 'never' + : 'auto', + state: enabledBillingFields.includes( 'billing_state' ) + ? 'never' + : 'auto', + postalCode: enabledBillingFields.includes( 'billing_postcode' ) + ? 'never' + : 'auto', + }, + }; +}; + +export const getUpeSettings = () => { + const upeSettings = {}; + const showTerms = shouldIncludeTerms() ? 'always' : 'never'; + + upeSettings.terms = getUPETerms( showTerms ); + + if ( + getStripeServerData()?.isCheckout && + ! ( + getStripeServerData()?.isOrderPay || + getStripeServerData()?.isChangingPayment + ) + ) { + upeSettings.fields = { + billingDetails: getHiddenBillingFields( + getStripeServerData()?.enabledBillingFields + ), + }; + } + + return upeSettings; +}; + +/** + * Show error notice at top of checkout form. + * Will try to use a translatable message using the message code if available + * + * @param {string} errorMessage + */ +export const showErrorCheckout = ( errorMessage ) => { + if ( + typeof errorMessage !== 'string' && + ! ( errorMessage instanceof String ) + ) { + if ( errorMessage.code && getStripeServerData()[ errorMessage.code ] ) { + errorMessage = getStripeServerData()[ errorMessage.code ]; + } else { + errorMessage = errorMessage.message; + } + } + + let messageWrapper = ''; + if ( errorMessage.includes( 'woocommerce-error' ) ) { + messageWrapper = errorMessage; + } else { + messageWrapper = + ''; + } + const $container = jQuery( '.woocommerce-notices-wrapper' ).first(); + + if ( ! $container.length ) { + return; + } + + // Adapted from WooCommerce core @ ea9aa8c, assets/js/frontend/checkout.js#L514-L529 + jQuery( + '.woocommerce-NoticeGroup-checkout, .woocommerce-error, .woocommerce-message' + ).remove(); + $container.prepend( messageWrapper ); + jQuery( 'form.checkout' ) + .find( '.input-text, select, input:checkbox' ) + .trigger( 'validate' ) + .trigger( 'blur' ); + + jQuery.scroll_to_notices( $container ); + jQuery( document.body ).trigger( 'checkout_error' ); +}; diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index 0bfbe87d9f..a60b87ee0e 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -18,11 +18,11 @@ class WC_Stripe_Intent_Controller { protected $gateway; /** - * Class constructor, adds the necessary hooks. + * Adds the necessary hooks. * * @since 4.2.0 */ - public function __construct() { + public function init_hooks() { add_action( 'wc_ajax_wc_stripe_verify_intent', [ $this, 'verify_intent' ] ); add_action( 'wc_ajax_wc_stripe_create_setup_intent', [ $this, 'create_setup_intent' ] ); @@ -676,6 +676,151 @@ public function maybe_process_upe_redirect() { $gateway->maybe_process_upe_redirect(); } } -} -new WC_Stripe_Intent_Controller(); + /** + * Creates and confirm a payment intent with the given payment information. + * Used for dPE. + * + * @param object $payment_information The payment information needed for creating and confirming the intent. + * + * @throws WC_Stripe_Exception - If the create intent call returns with an error. + * + * @return array + */ + public function create_and_confirm_payment_intent( $payment_information ) { + // Throws a WC_Stripe_Exception if required information is missing. + $this->validate_create_and_confirm_intent_payment_information( $payment_information ); + + $order = $payment_information['order']; + $selected_payment_type = $payment_information['selected_payment_type']; + $payment_method_types = $this->get_payment_method_types_for_intent_creation( $selected_payment_type, $order->get_id() ); + + $request = [ + 'amount' => $payment_information['amount'], + 'capture_method' => $payment_information['capture_method'], + 'confirm' => 'true', + 'currency' => $payment_information['currency'], + 'customer' => $payment_information['customer'], + /* translators: 1) blog name 2) order number */ + 'description' => sprintf( __( '%1$s - Order %2$s', 'woocommerce-gateway-stripe' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $order->get_order_number() ), + 'metadata' => $payment_information['metadata'], + 'payment_method' => $payment_information['payment_method'], + 'payment_method_types' => $payment_method_types, + 'shipping' => $payment_information['shipping'], + 'statement_descriptor' => $payment_information['statement_descriptor'], + ]; + + if ( $payment_information['save_payment_method_to_store'] ) { + $request['setup_future_usage'] = 'off_session'; + } + + $payment_intent = WC_Stripe_API::request_with_level3_data( + $request, + 'payment_intents', + $payment_information['level3'], + $order + ); + + // Only update the payment_type if we have a reference to the payment type the customer selected. + if ( '' !== $selected_payment_type ) { + $order->update_meta_data( '_stripe_upe_payment_type', $selected_payment_type ); + } + + // Throw an exception when there's an error. + if ( ! empty( $payment_intent->error ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r + throw new WC_Stripe_Exception( print_r( $payment_intent->error, true ), $payment_intent->error->message ); + } + + WC_Stripe_Helper::add_payment_intent_to_order( $payment_intent->id, $order ); + + return $payment_intent; + } + + /** + * Validate the provided information for creating and confirming a payment intent. + * + * @param array $payment_information The payment information to be validated. + * + * @throws WC_Stripe_Exception If the required data is missing. + */ + private function validate_create_and_confirm_intent_payment_information( array $payment_information ) { + $required_params = [ + 'amount', + 'capture_method', + 'currency', + 'customer', + 'level3', + 'metadata', + 'order', + 'payment_method', + 'save_payment_method_to_store', + 'shipping', + 'statement_descriptor', + ]; + + $missing_params = []; + foreach ( $required_params as $param ) { + // Check if they're set. Some can be null. + if ( ! array_key_exists( $param, $payment_information ) ) { + $missing_params[] = $param; + } + } + + $shopper_error_message = __( 'There was a problem processing the payment.', 'woocommerce-gateway-stripe' ); + + // Bail out if we're missing required information. + if ( ! empty( $missing_params ) ) { + throw new WC_Stripe_Exception( + sprintf( + 'The information for creating and confirming the intent is missing the following data: %s.', + implode( ', ', $missing_params ) + ), + $shopper_error_message + ); + } + + // Bail out if the "order" parameter isn't a WC_Order. + if ( ! is_a( $payment_information['order'], 'WC_Order' ) ) { + throw new WC_Stripe_Exception( + 'The provided value for the "order" parameter is not a WC_Order', + $shopper_error_message + ); + } + } + + /** + * Returns the payment method types for the intent creation request, given the selected payment type. + * + * @param string $selected_payment_type The payment type the shopper selected, if any. + * @param int $order_id ID of the WC order we're handling. + * + * @return array + */ + private function get_payment_method_types_for_intent_creation( string $selected_payment_type, int $order_id ): array { + $gateway = $this->get_upe_gateway(); + + // If the shopper didn't select a payment type, return all the enabled ones. + if ( '' === $selected_payment_type ) { + return $gateway->get_upe_enabled_at_checkout_payment_method_ids( $order_id ); + } + + // If the "card" type was selected and Link is enabled, include Link in the types. + if ( + WC_Stripe_UPE_Payment_Method_CC::STRIPE_ID === $selected_payment_type && + in_array( + WC_Stripe_UPE_Payment_Method_Link::STRIPE_ID, + $gateway->get_upe_enabled_payment_method_ids(), + true + ) + ) { + return [ + WC_Stripe_UPE_Payment_Method_CC::STRIPE_ID, + WC_Stripe_UPE_Payment_Method_Link::STRIPE_ID, + ]; + } + + // Otherwise, return the selected payment method type. + return [ $selected_payment_type ]; + } +} diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php index 10e3029377..3c80dc6d62 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -83,6 +83,13 @@ class WC_Stripe_UPE_Payment_Gateway extends WC_Gateway_Stripe { */ public $publishable_key; + /** + * Instance of WC_Stripe_Intent_Controller. + * + * @var WC_Stripe_Intent_Controller + */ + public $intent_controller; + /** * Array mapping payment method string IDs to classes * @@ -112,6 +119,8 @@ public function __construct() { $this->payment_methods[ $payment_method->get_id() ] = $payment_method; } + $this->intent_controller = new WC_Stripe_Intent_Controller(); + // Load the form fields. $this->init_form_fields(); @@ -317,6 +326,12 @@ public function javascript_params() { $stripe_params['addPaymentReturnURL'] = wc_get_account_endpoint_url( 'payment-methods' ); $stripe_params['enabledBillingFields'] = $enabled_billing_fields; + $cart_total = ( WC()->cart ? WC()->cart->get_total( '' ) : 0 ); + $currency = get_woocommerce_currency(); + + $stripe_params['cartTotal'] = WC_Stripe_Helper::get_stripe_amount( $cart_total, strtolower( $currency ) ); + $stripe_params['currency'] = $currency; + if ( parent::is_valid_pay_for_order_endpoint() || $is_change_payment_method ) { if ( $this->is_subscriptions_enabled() && $is_change_payment_method ) { $stripe_params['isChangingPayment'] = true; @@ -465,7 +480,7 @@ public function payment_fields() { ?>
-
+
@@ -503,6 +518,11 @@ public function payment_fields() { * @return array|null An array with result of payment and redirect URL, or nothing. */ public function process_payment( $order_id, $retry = true, $force_save_source = false, $previous_error = false, $use_order_source = false ) { + // Flag for using a deferred intent. To be removed. + if ( ! empty( $_POST['wc-stripe-is-deferred-intent'] ) ) { + return $this->process_payment_with_deferred_intent( $order_id ); + } + if ( $this->maybe_change_subscription_payment_method( $order_id ) ) { return $this->process_change_subscription_payment_method( $order_id ); } @@ -633,6 +653,70 @@ public function process_payment( $order_id, $retry = true, $force_save_source = ]; } + /** + * Process the payment for an order using a deferred intent. + * + * @param int $order_id WC Order ID to be paid for. + * + * @return array An array with the result of the payment processing, and a redirect URL on success. + */ + private function process_payment_with_deferred_intent( int $order_id ) { + $order = wc_get_order( $order_id ); + + try { + if ( $this->is_using_saved_payment_method() ) { + return [ 'result' => 'failure' ]; + } + + $payment_needed = $this->is_payment_needed( $order->get_id() ); + + $payment_information = $this->prepare_payment_information_from_request( $order ); + + if ( $payment_needed ) { + $this->validate_selected_payment_method_type( $payment_information, $order->get_billing_country() ); + + // Throw an exception if the minimum order amount isn't met. + $this->validate_minimum_order_amount( $order ); + + // Throws an exception on error. + $payment_intent = $this->intent_controller->create_and_confirm_payment_intent( $payment_information ); + + // Use the last charge within the intent to proceed. + $this->process_response( end( $payment_intent->charges->data ), $order ); + + // Set the selected UPE payment method type title in the WC order. + $this->set_payment_method_title_for_order( $order, $payment_information['selected_payment_type'] ); + } else { + return [ 'result' => 'failure' ]; + } + + return [ + 'result' => 'success', + 'redirect' => $this->get_return_url( $order ), + ]; + } catch ( WC_Stripe_Exception $e ) { + $shopper_error_message = sprintf( + /* translators: localized exception message */ + __( 'There was an error processing the payment: %s', 'woocommerce-gateway-stripe' ), + $e->getLocalizedMessage() + ); + + wc_add_notice( $shopper_error_message, 'error' ); + + WC_Stripe_Logger::log( 'Error: ' . $e->getMessage() ); + + do_action( 'wc_gateway_stripe_process_payment_error', $e, $order ); + + $order->update_status( + 'failed', + /* translators: localized exception message */ + sprintf( __( 'Payment failed: %s', 'woocommerce-gateway-stripe' ), $e->getLocalizedMessage() ) + ); + + return [ 'result' => 'failure' ]; + } + } + /** * Process payment using saved payment method. * This follows WC_Gateway_Stripe::process_payment, @@ -1489,4 +1573,131 @@ private function get_address_data_for_payment_request( $order ) { ]; } + /** + * Collects the payment information needed for processing a payment intent. + * + * @param WC_Order $order The WC Order to be paid for. + * @return array An array containing the payment information for processing a payment intent. + */ + private function prepare_payment_information_from_request( WC_Order $order ) { + $payment_method = sanitize_text_field( wp_unslash( $_POST['wc-stripe-payment-method'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + $selected_payment_type = sanitize_text_field( wp_unslash( $_POST['wc_stripe_selected_upe_payment_type'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + $capture_method = empty( $this->get_option( 'capture' ) ) || $this->get_option( 'capture' ) === 'yes' ? 'automatic' : 'manual'; // automatic | manual. + $save_payment_method_to_store = isset( $_POST['save_payment_method'] ) ? 'yes' === wc_clean( wp_unslash( $_POST['save_payment_method'] ) ) : false; + $currency = strtolower( $order->get_currency() ); + $amount = $order->get_total(); + $shipping_details = null; + + // If order requires shipping, add the shipping address details to the payment intent request. + if ( method_exists( $order, 'get_shipping_postcode' ) && ! empty( $order->get_shipping_postcode() ) ) { + $shipping_details = $this->get_address_data_for_payment_request( $order ); + } + + $payment_information = [ + 'amount' => WC_Stripe_Helper::get_stripe_amount( $amount, $currency ), + 'currency' => $currency, + 'customer' => $this->get_customer_id_for_order( $order ), + 'capture_method' => $capture_method, + 'level3' => $this->get_level3_data_from_order( $order ), + 'metadata' => $this->get_metadata_from_order( $order ), + 'order' => $order, + 'payment_initiated_by' => 'initiated_by_customer', // initiated_by_merchant | initiated_by_customer. + 'payment_method' => $payment_method, + 'payment_type' => 'single', // single | recurring. + 'save_payment_method_to_store' => $save_payment_method_to_store, + 'selected_payment_type' => $selected_payment_type, + 'shipping' => $shipping_details, + 'statement_descriptor' => $this->get_statement_descriptor( $selected_payment_type ), + ]; + + return $payment_information; + } + + /** + * Gets the Stripe customer ID associated with an order, creates one if none is associated. + * + * @param WC_Order $order The WC order from which to get the Stripe customer. + * @return string The Stripe customer ID. + */ + private function get_customer_id_for_order( WC_Order $order ): string { + + // Get the user/customer from the order. + $customer_id = $this->get_stripe_customer_id( $order ); + if ( ! empty( $customer_id ) ) { + return $customer_id; + } + + // Update customer or create customer if one does not exist. + $user = $this->get_user_from_order( $order ); + $customer = new WC_Stripe_Customer( $user->ID ); + + return $customer->update_or_create_customer(); + } + + /** + * Returns the statement descriptor given the selected payment type. + * + * @param string $selected_payment_type The selected payment type. + * @return string|null + */ + private function get_statement_descriptor( string $selected_payment_type ) { + $statement_descriptor = ! empty( $this->get_option( 'statement_descriptor' ) ) ? str_replace( "'", '', $this->get_option( 'statement_descriptor' ) ) : ''; + $short_statement_descriptor = ! empty( $this->get_option( 'short_statement_descriptor' ) ) ? str_replace( "'", '', $this->get_option( 'short_statement_descriptor' ) ) : ''; + $is_short_statement_descriptor_enabled = ! empty( $this->get_option( 'is_short_statement_descriptor_enabled' ) ) && 'yes' === $this->get_option( 'is_short_statement_descriptor_enabled' ); + + // Use the shortened statement descriptor for card transactions only. + if ( + 'card' === $selected_payment_type && + $is_short_statement_descriptor_enabled && + ! ( empty( $short_statement_descriptor ) && empty( $statement_descriptor ) ) + ) { + return WC_Stripe_Helper::get_dynamic_statement_descriptor( $short_statement_descriptor, $order, $statement_descriptor ); + } + + if ( ! empty( $statement_descriptor ) ) { + return WC_Stripe_Helper::clean_statement_descriptor( $statement_descriptor ); + } + + return null; + } + + /** + * Throws an exception when the given payment method type is not valid. + * + * @param array $payment_information Payment information to process the payment. + * @param string $billing_country Order billing country. + * + * @throws WC_Stripe_Exception When the payment method type is not allowed in the given country. + */ + private function validate_selected_payment_method_type( $payment_information, $billing_country ) { + $invalid_method_message = __( 'The selected payment method type is invalid.', 'woocommerce-gateway-stripe' ); + + // No payment method type was provided. + if ( empty( $payment_information['selected_payment_type'] ) ) { + throw new WC_Stripe_Exception( 'No payment method type selected.', $invalid_method_message ); + } + + $payment_method_type = $payment_information['selected_payment_type']; + + // The provided payment method type is not among the available payment method types. + if ( ! isset( $this->payment_methods[ $payment_method_type ] ) ) { + throw new WC_Stripe_Exception( + sprintf( + 'The selected payment method type is not within the available payment methods.%1$sSelected payment method type: %2$s. Available payment methods: %3$s', + PHP_EOL, + $payment_method_type, + implode( ', ', array_keys( $this->payment_methods ) ) + ), + $invalid_method_message + ); + } + + // The selected payment method is allowed in the billing country. + if ( ! $this->payment_methods[ $payment_method_type ]->is_allowed_on_country( $billing_country ) ) { + throw new WC_Stripe_Exception( + sprintf( 'The payment method type "%1$s" is not available in %2$s.', $payment_method_type, $billing_country ), + __( 'This payment method type is not available in the selected country.', 'woocommerce-gateway-stripe' ) + ); + } + } } diff --git a/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php b/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php index 88c0a358fa..a85ef1108a 100644 --- a/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php +++ b/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php @@ -118,6 +118,10 @@ public function set_up() { $this->returnValue( self::MOCK_RETURN_URL ) ); + $this->mock_gateway->intent_controller = $this->getMockBuilder( WC_Stripe_Intent_Controller::class ) + ->setMethods( [ 'create_and_confirm_payment_intent' ] ) + ->getMock(); + $this->mock_stripe_customer = $this->getMockBuilder( WC_Stripe_Customer::class ) ->disableOriginalConstructor() ->setMethods( @@ -253,7 +257,7 @@ public function get_upe_available_payment_methods_provider() { */ public function test_payment_fields_outputs_fields() { $this->mock_gateway->payment_fields(); - $this->expectOutputRegex( '/
<\/div>/' ); + $this->expectOutputRegex( '/
<\/div>/' ); } /** @@ -311,6 +315,176 @@ public function test_process_payment_returns_valid_response() { $this->assertMatchesRegularExpression( '/save_payment_method=no/', $response['redirect_url'] ); } + /** + * Test basic checkout process_payment flow with deferred intent. + */ + public function test_process_payment_deferred_intent_returns_valid_response() { + $payment_intent_id = 'pi_mock'; + $customer_id = 'cus_mock'; + $order = WC_Helper_Order::create_order(); + $currency = $order->get_currency(); + $order_id = $order->get_id(); + + $mock_intent = (object) [ + 'charges' => (object) [ + 'data' => [ + (object) [ + 'id' => $order_id, + 'captured' => 'yes', + 'status' => 'succeeded', + ], + ], + ], + ]; + + $_POST = [ + 'wc_stripe_selected_upe_payment_type' => 'card', + 'wc-stripe-is-deferred-intent' => '1', + ]; + + $this->mock_gateway->intent_controller + ->expects( $this->once() ) + ->method( 'create_and_confirm_payment_intent' ) + ->willReturn( $mock_intent ); + + $this->mock_gateway + ->expects( $this->once() ) + ->method( 'get_stripe_customer_id' ) + ->willReturn( $customer_id ); + + $response = $this->mock_gateway->process_payment( $order_id ); + + $this->assertEquals( 'success', $response['result'] ); + $this->assertEquals( self::MOCK_RETURN_URL, $response['redirect'] ); + } + + /** + * Exception handling of the process_payment flow with deferred intent. + */ + public function test_process_payment_deferred_intent_handles_exception() { + $payment_intent_id = 'pi_mock'; + $customer_id = 'cus_mock'; + $order = WC_Helper_Order::create_order(); + $currency = $order->get_currency(); + $order_id = $order->get_id(); + + $mock_intent = (object) [ + 'charges' => (object) [ + 'data' => [ + (object) [ + 'id' => $order_id, + 'captured' => 'yes', + 'status' => 'succeeded', + ], + ], + ], + ]; + + $_POST = [ + 'wc_stripe_selected_upe_payment_type' => 'card', + 'wc-stripe-is-deferred-intent' => '1', + ]; + + $this->mock_gateway->intent_controller + ->expects( $this->once() ) + ->method( 'create_and_confirm_payment_intent' ) + ->willThrowException( new WC_Stripe_Exception( "It's a trap!" ) ); + + $this->mock_gateway + ->expects( $this->once() ) + ->method( 'get_stripe_customer_id' ) + ->willReturn( $customer_id ); + + $response = $this->mock_gateway->process_payment( $order_id ); + + $this->assertEquals( 'failure', $response['result'] ); + + $processed_order = wc_get_order( $order_id ); + $this->assertEquals( 'failed', $processed_order->get_status() ); + } + + public function test_process_payment_deferred_intent_bails_with_empty_payment_type() { + $payment_intent_id = 'pi_mock'; + $customer_id = 'cus_mock'; + $order = WC_Helper_Order::create_order(); + $currency = $order->get_currency(); + $order_id = $order->get_id(); + + $mock_intent = (object) [ + 'charges' => (object) [ + 'data' => [ + (object) [ + 'id' => $order_id, + 'captured' => 'yes', + 'status' => 'succeeded', + ], + ], + ], + ]; + + $_POST = [ + 'wc_stripe_selected_upe_payment_type' => '', + 'wc-stripe-is-deferred-intent' => '1', + ]; + + $this->mock_gateway->intent_controller + ->expects( $this->never() ) + ->method( 'create_and_confirm_payment_intent' ); + + $this->mock_gateway + ->expects( $this->once() ) + ->method( 'get_stripe_customer_id' ) + ->willReturn( $customer_id ); + + $response = $this->mock_gateway->process_payment( $order_id ); + + $this->assertEquals( 'failure', $response['result'] ); + + $processed_order = wc_get_order( $order_id ); + $this->assertEquals( 'failed', $processed_order->get_status() ); + } + + public function test_process_payment_deferred_intent_bails_with_invalid_payment_type() { + $payment_intent_id = 'pi_mock'; + $customer_id = 'cus_mock'; + $order = WC_Helper_Order::create_order(); + $currency = $order->get_currency(); + $order_id = $order->get_id(); + + $mock_intent = (object) [ + 'charges' => (object) [ + 'data' => [ + (object) [ + 'id' => $order_id, + 'captured' => 'yes', + 'status' => 'succeeded', + ], + ], + ], + ]; + + $_POST = [ + 'wc_stripe_selected_upe_payment_type' => 'some_invalid_type', + 'wc-stripe-is-deferred-intent' => '1', + ]; + + $this->mock_gateway->intent_controller + ->expects( $this->never() ) + ->method( 'create_and_confirm_payment_intent' ); + + $this->mock_gateway + ->expects( $this->once() ) + ->method( 'get_stripe_customer_id' ) + ->willReturn( $customer_id ); + + $response = $this->mock_gateway->process_payment( $order_id ); + + $this->assertEquals( 'failure', $response['result'] ); + + $processed_order = wc_get_order( $order_id ); + $this->assertEquals( 'failed', $processed_order->get_status() ); + } + /** * Test basic redirect payment processed correctly. */ diff --git a/woocommerce-gateway-stripe.php b/woocommerce-gateway-stripe.php index b85abbd944..e799200ea3 100644 --- a/woocommerce-gateway-stripe.php +++ b/woocommerce-gateway-stripe.php @@ -213,6 +213,9 @@ public function init() { $this->payment_request_configuration = new WC_Stripe_Payment_Request(); $this->account = new WC_Stripe_Account( $this->connect, 'WC_Stripe_API' ); + $intent_controller = new WC_Stripe_Intent_Controller(); + $intent_controller->init_hooks(); + if ( is_admin() ) { require_once dirname( __FILE__ ) . '/includes/admin/class-wc-stripe-admin-notices.php'; require_once dirname( __FILE__ ) . '/includes/admin/class-wc-stripe-settings-controller.php';