From e597d13b25851b7507a1d310c1c2fb9e01032177 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Fri, 10 Nov 2023 12:30:52 +0100 Subject: [PATCH 01/25] Separate adding hooks from the constructor for WC_Stripe_Intent_Controller --- includes/class-wc-stripe-intent-controller.php | 6 ++---- woocommerce-gateway-stripe.php | 3 +++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index 0bfbe87d9f..70f0b44065 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' ] ); @@ -677,5 +677,3 @@ public function maybe_process_upe_redirect() { } } } - -new WC_Stripe_Intent_Controller(); 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'; From beca67af783bb96d5278409740e2c33195baef11 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Fri, 10 Nov 2023 13:37:43 +0100 Subject: [PATCH 02/25] Make wc-stripe-upe-element a class instead of an id --- client/classic/upe/index.js | 14 +++++++------- client/classic/upe/style.scss | 2 +- .../class-wc-stripe-upe-payment-gateway.php | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/classic/upe/index.js b/client/classic/upe/index.js index aa991af156..9657b0f21d 100644 --- a/client/classic/upe/index.js +++ b/client/classic/upe/index.js @@ -200,7 +200,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 +221,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 +302,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 ) ); @@ -346,8 +346,8 @@ jQuery( function ( $ ) { // 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 && + $( '.wc-stripe-upe-element' ).length && + ! $( '.wc-stripe-upe-element' ).children().length && isUPEEnabled ) { const isSetupIntent = ! ( @@ -362,8 +362,8 @@ jQuery( function ( $ ) { $( '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/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/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php index 10e3029377..33367d4ca0 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -465,7 +465,7 @@ public function payment_fields() { ?>
-
+
From 9104244c9806d0fdde7ae6485781ac0555b2d9cb Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Fri, 10 Nov 2023 14:14:46 +0100 Subject: [PATCH 03/25] Render the Payment element in the classic checkout page without creating an intention --- client/classic/upe/deferred-intent.js | 40 +++++ client/classic/upe/index.js | 19 +- client/classic/upe/payment-processing.js | 163 ++++++++++++++++++ client/stripe-utils/utils.js | 117 +++++++++++++ .../class-wc-stripe-upe-payment-gateway.php | 6 + 5 files changed, 327 insertions(+), 18 deletions(-) create mode 100644 client/classic/upe/deferred-intent.js create mode 100644 client/classic/upe/payment-processing.js diff --git a/client/classic/upe/deferred-intent.js b/client/classic/upe/deferred-intent.js new file mode 100644 index 0000000000..8177657493 --- /dev/null +++ b/client/classic/upe/deferred-intent.js @@ -0,0 +1,40 @@ +import jQuery from 'jquery'; +import WCStripeAPI from '../../api'; +import { getStripeServerData } from '../../stripe-utils'; +import './style.scss'; +import { 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(); + } ); + + // 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 9657b0f21d..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; @@ -339,24 +340,6 @@ 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 diff --git a/client/classic/upe/payment-processing.js b/client/classic/upe/payment-processing.js new file mode 100644 index 0000000000..81d6e02104 --- /dev/null +++ b/client/classic/upe/payment-processing.js @@ -0,0 +1,163 @@ +import { + getPaymentMethodTypes, + getStripeServerData, + getUpeSettings, +} from '../../stripe-utils'; + +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() { + return {}; +} + +/** + * 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(), + }; + + 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 ) { + return createdStripePaymentElement; + } + + gatewayUPEComponents[ paymentMethodType ].elements = elements; + gatewayUPEComponents[ + paymentMethodType + ].upeElement = createdStripePaymentElement; + return createdStripePaymentElement; +} + +/** + * Mounts the existing Stripe Payment Element to the DOM element. + * Creates the Stipe 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 ) { + if ( show ) { + document.querySelector( + '.woocommerce-SavedPaymentMethods-saveNew' + ).style.visibility = 'visible'; + } else { + document.querySelector( + '.woocommerce-SavedPaymentMethods-saveNew' + ).style.visibility = 'hidden'; + document + .querySelector( 'input#wc-stripe-new-payment-method' ) + .setAttribute( 'checked', false ); + document + .querySelector( 'input#wc-stripe-new-payment-method' ) + .dispatchEvent( new Event( 'change' ) ); + } +} diff --git a/client/stripe-utils/utils.js b/client/stripe-utils/utils.js index 4d6aaff5d8..d0a89ac8f9 100644 --- a/client/stripe-utils/utils.js +++ b/client/stripe-utils/utils.js @@ -194,3 +194,120 @@ 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 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; +}; 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 33367d4ca0..8362b1e716 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -317,6 +317,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; From 5612e993ff977c661a271c2fc2c9833233f25a28 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Sun, 12 Nov 2023 17:15:09 +0100 Subject: [PATCH 04/25] Process payments with deferred intent in the classic checkout --- client/classic/upe/deferred-intent.js | 23 ++- client/classic/upe/payment-processing.js | 149 +++++++++++++++++- client/stripe-utils/utils.js | 119 ++++++++++++++ .../class-wc-stripe-intent-controller.php | 80 ++++++++++ .../class-wc-stripe-upe-payment-gateway.php | 146 +++++++++++++++++ 5 files changed, 514 insertions(+), 3 deletions(-) diff --git a/client/classic/upe/deferred-intent.js b/client/classic/upe/deferred-intent.js index 8177657493..1b69b4486c 100644 --- a/client/classic/upe/deferred-intent.js +++ b/client/classic/upe/deferred-intent.js @@ -1,8 +1,16 @@ import jQuery from 'jquery'; import WCStripeAPI from '../../api'; -import { getStripeServerData } from '../../stripe-utils'; +import { + generateCheckoutEventNames, + getSelectedUPEGatewayPaymentMethod, + getStripeServerData, + isUsingSavedPaymentMethod, +} from '../../stripe-utils'; import './style.scss'; -import { mountStripePaymentElement } from './payment-processing'; +import { + processPayment, + mountStripePaymentElement, +} from './payment-processing'; jQuery( function ( $ ) { // Create an API object, which will be used throughout the checkout. @@ -22,6 +30,17 @@ jQuery( function ( $ ) { 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. diff --git a/client/classic/upe/payment-processing.js b/client/classic/upe/payment-processing.js index 81d6e02104..6c5779bbd4 100644 --- a/client/classic/upe/payment-processing.js +++ b/client/classic/upe/payment-processing.js @@ -1,7 +1,10 @@ import { + appendIsUsingDeferredIntentToForm, + appendPaymentMethodIdToForm, getPaymentMethodTypes, getStripeServerData, getUpeSettings, + showErrorCheckout, } from '../../stripe-utils'; const gatewayUPEComponents = {}; @@ -26,6 +29,21 @@ function initializeAppearance() { return {}; } +/** + * 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, @@ -76,7 +94,11 @@ function createStripePaymentElement( api, paymentMethodType = null ) { // To be removed with Split PE. if ( paymentMethodType === null ) { - return createdStripePaymentElement; + paymentMethodType = 'stripe'; + gatewayUPEComponents.stripe = { + elements: null, + upeElement: null, + }; } gatewayUPEComponents[ paymentMethodType ].elements = elements; @@ -86,6 +108,67 @@ function createStripePaymentElement( api, paymentMethodType = null ) { 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 Stipe Payment Element instance if it doesn't exist and mounts it to the DOM element. @@ -161,3 +244,67 @@ function showNewPaymentMethodCheckbox( show = true ) { .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/stripe-utils/utils.js b/client/stripe-utils/utils.js index d0a89ac8f9..e04874978a 100644 --- a/client/stripe-utils/utils.js +++ b/client/stripe-utils/utils.js @@ -253,6 +253,78 @@ function shouldIncludeTerms() { 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: @@ -311,3 +383,50 @@ export const getUpeSettings = () => { 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 70f0b44065..9f542a6629 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -676,4 +676,84 @@ public function maybe_process_upe_redirect() { $gateway->maybe_process_upe_redirect(); } } + + /** + * Creates payment intent using current cart or order and store details. + * Used for dPE. + * + * @param int $order_id The id of the order if intent created from Order. + * @throws Exception - If the create intent call returns with an error. + * @return array + */ + public function create_and_confirm_payment_intent( $payment_information ) { + $gateway = $this->get_upe_gateway(); + $order = $payment_information->order; + + $selected_payment_type = $payment_information->selected_payment_type; + // TODO: put this in a method. + if ( '' !== $selected_payment_type ) { + // Only update the payment_method_types if we have a reference to the payment type the customer selected. + $payment_method_types = [ $selected_payment_type ]; + 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 + ) + ) { + $payment_method_types = [ + WC_Stripe_UPE_Payment_Method_CC::STRIPE_ID, + WC_Stripe_UPE_Payment_Method_Link::STRIPE_ID, + ]; + } + $order->update_meta_data( '_stripe_upe_payment_type', $selected_payment_type ); + } else { + $payment_method_types = $gateway->get_upe_enabled_at_checkout_payment_method_ids( $order->get_id() ); + } + + $currency = strtolower( get_woocommerce_currency() ); + $amount = $order->get_total(); + + $request = [ + 'amount' => WC_Stripe_Helper::get_stripe_amount( $amount, $currency ), + 'confirm' => 'true', + 'currency' => $currency, + 'capture_method' => $payment_information->capture_method, + /* 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' => $gateway->get_metadata_from_order( $order ), + 'payment_method' => $payment_information->payment_method, + 'payment_method_types' => $payment_method_types, + 'statement_descriptor' => $payment_information->statement_descriptor, + ]; + + $customer = new WC_Stripe_Customer( wp_get_current_user()->ID ); + if ( ! empty( $customer ) && $customer->get_id() ) { + $request['customer'] = $customer->get_id(); + } + + if ( $payment_information->save_payment_method_to_store ) { + $request['setup_future_usage'] = 'off_session'; + } + + $level3_data = $gateway->get_level3_data_from_order( $order ); + $payment_intent = WC_Stripe_API::request_with_level3_data( + $request, + 'payment_intents', + $level3_data, + $order + ); + + $order->update_status( 'pending', __( 'Awaiting payment.', 'woocommerce-gateway-stripe' ) ); + $order->save(); + + if ( ! empty( $payment_intent->error ) ) { + throw new Exception( $payment_intent->error->message ); + } + + WC_Stripe_Helper::add_payment_intent_to_order( $payment_intent->id, $order ); + + return $payment_intent; + } } 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 8362b1e716..0f04c55f54 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -509,6 +509,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 ); } @@ -639,6 +644,71 @@ 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( $order_id ) { + $order = wc_get_order( $order_id ); + + // TODO: check we're processing a payment for an order with a pending status. + + try { + if ( $this->is_using_saved_payment_method() ) { + // TODO: if using a saved payment method. + return [ 'result' => 'failure' ]; + } + + $payment_needed = $this->is_payment_needed( $order->get_id() ); + + $payment_information = $this->prepare_payment_information_from_request( $order ); + // TODO: if 0-amount and not saving a payment method. + + if ( $payment_needed ) { + // Throw an exception if the minimum order amount isn't met. + $this->validate_minimum_order_amount( $order ); + + // TODO: pass this as a class dependency to facilitate unit tests. + $intent_controller = new WC_Stripe_Intent_Controller(); + + // Throws an exception on error. + $payment_intent = $intent_controller->create_and_confirm_payment_intent( $payment_information ); + + if ( isset( $payment_intent->last_payment_error ) ) { + throw new WC_Stripe_Exception( $payment_intent->last_payment_error->message ); + } + + // Use the last charge within the intent to proceed. + $this->process_response( end( $payment_intent->charges->data ), $order ); + + } else { + // TODO: no payment is needed. + return [ 'result' => 'failure' ]; + } + + return [ + 'result' => 'success', + 'redirect' => $this->get_return_url( $order ), + ]; + } catch ( WC_Stripe_Exception $e ) { + // TODO: use getLocalizedMessage()? + wc_add_notice( __( "We're not able to process this payment. Please try again later.", 'woocommerce-gateway-stripe' ), 'error' ); + + // TODO: Maybe add the error message as an order note? + WC_Stripe_Logger::log( 'Error: ' . $e->getMessage() ); + + do_action( 'wc_gateway_stripe_process_payment_error', $e, $order ); + + /* translators: localized exception message */ + $order->update_status( 'failed', sprintf( __( 'UPE payment failed: %s', 'woocommerce-gateway-stripe' ), $e->getMessage() ) ); + + return [ 'result' => 'failure' ]; + } + } + /** * Process payment using saved payment method. * This follows WC_Gateway_Stripe::process_payment, @@ -1495,4 +1565,80 @@ 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 object An object containing the payment information for processing a payment intent. + */ + private function prepare_payment_information_from_request( WC_Order $order ) { + // TODO: throw exception if any required information is missing. + + $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; + + $payment_information = [ + 'customer' => $this->get_customer_id_for_order( $order ), + 'capture_method' => $capture_method, + '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, + 'statement_descriptor' => $this->get_statement_descriptor( $selected_payment_type ), + ]; + + return (object) $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; + } } From 7fc37e0b6ef9439c606f2da6c0f3cb5fefb429c8 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Mon, 13 Nov 2023 17:55:33 +0100 Subject: [PATCH 05/25] Fix expected dom element in unit tests --- tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..a562cf7575 100644 --- a/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php +++ b/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php @@ -253,7 +253,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>/' ); } /** From 75a876b2646ef72bb7911a5a62cddd87bac52355 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Mon, 13 Nov 2023 23:18:56 +0100 Subject: [PATCH 06/25] Update process_payment_with_deferred_intent --- .../class-wc-stripe-upe-payment-gateway.php | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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 0f04c55f54..57bb33a860 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,9 @@ public function __construct() { $this->payment_methods[ $payment_method->get_id() ] = $payment_method; } + // TODO: Maybe pass this as a class dependency. + $this->intent_controller = new WC_Stripe_Intent_Controller(); + // Load the form fields. $this->init_form_fields(); @@ -671,11 +681,8 @@ private function process_payment_with_deferred_intent( $order_id ) { // Throw an exception if the minimum order amount isn't met. $this->validate_minimum_order_amount( $order ); - // TODO: pass this as a class dependency to facilitate unit tests. - $intent_controller = new WC_Stripe_Intent_Controller(); - // Throws an exception on error. - $payment_intent = $intent_controller->create_and_confirm_payment_intent( $payment_information ); + $payment_intent = $this->intent_controller->create_and_confirm_payment_intent( $payment_information ); if ( isset( $payment_intent->last_payment_error ) ) { throw new WC_Stripe_Exception( $payment_intent->last_payment_error->message ); @@ -694,10 +701,8 @@ private function process_payment_with_deferred_intent( $order_id ) { 'redirect' => $this->get_return_url( $order ), ]; } catch ( WC_Stripe_Exception $e ) { - // TODO: use getLocalizedMessage()? - wc_add_notice( __( "We're not able to process this payment. Please try again later.", 'woocommerce-gateway-stripe' ), 'error' ); + wc_add_notice( $e->getLocalizedMessage(), 'error' ); - // TODO: Maybe add the error message as an order note? WC_Stripe_Logger::log( 'Error: ' . $e->getMessage() ); do_action( 'wc_gateway_stripe_process_payment_error', $e, $order ); From 93339f56340c6424b44e11875467a077b56a4b59 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Mon, 13 Nov 2023 23:20:21 +0100 Subject: [PATCH 07/25] Add base uni test for PE with deferred intent processing --- ...st-class-wc-stripe-upe-payment-gateway.php | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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 a562cf7575..b4190c4097 100644 --- a/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php +++ b/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php @@ -311,6 +311,50 @@ public function test_process_payment_returns_valid_response() { $this->assertMatchesRegularExpression( '/save_payment_method=no/', $response['redirect_url'] ); } + /** + * Test basic checkout process_payment flow. + */ + public function test_process_payment_deferred_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-is-deferred-intent' => '1' ]; + + $this->mock_gateway->intent_controller = $this->getMockBuilder( WC_Stripe_Intent_Controller::class ) + ->setMethods( [ 'create_and_confirm_payment_intent' ] ) + ->getMock(); + + $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'] ); + } + /** * Test basic redirect payment processed correctly. */ From 60c3529c397be2a4519f90052023a44997d8181d Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Tue, 14 Nov 2023 13:11:33 +0100 Subject: [PATCH 08/25] Move some logic out of create_and_confirm_payment_intent --- .../class-wc-stripe-intent-controller.php | 74 +++++++++++-------- .../class-wc-stripe-upe-payment-gateway.php | 4 + 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index 9f542a6629..2d2ba6717c 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -690,35 +690,12 @@ public function create_and_confirm_payment_intent( $payment_information ) { $order = $payment_information->order; $selected_payment_type = $payment_information->selected_payment_type; - // TODO: put this in a method. - if ( '' !== $selected_payment_type ) { - // Only update the payment_method_types if we have a reference to the payment type the customer selected. - $payment_method_types = [ $selected_payment_type ]; - 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 - ) - ) { - $payment_method_types = [ - WC_Stripe_UPE_Payment_Method_CC::STRIPE_ID, - WC_Stripe_UPE_Payment_Method_Link::STRIPE_ID, - ]; - } - $order->update_meta_data( '_stripe_upe_payment_type', $selected_payment_type ); - } else { - $payment_method_types = $gateway->get_upe_enabled_at_checkout_payment_method_ids( $order->get_id() ); - } - - $currency = strtolower( get_woocommerce_currency() ); - $amount = $order->get_total(); + $payment_method_types = $this->get_payment_method_types_for_intent_creation( $selected_payment_type, $order->get_id() ); $request = [ - 'amount' => WC_Stripe_Helper::get_stripe_amount( $amount, $currency ), + 'amount' => $payment_information->amount, 'confirm' => 'true', - 'currency' => $currency, + 'currency' => $payment_information->currency, 'capture_method' => $payment_information->capture_method, /* 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() ), @@ -737,23 +714,62 @@ public function create_and_confirm_payment_intent( $payment_information ) { $request['setup_future_usage'] = 'off_session'; } - $level3_data = $gateway->get_level3_data_from_order( $order ); $payment_intent = WC_Stripe_API::request_with_level3_data( $request, 'payment_intents', - $level3_data, + $gateway->get_level3_data_from_order( $order ), $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 ); + } + $order->update_status( 'pending', __( 'Awaiting payment.', 'woocommerce-gateway-stripe' ) ); $order->save(); if ( ! empty( $payment_intent->error ) ) { - throw new Exception( $payment_intent->error->message ); + throw new WC_Stripe_Exception( $payment_intent->error->message ); } WC_Stripe_Helper::add_payment_intent_to_order( $payment_intent->id, $order ); return $payment_intent; } + + /** + * 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 57bb33a860..8e9ef3650d 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -1583,8 +1583,12 @@ private function prepare_payment_information_from_request( WC_Order $order ) { $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( get_woocommerce_currency() ); + $amount = $order->get_total(); $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, 'order' => $order, From df620b9286b9a030e99d0598da2a4b74b54bea08 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Tue, 14 Nov 2023 21:44:39 +0100 Subject: [PATCH 09/25] Add validation for the payment information array --- .../class-wc-stripe-intent-controller.php | 88 +++++++++++++++---- .../class-wc-stripe-upe-payment-gateway.php | 8 +- 2 files changed, 74 insertions(+), 22 deletions(-) diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index 2d2ba6717c..02e3141a67 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -678,46 +678,45 @@ public function maybe_process_upe_redirect() { } /** - * Creates payment intent using current cart or order and store details. + * Creates and confirm a payment intent with the given payment information. * Used for dPE. * - * @param int $order_id The id of the order if intent created from Order. - * @throws Exception - If the create intent call returns with an error. + * @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 ) { - $gateway = $this->get_upe_gateway(); - $order = $payment_information->order; + // Throws a WC_Stripe_Exception if required information is missing. + $this->validate_create_and_confirm_intent_payment_information( $payment_information ); - $selected_payment_type = $payment_information->selected_payment_type; + $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, + 'amount' => $payment_information['amount'], + 'capture_method' => $payment_information['capture_method'], 'confirm' => 'true', - 'currency' => $payment_information->currency, - 'capture_method' => $payment_information->capture_method, + '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' => $gateway->get_metadata_from_order( $order ), - 'payment_method' => $payment_information->payment_method, + 'metadata' => $payment_information['metadata'], + 'payment_method' => $payment_information['payment_method'], 'payment_method_types' => $payment_method_types, - 'statement_descriptor' => $payment_information->statement_descriptor, + 'statement_descriptor' => $payment_information['statement_descriptor'], ]; - $customer = new WC_Stripe_Customer( wp_get_current_user()->ID ); - if ( ! empty( $customer ) && $customer->get_id() ) { - $request['customer'] = $customer->get_id(); - } - - if ( $payment_information->save_payment_method_to_store ) { + 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', - $gateway->get_level3_data_from_order( $order ), + $payment_information['level3'], $order ); @@ -738,6 +737,57 @@ public function create_and_confirm_payment_intent( $payment_information ) { 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', + 'payment_method', + 'order', + 'save_payment_method_to_store', + '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. * 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 8e9ef3650d..ab24906823 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -661,7 +661,7 @@ public function process_payment( $order_id, $retry = true, $force_save_source = * * @return array An array with the result of the payment processing, and a redirect URL on success. */ - private function process_payment_with_deferred_intent( $order_id ) { + private function process_payment_with_deferred_intent( int $order_id ) { $order = wc_get_order( $order_id ); // TODO: check we're processing a payment for an order with a pending status. @@ -1574,7 +1574,7 @@ 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 object An object containing the payment information for processing a payment intent. + * @return array An array containing the payment information for processing a payment intent. */ private function prepare_payment_information_from_request( WC_Order $order ) { // TODO: throw exception if any required information is missing. @@ -1591,6 +1591,8 @@ private function prepare_payment_information_from_request( WC_Order $order ) { '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, @@ -1600,7 +1602,7 @@ private function prepare_payment_information_from_request( WC_Order $order ) { 'statement_descriptor' => $this->get_statement_descriptor( $selected_payment_type ), ]; - return (object) $payment_information; + return $payment_information; } /** From f30d1e823c86e70af77cb10b8f5303084679b967 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Tue, 14 Nov 2023 21:48:27 +0100 Subject: [PATCH 10/25] Update unit tests for processing payments with a deferred intent --- ...st-class-wc-stripe-upe-payment-gateway.php | 90 +++++++++++++++++-- 1 file changed, 84 insertions(+), 6 deletions(-) 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 b4190c4097..bff43c0767 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( @@ -312,9 +316,9 @@ public function test_process_payment_returns_valid_response() { } /** - * Test basic checkout process_payment flow. + * Test basic checkout process_payment flow with deferred intent. */ - public function test_process_payment_deferred_returns_valid_response() { + 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(); @@ -335,10 +339,6 @@ public function test_process_payment_deferred_returns_valid_response() { $_POST = [ 'wc-stripe-is-deferred-intent' => '1' ]; - $this->mock_gateway->intent_controller = $this->getMockBuilder( WC_Stripe_Intent_Controller::class ) - ->setMethods( [ 'create_and_confirm_payment_intent' ] ) - ->getMock(); - $this->mock_gateway->intent_controller ->expects( $this->once() ) ->method( 'create_and_confirm_payment_intent' ) @@ -355,6 +355,84 @@ public function test_process_payment_deferred_returns_valid_response() { $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-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() ); + } + + /** + * Exception handling of the process_payment flow with deferred intent. + */ + public function test_process_payment_deferred_intent_handles_failed_intent_creation() { + $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) [ + 'last_payment_error' => (object) [ + 'message' => 'Still a trap', + ], + ]; + + $_POST = [ '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( 'failure', $response['result'] ); + + $processed_order = wc_get_order( $order_id ); + $this->assertEquals( 'failed', $processed_order->get_status() ); + } + /** * Test basic redirect payment processed correctly. */ From fc72a6331dc63c866a382c063608899c3a7fdbd8 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Fri, 17 Nov 2023 11:08:27 +0100 Subject: [PATCH 11/25] Improve the error messaging on processing failures --- .../class-wc-stripe-intent-controller.php | 4 +++- .../class-wc-stripe-upe-payment-gateway.php | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index 02e3141a67..b26ea77822 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -728,8 +728,10 @@ public function create_and_confirm_payment_intent( $payment_information ) { $order->update_status( 'pending', __( 'Awaiting payment.', 'woocommerce-gateway-stripe' ) ); $order->save(); + // Throw an exception when there's an error. if ( ! empty( $payment_intent->error ) ) { - throw new WC_Stripe_Exception( $payment_intent->error->message ); + // 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 ); 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 ab24906823..11c4ba5a07 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -681,13 +681,11 @@ private function process_payment_with_deferred_intent( int $order_id ) { // Throw an exception if the minimum order amount isn't met. $this->validate_minimum_order_amount( $order ); + // TODO: Update the payment intent if one is already associated with the order. Order meta '_stripe_intent_id'. + // Throws an exception on error. $payment_intent = $this->intent_controller->create_and_confirm_payment_intent( $payment_information ); - if ( isset( $payment_intent->last_payment_error ) ) { - throw new WC_Stripe_Exception( $payment_intent->last_payment_error->message ); - } - // Use the last charge within the intent to proceed. $this->process_response( end( $payment_intent->charges->data ), $order ); @@ -701,14 +699,23 @@ private function process_payment_with_deferred_intent( int $order_id ) { 'redirect' => $this->get_return_url( $order ), ]; } catch ( WC_Stripe_Exception $e ) { - wc_add_notice( $e->getLocalizedMessage(), 'error' ); + $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 ); - /* translators: localized exception message */ - $order->update_status( 'failed', sprintf( __( 'UPE payment failed: %s', 'woocommerce-gateway-stripe' ), $e->getMessage() ) ); + $order->update_status( + 'failed', + /* translators: localized exception message */ + sprintf( __( 'Payment failed: %s', 'woocommerce-gateway-stripe' ), $e->getLocalizedMessage() ) + ); return [ 'result' => 'failure' ]; } From bd1ff2d7485384f6cad99bd9ecf08294c6606be2 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Fri, 17 Nov 2023 11:45:08 +0100 Subject: [PATCH 12/25] Remove unneeded test --- ...st-class-wc-stripe-upe-payment-gateway.php | 36 ------------------- 1 file changed, 36 deletions(-) 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 bff43c0767..2fa633390f 100644 --- a/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php +++ b/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php @@ -397,42 +397,6 @@ public function test_process_payment_deferred_intent_handles_exception() { $this->assertEquals( 'failed', $processed_order->get_status() ); } - /** - * Exception handling of the process_payment flow with deferred intent. - */ - public function test_process_payment_deferred_intent_handles_failed_intent_creation() { - $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) [ - 'last_payment_error' => (object) [ - 'message' => 'Still a trap', - ], - ]; - - $_POST = [ '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( 'failure', $response['result'] ); - - $processed_order = wc_get_order( $order_id ); - $this->assertEquals( 'failed', $processed_order->get_status() ); - } - /** * Test basic redirect payment processed correctly. */ From 19030899609fcca977ec55cdc56b3da1aea1f03b Mon Sep 17 00:00:00 2001 From: Danae Millan <41606954+a-danae@users.noreply.github.com> Date: Tue, 21 Nov 2023 09:37:15 +0100 Subject: [PATCH 13/25] Fix typo in doc block Co-authored-by: Matt Allan --- client/classic/upe/payment-processing.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/classic/upe/payment-processing.js b/client/classic/upe/payment-processing.js index 6c5779bbd4..31a0ea6df8 100644 --- a/client/classic/upe/payment-processing.js +++ b/client/classic/upe/payment-processing.js @@ -171,7 +171,7 @@ function createStripePaymentMethod( /** * Mounts the existing Stripe Payment Element to the DOM element. - * Creates the Stipe Payment Element instance if it doesn't exist and mounts it 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. * From 8098d581ea806a23d76e5afe43b160793f2a9f58 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Tue, 21 Nov 2023 09:49:17 +0100 Subject: [PATCH 14/25] Remove resolved TODO comment --- .../payment-methods/class-wc-stripe-upe-payment-gateway.php | 2 -- 1 file changed, 2 deletions(-) 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 11c4ba5a07..ab80e51d5a 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -1584,8 +1584,6 @@ private function get_address_data_for_payment_request( $order ) { * @return array An array containing the payment information for processing a payment intent. */ private function prepare_payment_information_from_request( WC_Order $order ) { - // TODO: throw exception if any required information is missing. - $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. From 3c4fb1e7433a87559bf51b48ea4db705761d4810 Mon Sep 17 00:00:00 2001 From: Danae Millan <41606954+a-danae@users.noreply.github.com> Date: Tue, 21 Nov 2023 09:59:58 +0100 Subject: [PATCH 15/25] Use the order currency instead of the store currency when creating an intent Co-authored-by: Matt Allan --- .../payment-methods/class-wc-stripe-upe-payment-gateway.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ab80e51d5a..6987b11f74 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -1588,7 +1588,7 @@ private function prepare_payment_information_from_request( WC_Order $order ) { $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( get_woocommerce_currency() ); + $currency = strtolower( $order->get_currency() ); $amount = $order->get_total(); $payment_information = [ From cf90c3c838baae55eab87de5098239b27f443fd1 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Wed, 22 Nov 2023 13:30:05 +0100 Subject: [PATCH 16/25] Remove inline TODO comments that already have issues to address them --- .../payment-methods/class-wc-stripe-upe-payment-gateway.php | 6 ------ 1 file changed, 6 deletions(-) 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 6987b11f74..927c906b16 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -119,7 +119,6 @@ public function __construct() { $this->payment_methods[ $payment_method->get_id() ] = $payment_method; } - // TODO: Maybe pass this as a class dependency. $this->intent_controller = new WC_Stripe_Intent_Controller(); // Load the form fields. @@ -668,21 +667,17 @@ private function process_payment_with_deferred_intent( int $order_id ) { try { if ( $this->is_using_saved_payment_method() ) { - // TODO: if using a saved payment method. return [ 'result' => 'failure' ]; } $payment_needed = $this->is_payment_needed( $order->get_id() ); $payment_information = $this->prepare_payment_information_from_request( $order ); - // TODO: if 0-amount and not saving a payment method. if ( $payment_needed ) { // Throw an exception if the minimum order amount isn't met. $this->validate_minimum_order_amount( $order ); - // TODO: Update the payment intent if one is already associated with the order. Order meta '_stripe_intent_id'. - // Throws an exception on error. $payment_intent = $this->intent_controller->create_and_confirm_payment_intent( $payment_information ); @@ -690,7 +685,6 @@ private function process_payment_with_deferred_intent( int $order_id ) { $this->process_response( end( $payment_intent->charges->data ), $order ); } else { - // TODO: no payment is needed. return [ 'result' => 'failure' ]; } From fef7018fddf43487b8205e46ed3ad4145e8ae289 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Wed, 22 Nov 2023 14:00:01 +0100 Subject: [PATCH 17/25] Remove unnecesary order status assignment --- includes/class-wc-stripe-intent-controller.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index b26ea77822..663e780e8c 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -725,9 +725,6 @@ public function create_and_confirm_payment_intent( $payment_information ) { $order->update_meta_data( '_stripe_upe_payment_type', $selected_payment_type ); } - $order->update_status( 'pending', __( 'Awaiting payment.', 'woocommerce-gateway-stripe' ) ); - $order->save(); - // Throw an exception when there's an error. if ( ! empty( $payment_intent->error ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r From 7a4d6340e217ac14746efc8fa7b988dc0bb90a9c Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Wed, 22 Nov 2023 17:02:15 +0100 Subject: [PATCH 18/25] Remove TODO that will be addressed in a separate issue --- .../payment-methods/class-wc-stripe-upe-payment-gateway.php | 2 -- 1 file changed, 2 deletions(-) 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 927c906b16..c50f350c8c 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -663,8 +663,6 @@ public function process_payment( $order_id, $retry = true, $force_save_source = private function process_payment_with_deferred_intent( int $order_id ) { $order = wc_get_order( $order_id ); - // TODO: check we're processing a payment for an order with a pending status. - try { if ( $this->is_using_saved_payment_method() ) { return [ 'result' => 'failure' ]; From 36f4f53a59467ead26304c30a9b0d80c5e55d6fa Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Thu, 23 Nov 2023 11:47:49 +0100 Subject: [PATCH 19/25] Set the selected payment method type as the order's payment method title --- .../payment-methods/class-wc-stripe-upe-payment-gateway.php | 2 ++ 1 file changed, 2 insertions(+) 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 c50f350c8c..7dae251cce 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -682,6 +682,8 @@ private function process_payment_with_deferred_intent( int $order_id ) { // 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' ]; } From 61d9e34c711a13ab492cb61da6f76d632ec5728a Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Thu, 23 Nov 2023 12:19:21 +0100 Subject: [PATCH 20/25] Include shipping information for the payment intent when shipping is needed --- includes/class-wc-stripe-intent-controller.php | 4 +++- .../class-wc-stripe-upe-payment-gateway.php | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index 663e780e8c..a60b87ee0e 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -706,6 +706,7 @@ public function create_and_confirm_payment_intent( $payment_information ) { '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'], ]; @@ -751,9 +752,10 @@ private function validate_create_and_confirm_intent_payment_information( array $ 'customer', 'level3', 'metadata', - 'payment_method', 'order', + 'payment_method', 'save_payment_method_to_store', + 'shipping', 'statement_descriptor', ]; 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 7dae251cce..47a0a37369 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -1584,6 +1584,12 @@ private function prepare_payment_information_from_request( WC_Order $order ) { $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 ), @@ -1598,6 +1604,7 @@ private function prepare_payment_information_from_request( WC_Order $order ) { '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 ), ]; From 66977492f0ed1ee986675d50f87b2297132610b3 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Thu, 23 Nov 2023 16:04:27 +0100 Subject: [PATCH 21/25] Pass the appearance and fonts parameters to the Elements initialization object --- client/classic/upe/payment-processing.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/client/classic/upe/payment-processing.js b/client/classic/upe/payment-processing.js index 31a0ea6df8..df2d98aa80 100644 --- a/client/classic/upe/payment-processing.js +++ b/client/classic/upe/payment-processing.js @@ -2,10 +2,14 @@ import { appendIsUsingDeferredIntentToForm, appendPaymentMethodIdToForm, getPaymentMethodTypes, + getStorageWithExpiration, getStripeServerData, getUpeSettings, + setStorageWithExpiration, showErrorCheckout, + storageKeys, } from '../../stripe-utils'; +import { getFontRulesFromPage, getAppearance } from '../../styles/upe'; const gatewayUPEComponents = {}; @@ -26,7 +30,17 @@ for ( const paymentMethodType in paymentMethodsConfig ) { * @return {Object} The appearance object for the UPE. */ function initializeAppearance() { - return {}; + 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; } /** @@ -81,6 +95,7 @@ function createStripePaymentElement( api, paymentMethodType = null ) { paymentMethodCreation: 'manual', paymentMethodTypes, appearance: initializeAppearance(), + fonts: getFontRulesFromPage(), }; const elements = api.getStripe().elements( options ); From 646299c4eefd68f302f7c82830729f74beca654a Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Thu, 23 Nov 2023 20:47:36 +0100 Subject: [PATCH 22/25] Validate whether the provided payment method type is valid and allowed in the selected country --- .../class-wc-stripe-upe-payment-gateway.php | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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 47a0a37369..3c80dc6d62 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -673,6 +673,8 @@ private function process_payment_with_deferred_intent( int $order_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 ); @@ -1658,4 +1660,44 @@ private function get_statement_descriptor( string $selected_payment_type ) { 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' ) + ); + } + } } From 863cfc790eb88b12d2b496c997e894ea1334002e Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Fri, 24 Nov 2023 11:17:42 +0100 Subject: [PATCH 23/25] Check whether the dom elements exist before changing their attributes --- client/classic/upe/payment-processing.js | 29 ++++++++++++------------ 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/client/classic/upe/payment-processing.js b/client/classic/upe/payment-processing.js index df2d98aa80..916c33c1a1 100644 --- a/client/classic/upe/payment-processing.js +++ b/client/classic/upe/payment-processing.js @@ -243,20 +243,21 @@ function setSelectedUPEPaymentType( paymentType ) { // Show or hide save payment information checkbox function showNewPaymentMethodCheckbox( show = true ) { - if ( show ) { - document.querySelector( - '.woocommerce-SavedPaymentMethods-saveNew' - ).style.visibility = 'visible'; - } else { - document.querySelector( - '.woocommerce-SavedPaymentMethods-saveNew' - ).style.visibility = 'hidden'; - document - .querySelector( 'input#wc-stripe-new-payment-method' ) - .setAttribute( 'checked', false ); - document - .querySelector( 'input#wc-stripe-new-payment-method' ) - .dispatchEvent( new Event( 'change' ) ); + 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' ) ); } } From 1c2f3089869c99e36d1240f3e4f014efcd935759 Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Fri, 24 Nov 2023 11:31:39 +0100 Subject: [PATCH 24/25] Fix console error when checking out as a guest --- client/stripe-utils/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/stripe-utils/utils.js b/client/stripe-utils/utils.js index e04874978a..34813bb464 100644 --- a/client/stripe-utils/utils.js +++ b/client/stripe-utils/utils.js @@ -281,7 +281,7 @@ export const appendPaymentMethodIdToForm = ( form, paymentMethodId ) => { */ export const isUsingSavedPaymentMethod = () => { return ( - document.querySelector( '#wc-stripe-new-payment-method' ).length && + document.querySelector( '#wc-stripe-new-payment-method' )?.length && ! document .querySelector( '#wc-stripe-new-payment-method' ) .is( ':checked' ) From 59c3428439124a68e01f346a7fb24ccba74260dd Mon Sep 17 00:00:00 2001 From: Danae Millan Date: Fri, 24 Nov 2023 12:27:32 +0100 Subject: [PATCH 25/25] Adjust unit tests --- ...st-class-wc-stripe-upe-payment-gateway.php | 92 ++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) 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 2fa633390f..a85ef1108a 100644 --- a/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php +++ b/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php @@ -337,7 +337,10 @@ public function test_process_payment_deferred_intent_returns_valid_response() { ], ]; - $_POST = [ 'wc-stripe-is-deferred-intent' => '1' ]; + $_POST = [ + 'wc_stripe_selected_upe_payment_type' => 'card', + 'wc-stripe-is-deferred-intent' => '1', + ]; $this->mock_gateway->intent_controller ->expects( $this->once() ) @@ -377,7 +380,10 @@ public function test_process_payment_deferred_intent_handles_exception() { ], ]; - $_POST = [ 'wc-stripe-is-deferred-intent' => '1' ]; + $_POST = [ + 'wc_stripe_selected_upe_payment_type' => 'card', + 'wc-stripe-is-deferred-intent' => '1', + ]; $this->mock_gateway->intent_controller ->expects( $this->once() ) @@ -397,6 +403,88 @@ public function test_process_payment_deferred_intent_handles_exception() { $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. */