diff --git a/client/api/index.js b/client/api/index.js index e76f7010c..65b729bc8 100644 --- a/client/api/index.js +++ b/client/api/index.js @@ -137,13 +137,15 @@ export default class WCStripeAPI { /** * Creates an intent based on a payment method. * - * @param {number} orderId The id of the order if creating the intent on Order Pay page. + * @param {number|null} orderId The id of the order if creating the intent on Order Pay page. + * @param {string|null} paymentMethodType The type of payment method. * * @return {Promise} The final promise for the request to the server. */ - createIntent( orderId ) { + createIntent( orderId = null, paymentMethodType = null ) { return this.request( this.getAjaxUrl( 'create_payment_intent' ), { stripe_order_id: orderId, + payment_method_type: paymentMethodType, _ajax_nonce: this.options?.createPaymentIntentNonce, } ) .then( ( response ) => { diff --git a/client/classic/upe/deferred-intent.js b/client/classic/upe/deferred-intent.js index c222796e5..3ae3830f6 100644 --- a/client/classic/upe/deferred-intent.js +++ b/client/classic/upe/deferred-intent.js @@ -6,6 +6,7 @@ import { getStripeServerData, isPaymentMethodRestrictedToLocation, isUsingSavedPaymentMethod, + paymentMethodSupportsDeferredIntent, togglePaymentMethodForCountry, } from '../../stripe-utils'; import './style.scss'; @@ -54,6 +55,11 @@ jQuery( function ( $ ) { maybeMountStripePaymentElement(); } + // For payment methods that don't support deferred intents, we mount the Payment Element only when it's selected. + $( 'form.checkout' ).on( 'change', 'input[name="payment_method"]', () => { + maybeMountStripePaymentElement(); + } ); + // My Account > Payment Methods page submit. $( 'form#add_payment_method' ).on( 'submit', function () { return processPayment( @@ -76,20 +82,32 @@ 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. 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 ); - restrictPaymentMethodToLocation( upeElement ); + // If the card element selector doesn't exist, do nothing. + // For example, when a 100% discount coupon is applied. + if ( ! $( '.wc-stripe-upe-element' ).length ) { + return; + } + + const selectedMethod = getSelectedUPEGatewayPaymentMethod(); + for ( const upeElement of $( '.wc-stripe-upe-element' ).toArray() ) { + // Maybe hide the payment method based on the billing country. + restrictPaymentMethodToLocation( upeElement ); + + // Don't mount if it's already mounted. + if ( $( upeElement ).children().length ) { + continue; + } + + // Payment methods that don't support deferred intents don't need to be mounted unless they are selected. + if ( + upeElement.dataset.paymentMethodType !== selectedMethod && + ! paymentMethodSupportsDeferredIntent( upeElement ) + ) { + continue; } + + await mountStripePaymentElement( api, upeElement ); } } @@ -97,7 +115,7 @@ jQuery( function ( $ ) { if ( isPaymentMethodRestrictedToLocation( upeElement ) ) { togglePaymentMethodForCountry( upeElement ); - // this event only applies to the checkout form, but not "place order" or "add payment method" pages. + // This event only applies to the checkout form, but not "pay for order" or "add payment method" pages. $( '#billing_country' ).on( 'change', function () { togglePaymentMethodForCountry( upeElement ); } ); diff --git a/client/classic/upe/payment-processing.js b/client/classic/upe/payment-processing.js index 8a6d473c3..b54b3a2e4 100644 --- a/client/classic/upe/payment-processing.js +++ b/client/classic/upe/payment-processing.js @@ -1,6 +1,7 @@ import { __, sprintf } from '@wordpress/i18n'; import { appendPaymentMethodIdToForm, + appendPaymentIntentIdToForm, getPaymentMethodTypes, initializeUPEAppearance, isLinkEnabled, @@ -24,11 +25,11 @@ import { } from 'wcstripe/stripe-utils/constants'; const gatewayUPEComponents = {}; - const paymentMethodsConfig = getStripeServerData()?.paymentMethodsConfig; for ( const paymentMethodType in paymentMethodsConfig ) { gatewayUPEComponents[ paymentMethodType ] = { + intentId: null, elements: null, upeElement: null, }; @@ -66,28 +67,46 @@ export function validateElements( elements ) { } /** - * 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. + * Creates a Stripe payment element with the specified payment method type and options. + * + * If the payment method doesn't support deferred intent, the intent must be created first. + * Then, the payment element is created with the intent's client secret. * - * @todo Make paymentMethodType required when Split is implemented. + * Finally, the payment element is mounted and attached to the gatewayUPEComponents object. * * @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 ) { +async function createStripePaymentElement( api, paymentMethodType ) { 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: initializeUPEAppearance( api ), - fonts: getFontRulesFromPage(), - }; + const { supportsDeferredIntent } = + paymentMethodsConfig[ paymentMethodType ] || {}; + let options; + + // If the payment method doesn't support deferred intent, the intent must be created here. + if ( ! supportsDeferredIntent ) { + const intent = await api.createIntent( null, paymentMethodType ); + gatewayUPEComponents[ paymentMethodType ].intentId = intent.id; + + options = { + appearance: initializeUPEAppearance( api ), + paymentMethodCreation: 'manual', + fonts: getFontRulesFromPage(), + clientSecret: intent.client_secret, + }; + } else { + options = { + mode: amount < 1 ? 'setup' : 'payment', + currency: getStripeServerData()?.currency.toLowerCase(), + amount, + paymentMethodCreation: 'manual', + paymentMethodTypes, + appearance: initializeUPEAppearance( api ), + fonts: getFontRulesFromPage(), + }; + } const elements = api.getStripe().elements( options ); @@ -333,6 +352,14 @@ export const processPayment = ( paymentMethodObject.paymentMethod.id ); + // Append the intent ID to the form if it was previously created through a non-deferred intent. + if ( gatewayUPEComponents[ paymentMethodType ].intentId ) { + appendPaymentIntentIdToForm( + jQueryForm, + gatewayUPEComponents[ paymentMethodType ].intentId + ); + } + let stopFormSubmission = false; await additionalActionsHandler( paymentMethodObject.paymentMethod, diff --git a/client/payment-method-icons/index.js b/client/payment-method-icons/index.js index 7e032411d..c533bd1c5 100644 --- a/client/payment-method-icons/index.js +++ b/client/payment-method-icons/index.js @@ -39,4 +39,5 @@ export default { cashapp: CashAppIcon, us_bank_account: BankDebitIcon, bacs_debit: BankDebitIcon, + acss_debit: BankDebitIcon, }; diff --git a/client/payment-methods-map.js b/client/payment-methods-map.js index ed1bf0371..d21b81257 100644 --- a/client/payment-methods-map.js +++ b/client/payment-methods-map.js @@ -4,6 +4,8 @@ import icons from './payment-method-icons'; const accountCountry = window.wc_stripe_settings_params?.account_country || 'US'; const isAchEnabled = window.wc_stripe_settings_params?.is_ach_enabled === '1'; +const isAcssEnabled = window.wc_stripe_settings_params?.is_acss_enabled === '1'; +const isBacsEnabled = window.wc_stripe_settings_params?.is_bacs_enabled === '1'; const paymentMethodsMap = { card: { @@ -242,6 +244,7 @@ const paymentMethodsMap = { }, }; +// Enable ACH according to feature flag value. if ( isAchEnabled ) { paymentMethodsMap.us_bank_account = { id: 'us_bank_account', @@ -255,8 +258,22 @@ if ( isAchEnabled ) { }; } -// Enable Bacs according to feature flag value -if ( window.wc_stripe_settings_params?.is_bacs_enabled ) { +// Enable ACSS according to feature flag value. +if ( isAcssEnabled ) { + paymentMethodsMap.acss_debit = { + id: 'acss_debit', + label: __( 'Pre-Authorized Debit', 'woocommerce-gateway-stripe' ), + description: __( + 'Canadian Pre-Authorized Debit is a payment method that allows customers to pay using their Canadian bank account.', + 'woocommerce-gateway-stripe' + ), + Icon: icons.acss_debit, + currencies: [ 'CAD' ], + }; +} + +// Enable Bacs according to feature flag value. +if ( isBacsEnabled ) { paymentMethodsMap.bacs_debit = { id: 'bacs_debit', label: 'Bacs Direct Debit', diff --git a/client/stripe-utils/constants.js b/client/stripe-utils/constants.js index 4b1f91195..26af9e484 100644 --- a/client/stripe-utils/constants.js +++ b/client/stripe-utils/constants.js @@ -45,6 +45,7 @@ export const PAYMENT_METHOD_STRIPE_AFTERPAY_CLEARPAY = 'stripe_afterpay_clearpay'; export const PAYMENT_METHOD_STRIPE_WECHAT_PAY = 'stripe_wechat_pay'; export const PAYMENT_METHOD_STRIPE_CASHAPP = 'stripe_cashapp'; +export const PAYMENT_METHOD_STRIPE_ACSS = 'stripe_acss_debit'; export const PAYMENT_METHOD_STRIPE_BACS = 'stripe_bacs_debit'; export function getPaymentMethodsConstants() { @@ -67,6 +68,7 @@ export function getPaymentMethodsConstants() { afterpay_clearpay: PAYMENT_METHOD_STRIPE_AFTERPAY_CLEARPAY, wechat_pay: PAYMENT_METHOD_STRIPE_WECHAT_PAY, cashapp: PAYMENT_METHOD_STRIPE_CASHAPP, + acss_debit: PAYMENT_METHOD_STRIPE_ACSS, bacs_debit: PAYMENT_METHOD_STRIPE_BACS, }; } diff --git a/client/stripe-utils/utils.js b/client/stripe-utils/utils.js index a2014433e..6d4cb36d2 100644 --- a/client/stripe-utils/utils.js +++ b/client/stripe-utils/utils.js @@ -309,6 +309,12 @@ export const appendPaymentMethodIdToForm = ( form, paymentMethodId ) => { ); }; +export const appendPaymentIntentIdToForm = ( form, paymentIntentId ) => { + form.append( + `` + ); +}; + export const appendSetupIntentToForm = ( form, setupIntent ) => { form.append( `` @@ -554,7 +560,7 @@ export const getPaymentMethodName = ( paymentMethodType ) => { * * @param {Object} upeElement The selector of the DOM element of particular payment method to mount the UPE element to. * @return {boolean} Whether the payment method is restricted to selected billing country. - **/ + */ export const isPaymentMethodRestrictedToLocation = ( upeElement ) => { const paymentMethodsConfig = getStripeServerData()?.paymentMethodsConfig || {}; @@ -563,8 +569,21 @@ export const isPaymentMethodRestrictedToLocation = ( upeElement ) => { }; /** + * Determines if the payment method supports deferred intent. + * * @param {Object} upeElement The selector of the DOM element of particular payment method to mount the UPE element to. - **/ + * @return {boolean} Whether the payment method supports deferred intent. + */ +export const paymentMethodSupportsDeferredIntent = ( upeElement ) => { + const paymentMethodsConfig = + getStripeServerData()?.paymentMethodsConfig || {}; + const paymentMethodType = upeElement.dataset.paymentMethodType; + return !! paymentMethodsConfig[ paymentMethodType ]?.supportsDeferredIntent; +}; + +/** + * @param {Object} upeElement The selector of the DOM element of particular payment method to mount the UPE element to. + */ export const togglePaymentMethodForCountry = ( upeElement ) => { const paymentMethodsConfig = getStripeServerData()?.paymentMethodsConfig || {}; @@ -585,6 +604,14 @@ export const togglePaymentMethodForCountry = ( upeElement ) => { upeContainer.style.display = 'block'; } else { upeContainer.style.display = 'none'; + // Also uncheck the radio button if it's selected. + const radioButton = document.querySelector( + `input[name="payment_method"][value="stripe_${ paymentMethodType }"]` + ); + + if ( radioButton ) { + radioButton.checked = false; + } } }; diff --git a/includes/abstracts/abstract-wc-stripe-payment-gateway.php b/includes/abstracts/abstract-wc-stripe-payment-gateway.php index bca889a54..5548f7b79 100644 --- a/includes/abstracts/abstract-wc-stripe-payment-gateway.php +++ b/includes/abstracts/abstract-wc-stripe-payment-gateway.php @@ -338,21 +338,22 @@ public function payment_icons() { 'wc_stripe_payment_icons', [ WC_Stripe_Payment_Methods::ACH => 'ACH', + WC_Stripe_Payment_Methods::ACSS_DEBIT => '' . __( 'Pre-Authorized Debit', 'woocommerce-gateway-stripe' ) . '', WC_Stripe_Payment_Methods::ALIPAY => 'Alipay', WC_Stripe_Payment_Methods::WECHAT_PAY => 'Wechat Pay', WC_Stripe_Payment_Methods::BANCONTACT => 'Bancontact', WC_Stripe_Payment_Methods::IDEAL => 'iDEAL', WC_Stripe_Payment_Methods::P24 => 'P24', WC_Stripe_Payment_Methods::GIROPAY => 'giropay', - WC_Stripe_Payment_Methods::KLARNA => 'klarna', - WC_Stripe_Payment_Methods::AFFIRM => 'affirm', + WC_Stripe_Payment_Methods::KLARNA => 'Klarna', + WC_Stripe_Payment_Methods::AFFIRM => 'Affirm', WC_Stripe_Payment_Methods::EPS => 'EPS', WC_Stripe_Payment_Methods::MULTIBANCO => 'Multibanco', WC_Stripe_Payment_Methods::SOFORT => 'Sofort', WC_Stripe_Payment_Methods::SEPA => 'SEPA', WC_Stripe_Payment_Methods::BOLETO => 'Boleto', WC_Stripe_Payment_Methods::OXXO => 'OXXO', - 'cards' => 'credit / debit card', + 'cards' => '' . __( 'Credit / Debit Card', 'woocommerce-gateway-stripe' ) . '', WC_Stripe_Payment_Methods::CASHAPP_PAY => 'Cash App Pay', ] ); diff --git a/includes/class-wc-stripe-account.php b/includes/class-wc-stripe-account.php index e29abd048..33fa8dfd5 100644 --- a/includes/class-wc-stripe-account.php +++ b/includes/class-wc-stripe-account.php @@ -38,6 +38,7 @@ class WC_Stripe_Account { 'charge.refund.updated', 'review.opened', 'review.closed', + 'payment_intent.processing', 'payment_intent.succeeded', 'payment_intent.payment_failed', 'payment_intent.amount_capturable_updated', diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index 2c79df285..d8c8528b8 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -322,7 +322,8 @@ public function create_payment_intent_ajax() { } // If paying from order, we need to get the total from the order instead of the cart. - $order_id = isset( $_POST['stripe_order_id'] ) ? absint( $_POST['stripe_order_id'] ) : null; + $order_id = isset( $_POST['stripe_order_id'] ) ? absint( $_POST['stripe_order_id'] ) : null; + $payment_method_type = isset( $_POST['payment_method_type'] ) ? wc_clean( wp_unslash( $_POST['payment_method_type'] ) ) : ''; if ( $order_id ) { $order = wc_get_order( $order_id ); @@ -331,7 +332,7 @@ public function create_payment_intent_ajax() { } } - wp_send_json_success( $this->create_payment_intent( $order_id ), 200 ); + wp_send_json_success( $this->create_payment_intent( $order_id, $payment_method_type ), 200 ); } catch ( Exception $e ) { WC_Stripe_Logger::log( 'Create payment intent error: ' . $e->getMessage() ); // Send back error so it can be displayed to the customer. @@ -348,11 +349,13 @@ public function create_payment_intent_ajax() { /** * Creates payment intent using current cart or order and store details. * - * @param {int} $order_id The id of the order if intent created from Order. + * @param int|null $order_id The id of the order if intent created from Order. + * @param string|null $payment_method_type The type of payment method to use for the intent. + * * @throws Exception - If the create intent call returns with an error. * @return array */ - public function create_payment_intent( $order_id = null ) { + public function create_payment_intent( $order_id = null, $payment_method_type = null ) { $amount = WC()->cart->get_total( false ); $order = wc_get_order( $order_id ); if ( is_a( $order, 'WC_Order' ) ) { @@ -360,19 +363,20 @@ public function create_payment_intent( $order_id = null ) { } $gateway = $this->get_upe_gateway(); - $enabled_payment_methods = $gateway->get_upe_enabled_at_checkout_payment_method_ids( $order_id ); + $enabled_payment_methods = $payment_method_type ? [ $payment_method_type ] : $gateway->get_upe_enabled_at_checkout_payment_method_ids( $order_id ); $currency = get_woocommerce_currency(); $capture = $gateway->is_automatic_capture_enabled(); - $payment_intent = WC_Stripe_API::request( - [ - 'amount' => WC_Stripe_Helper::get_stripe_amount( $amount, strtolower( $currency ) ), - 'currency' => strtolower( $currency ), - 'payment_method_types' => $enabled_payment_methods, - 'capture_method' => $capture ? 'automatic' : 'manual', - ], - 'payment_intents' - ); + $request = [ + 'amount' => WC_Stripe_Helper::get_stripe_amount( $amount, strtolower( $currency ) ), + 'currency' => strtolower( $currency ), + 'payment_method_types' => $enabled_payment_methods, + 'capture_method' => $capture ? 'automatic' : 'manual', + ]; + + $request = $this->maybe_add_mandate_options( $request, $payment_method_type ); + + $payment_intent = WC_Stripe_API::request( $request, 'payment_intents' ); if ( ! empty( $payment_intent->error ) ) { throw new Exception( $payment_intent->error->message ); @@ -818,6 +822,31 @@ private function add_mandate_data( $request ) { return $request; } + /** + * Adds mandate options to the request if required. + * + * @param array $request The request array to add the mandate options to. + * @param string|null $payment_method_type The type of payment method to use for the intent. + * + * @return array + */ + private function maybe_add_mandate_options( $request, $payment_method_type ) { + // Add required mandate options for ACSS. + if ( WC_Stripe_UPE_Payment_Method_ACSS::STRIPE_ID === $payment_method_type ) { + $request['payment_method_options'] = [ + 'acss_debit' => [ + 'mandate_options' => [ + 'payment_schedule' => 'interval', + 'interval_description' => __( 'One-time payment', 'woocommerce-gateway-stripe' ), // TODO: Change to cadence if purchasing a subscription. + 'transaction_type' => 'personal', + ], + ], + ]; + } + + return $request; + } + /** * Updates and confirm a payment intent with the given payment information. * Used for dPE. @@ -972,7 +1001,8 @@ private function build_base_payment_intent_request_params( $payment_information * Determines if mandate data is required for deferred intent UPE payment. * * A mandate must be provided before a deferred intent UPE payment can be processed. - * This applies to SEPA, Bancontact, iDeal, Sofort, Cash App and Link payment methods. + * This applies to SEPA, Bancontact, iDeal, Sofort, Cash App, Link payment methods, + * ACH, ACSS Debit and BACS. * https://docs.stripe.com/payments/finalize-payments-on-the-server * * @param string $selected_payment_type The name of the selected UPE payment type. @@ -983,6 +1013,7 @@ private function build_base_payment_intent_request_params( $payment_information public function is_mandate_data_required( $selected_payment_type, $is_using_saved_payment_method = false ) { $payment_methods_with_mandates = [ WC_Stripe_Payment_Methods::ACH, + WC_Stripe_Payment_Methods::ACSS_DEBIT, WC_Stripe_Payment_Methods::BACS_DEBIT, WC_Stripe_Payment_Methods::SEPA_DEBIT, WC_Stripe_Payment_Methods::BANCONTACT, diff --git a/includes/class-wc-stripe-webhook-handler.php b/includes/class-wc-stripe-webhook-handler.php index c188ae5c7..0e67180a5 100644 --- a/includes/class-wc-stripe-webhook-handler.php +++ b/includes/class-wc-stripe-webhook-handler.php @@ -991,6 +991,16 @@ public function process_payment_intent( $notification ) { $is_wallet_payment = in_array( $payment_type_meta, WC_Stripe_Payment_Methods::WALLET_PAYMENT_METHODS, true ); switch ( $notification->type ) { + // Asynchronous payment methods such as bank debits will only provide a charge ID at `payment_intent.processing`, once the required actions are taken by the customer. + // We need to update the order transaction ID, so that the `payment_intent.succeeded` webhook is able to process the order. + case 'payment_intent.processing': + $charge = $this->get_latest_charge_from_intent( $intent ); + if ( $charge ) { + $order->set_transaction_id( $charge->id ); + /* translators: transaction id */ + $order->update_status( 'on-hold', sprintf( __( 'Stripe charge awaiting payment: %s.', 'woocommerce-gateway-stripe' ), $charge->id ) ); + } + break; case 'payment_intent.requires_action': do_action( 'wc_gateway_stripe_process_payment_intent_requires_action', $order, $notification->data->object ); @@ -1274,6 +1284,7 @@ public function process_webhook( $request_body ) { $this->process_review_closed( $notification ); break; + case 'payment_intent.processing': case 'payment_intent.succeeded': case 'payment_intent.payment_failed': case 'payment_intent.amount_capturable_updated': diff --git a/includes/constants/class-wc-stripe-payment-methods.php b/includes/constants/class-wc-stripe-payment-methods.php index 936ba44b2..06540fd65 100644 --- a/includes/constants/class-wc-stripe-payment-methods.php +++ b/includes/constants/class-wc-stripe-payment-methods.php @@ -26,6 +26,7 @@ class WC_Stripe_Payment_Methods { const SOFORT = 'sofort'; const WECHAT_PAY = 'wechat_pay'; const CARD_PRESENT = 'card_present'; + const ACSS_DEBIT = 'acss_debit'; const BACS_DEBIT = 'bacs_debit'; const AMAZON_PAY = 'amazon_pay'; 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 f9f23aa44..b8ec21d56 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -40,6 +40,7 @@ class WC_Stripe_UPE_Payment_Gateway extends WC_Gateway_Stripe { WC_Stripe_UPE_Payment_Method_Link::class, WC_Stripe_UPE_Payment_Method_Wechat_Pay::class, WC_Stripe_UPE_Payment_Method_Cash_App_Pay::class, + WC_Stripe_UPE_Payment_Method_ACSS::class, ]; /** @@ -156,11 +157,6 @@ public function __construct() { $this->payment_methods = []; - if ( WC_Stripe_Feature_Flags::is_bacs_lpm_enabled() ) { - // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - $this->UPE_AVAILABLE_METHODS[] = WC_Stripe_UPE_Payment_Method_Bacs::class; - } - foreach ( self::UPE_AVAILABLE_METHODS as $payment_method_class ) { // Show ACH only if feature is enabled. @@ -168,6 +164,16 @@ public function __construct() { continue; } + // Show ACSS only if feature is enabled. + if ( WC_Stripe_UPE_Payment_Method_ACSS::class === $payment_method_class && ! WC_Stripe_Feature_Flags::is_acss_lpm_enabled() ) { + continue; + } + + // Show BACS only if feature is enabled. + if ( WC_Stripe_UPE_Payment_Method_Bacs::class === $payment_method_class && ! WC_Stripe_Feature_Flags::is_bacs_lpm_enabled() ) { + continue; + } + /** Show Sofort if it's already enabled. Hide from the new merchants and keep it for the old ones who are already using this gateway, until we remove it completely. * Stripe is deprecating Sofort https://support.stripe.com/questions/sofort-is-being-deprecated-as-a-standalone-payment-method. */ @@ -531,12 +537,13 @@ private function get_enabled_payment_method_config() { $payment_method = $this->payment_methods[ $payment_method_id ]; $settings[ $payment_method_id ] = [ - 'isReusable' => $payment_method->is_reusable(), - 'title' => $payment_method->get_title(), - 'description' => $payment_method->get_description(), - 'testingInstructions' => $payment_method->get_testing_instructions(), - 'showSaveOption' => $this->should_upe_payment_method_show_save_option( $payment_method ), - 'countries' => $payment_method->get_available_billing_countries(), + 'isReusable' => $payment_method->is_reusable(), + 'title' => $payment_method->get_title(), + 'description' => $payment_method->get_description(), + 'testingInstructions' => $payment_method->get_testing_instructions(), + 'showSaveOption' => $this->should_upe_payment_method_show_save_option( $payment_method ), + 'supportsDeferredIntent' => $payment_method->supports_deferred_intent(), + 'countries' => $payment_method->get_available_billing_countries(), ]; } @@ -671,16 +678,27 @@ public function payment_fields() { /** * Process the payment for a given order. * - * @param int $order_id Reference. - * @param bool $retry Should we retry on fail. - * @param bool $force_save_source Force save the payment source. - * @param mix $previous_error Any error message from previous request. - * @param bool $use_order_source Whether to use the source, which should already be attached to the order. + * @param int $order_id Reference. + * @param bool $retry Should we retry on fail. + * @param bool $force_save_source Force save the payment source. + * @param mixed $previous_error Any error message from previous request. + * @param bool $use_order_source Whether to use the source, which should already be attached to the order. * * @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 ) { + $payment_intent_id = isset( $_POST['wc_payment_intent_id'] ) ? wc_clean( wp_unslash( $_POST['wc_payment_intent_id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing + $order = wc_get_order( $order_id ); + $selected_payment_type = $this->get_selected_payment_method_type_from_request(); + + if ( $payment_intent_id && ! $this->payment_methods[ $selected_payment_type ]->supports_deferred_intent() ) { + // Adds customer and metadata to PaymentIntent. + // These parameters cannot be added upon updating the intent via the `/confirm` API. + $this->intent_controller->update_payment_intent( $payment_intent_id, $order_id ); + } + // Flag for using a deferred intent. To be removed. + // https://github.com/woocommerce/woocommerce-gateway-stripe/issues/3868 if ( ! empty( $_POST['wc-stripe-is-deferred-intent'] ) ) { return $this->process_payment_with_deferred_intent( $order_id ); } @@ -693,8 +711,6 @@ public function process_payment( $order_id, $retry = true, $force_save_source = return $this->process_payment_with_saved_payment_method( $order_id ); } - $payment_intent_id = isset( $_POST['wc_payment_intent_id'] ) ? wc_clean( wp_unslash( $_POST['wc_payment_intent_id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing - $order = wc_get_order( $order_id ); $payment_needed = $this->is_payment_needed( $order_id ); $save_payment_method = $this->has_subscription( $order_id ) || ! empty( $_POST[ 'wc-' . self::ID . '-new-payment-method' ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing $selected_upe_payment_type = ! empty( $_POST['wc_stripe_selected_upe_payment_type'] ) ? wc_clean( wp_unslash( $_POST['wc_stripe_selected_upe_payment_type'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-method-acss.php b/includes/payment-methods/class-wc-stripe-upe-payment-method-acss.php new file mode 100644 index 000000000..2ab20fb90 --- /dev/null +++ b/includes/payment-methods/class-wc-stripe-upe-payment-method-acss.php @@ -0,0 +1,38 @@ +stripe_id = self::STRIPE_ID; + $this->title = __( 'Pre-Authorized Debit', 'woocommerce-gateway-stripe' ); + $this->is_reusable = true; + $this->supported_currencies = [ WC_Stripe_Currency_Code::CANADIAN_DOLLAR ]; // The US dollar is supported, but has a high risk of failure since only a few Canadian bank accounts support it. + $this->supported_countries = [ 'CA' ]; + $this->label = __( 'Pre-Authorized Debit', 'woocommerce-gateway-stripe' ); + $this->description = __( + 'Canadian Pre-Authorized Debit is a payment method that allows customers to pay using their Canadian bank account.', + 'woocommerce-gateway-stripe' + ); + $this->supports_deferred_intent = false; + } + + /** + * Returns string representing payment method type + * to query to retrieve saved payment methods from Stripe. + */ + public function get_retrievable_type() { + return $this->get_id(); + } +} diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-method.php b/includes/payment-methods/class-wc-stripe-upe-payment-method.php index a3ba9a7cc..0bfc53607 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-method.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-method.php @@ -105,6 +105,13 @@ abstract class WC_Stripe_UPE_Payment_Method extends WC_Payment_Gateway { */ public $testmode; + /** + * Wether this payment method supports deferred intent creation. + * + * @var bool + */ + protected $supports_deferred_intent; + /** * Create instance of payment method */ @@ -112,11 +119,12 @@ public function __construct() { $main_settings = WC_Stripe_Helper::get_stripe_settings(); $is_stripe_enabled = ! empty( $main_settings['enabled'] ) && 'yes' === $main_settings['enabled']; - $this->enabled = $is_stripe_enabled && in_array( static::STRIPE_ID, $this->get_option( 'upe_checkout_experience_accepted_payments', [ WC_Stripe_Payment_Methods::CARD ] ), true ) ? 'yes' : 'no'; // @phpstan-ignore-line (STRIPE_ID is defined in classes using this class) - $this->id = WC_Gateway_Stripe::ID . '_' . static::STRIPE_ID; // @phpstan-ignore-line (STRIPE_ID is defined in classes using this class) - $this->has_fields = true; - $this->testmode = WC_Stripe_Mode::is_test(); - $this->supports = [ 'products', 'refunds' ]; + $this->enabled = $is_stripe_enabled && in_array( static::STRIPE_ID, $this->get_option( 'upe_checkout_experience_accepted_payments', [ WC_Stripe_Payment_Methods::CARD ] ), true ) ? 'yes' : 'no'; // @phpstan-ignore-line (STRIPE_ID is defined in classes using this class) + $this->id = WC_Gateway_Stripe::ID . '_' . static::STRIPE_ID; // @phpstan-ignore-line (STRIPE_ID is defined in classes using this class) + $this->has_fields = true; + $this->testmode = WC_Stripe_Mode::is_test(); + $this->supports = [ 'products', 'refunds' ]; + $this->supports_deferred_intent = true; } /** @@ -726,4 +734,13 @@ public function get_transaction_url( $order ) { return parent::get_transaction_url( $order ); } + + /** + * Whether this payment method supports deferred intent creation. + * + * @return bool + */ + public function supports_deferred_intent() { + return $this->supports_deferred_intent; + } } diff --git a/tests/phpunit/payment-methods/test-class-wc-stripe-upe-payment-gateway.php b/tests/phpunit/payment-methods/test-class-wc-stripe-upe-payment-gateway.php index 3cfc11fd7..57d64c0f7 100644 --- a/tests/phpunit/payment-methods/test-class-wc-stripe-upe-payment-gateway.php +++ b/tests/phpunit/payment-methods/test-class-wc-stripe-upe-payment-gateway.php @@ -118,6 +118,7 @@ public function set_up() { parent::set_up(); update_option( WC_Stripe_Feature_Flags::LPM_ACH_FEATURE_FLAG_NAME, 'yes' ); + update_option( WC_Stripe_Feature_Flags::LPM_ACSS_FEATURE_FLAG_NAME, 'yes' ); $stripe_settings = WC_Stripe_Helper::get_stripe_settings(); $stripe_settings['sepa_tokens_for_other_methods'] = 'yes'; @@ -190,6 +191,7 @@ public function set_up() { public function tear_down() { parent::tear_down(); delete_option( WC_Stripe_Feature_Flags::LPM_ACH_FEATURE_FLAG_NAME ); + delete_option( WC_Stripe_Feature_Flags::LPM_ACSS_FEATURE_FLAG_NAME ); } /** @@ -285,6 +287,7 @@ public function get_upe_available_payment_methods_provider() { WC_Stripe_UPE_Payment_Method_Link::STRIPE_ID, WC_Stripe_UPE_Payment_Method_Wechat_Pay::STRIPE_ID, WC_Stripe_UPE_Payment_Method_Cash_App_Pay::STRIPE_ID, + WC_Stripe_UPE_Payment_Method_ACSS::STRIPE_ID, ], ], [ @@ -299,6 +302,7 @@ public function get_upe_available_payment_methods_provider() { WC_Stripe_UPE_Payment_Method_Oxxo::STRIPE_ID, WC_Stripe_UPE_Payment_Method_Sepa::STRIPE_ID, WC_Stripe_UPE_Payment_Method_P24::STRIPE_ID, + WC_Stripe_UPE_Payment_Method_ACSS::STRIPE_ID, ], ], ]; @@ -341,7 +345,10 @@ public function test_process_payment_returns_valid_response() { 'metadata' => $metadata, ]; - $_POST = [ 'wc_payment_intent_id' => $payment_intent_id ]; + $_POST = [ + 'payment_method' => 'stripe', + 'wc_payment_intent_id' => $payment_intent_id, + ]; $this->mock_gateway->expects( $this->any() ) ->method( 'get_stripe_customer_from_order' ) @@ -1782,7 +1789,10 @@ public function test_if_order_has_subscription_payment_method_will_be_saved() { 'setup_future_usage' => 'off_session', ]; - $_POST = [ 'wc_payment_intent_id' => $payment_intent_id ]; + $_POST = [ + 'payment_method' => 'stripe', + 'wc_payment_intent_id' => $payment_intent_id, + ]; $this->mock_gateway->expects( $this->any() ) ->method( 'is_subscriptions_enabled' ) @@ -1831,7 +1841,10 @@ public function test_if_free_trial_subscription_will_not_update_intent() { $order->set_total( 0 ); $order->save(); - $_POST = [ 'wc_payment_intent_id' => $setup_intent_id ]; + $_POST = [ + 'payment_method' => 'stripe', + 'wc_payment_intent_id' => $setup_intent_id, + ]; $this->mock_gateway->expects( $this->any() ) ->method( 'has_subscription' ) diff --git a/tests/phpunit/payment-methods/test-class-wc-stripe-upe-payment-method.php b/tests/phpunit/payment-methods/test-class-wc-stripe-upe-payment-method.php index 9ca8bbd7a..5f0db569c 100644 --- a/tests/phpunit/payment-methods/test-class-wc-stripe-upe-payment-method.php +++ b/tests/phpunit/payment-methods/test-class-wc-stripe-upe-payment-method.php @@ -112,26 +112,27 @@ class WC_Stripe_UPE_Payment_Method_Test extends WP_UnitTestCase { * Mock capabilities object from Stripe response--all active. */ const MOCK_ACTIVE_CAPABILITIES_RESPONSE = [ - 'alipay_payments' => 'active', - 'bancontact_payments' => 'active', - 'card_payments' => 'active', - 'eps_payments' => 'active', - 'giropay_payments' => 'active', - 'klarna_payments' => 'active', - 'affirm_payments' => 'active', - 'clearpay_afterpay_payments' => 'active', - 'ideal_payments' => 'active', - 'p24_payments' => 'active', - 'sepa_debit_payments' => 'active', - 'sofort_payments' => 'active', - 'transfers' => 'active', - 'multibanco_payments' => 'active', - 'boleto_payments' => 'active', - 'oxxo_payments' => 'active', - 'link_payments' => 'active', - 'cashapp_payments' => 'active', - 'wechat_pay_payments' => 'active', - 'us_bank_account_payments' => 'active', + 'alipay_payments' => 'active', + 'bancontact_payments' => 'active', + 'card_payments' => 'active', + 'eps_payments' => 'active', + 'giropay_payments' => 'active', + 'klarna_payments' => 'active', + 'affirm_payments' => 'active', + 'clearpay_afterpay_payments' => 'active', + 'ideal_payments' => 'active', + 'p24_payments' => 'active', + 'sepa_debit_payments' => 'active', + 'sofort_payments' => 'active', + 'transfers' => 'active', + 'multibanco_payments' => 'active', + 'boleto_payments' => 'active', + 'oxxo_payments' => 'active', + 'link_payments' => 'active', + 'cashapp_payments' => 'active', + 'wechat_pay_payments' => 'active', + 'acss_debit_payments' => 'active', + 'us_bank_account_payments' => 'active', ]; /** @@ -250,6 +251,9 @@ public function test_payment_methods_show_correct_default_outputs() { $mock_wechat_pay_details = [ 'type' => WC_Stripe_Payment_Methods::WECHAT_PAY, ]; + $mock_acss_details = [ + 'type' => WC_Stripe_Payment_Methods::ACSS_DEBIT, + ]; $card_method = $this->mock_payment_methods['card']; $alipay_method = $this->mock_payment_methods['alipay']; @@ -264,6 +268,7 @@ public function test_payment_methods_show_correct_default_outputs() { $oxxo_method = $this->mock_payment_methods['oxxo']; $wechat_pay_method = $this->mock_payment_methods['wechat_pay']; $ach_method = $this->mock_payment_methods['us_bank_account']; + $acss_method = $this->mock_payment_methods['acss_debit']; $this->assertEquals( WC_Stripe_Payment_Methods::CARD, $card_method->get_id() ); $this->assertEquals( 'Credit / Debit Card', $card_method->get_label() ); @@ -371,6 +376,14 @@ public function test_payment_methods_show_correct_default_outputs() { $this->assertTrue( $ach_method->is_reusable() ); $this->assertEquals( WC_Stripe_Payment_Methods::ACH, $ach_method->get_retrievable_type() ); $this->assertEquals( '', $ach_method->get_testing_instructions() ); + + $this->assertEquals( WC_Stripe_Payment_Methods::ACSS_DEBIT, $acss_method->get_id() ); + $this->assertEquals( 'Pre-Authorized Debit', $acss_method->get_label() ); + $this->assertEquals( 'Pre-Authorized Debit', $acss_method->get_title() ); + $this->assertEquals( 'Pre-Authorized Debit', $acss_method->get_title( $mock_acss_details ) ); + $this->assertTrue( $acss_method->is_reusable() ); + $this->assertEquals( WC_Stripe_Payment_Methods::ACSS_DEBIT, $acss_method->get_retrievable_type() ); + $this->assertEquals( '', $acss_method->get_testing_instructions() ); } /** @@ -402,6 +415,7 @@ public function test_card_payment_method_capability_is_always_enabled() { $oxxo_method = $this->mock_payment_methods['oxxo']; $wechat_pay_method = $this->mock_payment_methods['wechat_pay']; $ach_method = $this->mock_payment_methods['us_bank_account']; + $acss_method = $this->mock_payment_methods['acss_debit']; $this->assertTrue( $card_method->is_enabled_at_checkout() ); $this->assertFalse( $klarna_method->is_enabled_at_checkout() ); @@ -418,6 +432,7 @@ public function test_card_payment_method_capability_is_always_enabled() { $this->assertFalse( $oxxo_method->is_enabled_at_checkout() ); $this->assertFalse( $wechat_pay_method->is_enabled_at_checkout() ); $this->assertFalse( $ach_method->is_enabled_at_checkout() ); + $this->assertFalse( $acss_method->is_enabled_at_checkout() ); } /** @@ -611,7 +626,8 @@ public function test_payment_methods_are_reusable_if_cart_contains_subscription( $store_currency = in_array( $payment_method_id, [ WC_Stripe_UPE_Payment_Method_Link::STRIPE_ID, WC_Stripe_UPE_Payment_Method_ACH::STRIPE_ID ] ) ? WC_Stripe_Currency_Code::UNITED_STATES_DOLLAR : 'EUR'; $account_currency = null; - if ( $payment_method->has_domestic_transactions_restrictions() ) { + // Use different currencies for ACSS or payment methods that have domestic transactions restrictions. + if ( $payment_method->has_domestic_transactions_restrictions() || WC_Stripe_UPE_Payment_Method_ACSS::STRIPE_ID === $payment_method_id ) { $store_currency = $payment_method->get_supported_currencies()[0]; $account_currency = $store_currency; } @@ -634,6 +650,7 @@ public function test_payment_methods_are_reusable_if_cart_contains_subscription( public function test_payment_methods_support_custom_name_and_description() { $payment_method_ids = [ WC_Stripe_Payment_Methods::ACH, + WC_Stripe_Payment_Methods::ACSS_DEBIT, WC_Stripe_Payment_Methods::CARD, WC_Stripe_Payment_Methods::KLARNA, WC_Stripe_Payment_Methods::AFTERPAY_CLEARPAY, diff --git a/tests/phpunit/test-wc-stripe-webhook-handler.php b/tests/phpunit/test-wc-stripe-webhook-handler.php index fc850e8f6..857eaf050 100644 --- a/tests/phpunit/test-wc-stripe-webhook-handler.php +++ b/tests/phpunit/test-wc-stripe-webhook-handler.php @@ -473,6 +473,57 @@ public function test_process_payment_intent( $this->assertEquals( $expected_process_payment_intent_incomplete_calls, $mock_action_process_payment_intent_incomplete->get_call_count() ); } + /** + * Test that when a PaymentIntent is in the `processing` status, + * the order is updated to on-hold and the transaction ID is set. + */ + public function test_process_webhook_payment_intent_processing() { + $notification = (object) [ + 'type' => 'payment_intent.processing', + 'data' => (object) [ + 'object' => (object) [ + 'id' => 'pi_mock', + 'charges' => (object) [ + 'data' => [ + (object) [ + 'id' => 'ch_mock', + ], + ], + ], + ], + ], + ]; + + // Order must be previously set to pending and have at least the payment intent set. + $order = WC_Helper_Order::create_order(); + WC_Stripe_Helper::add_payment_intent_to_order( $notification->data->object->id, $order ); + $order->set_status( 'pending' ); + $order->save(); + + $this->mock_webhook_handler = $this->getMockBuilder( WC_Stripe_Webhook_Handler::class ) + ->setMethods( [ 'lock_order_payment' ] ) + ->getMock(); + + $this->mock_webhook_handler->method( 'lock_order_payment' )->willReturn( false ); + + $this->mock_webhook_handler->process_payment_intent( $notification ); + + $updated_order = wc_get_order( $order->get_id() ); + $this->assertEquals( 'on-hold', $updated_order->get_status() ); + $this->assertEquals( 'ch_mock', $updated_order->get_transaction_id() ); + + // Grab the latest order note and verify the content. + $notes = wc_get_order_notes( + [ + 'order_id' => $updated_order->get_id(), + 'limit' => 1, + ] + ); + $this->assertCount( 1, $notes ); + $this->assertStringContainsString( 'Stripe charge awaiting payment: ch_mock.', $notes[0]->content ); + } + + /** * Provider for `test_process_payment_intent`. * diff --git a/woocommerce-gateway-stripe.php b/woocommerce-gateway-stripe.php index b1c6272cc..be6844f55 100644 --- a/woocommerce-gateway-stripe.php +++ b/woocommerce-gateway-stripe.php @@ -242,6 +242,7 @@ public function init() { require_once __DIR__ . '/includes/payment-methods/class-wc-stripe-upe-payment-method-link.php'; require_once __DIR__ . '/includes/payment-methods/class-wc-stripe-upe-payment-method-cash-app-pay.php'; require_once __DIR__ . '/includes/payment-methods/class-wc-stripe-upe-payment-method-wechat-pay.php'; + require_once __DIR__ . '/includes/payment-methods/class-wc-stripe-upe-payment-method-acss.php'; require_once __DIR__ . '/includes/payment-methods/class-wc-gateway-stripe-bancontact.php'; require_once __DIR__ . '/includes/payment-methods/class-wc-gateway-stripe-sofort.php'; require_once __DIR__ . '/includes/payment-methods/class-wc-gateway-stripe-giropay.php';