diff --git a/includes/abstracts/abstract-wc-stripe-payment-gateway.php b/includes/abstracts/abstract-wc-stripe-payment-gateway.php index bea2fc19ee..214a2f21c3 100644 --- a/includes/abstracts/abstract-wc-stripe-payment-gateway.php +++ b/includes/abstracts/abstract-wc-stripe-payment-gateway.php @@ -20,6 +20,7 @@ abstract class WC_Stripe_Payment_Gateway extends WC_Payment_Gateway_CC { use WC_Stripe_Subscriptions_Trait; use WC_Stripe_Pre_Orders_Trait; + use WC_Stripe_Deposits_Trait; /** * The delay between retries. @@ -512,7 +513,7 @@ public function generate_payment_request( $order, $prepared_payment_method ) { 'signature' => $this->get_order_signature( $order ), ]; - if ( $this->has_subscription( $order->get_id() ) ) { + if ( $this->has_subscription( $order->get_id() ) || $this->order_contains_deposit( $order ) ) { $metadata += [ 'payment_type' => 'recurring', ]; @@ -1585,7 +1586,7 @@ public function update_existing_intent( $intent, $order, $prepared_source ) { $request['payment_method_types'] = [ WC_Stripe_Payment_Methods::CARD ]; - if ( $this->has_subscription( $order->get_id() ) ) { + if ( $this->has_subscription( $order->get_id() ) || $this->order_contains_deposit( $order ) ) { // If this is a failed subscription order payment, the intent should be // prepared for future usage. $request['setup_future_usage'] = 'off_session'; diff --git a/includes/class-wc-gateway-stripe.php b/includes/class-wc-gateway-stripe.php index 2de2197d39..265cdc0372 100644 --- a/includes/class-wc-gateway-stripe.php +++ b/includes/class-wc-gateway-stripe.php @@ -104,6 +104,9 @@ public function __construct() { // Check if pre-orders are enabled and add support for them. $this->maybe_init_pre_orders(); + // Check if WooCommerce Deposits is enabled and add support for it. + $this->maybe_init_deposits(); + // Get setting values. $this->title = $this->get_validated_option( 'title' ); $this->description = $this->get_validated_option( 'description' ); @@ -260,6 +263,7 @@ public function payment_fields() { $this->elements_form(); + /** Documented in includes/payment-methods/class-wc-stripe-upe-payment-gateway.php */ if ( apply_filters( 'wc_stripe_display_save_payment_method_checkbox', $display_tokenization ) && ! is_add_payment_method_page() && ! isset( $_GET['change_payment_method'] ) ) { // wpcs: csrf ok. $this->save_payment_method_checkbox(); diff --git a/includes/class-wc-stripe-blocks-support.php b/includes/class-wc-stripe-blocks-support.php index a6dab69d45..aa2075cfb1 100644 --- a/includes/class-wc-stripe-blocks-support.php +++ b/includes/class-wc-stripe-blocks-support.php @@ -375,6 +375,7 @@ private function get_show_save_option() { // https://github.com/woocommerce/woocommerce-gateway-stripe/blob/master/includes/class-wc-gateway-stripe.php#L95 . // See https://github.com/woocommerce/woocommerce-gateway-stripe/blob/ad19168b63df86176cbe35c3e95203a245687640/includes/class-wc-gateway-stripe.php#L271 and // https://github.com/woocommerce/woocommerce/wiki/Payment-Token-API . + /** Documented in includes/payment-methods/class-wc-stripe-upe-payment-gateway.php */ return apply_filters( 'wc_stripe_display_save_payment_method_checkbox', filter_var( $saved_cards, FILTER_VALIDATE_BOOLEAN ) ); } diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index 3ba813c210..7f89211d4b 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -1030,7 +1030,7 @@ private function build_base_payment_intent_request_params( $payment_information // If the customer is saving the payment method to the store or has a subscription, we should set the setup_future_usage to off_session. // Only exception is when using a confirmation token. For confirmations tokens, the setup_future_usage is set within the payment method. - if ( ! $is_using_confirmation_token && ( $payment_information['save_payment_method_to_store'] || ! empty( $payment_information['has_subscription'] ) ) ) { + if ( ! $is_using_confirmation_token && ( $payment_information['save_payment_method_to_store'] || ! empty( $payment_information['has_subscription'] ) || $payment_information['has_deposit'] ) ) { $request['setup_future_usage'] = 'off_session'; } diff --git a/includes/compat/trait-wc-stripe-deposits.php b/includes/compat/trait-wc-stripe-deposits.php new file mode 100644 index 0000000000..c5bc71c4a7 --- /dev/null +++ b/includes/compat/trait-wc-stripe-deposits.php @@ -0,0 +1,457 @@ +is_deposits_enabled() ) { + return; + } + + $this->supports[] = 'forced-tokenization'; // @phpstan-ignore-line (supports is defined in the classes that use this trait) + + add_action( 'wc_deposits_' . $this->id . '_charge_order_token', [ $this, 'charge_order_token' ], 10, 2 ); // @phpstan-ignore-line (id is defined in the classes that use this trait) + + /** + * The callbacks attached below only need to be attached once. We don't need each gateway instance to have its own callback. + * Therefore we only attach them once on the main `stripe` gateway and store a flag to indicate that they have been attached. + */ + if ( self::$has_attached_deposits_integration_hooks || WC_Gateway_Stripe::ID !== $this->id ) { // @phpstan-ignore-line (id is defined in the classes that use this trait) + return; + } + + add_filter( 'pre_wc_deposits_get_order_payment_token', [ $this, 'get_order_payment_token' ], 10, 2 ); + + add_action( 'wc_deposits_delete_order_payment_token', [ $this, 'delete_order_payment_token' ], 10, 2 ); + + self::$has_attached_deposits_integration_hooks = true; + } + + /** + * Filter the order payment token to include the source and intent. + * + * Runs on the `pre_wc_deposits_get_order_payment_token` filter. + * + * @since x.x.x + * + * @param array $token The order payment token. + * @param WC_Order $top_most_order The top most order in the chain. + * @return array The modified order payment token. + */ + public function get_order_payment_token( $token, $top_most_order ) { + if ( $top_most_order->get_payment_method() !== $this->id ) { + return $token; + } + + $intent = $this->get_intent_from_order( $top_most_order ); + + if ( + ! $intent || + ! isset( $intent->setup_future_usage ) || + ! 'off_session' === $intent->setup_future_usage + ) { + /* + * The token is not valid for off-session payments. + * + * As the deposits plugin feature requires the token to + * be available for re-use, do not consider the token to + * be set. + */ + return $token; + } + + $token = [ + 'gateway' => $this->id, + 'token' => $intent->id, + 'data' => [ + 'intent' => $intent, + ], + ]; + + return $token; + } + + /** + * Checks if the deposits gateway feature is supported on this site. + * + * Deposits is only supported under the following circumstances: + * - The gateway supports tokenization. + * - UPE Checkout is enabled (not supported for legacy checkouts) + * - The wc_deposits_feature_support function exists. + * - `wc_deposits_feature_support( 'deposits_payment_gateway_feature' )` returns true. + * + * @since x.x.x + * + * @return bool + */ + public function is_deposits_enabled() { + return $this->supports( 'tokenization' ) + && WC_Stripe_Feature_Flags::is_upe_checkout_enabled() + && function_exists( 'wc_deposits_feature_support' ) + && wc_deposits_feature_support( 'deposits_payment_gateway_feature' ); + } + + /** + * Whether the current cart contains a deposit. + * + * @since x.x.x + * + * @param int $order_id + * @return bool + */ + public function cart_contains_deposit() { + if ( ! $this->is_deposits_enabled() || ! class_exists( 'WC_Deposits_Cart_Manager' ) ) { + return false; + } + $cart_manager = WC_Deposits_Cart_Manager::get_instance(); + return $cart_manager->has_deposit(); + } + + /** + * Whether the current order contains a deposit. + * + * @since x.x.x + * + * @param int|\WC_Order $order_id The order ID or order object. + * @return bool True if the order includes a deposit item, false otherwise. + */ + public function order_contains_deposit( $order_id ) { + if ( ! $this->is_deposits_enabled() || ! class_exists( 'WC_Deposits_Order_Manager' ) ) { + return false; + } + + $order = wc_get_order( $order_id ); + if ( ! $order ) { + return false; + } + + $order_manager = WC_Deposits_Order_Manager::get_instance(); + return $order_manager->has_deposit( $order ); + } + + /** + * Return the top-most order function in a hierarchy. + * + * Payment tokens are stored against the top-most order in a hierarchy. This function + * traverses the order hierarchy to find one that does not have a parent. + * + * Not having a parent may be because the order parent is set to zero or because + * the order parent references an order that does not exist. + * + * @since x.x.x + * + * @param \WC_Order|int $order The order. + * @return \WC_Order|false The top-most order in the hierarchy. False if the order does not exist. + */ + public function get_top_most_order( $order ) { + if ( ! $this->is_deposits_enabled() || ! class_exists( 'WC_Deposits_Order_Manager' ) ) { + return false; + } + + $order = wc_get_order( $order ); + if ( ! $order ) { + return false; + } + + $order_manager = WC_Deposits_Order_Manager::get_instance(); + return $order_manager->get_top_most_order( $order ); + } + + /** + * Process a payment and store a reusable token against the order. + * + * @param WC_Order $order Order object. + * @param bool $retry Whether this is a retry. + * @param mixed $error Previous error. + */ + public function process_order_saving_order_token( $order_id, $retry = true, $error = false ) { + try { + $order = wc_get_order( $order_id ); + if ( ! $order ) { + return false; + } + + $future_payments = function ( $request ) { + $request['setup_future_usage'] = 'off_session'; + return $request; + }; + + add_filter( 'wc_stripe_generate_create_intent_request', $future_payments ); + + $source = $this->prepare_order_source( $order ); // @phpstan-ignore-line (prepare_order_source is defined in the classes that use this trait) + $response = $this->create_and_confirm_intent_for_off_session( $order, $source ); // @phpstan-ignore-line (create_and_confirm_intent_for_off_session is defined in the classes that use this trait) + + add_filter( 'wc_stripe_generate_create_intent_request', $future_payments ); + + $is_authentication_required = $this->is_authentication_required_for_payment( $response ); // @phpstan-ignore-line (is_authentication_required_for_payment is defined in the classes that use this trait) + + if ( ! empty( $response->error ) && ! $is_authentication_required ) { + if ( ! $retry ) { + throw new Exception( $response->error->message ); + } + $this->remove_order_source_before_retry( $order ); + } elseif ( $is_authentication_required ) { + $charge = $this->get_latest_charge_from_intent( $response->error->payment_intent ); + $id = $charge->id; + + $order->set_transaction_id( $id ); + /* translators: %s is the charge Id */ + $order->update_status( 'failed', sprintf( __( 'Stripe charge awaiting authentication by user: %s.', 'woocommerce-gateway-stripe' ), $id ) ); + if ( is_callable( [ $order, 'save' ] ) ) { + $order->save(); + } + + WC_Emails::instance(); + + do_action( 'wc_gateway_stripe_process_payment_authentication_required', $order ); + + throw new WC_Stripe_Exception( print_r( $response, true ), $response->error->message ); + } else { + // Successful + $this->process_response( $this->get_latest_charge_from_intent( $response ), $order ); // @phpstan-ignore-line (process_response is defined in the classes that use this trait) + } + } catch ( Exception $e ) { + $error_message = is_callable( [ $e, 'getLocalizedMessage' ] ) ? $e->getLocalizedMessage() : $e->getMessage(); + /* translators: error message */ + $order_note = sprintf( __( 'Stripe Transaction Failed (%s)', 'woocommerce-gateway-stripe' ), $error_message ); + + // Mark order as failed if not already set, + // otherwise, make sure we add the order note so we can detect when someone fails to check out multiple times + if ( ! $order->has_status( 'failed' ) ) { + $order->update_status( 'failed', $order_note ); + } else { + $order->add_order_note( $order_note ); + } + } + } + + /** + * Process a scheduled payment for a deposits order. + * + * Runs on the "wc_deposits_{$this->id}_charge_order_token" action. + * + * @param WC_Order $order Scheduled order. + */ + public function charge_order_token( $order ) { + return $this->process_order_token_payment( $order ); + } + + /** + * Process a scheduled payment. + * + * @param WC_Order $order Scheduled deposit order. + * @param bool $retry Whether to retry the payment. + * @param mixed $previous_error Previous error. + */ + public function process_order_token_payment( $order, $retry = true, $previous_error = false ) { + $amount = $order->get_total(); + $top_most_order = $this->get_top_most_order( $order ); + + try { + $order_id = $order->get_id(); + + // Get source from order + $prepared_source = $this->prepare_order_source( $top_most_order ); + $source_object = $prepared_source->source_object; + + $this->check_source( $prepared_source ); + $this->save_source_to_order( $order, $prepared_source ); + + if ( ! $prepared_source->customer ) { + throw new WC_Stripe_Exception( + 'Failed to process payment for order ' . $order->get_id() . '. Stripe customer id is missing in the order', + __( 'Customer not found', 'woocommerce-gateway-stripe' ) + ); + } + + WC_Stripe_Logger::log( "Info: Begin processing scheduled payment for order {$order_id} for the amount of {$amount}" ); + + /* + * If we're doing a retry and source is chargeable, we need to pass + * a different idempotency key and retry for success. + */ + if ( is_object( $source_object ) && empty( $source_object->error ) && $this->need_update_idempotency_key( $source_object, $previous_error ) ) { + add_filter( 'wc_stripe_idempotency_key', [ $this, 'change_idempotency_key' ], 10, 2 ); + } + + if ( ( $this->is_no_such_source_error( $previous_error ) || $this->is_no_linked_source_error( $previous_error ) ) && apply_filters( 'wc_stripe_use_default_customer_source', true ) ) { + // Passing empty source will charge customer default. + $prepared_source->source = ''; + } + + // If the payment gateway is SEPA, use the charges API. + // TODO: Remove when SEPA is migrated to payment intents. + if ( 'stripe_sepa' === $this->id ) { + $request = $this->generate_payment_request( $order, $prepared_source ); + $request['capture'] = 'true'; + $request['amount'] = WC_Stripe_Helper::get_stripe_amount( $amount, $request['currency'] ); + $response = WC_Stripe_API::request( $request ); + + $is_authentication_required = false; + } else { + $this->lock_order_payment( $order ); + $response = $this->create_and_confirm_intent_for_off_session( $order, $prepared_source, $amount ); + $is_authentication_required = $this->is_authentication_required_for_payment( $response ); + } + + // It's only a failed payment if it's an error and it's not of the type 'authentication_required'. + // If it's 'authentication_required', then we should email the user and ask them to authenticate. + if ( ! empty( $response->error ) && ! $is_authentication_required ) { + // We want to retry. + if ( $this->is_retryable_error( $response->error ) ) { + if ( $retry ) { + // Don't do anymore retries after this. + if ( 5 <= $this->retry_interval ) { // @phpstan-ignore-line (retry_interval is defined in classes using this class) + return $this->process_order_token_payment( $order, false, $response->error ); + } + + sleep( $this->retry_interval ); + + ++$this->retry_interval; + + return $this->process_order_token_payment( $order, true, $response->error ); + } else { + $localized_message = sprintf( + /* translators: 1) error message from Stripe; 2) request log URL */ + __( 'Sorry, we are unable to process the payment at this time. Reason: %1$s %2$s', 'woocommerce-gateway-stripe' ), + $response->error->message, + isset( $response->error->request_log_url ) ? make_clickable( $response->error->request_log_url ) : '' + ); + $order->add_order_note( $localized_message ); + throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message ); + } + } + + $localized_messages = WC_Stripe_Helper::get_localized_messages(); + + if ( 'card_error' === $response->error->type ) { + $localized_message = isset( $localized_messages[ $response->error->code ] ) ? $localized_messages[ $response->error->code ] : $response->error->message; + } elseif ( 'payment_intent_mandate_invalid' === $response->error->type ) { + $localized_message = __( + 'The mandate used for this scheduled payment is invalid. You may need to bring the customer back to your store and ask them to resubmit their payment information.', + 'woocommerce-gateway-stripe' + ); + } else { + $localized_message = isset( $localized_messages[ $response->error->type ] ) ? $localized_messages[ $response->error->type ] : $response->error->message; + } + + if ( isset( $response->error->request_log_url ) ) { + $localized_message .= ' ' . make_clickable( $response->error->request_log_url ); + } + + $order->add_order_note( $localized_message ); + + throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message ); + } + } catch ( WC_Stripe_Exception $e ) { + WC_Stripe_Logger::log( 'Error: ' . $e->getMessage() ); + + do_action( 'wc_gateway_stripe_process_payment_error', $e, $order ); + + /* translators: error message */ + $order->update_status( 'failed' ); + $this->unlock_order_payment( $order ); + + return; + } + + try { + + // Either the charge was successfully captured, or it requires further authentication. + if ( $is_authentication_required ) { + do_action( 'wc_gateway_stripe_process_payment_authentication_required', $order, $response ); + + $error_message = __( 'This transaction requires authentication.', 'woocommerce-gateway-stripe' ); + $order->add_order_note( $error_message ); + + $charge = $this->get_latest_charge_from_intent( $response->error->payment_intent ); + $id = $charge->id; + + $order->set_transaction_id( $id ); + /* translators: %s is the charge Id */ + $order->update_status( 'failed', sprintf( __( 'Stripe charge awaiting authentication by user: %s.', 'woocommerce-gateway-stripe' ), $id ) ); + if ( is_callable( [ $order, 'save' ] ) ) { + $order->save(); + } + } elseif ( $this->is_charge_attempt_delayed( $response ) ) { + $charge_attempt_at = $response->processing->card->customer_notification->completes_at; + $attempt_date = wp_date( get_option( 'date_format', 'F j, Y' ), $charge_attempt_at, wp_timezone() ); + $attempt_time = wp_date( get_option( 'time_format', 'g:i a' ), $charge_attempt_at, wp_timezone() ); + + $message = sprintf( + /* translators: 1) a date in the format yyyy-mm-dd, e.g. 2021-09-21; 2) time in the 24-hour format HH:mm, e.g. 23:04 */ + __( 'The customer must authorize this payment via the pre-debit notification sent to them by their card issuing bank, before %1$s at %2$s, when the charge will be attempted.', 'woocommerce-gateway-stripe' ), + $attempt_date, + $attempt_time + ); + $order->add_order_note( $message ); + $order->update_status( 'pending' ); + if ( is_callable( [ $order, 'save' ] ) ) { + $order->save(); + } + } else { + // The charge was successfully captured + do_action( 'wc_gateway_stripe_process_payment', $response, $order ); + + // Use the last charge within the intent or the full response body in case of SEPA. + $latest_charge = $this->get_latest_charge_from_intent( $response ); + $this->process_response( ( ! empty( $latest_charge ) ) ? $latest_charge : $response, $order ); + } + } catch ( WC_Stripe_Exception $e ) { + WC_Stripe_Logger::log( 'Error: ' . $e->getMessage() ); + + do_action( 'wc_gateway_stripe_process_payment_error', $e, $order ); + } + + $this->unlock_order_payment( $order ); + } + + /** + * Delete the Stripe token data from the order. + * + * Detach the payment method from the customer as the order is now + * completed and the payment method is no longer required. + * + * The payment method is invalidated via the API rather than deleting + * the meta data to allow for future references in refunds and other + * operations. + * + * @see https://docs.stripe.com/api/payment_methods/detach + * + * @since x.x.x + * + * @param WC_Order $order The order object the token is stored against. + */ + public function delete_order_payment_token( $order ) { + // API request to detach the payment method from the customer. + $payment_method_id = $order->get_meta( '_stripe_source_id' ); // Payment method is stored as source ID. + $customer_id = $order->get_meta( '_stripe_customer_id' ); + + if ( ! $payment_method_id || ! $customer_id ) { + return; + } + + WC_Stripe_API::detach_payment_method_from_customer( $customer_id, $payment_method_id ); + } +} diff --git a/includes/payment-methods/class-wc-gateway-stripe-sepa.php b/includes/payment-methods/class-wc-gateway-stripe-sepa.php index 02d504197d..1861e234b8 100644 --- a/includes/payment-methods/class-wc-gateway-stripe-sepa.php +++ b/includes/payment-methods/class-wc-gateway-stripe-sepa.php @@ -91,6 +91,9 @@ public function __construct() { // Check if pre-orders are enabled and add support for them. $this->maybe_init_pre_orders(); + // Check if WooCommerce Deposits is enabled and add support for it. + $this->maybe_init_deposits(); + $main_settings = WC_Stripe_Helper::get_stripe_settings(); $this->title = $this->get_option( 'title' ); $this->description = $this->get_option( 'description' ); @@ -261,6 +264,7 @@ public function payment_fields() { $this->form(); + /** Documented in includes/payment-methods/class-wc-stripe-upe-payment-gateway.php */ if ( apply_filters( 'wc_stripe_display_save_payment_method_checkbox', $display_tokenization ) && ! is_add_payment_method_page() && ! isset( $_GET['change_payment_method'] ) ) { $this->save_payment_method_checkbox(); } diff --git a/includes/payment-methods/class-wc-stripe-express-checkout-helper.php b/includes/payment-methods/class-wc-stripe-express-checkout-helper.php index 087d3125be..822b0ba8b7 100644 --- a/includes/payment-methods/class-wc-stripe-express-checkout-helper.php +++ b/includes/payment-methods/class-wc-stripe-express-checkout-helper.php @@ -10,6 +10,7 @@ class WC_Stripe_Express_Checkout_Helper { use WC_Stripe_Pre_Orders_Trait; + use WC_Stripe_Deposits_Trait; /** * Stripe settings. diff --git a/includes/payment-methods/class-wc-stripe-payment-request.php b/includes/payment-methods/class-wc-stripe-payment-request.php index 93e38d217c..2723df93cd 100644 --- a/includes/payment-methods/class-wc-stripe-payment-request.php +++ b/includes/payment-methods/class-wc-stripe-payment-request.php @@ -18,6 +18,7 @@ class WC_Stripe_Payment_Request { use WC_Stripe_Pre_Orders_Trait; + use WC_Stripe_Deposits_Trait; /** * Enabled. 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 afe943b319..b6517396c5 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -236,6 +236,9 @@ public function __construct() { // Check if pre-orders are enabled and add support for them. $this->maybe_init_pre_orders(); + // Check if WooCommerce Deposits is enabled and add support for it. + $this->maybe_init_deposits(); + $this->title = $this->payment_methods['card']->get_title(); $this->description = $this->payment_methods['card']->get_description(); $this->enabled = $this->get_option( 'enabled' ); @@ -799,6 +802,7 @@ public function payment_fields() { get_upe_enabled_payment_method_ids(), [ $this, 'is_enabled_for_saved_payments' ] ); if ( $this->is_saved_cards_enabled() && ! empty( $methods_enabled_for_saved_payments ) ) { + /** Documented in includes/payment-methods/class-wc-stripe-upe-payment-gateway.php */ $force_save_payment = ( $display_tokenization && ! apply_filters( 'wc_stripe_display_save_payment_method_checkbox', $display_tokenization ) ) || is_add_payment_method_page(); $this->save_payment_method_checkbox( $force_save_payment ); } @@ -2434,6 +2438,7 @@ protected function prepare_payment_information_from_request( WC_Order $order ) { 'payment_type' => 'single', // single | recurring. 'save_payment_method_to_store' => $save_payment_method_to_store, 'capture_method' => $capture_method, + 'has_deposit' => $this->order_contains_deposit( $order ), ]; if ( WC_Stripe_Payment_Methods::ACH === $selected_payment_type ) { @@ -3050,7 +3055,17 @@ private function get_payment_method_types_for_intent_creation( private function should_upe_payment_method_show_save_option( $payment_method ) { if ( $payment_method->is_reusable() ) { // If a subscription in the cart, it will be saved by default so no need to show the option. - return $this->is_saved_cards_enabled() && ! $this->is_subscription_item_in_cart() && ! $this->is_pre_order_charged_upon_release_in_cart(); + $display_option = $this->is_saved_cards_enabled() && ! $this->is_subscription_item_in_cart() && ! $this->is_pre_order_charged_upon_release_in_cart(); + + /** + * Filters whether the save payment checkbox should be displayed for the payment method. + * + * @since x.x.x + * + * @param bool $result Whether the save payment checkbox should be displayed for the payment method. + */ + $display_option = apply_filters( 'wc_stripe_display_save_payment_method_checkbox', filter_var( $display_option, FILTER_VALIDATE_BOOLEAN ) ); + return $display_option; } return false; diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-method-ach.php b/includes/payment-methods/class-wc-stripe-upe-payment-method-ach.php index a13e957a2b..9b2133174b 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-method-ach.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-method-ach.php @@ -37,6 +37,9 @@ public function __construct() { // Add support for pre-orders. $this->maybe_init_pre_orders(); + + // Check if WooCommerce Deposits is enabled and add support for it. + $this->maybe_init_deposits(); } /** diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-method-bacs-debit.php b/includes/payment-methods/class-wc-stripe-upe-payment-method-bacs-debit.php index e74493f07f..a868fef9f1 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-method-bacs-debit.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-method-bacs-debit.php @@ -38,6 +38,9 @@ public function __construct() { // Add support for pre-orders. $this->maybe_init_pre_orders(); + // Check if WooCommerce Deposits is enabled and add support for it. + $this->maybe_init_deposits(); + $this->maybe_hide_bacs_payment_gateway(); } diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-method-bancontact.php b/includes/payment-methods/class-wc-stripe-upe-payment-method-bancontact.php index 02ae147b64..3e81510141 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-method-bancontact.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-method-bancontact.php @@ -35,6 +35,9 @@ public function __construct() { // Add support for pre-orders. $this->maybe_init_pre_orders(); + + // Check if WooCommerce Deposits is enabled and add support for it. + $this->maybe_init_deposits(); } /** diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-method-ideal.php b/includes/payment-methods/class-wc-stripe-upe-payment-method-ideal.php index fa05c41050..fb5f19cfe8 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-method-ideal.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-method-ideal.php @@ -35,6 +35,9 @@ public function __construct() { // Add support for pre-orders. $this->maybe_init_pre_orders(); + + // Check if WooCommerce Deposits is enabled and add support for it. + $this->maybe_init_deposits(); } /** diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-method-sepa.php b/includes/payment-methods/class-wc-stripe-upe-payment-method-sepa.php index 6bdb8ffa92..203ccb1bb4 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-method-sepa.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-method-sepa.php @@ -36,6 +36,9 @@ public function __construct() { // Add support for pre-orders. $this->maybe_init_pre_orders(); + + // Check if WooCommerce Deposits is enabled and add support for it. + $this->maybe_init_deposits(); } /** diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-method-sofort.php b/includes/payment-methods/class-wc-stripe-upe-payment-method-sofort.php index 4bd633efed..d71b74f942 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-method-sofort.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-method-sofort.php @@ -32,5 +32,8 @@ public function __construct() { // Add support for pre-orders. $this->maybe_init_pre_orders(); + + // Check if WooCommerce Deposits is enabled and add support for it. + $this->maybe_init_deposits(); } } 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 1bde1e809e..0ccdb81d8c 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-method.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-method.php @@ -18,6 +18,7 @@ abstract class WC_Stripe_UPE_Payment_Method extends WC_Payment_Gateway { use WC_Stripe_Subscriptions_Utilities_Trait; use WC_Stripe_Pre_Orders_Trait; + use WC_Stripe_Deposits_Trait; /** * Stripe key name @@ -301,6 +302,17 @@ public function is_enabled_at_checkout( $order_id = null, $account_domestic_curr return $this->is_reusable() || WC_Stripe_Payment_Methods::BLIK === $this->stripe_id; } + /* + * If cart or order contains a deposit product, enable payment method if it's reusable. + * + * These methods include checks that deposits are enabled and that the version of the deposits + * extension supports payment tokens stored against the order. This is to ensure that we don't + * store payment tokens when they are not able to be reused. + */ + if ( $this->cart_contains_deposit() || ( ! empty( $order_id ) && $this->order_contains_deposit( $order_id ) ) ) { + return $this->is_reusable(); + } + // Note: this $this->is_automatic_capture_enabled() call will be handled by $this->__call() and fall through to the UPE gateway class. if ( $this->requires_automatic_capture() && ! $this->is_automatic_capture_enabled() ) { return false; @@ -620,6 +632,7 @@ public function payment_fields() { should_show_save_option() ) { + /** Documented in includes/payment-methods/class-wc-stripe-upe-payment-gateway.php */ $force_save_payment = ( $display_tokenization && ! apply_filters( 'wc_stripe_display_save_payment_method_checkbox', $display_tokenization ) ) || is_add_payment_method_page(); if ( is_user_logged_in() ) { $this->save_payment_method_checkbox( $force_save_payment ); 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 f340103cfb..3c61b1ff46 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 @@ -155,6 +155,9 @@ public function set_up() { 'has_pre_order', 'is_subscriptions_enabled', 'update_saved_payment_method', + 'is_deposits_enabled', + 'cart_contains_deposit', + 'order_contains_deposit', ] ) ->getMock(); @@ -2240,6 +2243,97 @@ public function test_pre_order_without_payment_uses_setup_intents() { $this->assertTrue( $final_order->is_upe_redirect_processed() ); } + /** + * Deposits order sets up payment intent. + */ + public function test_deposit_product_uses_setup_intents() { + $setup_intent_id = 'seti_mock'; + $payment_method_id = 'pm_mock'; + $customer_id = 'cus_mock'; + $order = WC_Helper_Order::create_order(); + $order_id = $order->get_id(); + + $order->set_payment_method( WC_Stripe_UPE_Payment_Gateway::ID ); + + foreach ( $order->get_items() as $item ) { + $order->remove_item( $item->get_id() ); + } + + // Add item with deposit to order. + $product = WC_Helper_Product::create_simple_product(); + $item = new WC_Order_Item_Product(); + $item->set_props( + [ + 'product' => $product, + 'quantity' => 1, + 'subtotal' => wc_get_price_excluding_tax( $product ), + 'total' => wc_get_price_excluding_tax( $product ), + 'is_deposit' => true, + 'deposit_full_amount' => 20, + 'deposit_full_amount_ex_tax' => 20, + 'deposit_deposit_amount_ex_tax' => 10, + ] + ); + $item->save(); + $order->add_item( $item ); + $order->set_total( 10 ); + $order->save(); + + $payment_method_mock = self::MOCK_CARD_PAYMENT_METHOD_TEMPLATE; + $payment_method_mock['id'] = $payment_method_id; + $payment_method_mock['customer'] = $customer_id; + $payment_method_mock['card']['exp_year'] = intval( gmdate( 'Y' ) ) + 1; + + $setup_intent_mock = self::MOCK_CARD_SETUP_INTENT_TEMPLATE; + $setup_intent_mock['id'] = $setup_intent_id; + $setup_intent_mock['payment_method'] = $payment_method_mock; + $setup_intent_mock['latest_charge'] = []; + + $this->mock_gateway->expects( $this->any() ) + ->method( 'get_stripe_customer_from_order' ) + ->with( WC_Stripe_Order::get_by_id( $order_id ) ) + ->will( + $this->returnValue( $this->mock_stripe_customer ) + ); + + // Mock order has pre-order product. + $this->mock_gateway->expects( $this->once() ) + ->method( 'is_deposits_enabled' ) + ->will( $this->returnValue( true ) ); + + $this->mock_gateway->expects( $this->once() ) + ->method( 'cart_contains_deposit' ) + ->will( $this->returnValue( true ) ); + + $this->mock_gateway->expects( $this->once() ) + ->method( 'stripe_request' ) + ->with( "payment_intents/$setup_intent_id?expand[]=payment_method" ) + ->will( + $this->returnValue( + $this->array_to_object( $setup_intent_mock ) + ) + ); + + $charge = [ + 'id' => 'ch_mock', + 'captured' => true, + 'status' => 'succeeded', + 'payment_method_details' => $payment_method_mock, + ]; + $this->mock_gateway + ->expects( $this->exactly( 2 ) ) + ->method( 'get_latest_charge_from_intent' ) + ->willReturn( $this->array_to_object( $charge ) ); + + $this->mock_gateway->process_upe_redirect_payment( $order_id, $setup_intent_id, true ); + + $final_order = WC_Stripe_Order::get_by_id( $order_id ); + + $this->assertEquals( $payment_method_id, $final_order->get_source_id() ); + $this->assertEquals( $customer_id, $final_order->get_stripe_customer_id() ); + $this->assertTrue( $final_order->is_upe_redirect_processed() ); + } + /** * Test if `display_order_fee` and `display_order_payout` are called when viewing an order on the admin panel. * diff --git a/woocommerce-gateway-stripe.php b/woocommerce-gateway-stripe.php index f8a484c094..4c602a0f26 100644 --- a/woocommerce-gateway-stripe.php +++ b/woocommerce-gateway-stripe.php @@ -203,6 +203,7 @@ public function init() { require_once __DIR__ . '/includes/class-wc-stripe-payment-method-configurations.php'; include_once __DIR__ . '/includes/class-wc-stripe-api.php'; include_once __DIR__ . '/includes/class-wc-stripe-mode.php'; + require_once __DIR__ . '/includes/compat/trait-wc-stripe-deposits.php'; require_once __DIR__ . '/includes/compat/class-wc-stripe-subscriptions-helper.php'; require_once __DIR__ . '/includes/compat/trait-wc-stripe-subscriptions-utilities.php'; require_once __DIR__ . '/includes/compat/trait-wc-stripe-subscriptions.php';