From 7573cca88c04bbf9ac3bedc5e6c13999648f4a12 Mon Sep 17 00:00:00 2001 From: Rafael Meneses Date: Tue, 12 Aug 2025 17:03:56 -0300 Subject: [PATCH 1/2] BECS is also required to send mandate data --- includes/class-wc-payment-gateway-wcpay.php | 5 ++- .../test-class-wc-payment-gateway-wcpay.php | 41 ++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 5e5801cfe2e..730ea21f22b 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -2042,7 +2042,7 @@ public function process_redirect_payment( $order, $intent_id, $save_payment_meth /** * Mandate must be shown and acknowledged by customer before deferred intent UPE payment can be processed. - * This applies to SEPA and Link payment methods. + * This applies to SEPA, BECS, and Link payment methods. * https://stripe.com/docs/payments/finalize-payments-on-the-server * * @return boolean True if mandate must be shown and acknowledged by customer before deferred intent UPE payment can be processed, false otherwise. @@ -2050,8 +2050,9 @@ public function process_redirect_payment( $order, $intent_id, $save_payment_meth public function is_mandate_data_required() { $is_stripe_link_enabled = Payment_Method::CARD === $this->get_selected_stripe_payment_type_id() && in_array( Payment_Method::LINK, $this->get_upe_enabled_payment_method_ids(), true ); $is_sepa_debit_payment = Payment_Method::SEPA === $this->get_selected_stripe_payment_type_id(); + $is_becs_debit_payment = Payment_Method::BECS === $this->get_selected_stripe_payment_type_id(); - return $is_stripe_link_enabled || $is_sepa_debit_payment; + return $is_stripe_link_enabled || $is_sepa_debit_payment || $is_becs_debit_payment; } /** diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index d8fbd1d0f5f..26532dc44b5 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -2796,6 +2796,40 @@ function ( $data ) { $gateway->process_payment_for_order( WC()->cart, $pi ); } + public function test_set_mandate_data_to_payment_intent_for_becs() { + // Mandate data is required for BECS payments, hence creating the gateway with a BECS payment method should add mandate data. + $gateway = $this->get_gateway( Payment_Method::BECS ); + $payment_method = 'woocommerce_payments_au_becs_debit'; + $order = WC_Helper_Order::create_order(); + $order->set_currency( 'AUD' ); + $order->set_total( 100 ); + $order->save(); + + $_POST['wcpay-fraud-prevention-token'] = 'correct-token'; + $_POST['payment_method'] = $payment_method; + $pi = new Payment_Information( 'pm_test', $order, null, null, null, null, null, '', 'card' ); + + $request = $this->mock_wcpay_request( Create_And_Confirm_Intention::class ); + $request->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( WC_Helper_Intention::create_intention( [ 'status' => 'success' ] ) ); + + $request->expects( $this->once() ) + ->method( 'set_mandate_data' ) + ->with( + $this->callback( + function ( $data ) { + return isset( $data['customer_acceptance']['type'] ) && + 'online' === $data['customer_acceptance']['type'] && + isset( $data['customer_acceptance']['online'] ) && + is_array( $data['customer_acceptance']['online'] ); + } + ) + ); + + $gateway->process_payment_for_order( WC()->cart, $pi ); + } + public function test_set_mandate_data_with_setup_intent_request_when_link_is_disabled() { // Disabled link is reflected in upe_enabled_payment_method_ids: when link is disabled, the array contains only card. $this->card_gateway->settings['upe_enabled_payment_method_ids'] = [ 'card' ]; @@ -2898,8 +2932,13 @@ public function test_is_mandate_data_required_sepa() { $this->assertTrue( $sepa->is_mandate_data_required() ); } + public function test_is_mandate_data_required_becs() { + $becs = $this->get_gateway( Payment_Method::BECS ); + $this->assertTrue( $becs->is_mandate_data_required() ); + } + public function test_is_mandate_data_required_returns_false() { - foreach ( $this->get_gateways_excluding( [ Payment_Method::SEPA, Payment_Method::CARD ] ) as $gateway ) { + foreach ( $this->get_gateways_excluding( [ Payment_Method::SEPA, Payment_Method::BECS, Payment_Method::CARD ] ) as $gateway ) { $this->assertFalse( $gateway->is_mandate_data_required() ); } } From 3a97c85c2410fc57b0fd48b46e02abf38e2240b5 Mon Sep 17 00:00:00 2001 From: Rafael Meneses Date: Tue, 12 Aug 2025 17:07:13 -0300 Subject: [PATCH 2/2] lint --- includes/class-wc-payment-gateway-wcpay.php | 3244 +++++++++---------- 1 file changed, 1622 insertions(+), 1622 deletions(-) diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 730ea21f22b..306b2c91b68 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -667,36 +667,6 @@ public function create_token_from_setup_intent( $setup_intent_id, $user ) { } } - /** - * Validate order_id received from the request vs value saved in the intent metadata. - * Throw an exception if they're not matched. - * - * @param WC_Order $order The received order to process. - * @param array $intent_metadata The metadata of attached intent to the order. - * - * @return void - * @throws Process_Payment_Exception - */ - private function validate_order_id_received_vs_intent_meta_order_id( WC_Order $order, array $intent_metadata ): void { - $intent_meta_order_id_raw = $intent_metadata['order_id'] ?? ''; - $intent_meta_order_id = is_numeric( $intent_meta_order_id_raw ) ? intval( $intent_meta_order_id_raw ) : 0; - - if ( $order->get_id() !== $intent_meta_order_id ) { - Logger::error( - sprintf( - 'UPE Process Redirect Payment - Order ID mismatched. Received: %1$d. Intent Metadata Value: %2$d', - $order->get_id(), - $intent_meta_order_id - ) - ); - - throw new Process_Payment_Exception( - __( "We're not able to process this payment due to the order ID mismatch. Please try again later.", 'woocommerce-payments' ), - self::PROCESS_REDIRECT_ORDER_MISMATCH_ERROR_CODE - ); - } - } - /** * If we're in a WooPay preflight check, remove all the checkout order processed * actions to prevent a quantity reduction of the available resources. @@ -722,26 +692,6 @@ public function remove_all_actions_on_preflight_check( $response, $handler, $req return $response; } - /** - * Gets and formats payment request data. - * - * @param \WP_REST_Request $request Request object. - * @return array - */ - private function get_request_payment_data( \WP_REST_Request $request ) { - static $payment_data = []; - if ( ! empty( $payment_data ) ) { - return $payment_data; - } - if ( ! empty( $request['payment_data'] ) ) { - foreach ( $request['payment_data'] as $data ) { - $payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] ); - } - } - - return $payment_data; - } - /** * Proceed with current request using new login session (to ensure consistent nonce). * Only apply during the checkout process with the account creation. @@ -1097,7 +1047,7 @@ public function process_payment( $order_id ) { // Check if session exists and we're currently not processing a WooPay request before instantiating `Fraud_Prevention_Service`. if ( WC()->session && ! apply_filters( 'wcpay_is_woopay_store_api_request', false ) ) { $fraud_prevention_service = Fraud_Prevention_Service::get_instance(); - // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( $fraud_prevention_service->is_enabled() && ! $fraud_prevention_service->verify_token( $_POST['wcpay-fraud-prevention-token'] ?? null ) ) { throw new Fraud_Prevention_Enabled_Exception( __( "We're not able to process this payment. Please refresh the page and try again.", 'woocommerce-payments' ), @@ -1114,7 +1064,7 @@ public function process_payment( $order_id ) { } // The request is a preflight check from WooPay. - // phpcs:ignore WordPress.Security.NonceVerification.Missing + // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ! empty( $_POST['is-woopay-preflight-check'] ) ) { // Set the order status to "pending payment". $order->update_status( 'pending' ); @@ -1242,30 +1192,6 @@ public function process_payment( $order_id ) { } } - /** - * Prepares the payment information object. - * - * @param WC_Order $order The order whose payment will be processed. - * @return Payment_Information An object, which describes the payment. - */ - protected function prepare_payment_information( $order ) { - // phpcs:ignore WordPress.Security.NonceVerification.Missing - $payment_information = Payment_Information::from_payment_request( $_POST, $order, Payment_Type::SINGLE(), Payment_Initiated_By::CUSTOMER(), $this->get_capture_type(), $this->get_payment_method_to_use_for_intent() ); - $payment_information = $this->maybe_prepare_subscription_payment_information( $payment_information, $order->get_id() ); - - if ( ! empty( $_POST[ 'wc-' . static::GATEWAY_ID . '-new-payment-method' ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing - // During normal orders the payment method is saved when the customer enters a new one and chooses to save it. - $payment_information->must_save_payment_method_to_store(); - } - - if ( $this->woopay_util->should_save_platform_customer() ) { - do_action( 'woocommerce_payments_save_user_in_woopay' ); - $payment_information->must_save_payment_method_to_platform(); - } - - return $payment_information; - } - /** * Update the customer details with the incoming order data, in a CRON job. * @@ -1327,35 +1253,6 @@ function ( $error ) use ( &$result ) { ); } - /** - * Manages customer details held on WCPay server for WordPress user associated with an order. - * - * @param WC_Order $order WC Order object. - * @param array $options Additional options to apply. - * - * @return array First element is the new or updated WordPress user, the second element is the WCPay customer ID. - */ - protected function manage_customer_details_for_order( $order, $options = [] ) { - $user = $order->get_user(); - if ( false === $user ) { - $user = wp_get_current_user(); - } - - // Determine the customer making the payment, create one if we don't have one already. - $customer_id = $this->customer_service->get_customer_id_by_user_id( $user->ID ); - - if ( null === $customer_id ) { - $customer_data = WC_Payments_Customer_Service::map_customer_data( $order, new WC_Customer( $user->ID ) ); - // Create a new customer. - $customer_id = $this->customer_service->create_customer_for_user( $user, $customer_data ); - } else { - // Update the customer with order data async. - $this->update_customer_with_order_data( $order, $customer_id, WC_Payments::mode()->is_test(), $options['is_woopay'] ?? false ); - } - - return [ $user, $customer_id ]; - } - /** * Update the saved payment method information with checkout values, in a CRON job. * @@ -1492,7 +1389,7 @@ public function process_payment_for_order( $cart, $payment_information, $schedul throw new Exception( WC_Payments_Utils::get_filtered_error_message( $e ) ); } - // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $woopay_intent_id = WooPay_Utilities::sanitize_intent_id( wp_unslash( $_POST['platform-checkout-intent'] ?? '' ) ); // Initializing the intent variable here to ensure we don't try to use an undeclared @@ -1606,7 +1503,7 @@ public function process_payment_for_order( $cart, $payment_information, $schedul $this->order_service->mark_payment_failed( $order, $intent_id, $status, $charge_id ); } } else { - // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $woopay_intent_id = WooPay_Utilities::sanitize_intent_id( wp_unslash( $_POST['platform-checkout-intent'] ?? '' ) ); if ( ! empty( $woopay_intent_id ) ) { @@ -2117,23 +2014,6 @@ public function get_payment_methods_from_gateway_id( $gateway_id, $order_id = nu return $payment_methods; } - /** - * Get values for Stripe mandate_data parameter - * - * @return array mandate_data values to use in request. - */ - private function get_mandate_data() { - return [ - 'customer_acceptance' => [ - 'type' => 'online', - 'online' => [ - 'ip_address' => WC_Geolocation::get_ip_address(), - 'user_agent' => 'WooCommerce Payments/' . WCPAY_VERSION_NUMBER . '; ' . get_bloginfo( 'url' ), - ], - ], - ]; - } - /** * Set formatted readable payment method title for order, * using payment method details from accompanying charge. @@ -2157,21 +2037,6 @@ public function set_payment_method_title_for_order( $order, $payment_method_type $order->save(); } - /** - * Prepares Stripe metadata for a given order. The metadata later injected into intents, and - * used in transactions listing/details. If merchant connects an account to new store, listing/details - * keeps working even if orders are not available anymore - the metadata provides needed details. - * - * @param WC_Order $order Order being processed. - * @param Payment_Type $payment_type Enum stating whether payment is single or recurring. - * - * @return array Array of keyed metadata values. - */ - protected function get_metadata_from_order( $order, $payment_type ) { - $service = wcpay_get_container()->get( OrderService::class ); - return $service->get_payment_metadata( $order->get_id(), $payment_type ); - } - /** * Given the charge data, checks if there was an exchange and adds it to the given order as metadata * @@ -2231,19 +2096,6 @@ public function add_token_to_order( $order, $token ) { $this->maybe_add_token_to_subscription_order( $order, $token ); } - /** - * Retrieve payment token from a subscription or order. - * - * @param WC_Order $order Order or subscription object. - * - * @return null|WC_Payment_Token Last token associated with order or subscription. - */ - protected function get_payment_token( $order ) { - $order_tokens = $order->get_payment_tokens(); - $token_id = end( $order_tokens ); - return ! $token_id ? null : WC_Payment_Tokens::get( $token_id ); - } - /** * Can the order be refunded? * @@ -2378,33 +2230,6 @@ public function has_refund_failed( $order ) { return Refund_Status::FAILED === $this->order_service->get_wcpay_refund_status_for_order( $order ); } - /** - * Gets the payment method type used for an order, if any - * - * @param WC_Order $order The order to get the payment method type for. - * - * @return string - */ - private function get_payment_method_type_for_order( $order ): string { - $payment_method_details = []; - if ( $this->order_service->get_payment_method_id_for_order( $order ) ) { - $payment_method_id = $this->order_service->get_payment_method_id_for_order( $order ); - $payment_method_details = $this->payments_api_client->get_payment_method( $payment_method_id ); - } elseif ( $this->order_service->get_intent_id_for_order( $order ) ) { - $payment_intent_id = $this->order_service->get_intent_id_for_order( $order ); - - $request = Get_Intention::create( $payment_intent_id ); - $request->set_hook_args( $order ); - - $payment_intent = $request->send(); - - $charge = $payment_intent ? $payment_intent->get_charge() : null; - $payment_method_details = $charge ? $charge->get_payment_method_details() : []; - } - - return $payment_method_details['type'] ?? 'unknown'; - } - /** * Get option from DB or connected account. * @@ -2556,15 +2381,6 @@ public function init_settings() { $this->enabled = ! empty( $this->settings[ static::METHOD_ENABLED_KEY ] ) && 'yes' === $this->settings[ static::METHOD_ENABLED_KEY ] ? 'yes' : 'no'; } - /** - * Get payment capture type from WCPay settings. - * - * @return Payment_Capture_Type MANUAL or AUTOMATIC depending on the settings. - */ - protected function get_capture_type() { - return 'yes' === $this->get_option( 'manual_capture' ) ? Payment_Capture_Type::MANUAL() : Payment_Capture_Type::AUTOMATIC(); - } - /** * Map fields that need to be updated and update the fields server side. * @@ -2644,1722 +2460,1930 @@ public function get_account_statement_descriptor_kana( string $empty_value = '' } /** - * Gets connected account business name. - * - * @param string $default_value Value to return when not connected or failed to fetch business name. + * Retrieves the domestic currency of the current account based on its country. + * It will fallback to the store's currency if the account's country is not supported. * - * @return string Business name or default value. + * @return string The domestic currency code. */ - protected function get_account_business_name( $default_value = '' ): string { - try { - if ( $this->is_connected() ) { - return $this->account->get_business_name(); - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get account business name.' . $e ); + public function get_account_domestic_currency(): string { + $merchant_country = strtoupper( $this->account->get_account_country() ); + $country_locale_data = $this->localization_service->get_country_locale_data( $merchant_country ); + + // Check for missing country locale data. + if ( ! isset( $country_locale_data['currency_code'] ) ) { + Logger::error( + sprintf( + 'Could not find locale data for merchant country: %s. Falling back to the merchant\'s default currency.', + $merchant_country + ) + ); + return $this->account->get_account_default_currency(); } - return $default_value; + return $country_locale_data['currency_code']; } /** - * Gets connected account business url. + * Gets connected account country. * - * @param string $default_value Value to return when not connected or failed to fetch business url. + * @param string $default_value Value to return when not connected or fails to fetch account details. Default is US. * - * @return string Business url or default value. + * @return string code of the country. */ - protected function get_account_business_url( $default_value = '' ): string { + public function get_account_country( string $default_value = Country_Code::UNITED_STATES ): string { try { if ( $this->is_connected() ) { - return $this->account->get_business_url(); + return $this->account->get_account_country() ?? $default_value; } } catch ( Exception $e ) { - Logger::error( 'Failed to get account business URL.' . $e ); + Logger::error( 'Failed to get account country.' . $e ); } - return $default_value; } /** - * Gets connected account business address. - * - * @param array $default_value Value to return when not connected or failed to fetch business address. + * Updates the fraud rules depending on some settings when those settings have changed. * - * @return array Business address or default value. + * @return void This is a readonly action. */ - protected function get_account_business_support_address( $default_value = [] ): array { - try { - if ( $this->is_connected() ) { - return $this->account->get_business_support_address(); - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get account business support address.' . $e ); + public function update_fraud_rules_based_on_general_options() { + // If the protection level is not "advanced", no need to run this, because it won't contain the IP country filter. + if ( 'advanced' !== $this->get_current_protection_level() ) { + return; } - return $default_value; - } + // If the ruleset can't be parsed, skip updating. + $ruleset = $this->get_advanced_fraud_protection_settings(); + if ( + 'error' === $ruleset + || ! is_array( $ruleset ) + || ! Fraud_Risk_Tools::is_valid_ruleset_array( $ruleset ) + ) { + return; + } - /** - * Gets connected account business support email. - * - * @param string $default_value Value to return when not connected or failed to fetch business support email. - * - * @return string Business support email or default value. - */ - protected function get_account_business_support_email( $default_value = '' ): string { - try { - if ( $this->is_connected() ) { - return $this->account->get_business_support_email(); + $needs_update = false; + foreach ( $ruleset as &$rule_array ) { + if ( isset( $rule_array['key'] ) && Fraud_Risk_Tools::RULE_INTERNATIONAL_IP_ADDRESS === $rule_array['key'] ) { + $new_rule_array = Fraud_Risk_Tools::get_international_ip_address_rule()->to_array(); + if ( isset( $rule_array['check'] ) + && isset( $new_rule_array['check'] ) + && wp_json_encode( $rule_array['check'] ) !== wp_json_encode( $new_rule_array['check'] ) + ) { + $rule_array = $new_rule_array; + $needs_update = true; + } } - } catch ( Exception $e ) { - Logger::error( 'Failed to get business support email.' . $e ); } - return $default_value; + // Update the possibly changed values on the server, and the transient. + if ( $needs_update ) { + $this->payments_api_client->save_fraud_ruleset( $ruleset ); + set_transient( 'wcpay_fraud_protection_settings', $ruleset, DAY_IN_SECONDS ); + } } /** - * Gets connected account business support phone. - * - * @param string $default_value Value to return when not connected or failed to fetch business support phone. + * The URL for the current payment method's icon. * - * @return string Business support phone or default value. + * @return string The payment method icon URL. */ - protected function get_account_business_support_phone( $default_value = '' ): string { - try { - if ( $this->is_connected() ) { - return $this->account->get_business_support_phone(); - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get account business support phone.' . $e ); - } - - return $default_value; + public function get_icon_url() { + return $this->payment_method->get_icon(); } /** - * Gets connected account branding logo. + * Handles connected account update when plugin settings saved. * - * @param string $default_value Value to return when not connected or failed to fetch branding logo. + * Adds error message to display in admin notices in case of failure. * - * @return string Business support branding logo or default value. - */ - protected function get_account_branding_logo( $default_value = '' ): string { - try { - if ( $this->is_connected() ) { - return $this->account->get_branding_logo(); - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get account branding logo.' . $e ); - } - - return $default_value; - } - - /** - * Gets connected account branding icon. + * @param array $account_settings Stripe account settings. + * Supported: statement_descriptor, business_name, business_url, business_support_address, + * business_support_email, business_support_phone, branding_logo, branding_icon, + * branding_primary_color, branding_secondary_color. * - * @param string $default_value Value to return when not connected or failed to fetch branding icon. + * $return array | WP_Error Update account result. * - * @return string Business support branding icon or default value. + * @throws Exception */ - protected function get_account_branding_icon( $default_value = '' ): string { - try { - if ( $this->is_connected() ) { - return $this->account->get_branding_icon(); - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get account\'s branding icon.' . $e ); + public function update_account( $account_settings ) { + if ( empty( $account_settings ) ) { + return; } - return $default_value; - } + $stripe_account_update_response = $this->account->update_stripe_account( $account_settings ); - /** - * Gets connected account branding primary color. - * - * @param string $default_value Value to return when not connected or failed to fetch branding primary color. - * - * @return string Business support branding primary color or default value. - */ - protected function get_account_branding_primary_color( $default_value = '' ): string { - try { - if ( $this->is_connected() ) { - return $this->account->get_branding_primary_color(); - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get account\'s branding primary color.' . $e ); + if ( is_wp_error( $stripe_account_update_response ) ) { + $msg = __( 'Failed to update Stripe account. ', 'woocommerce-payments' ) . $stripe_account_update_response->get_error_message(); + $this->add_error( $msg ); } - return $default_value; + return $stripe_account_update_response; } /** - * Gets connected account branding secondary color. + * Validates statement descriptor value * - * @param string $default_value Value to return when not connected or failed to fetch branding secondary color. + * @param string $key Field key. + * @param string $value Posted Value. * - * @return string Business support branding secondary color or default value. + * @return string Sanitized statement descriptor. + * @throws InvalidArgumentException When statement descriptor is invalid. */ - protected function get_account_branding_secondary_color( $default_value = '' ): string { - try { - if ( $this->is_connected() ) { - return $this->account->get_branding_secondary_color(); - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get account\'s branding secondary color.' . $e ); + public function validate_account_statement_descriptor_field( $key, $value ) { + // Since the value is escaped, and we are saving in a place that does not require escaping, apply stripslashes. + $value = trim( stripslashes( $value ) ); + + // Validation can be done with a single regex but splitting into multiple for better readability. + $valid_length = '/^.{5,22}$/'; + $has_one_letter = '/^.*[a-zA-Z]+/'; + $no_specials = '/^[^*"\'<>]*$/'; + + if ( + ! preg_match( $valid_length, $value ) || + ! preg_match( $has_one_letter, $value ) || + ! preg_match( $no_specials, $value ) + ) { + throw new InvalidArgumentException( __( 'Customer bank statement is invalid. Statement should be between 5 and 22 characters long, contain at least single Latin character and does not contain special characters: \' " * < >', 'woocommerce-payments' ) ); } - return $default_value; + return $value; } /** - * Retrieves the domestic currency of the current account based on its country. - * It will fallback to the store's currency if the account's country is not supported. + * Add capture and cancel actions for orders with an authorized charge. * - * @return string The domestic currency code. + * @param array $actions - Actions to make available in order actions metabox. */ - public function get_account_domestic_currency(): string { - $merchant_country = strtoupper( $this->account->get_account_country() ); - $country_locale_data = $this->localization_service->get_country_locale_data( $merchant_country ); + public function add_order_actions( $actions ) { + global $theorder; - // Check for missing country locale data. - if ( ! isset( $country_locale_data['currency_code'] ) ) { - Logger::error( - sprintf( - 'Could not find locale data for merchant country: %s. Falling back to the merchant\'s default currency.', - $merchant_country - ) - ); - return $this->account->get_account_default_currency(); + if ( ! is_object( $theorder ) ) { + return $actions; } - return $country_locale_data['currency_code']; - } - - /** - * Gets connected account deposit schedule interval. - * - * @param string $empty_value Empty value to return when not connected or fails to fetch deposit schedule. - * - * @return string Interval or default value. - */ - protected function get_deposit_schedule_interval( string $empty_value = '' ): string { - try { - if ( $this->is_connected() ) { - return $this->account->get_deposit_schedule_interval(); - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get deposit schedule interval.' . $e ); + if ( $this->id !== $theorder->get_payment_method() ) { + return $actions; } - return $empty_value; - } - /** - * Gets connected account deposit schedule weekly anchor. - * - * @param string $empty_value Empty value to return when not connected or fails to fetch deposit schedule weekly anchor. - * - * @return string Weekly anchor or default value. - */ - protected function get_deposit_schedule_weekly_anchor( string $empty_value = '' ): string { - try { - if ( $this->is_connected() ) { - return $this->account->get_deposit_schedule_weekly_anchor(); - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get deposit schedule weekly anchor.' . $e ); + if ( Intent_Status::REQUIRES_CAPTURE !== $this->order_service->get_intention_status_for_order( $theorder ) ) { + return $actions; } - return $empty_value; - } - /** - * Gets connected account deposit schedule monthly anchor. - * - * @param int|null $empty_value Empty value to return when not connected or fails to fetch deposit schedule monthly anchor. - * - * @return int|null Monthly anchor or default value. - */ - protected function get_deposit_schedule_monthly_anchor( $empty_value = null ) { - try { - if ( $this->is_connected() ) { - return $this->account->get_deposit_schedule_monthly_anchor(); - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get deposit schedule monthly anchor.' . $e ); + // if order is already completed, we shouldn't capture the charge anymore. + if ( in_array( $theorder->get_status(), wc_get_is_paid_statuses(), true ) ) { + return $actions; } - return null === $empty_value ? null : (int) $empty_value; + + $new_actions = [ + 'capture_charge' => __( 'Capture charge', 'woocommerce-payments' ), + 'cancel_authorization' => __( 'Cancel authorization', 'woocommerce-payments' ), + ]; + + return array_merge( $new_actions, $actions ); } /** - * Gets connected account deposit delay days. + * Capture previously authorized charge. * - * @param int $default_value Value to return when not connected or fails to fetch deposit delay days. Default is 7 days. + * @param WC_Order $order - Order to capture charge on. + * @param bool $include_level3 - Whether to include level 3 data in payment intent. + * @param array $intent_metadata - Intent metadata retrieved earlier in the calling method. * - * @return int number of days. + * @return array An array containing the status (succeeded/failed), id (intent ID), message (error message if any), and http code */ - protected function get_deposit_delay_days( int $default_value = 7 ): int { + public function capture_charge( $order, $include_level3 = true, $intent_metadata = [] ) { + $intent_id = null; + $amount = $order->get_total(); + $is_authorization_expired = false; + $intent = null; + $status = null; + $error_message = null; + $http_code = null; + $error_code = null; + try { - if ( $this->is_connected() ) { - return $this->account->get_deposit_delay_days() ?? $default_value; - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get deposit delay days.' . $e ); - } - return $default_value; - } + $intent_id = $order->get_transaction_id(); + $payment_type = $this->is_payment_recurring( $order->get_id() ) ? Payment_Type::RECURRING() : Payment_Type::SINGLE(); + $metadata_from_order = $this->get_metadata_from_order( $order, $payment_type ); + $merged_metadata = array_merge( (array) $metadata_from_order, (array) $intent_metadata ); // prioritize metadata from mobile app. - /** - * Gets connected account country. - * - * @param string $default_value Value to return when not connected or fails to fetch account details. Default is US. - * - * @return string code of the country. - */ - public function get_account_country( string $default_value = Country_Code::UNITED_STATES ): string { - try { - if ( $this->is_connected() ) { - return $this->account->get_account_country() ?? $default_value; + $capture_intention_request = Capture_Intention::create( $intent_id ); + $capture_intention_request->set_amount_to_capture( WC_Payments_Utils::prepare_amount( $amount, $order->get_currency() ) ); + $capture_intention_request->set_metadata( $merged_metadata ); + $capture_intention_request->set_hook_args( $order ); + if ( $include_level3 ) { + $capture_intention_request->set_level3( $this->get_level3_data_from_order( $order ) ); } - } catch ( Exception $e ) { - Logger::error( 'Failed to get account country.' . $e ); - } - return $default_value; - } + $intent = $capture_intention_request->send(); - /** - * Gets connected account deposit status. - * - * @param string $empty_value Empty value to return when not connected or fails to fetch deposit status. - * - * @return string deposit status or default value. - */ - protected function get_deposit_status( string $empty_value = '' ): string { - try { - if ( $this->is_connected() ) { - return $this->account->get_deposit_status(); - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get deposit status.' . $e ); - } - return $empty_value; - } + $status = $intent->get_status(); + $http_code = 200; + } catch ( API_Exception $e ) { + try { + $error_message = $e->getMessage(); + $http_code = $e->get_http_code(); + $error_code = $e->get_error_code(); + $extra_details = []; - /** - * Gets connected account deposit restrictions. - * - * @param string $empty_value Empty value to return when not connected or fails to fetch deposit restrictions. - * - * @return string deposit restrictions or default value. - */ - protected function get_deposit_restrictions( string $empty_value = '' ): string { - try { - if ( $this->is_connected() ) { - return $this->account->get_deposit_restrictions(); - } - } catch ( Exception $e ) { - Logger::error( 'Failed to get deposit restrictions.' . $e ); - } - return $empty_value; - } + if ( $e instanceof Amount_Too_Small_Exception ) { + $extra_details = [ + 'minimum_amount' => $e->get_minimum_amount(), + 'minimum_amount_currency' => strtoupper( $e->get_currency() ), + ]; + $minimum_amount_details = sprintf( + /* translators: %s: formatted minimum amount with currency */ + __( 'The minimum amount to capture is %s.', 'woocommerce-payments' ), + WC_Payments_Utils::format_explicit_currency( + WC_Payments_Utils::interpret_stripe_amount( $e->get_minimum_amount(), $e->get_currency() ), + $e->get_currency() + ) + ); + $error_message = $error_message . ' ' . $minimum_amount_details; + } - /** - * Gets the completed deposit waiting period value. - * - * @param bool $empty_value Empty value to return when not connected or fails to fetch the completed deposit waiting period value. - * - * @return bool The completed deposit waiting period value or default value. - */ - protected function get_deposit_completed_waiting_period( bool $empty_value = false ): bool { - try { - if ( $this->is_connected() ) { - return $this->account->get_deposit_completed_waiting_period(); + $request = Get_Intention::create( $intent_id ); + $request->set_hook_args( $order ); + // Fetch the Intent to check if it's already expired and the site missed the "charge.expired" webhook. + $intent = $request->send(); + + if ( Intent_Status::CANCELED === $intent->get_status() ) { + $is_authorization_expired = true; + } + } catch ( API_Exception $ge ) { + // Ignore any errors during the intent retrieval, and add the failed capture note below with the + // original error message. + $status = null; + $error_message = $e->getMessage(); + $http_code = $e->get_http_code(); + $error_code = $e->get_error_code(); } - } catch ( Exception $e ) { - Logger::error( 'Failed to get the deposit waiting period value.' . $e ); } - return $empty_value; - } - /** - * Gets the current fraud protection level value. - * - * @return string The current fraud protection level. - */ - protected function get_current_protection_level() { - $this->maybe_refresh_fraud_protection_settings(); - return get_option( 'current_protection_level', 'basic' ); - } + Tracker::track_admin( 'wcpay_merchant_captured_auth' ); - /** - * Gets the advanced fraud protection level settings value. - * - * @return array|string The advanced level fraud settings for the store, if not saved, the default ones. - * If there's a fetch error, it returns "error". - */ - protected function get_advanced_fraud_protection_settings() { - // Check if Stripe is connected. - if ( ! $this->is_connected() ) { - return []; + // There is a possibility of the intent being null, so we need to get the charge_id safely. + $charge = ! empty( $intent ) ? $intent->get_charge() : null; + $charge_id = ! empty( $charge ) ? $charge->get_id() : $this->order_service->get_charge_id_for_order( $order ); + + $this->attach_exchange_info_to_order( $order, $charge_id ); + + if ( Intent_Status::SUCCEEDED === $status ) { + $this->order_service->process_captured_payment( $order, $intent ); + } elseif ( $is_authorization_expired ) { + $this->order_service->mark_payment_capture_expired( $order, $intent_id, Intent_Status::CANCELED, $charge_id ); + } else { + if ( ! empty( $error_message ) ) { + $error_message = esc_html( $error_message ); + } else { + $http_code = 502; + } + + $this->order_service->mark_payment_capture_failed( $order, $intent_id, Intent_Status::REQUIRES_CAPTURE, $charge_id, $error_message ); } - $this->maybe_refresh_fraud_protection_settings(); - $transient_value = get_transient( 'wcpay_fraud_protection_settings' ); - return false === $transient_value ? 'error' : $transient_value; + return [ + 'status' => $status ?? 'failed', + 'id' => ! empty( $intent ) ? $intent->get_id() : null, + 'message' => $error_message, + 'http_code' => $http_code, + 'error_code' => $error_code, + 'extra_details' => $extra_details ?? [], + ]; } /** - * Checks if a fraud protection rule is enabled. - * - * @param string $rule The rule to check. + * Cancel previously authorized charge. * - * @return bool True if the rule is enabled, false otherwise. + * @param WC_Order $order - Order to cancel authorization on. */ - protected function is_fraud_rule_enabled( string $rule ): bool { - $settings = $this->get_advanced_fraud_protection_settings(); + public function cancel_authorization( $order ) { + $intent = null; + $status = null; + $error_message = null; + $http_code = null; - if ( ! is_array( $settings ) ) { - return false; + try { + $request = Cancel_Intention::create( $order->get_transaction_id() ); + $request->set_hook_args( $order ); + $intent = $request->send(); + $status = $intent->get_status(); + $http_code = 200; + } catch ( API_Exception $e ) { + try { + // Fetch the Intent to check if it's already expired and the site missed the "charge.expired" webhook. + $request = Get_Intention::create( $order->get_transaction_id() ); + $request->set_hook_args( $order ); + $intent = $request->send(); + + $status = $intent->get_status(); + if ( Intent_Status::CANCELED !== $status ) { + $error_message = $e->getMessage(); + } + } catch ( API_Exception $ge ) { + // Ignore any errors during the intent retrieval, and add the failed cancellation note below with the + // original error message. + $status = null; + $error_message = $e->getMessage(); + $http_code = $e->get_http_code(); + } } - foreach ( $settings as $setting ) { - if ( $rule === $setting['key'] ) { - return true; + if ( Intent_Status::CANCELED === $status ) { + $this->order_service->update_order_status_from_intent( $order, $intent ); + } else { + if ( ! empty( $error_message ) ) { + $note = sprintf( + WC_Payments_Utils::esc_interpolated_html( + /* translators: %1: error message */ + __( + 'Canceling authorization failed to complete with the following message: %1$s.', + 'woocommerce-payments' + ), + [ + 'strong' => '', + 'code' => '', + ] + ), + esc_html( $error_message ) + ); + $order->add_order_note( $note ); + } else { + $order->add_order_note( + WC_Payments_Utils::esc_interpolated_html( + __( 'Canceling authorization failed to complete.', 'woocommerce-payments' ), + [ 'strong' => '' ] + ) + ); } + + $this->order_service->set_intention_status_for_order( $order, $status ); + $order->save(); + $http_code = 502; } - return false; + return [ + 'status' => $status ?? 'failed', + 'id' => ! empty( $intent ) ? $intent->get_id() : null, + 'message' => $error_message, + 'http_code' => $http_code, + ]; } /** - * Checks if the transaction was blocked by AVS verification fraud rule. - * - * @param string|null $error_code The error code to check. - * @param string|null $error_type The error type to check. + * Create the level 3 data array to send to Stripe when making a purchase. * - * @return bool True if the transaction was blocked by the AVS verification fraud rule, false otherwise. + * @param WC_Order $order The order that is being paid for. + * @return array The level 3 data to send to Stripe. */ - private function is_blocked_by_avs_verification_fraud_rule( ?string $error_code, ?string $error_type ): bool { - $is_avs_verification_rule_enabled = $this->is_fraud_rule_enabled( 'avs_verification' ); - $is_incorrect_zip_error = 'card_error' === $error_type && 'incorrect_zip' === $error_code; - - return $is_avs_verification_rule_enabled && $is_incorrect_zip_error; + public function get_level3_data_from_order( WC_Order $order ): array { + return wcpay_get_container()->get( Level3Service::class )->get_data_from_order( $order->get_id() ); } /** - * Checks if the transaction was blocked by fraud rules. + * Handle AJAX request after authenticating payment at checkout. * - * @param Exception $e The exception to check. + * This function is used to update the order status after the user has + * been asked to authenticate their payment. * - * @return bool True if the transaction was blocked by fraud rules, false otherwise. + * This function is used for both: + * - regular checkout + * - Pay for Order page + * + * @throws Exception - If nonce is invalid. */ - protected function is_blocked_by_fraud_rules( Exception $e ): bool { - if ( ! ( $e instanceof API_Exception ) ) { - return false; - } - - $error_code = $e->get_error_code() ?? null; - $error_type = $e->get_error_type() ?? null; + public function update_order_status() { + $intent_id_received = null; + $order = null; + try { + $is_nonce_valid = check_ajax_referer( 'wcpay_update_order_status_nonce', false, false ); + if ( ! $is_nonce_valid ) { + throw new Process_Payment_Exception( + __( "We're not able to process this payment. Please refresh the page and try again.", 'woocommerce-payments' ), + 'invalid_referrer' + ); + } - $blocked_by_fraud_rule = 'wcpay_blocked_by_fraud_rule' === $error_code; + $order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : false; + $order = wc_get_order( $order_id ); + if ( ! $order ) { + throw new Process_Payment_Exception( + __( "We're not able to process this payment. Please try again later.", 'woocommerce-payments' ), + 'order_not_found' + ); + } - // Since the AVS mismatch is part of the advanced fraud prevention, we need to consider that as a blocked order. - $blocked_by_avs_mismatch = $this->is_blocked_by_avs_verification_fraud_rule( $error_code, $error_type ); + $intent_id = $this->order_service->get_intent_id_for_order( $order ); + $intent_id_received = isset( $_POST['intent_id'] ) + ? sanitize_text_field( wp_unslash( $_POST['intent_id'] ) ) + /* translators: This will be used to indicate an unknown value for an ID. */ + : __( 'unknown', 'woocommerce-payments' ); - return $blocked_by_fraud_rule || $blocked_by_avs_mismatch; - } + if ( empty( $intent_id ) ) { + throw new Intent_Authentication_Exception( + __( "We're not able to process this payment. Please try again later.", 'woocommerce-payments' ), + 'empty_intent_id' + ); + } - /** - * Checks the synchronicity of fraud protection settings with the server, and updates the local cache when needed. - * - * @return void - */ - protected function maybe_refresh_fraud_protection_settings() { - // It'll be good to run this only once per call, because if it succeeds, the latter won't require - // to run again, and if it fails, it will fail on other calls too. - static $runonce = false; + // Check that the intent saved in the order matches the intent used as part of the + // authentication process. The ID of the intent used is sent with + // the AJAX request. We are about to use the status of the intent saved in + // the order, so we need to make sure the intent that was used for authentication + // is the same as the one we're using to update the status. + if ( $intent_id !== $intent_id_received ) { + throw new Intent_Authentication_Exception( + __( "We're not able to process this payment. Please try again later.", 'woocommerce-payments' ), + 'intent_id_mismatch' + ); + } - // If already ran this before on this call, return. - if ( $runonce ) { - return; - } + $amount = $order->get_total(); + $payment_method_details = false; + $is_changing_payment = isset( $_POST['is_changing_payment'] ) && filter_var( wp_unslash( $_POST['is_changing_payment'] ), FILTER_VALIDATE_BOOLEAN ); - // Check if we have local cache available before pulling it from the server. - // If the transient exists, do nothing. - $cached_server_settings = get_transient( 'wcpay_fraud_protection_settings' ); + if ( $amount > 0 && ! $is_changing_payment ) { + // An exception is thrown if an intent can't be found for the given intent ID. + $request = Get_Intention::create( $intent_id ); + $request->set_hook_args( $order ); + $intent = $request->send(); - if ( false === $cached_server_settings ) { - // When both local and server values don't exist, we need to reset the protection level on both to "Basic". - $needs_reset = false; + $status = $intent->get_status(); + $charge = $intent->get_charge(); + $charge_id = ! empty( $charge ) ? $charge->get_id() : null; - try { - // There's no cached ruleset, or the cache has expired. Try to fetch it from the server. - $latest_server_ruleset = $this->payments_api_client->get_latest_fraud_ruleset(); - if ( isset( $latest_server_ruleset['ruleset_config'] ) ) { - // Update the local cache from the server. - set_transient( 'wcpay_fraud_protection_settings', $latest_server_ruleset['ruleset_config'], DAY_IN_SECONDS ); - // Get the matching level for the ruleset, and set the option. - update_option( 'current_protection_level', Fraud_Risk_Tools::get_matching_protection_level( $latest_server_ruleset['ruleset_config'] ) ); - return; - } - // If the response doesn't contain a ruleset, probably there's an error. Grey out the form. - } catch ( API_Exception $ex ) { - if ( 'wcpay_fraud_ruleset_not_found' === $ex->get_error_code() ) { - // If fetching returned a 'wcpay_fraud_ruleset_not_found' exception, save the basic protection as the server ruleset, - // and update the client with the same config. - $needs_reset = true; - } - // If the exception isn't what we want, probably there's an error. Grey out the form. + $this->attach_exchange_info_to_order( $order, $charge_id ); + $this->order_service->attach_intent_info_to_order( $order, $intent ); + $this->order_service->attach_transaction_fee_to_order( $order, $charge ); + } else { + // For $0 orders, fetch the Setup Intent instead. + $setup_intent_request = Get_Setup_Intention::create( $intent_id ); + /** @var WC_Payments_API_Setup_Intention $setup_intent */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort + $intent = $setup_intent_request->send(); + $status = $intent->get_status(); + $charge_id = ''; } - if ( $needs_reset ) { - // Set the Basic protection level as the default on both client and server. - $basic_protection_settings = Fraud_Risk_Tools::get_basic_protection_settings(); - $this->payments_api_client->save_fraud_ruleset( $basic_protection_settings ); - set_transient( 'wcpay_fraud_protection_settings', $basic_protection_settings, DAY_IN_SECONDS ); - update_option( 'current_protection_level', 'basic' ); + $payment_method_id = $intent->get_payment_method_id(); + + if ( Intent_Status::SUCCEEDED === $status ) { + $this->duplicate_payment_prevention_service->remove_session_processing_order( $order->get_id() ); } + $this->order_service->update_order_status_from_intent( $order, $intent ); - // Set the static flag to prevent duplicate calls to this method. - $runonce = true; - } - } + if ( $intent->is_authorized() ) { + wc_maybe_reduce_stock_levels( $order_id ); + WC()->cart->empty_cart(); - /** - * Updates the fraud rules depending on some settings when those settings have changed. - * - * @return void This is a readonly action. - */ - public function update_fraud_rules_based_on_general_options() { - // If the protection level is not "advanced", no need to run this, because it won't contain the IP country filter. - if ( 'advanced' !== $this->get_current_protection_level() ) { - return; - } + $is_subscription = function_exists( 'wcs_order_contains_subscription' ) && wcs_order_contains_subscription( $order ); + $should_save_payment_method = $is_subscription || ( isset( $_POST['should_save_payment_method'] ) && 'true' === $_POST['should_save_payment_method'] ); + if ( $should_save_payment_method && ! empty( $payment_method_id ) ) { + try { + $token = $this->token_service->add_payment_method_to_user( $payment_method_id, wp_get_current_user() ); + $this->add_token_to_order( $order, $token ); - // If the ruleset can't be parsed, skip updating. - $ruleset = $this->get_advanced_fraud_protection_settings(); - if ( - 'error' === $ruleset - || ! is_array( $ruleset ) - || ! Fraud_Risk_Tools::is_valid_ruleset_array( $ruleset ) - ) { - return; - } + if ( ! empty( $token ) ) { + $payment_method_type = $this->get_payment_method_type_for_setup_intent( $intent, $token ); + $this->set_payment_method_title_for_order( $order, $payment_method_type, $payment_method_details ); + } + } catch ( Exception $e ) { + // If saving the token fails, log the error message but catch the error to avoid crashing the checkout flow. + Logger::log( 'Error when saving payment method: ' . $e->getMessage() ); + } + } - $needs_update = false; - foreach ( $ruleset as &$rule_array ) { - if ( isset( $rule_array['key'] ) && Fraud_Risk_Tools::RULE_INTERNATIONAL_IP_ADDRESS === $rule_array['key'] ) { - $new_rule_array = Fraud_Risk_Tools::get_international_ip_address_rule()->to_array(); - if ( isset( $rule_array['check'] ) - && isset( $new_rule_array['check'] ) - && wp_json_encode( $rule_array['check'] ) !== wp_json_encode( $new_rule_array['check'] ) - ) { - $rule_array = $new_rule_array; - $needs_update = true; + $return_url = $this->get_return_url( $order ); + + if ( $is_changing_payment ) { + $payment_token = $this->get_payment_token( $order ); + if ( class_exists( 'WC_Subscriptions_Change_Payment_Gateway' ) ) { + WC_Subscriptions_Change_Payment_Gateway::update_payment_method( $order, $payment_token->get_gateway_id() ); + $notice = __( 'Payment method updated.', 'woocommerce-payments' ); + + if ( WC_Subscriptions_Change_Payment_Gateway::will_subscription_update_all_payment_methods( $order ) && WC_Subscriptions_Change_Payment_Gateway::update_all_payment_methods_from_subscription( $order, $token->get_gateway_id() ) ) { + $notice = __( 'Payment method updated for all your current subscriptions.', 'woocommerce-payments' ); + } + + wc_add_notice( $notice ); + } + $return_url = method_exists( $order, 'get_view_order_url' ) ? $order->get_view_order_url() : $this->get_return_url( $order ); } + + // Send back redirect URL in the successful case. + echo wp_json_encode( + [ + 'return_url' => $return_url, + ] + ); + wp_die(); } - } + } catch ( Intent_Authentication_Exception $e ) { + $error_code = $e->get_error_code(); - // Update the possibly changed values on the server, and the transient. - if ( $needs_update ) { - $this->payments_api_client->save_fraud_ruleset( $ruleset ); - set_transient( 'wcpay_fraud_protection_settings', $ruleset, DAY_IN_SECONDS ); + switch ( $error_code ) { + case 'intent_id_mismatch': + case 'empty_intent_id': // The empty_intent_id case needs the same handling. + $note = sprintf( + WC_Payments_Utils::esc_interpolated_html( + /* translators: %1: transaction ID of the payment or a translated string indicating an unknown ID. */ + __( 'A payment with ID %1$s was used in an attempt to pay for this order. This payment intent ID does not match any payments for this order, so it was ignored and the order was not updated.', 'woocommerce-payments' ), + [ + 'code' => '', + ] + ), + $intent_id_received + ); + $order->add_order_note( $note ); + break; + } + + // Send back error so it can be displayed to the customer. + echo wp_json_encode( + [ + 'error' => [ + 'message' => $e->getMessage(), + ], + ] + ); + wp_die(); + } catch ( Exception $e ) { + // Send back error so it can be displayed to the customer. + echo wp_json_encode( + [ + 'error' => [ + 'message' => $e->getMessage(), + ], + ] + ); + wp_die(); } } /** - * The URL for the current payment method's icon. + * Add payment method via account screen. * - * @return string The payment method icon URL. + * @throws Add_Payment_Method_Exception If payment method is missing. */ - public function get_icon_url() { - return $this->payment_method->get_icon(); - } + public function add_payment_method() { + try { - /** - * Handles connected account update when plugin settings saved. - * - * Adds error message to display in admin notices in case of failure. - * - * @param array $account_settings Stripe account settings. - * Supported: statement_descriptor, business_name, business_url, business_support_address, - * business_support_email, business_support_phone, branding_logo, branding_icon, - * branding_primary_color, branding_secondary_color. - * - * $return array | WP_Error Update account result. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( ! isset( $_POST['wcpay-setup-intent'] ) ) { + throw new Add_Payment_Method_Exception( + sprintf( + /* translators: %s: WooPayments */ + __( 'A %s payment method was not provided', 'woocommerce-payments' ), + 'WooPayments' + ), + 'payment_method_intent_not_provided' + ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash + $setup_intent_id = ! empty( $_POST['wcpay-setup-intent'] ) ? wc_clean( $_POST['wcpay-setup-intent'] ) : false; + + $customer_id = $this->customer_service->get_customer_id_by_user_id( get_current_user_id() ); + + if ( ! $setup_intent_id || null === $customer_id ) { + throw new Add_Payment_Method_Exception( + __( "We're not able to add this payment method. Please try again later", 'woocommerce-payments' ), + 'invalid_setup_intent_id' + ); + } + + $setup_intent_request = Get_Setup_Intention::create( $setup_intent_id ); + /** @var WC_Payments_API_Setup_Intention $setup_intent */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort + $setup_intent = $setup_intent_request->send(); + + if ( Intent_Status::SUCCEEDED !== $setup_intent->get_status() ) { + throw new Add_Payment_Method_Exception( + __( 'Failed to add the provided payment method. Please try again later', 'woocommerce-payments' ), + 'invalid_response_status' + ); + } + + $payment_method = $setup_intent->get_payment_method_id(); + $this->token_service->add_payment_method_to_user( $payment_method, wp_get_current_user() ); + + return [ + 'result' => 'success', + 'redirect' => apply_filters( 'wcpay_get_add_payment_method_redirect_url', wc_get_endpoint_url( 'payment-methods' ) ), + ]; + } catch ( Exception $e ) { + wc_add_notice( WC_Payments_Utils::get_filtered_error_message( $e ), 'error', [ 'icon' => 'error' ] ); + Logger::log( 'Error when adding payment method: ' . $e->getMessage() ); + return [ + 'result' => 'error', + ]; + } + } + + /** + * When an order is created/updated, we want to add an ActionScheduler job to send this data to + * the payment server. * - * @throws Exception + * @param int $order_id The ID of the order that has been created. + * @param WC_Order|null $order The order that has been created. */ - public function update_account( $account_settings ) { - if ( empty( $account_settings ) ) { + public function schedule_order_tracking( $order_id, $order = null ) { + $this->maybe_schedule_subscription_order_tracking( $order_id, $order ); + + // If Sift is not enabled, exit out and don't do the tracking here. + if ( ! isset( $this->fraud_service->get_fraud_services_config()['sift'] ) ) { return; } - $stripe_account_update_response = $this->account->update_stripe_account( $account_settings ); + // Sometimes the woocommerce_update_order hook might be called with just the order ID parameter, + // so we need to fetch the order here. + if ( is_null( $order ) ) { + $order = wc_get_order( $order_id ); + } - if ( is_wp_error( $stripe_account_update_response ) ) { - $msg = __( 'Failed to update Stripe account. ', 'woocommerce-payments' ) . $stripe_account_update_response->get_error_message(); - $this->add_error( $msg ); + // We only want to track orders created by our payment gateway, and orders with a payment method set. + if ( $order->get_payment_method() !== self::GATEWAY_ID || empty( $this->order_service->get_payment_method_id_for_order( $order ) ) ) { + return; } - return $stripe_account_update_response; + // Check whether this is an order we haven't previously tracked a creation event for. + if ( $order->get_meta( '_new_order_tracking_complete' ) !== 'yes' ) { + // Schedule the action to send this information to the payment server. + $this->action_scheduler_service->schedule_job( + strtotime( '+5 seconds' ), + 'wcpay_track_new_order', + [ 'order_id' => $order_id ] + ); + } else { + // Schedule an update action to send this information to the payment server. + $this->action_scheduler_service->schedule_job( + strtotime( '+5 seconds' ), + 'wcpay_track_update_order', + [ 'order_id' => $order_id ] + ); + } } /** - * Validates statement descriptor value + * Create a payment intent without confirming the intent. * - * @param string $key Field key. - * @param string $value Posted Value. + * @param WC_Order $order - Order based on which to create intent. + * @param array $payment_methods - A list of allowed payment methods. Eg. card, card_present. + * @param string $capture_method - Controls when the funds will be captured from the customer's account ("automatic" or "manual"). + * It must be "manual" for in-person (terminal) payments. * - * @return string Sanitized statement descriptor. - * @throws InvalidArgumentException When statement descriptor is invalid. + * @param array $metadata - A list of intent metadata. + * @param string|null $customer_id - Customer id for intent. + * + * @return array|WP_Error On success, an array containing info about the newly created intent. On failure, WP_Error object. + * + * @throws Exception - When an error occurs in intent creation. */ - public function validate_account_statement_descriptor_field( $key, $value ) { - // Since the value is escaped, and we are saving in a place that does not require escaping, apply stripslashes. - $value = trim( stripslashes( $value ) ); + public function create_intent( WC_Order $order, array $payment_methods, string $capture_method = 'automatic', array $metadata = [], ?string $customer_id = null ) { + $currency = strtolower( $order->get_currency() ); + $converted_amount = WC_Payments_Utils::prepare_amount( $order->get_total(), $currency ); + $order_number = $order->get_order_number(); + if ( $order_number ) { + $metadata['order_number'] = $order_number; + } - // Validation can be done with a single regex but splitting into multiple for better readability. - $valid_length = '/^.{5,22}$/'; - $has_one_letter = '/^.*[a-zA-Z]+/'; - $no_specials = '/^[^*"\'<>]*$/'; + try { + $request = Create_Intention::create(); + $request->set_amount( $converted_amount ); + $request->set_customer( $customer_id ); + $request->set_currency_code( $currency ); + $request->set_metadata( $metadata ); + $request->set_payment_method_types( $payment_methods ); + $request->set_capture_method( $capture_method ); + $request->set_hook_args( $order ); + $intent = $request->send(); - if ( - ! preg_match( $valid_length, $value ) || - ! preg_match( $has_one_letter, $value ) || - ! preg_match( $no_specials, $value ) - ) { - throw new InvalidArgumentException( __( 'Customer bank statement is invalid. Statement should be between 5 and 22 characters long, contain at least single Latin character and does not contain special characters: \' " * < >', 'woocommerce-payments' ) ); + return [ + 'id' => ! empty( $intent ) ? $intent->get_id() : null, + ]; + } catch ( API_Exception $e ) { + return new WP_Error( + 'wcpay_intent_creation_error', + sprintf( + // translators: %s: the error message. + __( 'Intent creation failed with the following message: %s', 'woocommerce-payments' ), + $e->getMessage() ?? __( 'Unknown error', 'woocommerce-payments' ) + ), + [ 'status' => $e->get_http_code() ] + ); } - - return $value; } /** - * Add capture and cancel actions for orders with an authorized charge. + * Create a setup intent when adding cards using the my account page. * - * @param array $actions - Actions to make available in order actions metabox. + * @return WC_Payments_API_Setup_Intention + * + * @throws API_Exception + * @throws \WCPay\Core\Exceptions\Server\Request\Extend_Request_Exception + * @throws \WCPay\Core\Exceptions\Server\Request\Immutable_Parameter_Exception + * @throws \WCPay\Core\Exceptions\Server\Request\Invalid_Request_Parameter_Exception */ - public function add_order_actions( $actions ) { - global $theorder; - - if ( ! is_object( $theorder ) ) { - return $actions; - } - - if ( $this->id !== $theorder->get_payment_method() ) { - return $actions; - } + public function create_and_confirm_setup_intent() { + $payment_information = Payment_Information::from_payment_request( $_POST, null, null, null, null, $this->get_payment_method_to_use_for_intent() ); // phpcs:ignore WordPress.Security.NonceVerification + $should_save_in_platform_account = false; - if ( Intent_Status::REQUIRES_CAPTURE !== $this->order_service->get_intention_status_for_order( $theorder ) ) { - return $actions; + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( ! empty( $_POST['save_payment_method_in_platform_account'] ) && filter_var( wp_unslash( $_POST['save_payment_method_in_platform_account'] ), FILTER_VALIDATE_BOOLEAN ) ) { + $should_save_in_platform_account = true; } - // if order is already completed, we shouldn't capture the charge anymore. - if ( in_array( $theorder->get_status(), wc_get_is_paid_statuses(), true ) ) { - return $actions; + // Determine the customer adding the payment method, create one if we don't have one already. + $user = wp_get_current_user(); + $customer_id = $this->customer_service->get_customer_id_by_user_id( $user->ID ); + if ( null === $customer_id ) { + $customer_data = WC_Payments_Customer_Service::map_customer_data( null, new WC_Customer( $user->ID ) ); + $customer_id = $this->customer_service->create_customer_for_user( $user, $customer_data ); } - $new_actions = [ - 'capture_charge' => __( 'Capture charge', 'woocommerce-payments' ), - 'cancel_authorization' => __( 'Cancel authorization', 'woocommerce-payments' ), - ]; - - return array_merge( $new_actions, $actions ); + $request = Create_And_Confirm_Setup_Intention::create(); + $request->set_customer( $customer_id ); + $request->set_payment_method( $payment_information->get_payment_method() ); + $request->assign_hook( 'wcpay_create_and_confirm_setup_intention_request' ); + $request->set_hook_args( $payment_information, $should_save_in_platform_account, false ); + return $request->send(); } /** - * Capture previously authorized charge. - * - * @param WC_Order $order - Order to capture charge on. - * @param bool $include_level3 - Whether to include level 3 data in payment intent. - * @param array $intent_metadata - Intent metadata retrieved earlier in the calling method. + * Handle AJAX request for creating a setup intent when adding cards using the my account page. * - * @return array An array containing the status (succeeded/failed), id (intent ID), message (error message if any), and http code + * @throws Add_Payment_Method_Exception - If nonce or setup intent is invalid. */ - public function capture_charge( $order, $include_level3 = true, $intent_metadata = [] ) { - $intent_id = null; - $amount = $order->get_total(); - $is_authorization_expired = false; - $intent = null; - $status = null; - $error_message = null; - $http_code = null; - $error_code = null; - + public function create_setup_intent_ajax() { try { - $intent_id = $order->get_transaction_id(); - $payment_type = $this->is_payment_recurring( $order->get_id() ) ? Payment_Type::RECURRING() : Payment_Type::SINGLE(); - $metadata_from_order = $this->get_metadata_from_order( $order, $payment_type ); - $merged_metadata = array_merge( (array) $metadata_from_order, (array) $intent_metadata ); // prioritize metadata from mobile app. + if ( ! check_ajax_referer( 'wcpay_create_setup_intent_nonce', false, false ) ) { + throw new Add_Payment_Method_Exception( + __( "We're not able to add this payment method. Please refresh the page and try again.", 'woocommerce-payments' ), + 'invalid_referrer' + ); + } - $capture_intention_request = Capture_Intention::create( $intent_id ); - $capture_intention_request->set_amount_to_capture( WC_Payments_Utils::prepare_amount( $amount, $order->get_currency() ) ); - $capture_intention_request->set_metadata( $merged_metadata ); - $capture_intention_request->set_hook_args( $order ); - if ( $include_level3 ) { - $capture_intention_request->set_level3( $this->get_level3_data_from_order( $order ) ); + if ( WC_Rate_Limiter::retried_too_soon( 'add_payment_method_' . get_current_user_id() ) ) { + throw new Add_Payment_Method_Exception( + __( 'You cannot add a new payment method so soon after the previous one. Please try again later.', 'woocommerce-payments' ), + 'retried_too_soon' + ); } - $intent = $capture_intention_request->send(); - $status = $intent->get_status(); - $http_code = 200; - } catch ( API_Exception $e ) { - try { - $error_message = $e->getMessage(); - $http_code = $e->get_http_code(); - $error_code = $e->get_error_code(); - $extra_details = []; - - if ( $e instanceof Amount_Too_Small_Exception ) { - $extra_details = [ - 'minimum_amount' => $e->get_minimum_amount(), - 'minimum_amount_currency' => strtoupper( $e->get_currency() ), - ]; - $minimum_amount_details = sprintf( - /* translators: %s: formatted minimum amount with currency */ - __( 'The minimum amount to capture is %s.', 'woocommerce-payments' ), - WC_Payments_Utils::format_explicit_currency( - WC_Payments_Utils::interpret_stripe_amount( $e->get_minimum_amount(), $e->get_currency() ), - $e->get_currency() - ) - ); - $error_message = $error_message . ' ' . $minimum_amount_details; - } - - $request = Get_Intention::create( $intent_id ); - $request->set_hook_args( $order ); - // Fetch the Intent to check if it's already expired and the site missed the "charge.expired" webhook. - $intent = $request->send(); + $setup_intent = $this->create_and_confirm_setup_intent(); + $setup_intent_output = [ + 'id' => $setup_intent->get_id(), + 'status' => $setup_intent->get_status(), + 'client_secret' => $setup_intent->get_client_secret(), + ]; - if ( Intent_Status::CANCELED === $intent->get_status() ) { - $is_authorization_expired = true; - } - } catch ( API_Exception $ge ) { - // Ignore any errors during the intent retrieval, and add the failed capture note below with the - // original error message. - $status = null; - $error_message = $e->getMessage(); - $http_code = $e->get_http_code(); - $error_code = $e->get_error_code(); - } + wp_send_json_success( $setup_intent_output, 200 ); + } catch ( Exception $e ) { + // Send back error so it can be displayed to the customer. + wp_send_json_error( + [ + 'error' => [ + 'message' => WC_Payments_Utils::get_filtered_error_message( $e ), + ], + ], + WC_Payments_Utils::get_filtered_error_status_code( $e ) + ); } + } - Tracker::track_admin( 'wcpay_merchant_captured_auth' ); - - // There is a possibility of the intent being null, so we need to get the charge_id safely. - $charge = ! empty( $intent ) ? $intent->get_charge() : null; - $charge_id = ! empty( $charge ) ? $charge->get_id() : $this->order_service->get_charge_id_for_order( $order ); - - $this->attach_exchange_info_to_order( $order, $charge_id ); - - if ( Intent_Status::SUCCEEDED === $status ) { - $this->order_service->process_captured_payment( $order, $intent ); - } elseif ( $is_authorization_expired ) { - $this->order_service->mark_payment_capture_expired( $order, $intent_id, Intent_Status::CANCELED, $charge_id ); - } else { - if ( ! empty( $error_message ) ) { - $error_message = esc_html( $error_message ); - } else { - $http_code = 502; - } + /** + * Checks whether the gateway is enabled. + * + * @return bool The result. + */ + public function is_enabled() { + return 'yes' === $this->get_option( 'enabled' ); + } - $this->order_service->mark_payment_capture_failed( $order, $intent_id, Intent_Status::REQUIRES_CAPTURE, $charge_id, $error_message ); - } + /** + * Disables gateway. + */ + public function disable() { + $this->update_option( 'enabled', 'no' ); + } - return [ - 'status' => $status ?? 'failed', - 'id' => ! empty( $intent ) ? $intent->get_id() : null, - 'message' => $error_message, - 'http_code' => $http_code, - 'error_code' => $error_code, - 'extra_details' => $extra_details ?? [], - ]; + /** + * Enables gateway. + */ + public function enable() { + $this->update_option( 'enabled', 'yes' ); } /** - * Cancel previously authorized charge. + * Returns the list of enabled payment method types for UPE. * - * @param WC_Order $order - Order to cancel authorization on. + * @return string[] */ - public function cancel_authorization( $order ) { - $intent = null; - $status = null; - $error_message = null; - $http_code = null; + public function get_upe_enabled_payment_method_ids() { + return $this->get_option( + 'upe_enabled_payment_method_ids', + [ + 'card', + ] + ); + } - try { - $request = Cancel_Intention::create( $order->get_transaction_id() ); - $request->set_hook_args( $order ); - $intent = $request->send(); - $status = $intent->get_status(); - $http_code = 200; - } catch ( API_Exception $e ) { - try { - // Fetch the Intent to check if it's already expired and the site missed the "charge.expired" webhook. - $request = Get_Intention::create( $order->get_transaction_id() ); - $request->set_hook_args( $order ); - $intent = $request->send(); + /** + * Returns the list of statuses and capabilities available for UPE payment methods in the cached account. + * + * @return mixed[] The payment method statuses. + */ + public function get_upe_enabled_payment_method_statuses() { + $account_data = $this->account->get_cached_account_data(); + $capabilities = $account_data['capabilities'] ?? []; + $requirements = $account_data['capability_requirements'] ?? []; + $statuses = []; - $status = $intent->get_status(); - if ( Intent_Status::CANCELED !== $status ) { - $error_message = $e->getMessage(); - } - } catch ( API_Exception $ge ) { - // Ignore any errors during the intent retrieval, and add the failed cancellation note below with the - // original error message. - $status = null; - $error_message = $e->getMessage(); - $http_code = $e->get_http_code(); + if ( $capabilities ) { + foreach ( $capabilities as $capability_id => $status ) { + $statuses[ $capability_id ] = [ + 'status' => $status, + 'requirements' => $requirements[ $capability_id ] ?? [], + ]; } } - if ( Intent_Status::CANCELED === $status ) { - $this->order_service->update_order_status_from_intent( $order, $intent ); - } else { - if ( ! empty( $error_message ) ) { - $note = sprintf( - WC_Payments_Utils::esc_interpolated_html( - /* translators: %1: error message */ - __( - 'Canceling authorization failed to complete with the following message: %1$s.', - 'woocommerce-payments' - ), - [ - 'strong' => '', - 'code' => '', - ] - ), - esc_html( $error_message ) - ); - $order->add_order_note( $note ); - } else { - $order->add_order_note( - WC_Payments_Utils::esc_interpolated_html( - __( 'Canceling authorization failed to complete.', 'woocommerce-payments' ), - [ 'strong' => '' ] - ) - ); - } - - $this->order_service->set_intention_status_for_order( $order, $status ); - $order->save(); - $http_code = 502; - } + return 0 === count( $statuses ) ? [ + 'card_payments' => [ + 'status' => 'active', + 'requirements' => [], + ], + ] : $statuses; + } - return [ - 'status' => $status ?? 'failed', - 'id' => ! empty( $intent ) ? $intent->get_id() : null, - 'message' => $error_message, - 'http_code' => $http_code, - ]; + /** + * Returns the mapping list between capability keys and payment type keys + * + * @return string[] + */ + public function get_payment_method_capability_key_map(): array { + return $this->payment_method_capability_key_map; } /** - * Create the level 3 data array to send to Stripe when making a purchase. + * Updates the account cache with the new payment method status, until it gets fetched again from the server. * - * @param WC_Order $order The order that is being paid for. - * @return array The level 3 data to send to Stripe. + * @return void */ - public function get_level3_data_from_order( WC_Order $order ): array { - return wcpay_get_container()->get( Level3Service::class )->get_data_from_order( $order->get_id() ); + public function refresh_cached_account_data() { + $this->account->refresh_account_data(); } /** - * Handle AJAX request after authenticating payment at checkout. + * Updates the cached account data. * - * This function is used to update the order status after the user has - * been asked to authenticate their payment. + * @param string $property Property to update. + * @param mixed $data Data to update. + */ + public function update_cached_account_data( $property, $data ) { + $this->account->update_account_data( $property, $data ); + } + + /** + * Returns the Stripe payment type of the selected payment method. * - * This function is used for both: - * - regular checkout - * - Pay for Order page + * @return string + */ + public function get_selected_stripe_payment_type_id() { + return $this->stripe_id; + } + + /** + * Returns the list of enabled payment method types for UPE that are available based on the manual capture setting. * - * @throws Exception - If nonce is invalid. + * @return string[] */ - public function update_order_status() { - $intent_id_received = null; - $order = null; - try { - $is_nonce_valid = check_ajax_referer( 'wcpay_update_order_status_nonce', false, false ); - if ( ! $is_nonce_valid ) { - throw new Process_Payment_Exception( - __( "We're not able to process this payment. Please refresh the page and try again.", 'woocommerce-payments' ), - 'invalid_referrer' - ); - } + public function get_upe_enabled_payment_method_ids_based_on_manual_capture() { + $automatic_capture = empty( $this->get_option( 'manual_capture' ) ) || $this->get_option( 'manual_capture' ) === 'no'; + if ( $automatic_capture ) { + return $this->get_upe_enabled_payment_method_ids(); + } - $order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : false; - $order = wc_get_order( $order_id ); - if ( ! $order ) { - throw new Process_Payment_Exception( - __( "We're not able to process this payment. Please try again later.", 'woocommerce-payments' ), - 'order_not_found' - ); - } + return array_intersect( $this->get_upe_enabled_payment_method_ids(), [ Payment_Method::CARD, Payment_Method::LINK ] ); + } - $intent_id = $this->order_service->get_intent_id_for_order( $order ); - $intent_id_received = isset( $_POST['intent_id'] ) - ? sanitize_text_field( wp_unslash( $_POST['intent_id'] ) ) - /* translators: This will be used to indicate an unknown value for an ID. */ - : __( 'unknown', 'woocommerce-payments' ); + /** + * Returns the list of enabled payment method types that will function with the current checkout. + * + * @param string $order_id optional Order ID. + * @param bool $force_currency_check optional Whether the currency check is required even if is_admin(). + * + * @return string[] + */ + public function get_payment_method_ids_enabled_at_checkout( $order_id = null, $force_currency_check = false ) { + $upe_enabled_payment_methods = $this->get_upe_enabled_payment_method_ids_based_on_manual_capture(); + if ( is_wc_endpoint_url( 'order-pay' ) ) { + $force_currency_check = true; + } - if ( empty( $intent_id ) ) { - throw new Intent_Authentication_Exception( - __( "We're not able to process this payment. Please try again later.", 'woocommerce-payments' ), - 'empty_intent_id' - ); - } + $enabled_payment_methods = []; + $active_payment_methods = $this->get_upe_enabled_payment_method_statuses(); - // Check that the intent saved in the order matches the intent used as part of the - // authentication process. The ID of the intent used is sent with - // the AJAX request. We are about to use the status of the intent saved in - // the order, so we need to make sure the intent that was used for authentication - // is the same as the one we're using to update the status. - if ( $intent_id !== $intent_id_received ) { - throw new Intent_Authentication_Exception( - __( "We're not able to process this payment. Please try again later.", 'woocommerce-payments' ), - 'intent_id_mismatch' - ); + foreach ( $upe_enabled_payment_methods as $payment_method_id ) { + $payment_method_capability_key = $this->payment_method_capability_key_map[ $payment_method_id ] ?? 'undefined_capability_key'; + if ( isset( $this->payment_methods[ $payment_method_id ] ) ) { + // When creating a payment intent, we need to ensure the currency is matching + // with the payment methods which are sent with the payment intent request, otherwise + // Stripe returns an error. + + // In order to allow payment methods to be displayed in admin pages (e.g. blocks editor), + // we need to skip the currency check (unless force_currency_check is true). + // force_currency_check = 0 is_admin = 0 -> skip_currency_check = 0. + // force_currency_check = 0 is_admin = 1 -> skip_currency_check = 1. + // force_currency_check = 1 is_admin = 0 -> skip_currency_check = 0. + // force_currency_check = 1 is_admin = 1 -> skip_currency_check = 0. + + $skip_currency_check = ! $force_currency_check && is_admin(); + $processing_payment_method = $this->payment_methods[ $payment_method_id ]; + if ( $processing_payment_method->is_enabled_at_checkout( $this->get_account_country(), $skip_currency_check ) && ( $skip_currency_check || $processing_payment_method->is_currency_valid( $this->get_account_domestic_currency(), $order_id ) ) ) { + $status = $active_payment_methods[ $payment_method_capability_key ]['status'] ?? null; + if ( 'active' === $status ) { + $enabled_payment_methods[] = $payment_method_id; + } + } } + } - $amount = $order->get_total(); - $payment_method_details = false; - $is_changing_payment = isset( $_POST['is_changing_payment'] ) && filter_var( wp_unslash( $_POST['is_changing_payment'] ), FILTER_VALIDATE_BOOLEAN ); + // if credit card payment method is not enabled, we don't use stripe link. + if ( + ! in_array( CC_Payment_Method::PAYMENT_METHOD_STRIPE_ID, $enabled_payment_methods, true ) && + in_array( Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID, $enabled_payment_methods, true ) ) { + $enabled_payment_methods = array_filter( + $enabled_payment_methods, + static function ( $method ) { + return Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID !== $method; + } + ); + } - if ( $amount > 0 && ! $is_changing_payment ) { - // An exception is thrown if an intent can't be found for the given intent ID. - $request = Get_Intention::create( $intent_id ); - $request->set_hook_args( $order ); - $intent = $request->send(); + return $enabled_payment_methods; + } - $status = $intent->get_status(); - $charge = $intent->get_charge(); - $charge_id = ! empty( $charge ) ? $charge->get_id() : null; + /** + * Returns the list of enabled payment method types that will function with the current checkout filtered by fees. + * + * @param string $order_id optional Order ID. + * @param bool $force_currency_check optional Whether the currency check is required even if is_admin(). + * @return string[] + */ + public function get_payment_method_ids_enabled_at_checkout_filtered_by_fees( $order_id = null, $force_currency_check = false ) { + $enabled_payment_methods = $this->get_payment_method_ids_enabled_at_checkout( $order_id, $force_currency_check ); + $methods_with_fees = array_keys( $this->account->get_fees() ); - $this->attach_exchange_info_to_order( $order, $charge_id ); - $this->order_service->attach_intent_info_to_order( $order, $intent ); - $this->order_service->attach_transaction_fee_to_order( $order, $charge ); - } else { - // For $0 orders, fetch the Setup Intent instead. - $setup_intent_request = Get_Setup_Intention::create( $intent_id ); - /** @var WC_Payments_API_Setup_Intention $setup_intent */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort - $intent = $setup_intent_request->send(); - $status = $intent->get_status(); - $charge_id = ''; - } + return array_values( array_intersect( $enabled_payment_methods, $methods_with_fees ) ); + } - $payment_method_id = $intent->get_payment_method_id(); + /** + * Returns the list of available payment method types for UPE. + * See https://stripe.com/docs/stripe-js/payment-element#web-create-payment-intent for a complete list. + * + * @return string[] + */ + public function get_upe_available_payment_methods() { + $available_methods = [ 'card' ]; - if ( Intent_Status::SUCCEEDED === $status ) { - $this->duplicate_payment_prevention_service->remove_session_processing_order( $order->get_id() ); - } - $this->order_service->update_order_status_from_intent( $order, $intent ); + /** + * FLAG: PAYMENT_METHODS_LIST + * As payment methods are converted to use definitions, they need to be removed from the list below. + */ + $available_methods[] = Becs_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = Bancontact_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = Eps_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = Ideal_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = Sepa_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = P24_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = Affirm_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = Afterpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = Klarna_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = Multibanco_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = Grabpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - if ( $intent->is_authorized() ) { - wc_maybe_reduce_stock_levels( $order_id ); - WC()->cart->empty_cart(); + // This gets all the registered payment method definitions. As new payment methods are converted from the legacy style, they need to be removed from the list above. + $payment_method_definitions = PaymentMethodDefinitionRegistry::instance()->get_all_payment_method_definitions(); - $is_subscription = function_exists( 'wcs_order_contains_subscription' ) && wcs_order_contains_subscription( $order ); - $should_save_payment_method = $is_subscription || ( isset( $_POST['should_save_payment_method'] ) && 'true' === $_POST['should_save_payment_method'] ); - if ( $should_save_payment_method && ! empty( $payment_method_id ) ) { - try { - $token = $this->token_service->add_payment_method_to_user( $payment_method_id, wp_get_current_user() ); - $this->add_token_to_order( $order, $token ); + foreach ( $payment_method_definitions as $definition_class ) { + $available_methods[] = $definition_class::get_id(); + } - if ( ! empty( $token ) ) { - $payment_method_type = $this->get_payment_method_type_for_setup_intent( $intent, $token ); - $this->set_payment_method_title_for_order( $order, $payment_method_type, $payment_method_details ); - } - } catch ( Exception $e ) { - // If saving the token fails, log the error message but catch the error to avoid crashing the checkout flow. - Logger::log( 'Error when saving payment method: ' . $e->getMessage() ); - } - } + $available_methods = array_values( + apply_filters( + 'wcpay_upe_available_payment_methods', + $available_methods + ) + ); - $return_url = $this->get_return_url( $order ); + $methods_with_fees = array_keys( $this->account->get_fees() ); - if ( $is_changing_payment ) { - $payment_token = $this->get_payment_token( $order ); - if ( class_exists( 'WC_Subscriptions_Change_Payment_Gateway' ) ) { - WC_Subscriptions_Change_Payment_Gateway::update_payment_method( $order, $payment_token->get_gateway_id() ); - $notice = __( 'Payment method updated.', 'woocommerce-payments' ); + return array_values( array_intersect( $available_methods, $methods_with_fees ) ); + } - if ( WC_Subscriptions_Change_Payment_Gateway::will_subscription_update_all_payment_methods( $order ) && WC_Subscriptions_Change_Payment_Gateway::update_all_payment_methods_from_subscription( $order, $token->get_gateway_id() ) ) { - $notice = __( 'Payment method updated for all your current subscriptions.', 'woocommerce-payments' ); - } + /** + * Handle AJAX request for saving UPE appearance value to transient. + * + * @throws Exception - If nonce or setup intent is invalid. + */ + public function save_upe_appearance_ajax() { + try { + $is_nonce_valid = check_ajax_referer( 'wcpay_save_upe_appearance_nonce', false, false ); + if ( ! $is_nonce_valid ) { + throw new Exception( + __( 'Unable to update UPE appearance values at this time.', 'woocommerce-payments' ) + ); + } - wc_add_notice( $notice ); - } - $return_url = method_exists( $order, 'get_view_order_url' ) ? $order->get_view_order_url() : $this->get_return_url( $order ); - } + $elements_location = isset( $_POST['elements_location'] ) ? wc_clean( wp_unslash( $_POST['elements_location'] ) ) : null; + $appearance = isset( $_POST['appearance'] ) ? json_decode( wc_clean( wp_unslash( $_POST['appearance'] ) ) ) : null; - // Send back redirect URL in the successful case. - echo wp_json_encode( - [ - 'return_url' => $return_url, - ] + $valid_locations = [ 'blocks_checkout', 'shortcode_checkout', 'bnpl_product_page', 'bnpl_classic_cart', 'bnpl_cart_block', 'add_payment_method' ]; + if ( ! $elements_location || ! in_array( $elements_location, $valid_locations, true ) ) { + throw new Exception( + __( 'Unable to update UPE appearance values at this time.', 'woocommerce-payments' ) ); - wp_die(); } - } catch ( Intent_Authentication_Exception $e ) { - $error_code = $e->get_error_code(); - switch ( $error_code ) { - case 'intent_id_mismatch': - case 'empty_intent_id': // The empty_intent_id case needs the same handling. - $note = sprintf( - WC_Payments_Utils::esc_interpolated_html( - /* translators: %1: transaction ID of the payment or a translated string indicating an unknown ID. */ - __( 'A payment with ID %1$s was used in an attempt to pay for this order. This payment intent ID does not match any payments for this order, so it was ignored and the order was not updated.', 'woocommerce-payments' ), - [ - 'code' => '', - ] - ), - $intent_id_received - ); - $order->add_order_note( $note ); - break; + if ( in_array( $elements_location, [ 'blocks_checkout', 'shortcode_checkout' ], true ) ) { + $is_blocks_checkout = 'blocks_checkout' === $elements_location; + /** + * This filter is only called on "save" of the appearance, to avoid calling it on every page load. + * If you apply changes through this filter, you'll need to clear the transient data to see them at checkout. + * + * @deprecated 7.4.0 Use {@see 'wcpay_elements_appearance'} instead. + * @since 7.3.0 + */ + $appearance = apply_filters_deprecated( 'wcpay_upe_appearance', [ $appearance, $is_blocks_checkout ], '7.4.0', 'wcpay_elements_appearance' ); } - // Send back error so it can be displayed to the customer. - echo wp_json_encode( - [ - 'error' => [ - 'message' => $e->getMessage(), - ], - ] - ); - wp_die(); + /** + * This filter is only called on "save" of the appearance, to avoid calling it on every page load. + * If you apply changes through this filter, you'll need to clear the transient data to see them at checkout. + * $elements_location can be 'blocks_checkout', 'shortcode_checkout', 'bnpl_product_page', 'bnpl_classic_cart', 'bnpl_cart_block', 'add_payment_method'. + * + * @since 7.4.0 + */ + $appearance = apply_filters( 'wcpay_elements_appearance', $appearance, $elements_location ); + + $appearance_transient = [ + 'shortcode_checkout' => self::UPE_APPEARANCE_TRANSIENT, + 'add_payment_method' => self::UPE_ADD_PAYMENT_METHOD_APPEARANCE_TRANSIENT, + 'blocks_checkout' => self::WC_BLOCKS_UPE_APPEARANCE_TRANSIENT, + 'bnpl_product_page' => self::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_TRANSIENT, + 'bnpl_classic_cart' => self::UPE_BNPL_CLASSIC_CART_APPEARANCE_TRANSIENT, + 'bnpl_cart_block' => self::UPE_BNPL_CART_BLOCK_APPEARANCE_TRANSIENT, + ][ $elements_location ]; + $appearance_theme_transient = [ + 'shortcode_checkout' => self::UPE_APPEARANCE_THEME_TRANSIENT, + 'add_payment_method' => self::UPE_ADD_PAYMENT_METHOD_APPEARANCE_THEME_TRANSIENT, + 'blocks_checkout' => self::WC_BLOCKS_UPE_APPEARANCE_THEME_TRANSIENT, + 'bnpl_product_page' => self::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_THEME_TRANSIENT, + 'bnpl_classic_cart' => self::UPE_BNPL_CLASSIC_CART_APPEARANCE_THEME_TRANSIENT, + 'bnpl_cart_block' => self::UPE_BNPL_CART_BLOCK_APPEARANCE_THEME_TRANSIENT, + ][ $elements_location ]; + + if ( null !== $appearance ) { + set_transient( $appearance_transient, $appearance, DAY_IN_SECONDS ); + set_transient( $appearance_theme_transient, $appearance->theme, DAY_IN_SECONDS ); + } + + wp_send_json_success( $appearance, 200 ); } catch ( Exception $e ) { // Send back error so it can be displayed to the customer. - echo wp_json_encode( + wp_send_json_error( [ 'error' => [ - 'message' => $e->getMessage(), + 'message' => WC_Payments_Utils::get_filtered_error_message( $e ), ], - ] + ], + WC_Payments_Utils::get_filtered_error_status_code( $e ) ); - wp_die(); } } /** - * Add payment method via account screen. + * Clear the saved UPE appearance transient value. + */ + public function clear_upe_appearance_transient() { + delete_transient( self::UPE_APPEARANCE_TRANSIENT ); + delete_transient( self::WC_BLOCKS_UPE_APPEARANCE_TRANSIENT ); + delete_transient( self::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_TRANSIENT ); + delete_transient( self::UPE_BNPL_CLASSIC_CART_APPEARANCE_TRANSIENT ); + delete_transient( self::UPE_BNPL_CART_BLOCK_APPEARANCE_TRANSIENT ); + delete_transient( self::UPE_APPEARANCE_THEME_TRANSIENT ); + delete_transient( self::WC_BLOCKS_UPE_APPEARANCE_THEME_TRANSIENT ); + delete_transient( self::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_THEME_TRANSIENT ); + delete_transient( self::UPE_BNPL_CLASSIC_CART_APPEARANCE_THEME_TRANSIENT ); + delete_transient( self::UPE_BNPL_CART_BLOCK_APPEARANCE_THEME_TRANSIENT ); + } + + /** + * Returns boolean for whether payment gateway supports saved payments. + * + * @return bool True, if gateway supports saved payments. False, otherwise. + */ + public function should_support_saved_payments() { + return $this->is_enabled_for_saved_payments( $this->stripe_id ); + } + + /** + * Verifies that the proper intent is used to process the order. + * + * @param WC_Order $order The order object based on the order_id received from the request. + * @param string $intent_id_from_request The intent ID received from the request. + * + * @return bool True if the proper intent is used to process the order, false otherwise. + */ + public function is_proper_intent_used_with_order( $order, $intent_id_from_request ) { + $intent_id_attached_to_order = $this->order_service->get_intent_id_for_order( $order ); + if ( ! hash_equals( $intent_id_attached_to_order, $intent_id_from_request ) ) { + Logger::error( + sprintf( + 'Intent ID mismatch. Received in request: %1$s. Attached to order: %2$s. Order ID: %3$d', + $intent_id_from_request, + $intent_id_attached_to_order, + $order->get_id() + ) + ); + return false; + } + return true; + } + + /** + * True if the request contains the values that indicates a redirection after a successful setup intent creation. + * + * @return bool + */ + public function is_setup_intent_success_creation_redirection() { + return ! empty( $_GET['setup_intent_client_secret'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ! empty( $_GET['setup_intent'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ! empty( $_GET['redirect_status'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended + 'succeeded' === $_GET['redirect_status']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + + /** + * Function to be used with array_filter + * to filter UPE payment methods that support saved payments + * + * @param string $payment_method_id Stripe payment method. + * + * @return bool + */ + public function is_enabled_for_saved_payments( $payment_method_id ) { + $payment_method = $this->get_selected_payment_method( $payment_method_id ); + if ( ! $payment_method ) { + return false; + } + return $payment_method->is_reusable() + && ( is_admin() || $payment_method->is_currency_valid( $this->get_account_domestic_currency() ) ); + } + + /** + * Move the email field to the top of the Checkout page. + * + * @param array $fields WooCommerce checkout fields. + * + * @return array WooCommerce checkout fields. + */ + public function checkout_update_email_field_priority( $fields ) { + if ( is_checkout() || has_block( 'woocommerce/checkout' ) ) { + $is_link_enabled = in_array( + Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + \WC_Payments::get_gateway()->get_payment_method_ids_enabled_at_checkout_filtered_by_fees( null, true ), + true + ); + + if ( $is_link_enabled && isset( $fields['billing_email'] ) ) { + // Update the field priority. + $fields['billing_email']['priority'] = 1; + + // Add extra `wcpay-checkout-email-field` class. + $fields['billing_email']['class'][] = 'wcpay-checkout-email-field'; + + add_filter( 'woocommerce_form_field_email', [ $this, 'append_stripelink_button' ], 10, 4 ); + } + } + + return $fields; + } + + /** + * Append StripeLink button within email field for logged in users. + * + * @param string $field - HTML content within email field. + * @param string $key - Key. + * @param array $args - Arguments. + * @param string $value - Default value. + * + * @return string $field - Updated email field content with the button appended. + */ + public function append_stripelink_button( $field, $key, $args, $value ) { + if ( 'billing_email' === $key ) { + $field = str_replace( '', '', $field ); + } + return $field; + } + + /** + * Gets UPE_Payment_Method instance from ID. * - * @throws Add_Payment_Method_Exception If payment method is missing. + * @param string $payment_method_type Stripe payment method type ID. + * @return UPE_Payment_Method|false UPE payment method instance. */ - public function add_payment_method() { - try { - - // phpcs:ignore WordPress.Security.NonceVerification.Missing - if ( ! isset( $_POST['wcpay-setup-intent'] ) ) { - throw new Add_Payment_Method_Exception( - sprintf( - /* translators: %s: WooPayments */ - __( 'A %s payment method was not provided', 'woocommerce-payments' ), - 'WooPayments' - ), - 'payment_method_intent_not_provided' - ); - } + public function get_selected_payment_method( $payment_method_type ) { + return WC_Payments::get_payment_method_by_id( $payment_method_type ); + } - // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash - $setup_intent_id = ! empty( $_POST['wcpay-setup-intent'] ) ? wc_clean( $_POST['wcpay-setup-intent'] ) : false; + /** + * This function wraps WC_Payments::get_payment_method_map, useful for unit testing. + * + * @return array Array of UPE_Payment_Method instances. + */ + public function wc_payments_get_payment_method_map() { + return WC_Payments::get_payment_method_map(); + } - $customer_id = $this->customer_service->get_customer_id_by_user_id( get_current_user_id() ); + /** + * Returns the payment methods for this gateway. + * + * @return array|UPE_Payment_Method[] + */ + public function get_payment_methods() { + return $this->payment_methods; + } - if ( ! $setup_intent_id || null === $customer_id ) { - throw new Add_Payment_Method_Exception( - __( "We're not able to add this payment method. Please try again later", 'woocommerce-payments' ), - 'invalid_setup_intent_id' - ); - } + /** + * Returns the UPE payment method for the gateway. + * + * @return UPE_Payment_Method + */ + public function get_payment_method() { + return $this->payment_method; + } - $setup_intent_request = Get_Setup_Intention::create( $setup_intent_id ); - /** @var WC_Payments_API_Setup_Intention $setup_intent */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort - $setup_intent = $setup_intent_request->send(); + /** + * Returns Stripe payment method type ID. + * + * @return string + */ + public function get_stripe_id() { + return $this->stripe_id; + } - if ( Intent_Status::SUCCEEDED !== $setup_intent->get_status() ) { - throw new Add_Payment_Method_Exception( - __( 'Failed to add the provided payment method. Please try again later', 'woocommerce-payments' ), - 'invalid_response_status' - ); - } + /** + * This function wraps WC_Payments::get_payment_gateway_by_id, useful for unit testing. + * + * @param string $payment_method_id Stripe payment method type ID. + * @return false|WC_Payment_Gateway_WCPay Matching UPE Payment Gateway instance. + */ + public function wc_payments_get_payment_gateway_by_id( $payment_method_id ) { + return WC_Payments::get_payment_gateway_by_id( $payment_method_id ); + } - $payment_method = $setup_intent->get_payment_method_id(); - $this->token_service->add_payment_method_to_user( $payment_method, wp_get_current_user() ); + /** + * This function wraps WC_Payments::get_payment_method_by_id, useful for unit testing. + * + * @param string $payment_method_id Stripe payment method type ID. + * @return false|UPE_Payment_Method Matching UPE Payment Method instance. + */ + public function wc_payments_get_payment_method_by_id( $payment_method_id ) { + return WC_Payments::get_payment_method_by_id( $payment_method_id ); + } - return [ - 'result' => 'success', - 'redirect' => apply_filters( 'wcpay_get_add_payment_method_redirect_url', wc_get_endpoint_url( 'payment-methods' ) ), - ]; - } catch ( Exception $e ) { - wc_add_notice( WC_Payments_Utils::get_filtered_error_message( $e ), 'error', [ 'icon' => 'error' ] ); - Logger::log( 'Error when adding payment method: ' . $e->getMessage() ); - return [ - 'result' => 'error', - ]; + /** + * Checks if UPE appearance theme is set and returns appropriate icon URL. + * + * @return string + */ + public function get_theme_icon() { + $upe_appearance_theme = get_transient( self::UPE_APPEARANCE_THEME_TRANSIENT ); + if ( $upe_appearance_theme ) { + return 'night' === $upe_appearance_theme ? $this->payment_method->get_dark_icon() : $this->payment_method->get_icon(); } + return $this->payment_method->get_icon(); } /** - * When an order is created/updated, we want to add an ActionScheduler job to send this data to - * the payment server. + * Get the right method description if WooPay is eligible. * - * @param int $order_id The ID of the order that has been created. - * @param WC_Order|null $order The order that has been created. + * @return string */ - public function schedule_order_tracking( $order_id, $order = null ) { - $this->maybe_schedule_subscription_order_tracking( $order_id, $order ); - - // If Sift is not enabled, exit out and don't do the tracking here. - if ( ! isset( $this->fraud_service->get_fraud_services_config()['sift'] ) ) { - return; - } + public function get_method_description() { + $description = sprintf( + /* translators: %1$s: WooPayments */ + __( + '%1$s gives your store flexibility to accept credit cards, debit cards, and Apple Pay. Enable popular local payment methods and other digital wallets like Google Pay to give customers even more choice.', + 'woocommerce-payments' + ), + 'WooPayments' + ); - // Sometimes the woocommerce_update_order hook might be called with just the order ID parameter, - // so we need to fetch the order here. - if ( is_null( $order ) ) { - $order = wc_get_order( $order_id ); + if ( WooPay_Utilities::is_store_country_available() ) { + $description = sprintf( + /* translators: %s: WooPay, */ + __( + 'Payments made simple — including %s, a new express checkout feature.', + 'woocommerce-payments' + ), + 'WooPay' + ); } - // We only want to track orders created by our payment gateway, and orders with a payment method set. - if ( $order->get_payment_method() !== self::GATEWAY_ID || empty( $this->order_service->get_payment_method_id_for_order( $order ) ) ) { - return; - } + return $description; + } - // Check whether this is an order we haven't previously tracked a creation event for. - if ( $order->get_meta( '_new_order_tracking_complete' ) !== 'yes' ) { - // Schedule the action to send this information to the payment server. - $this->action_scheduler_service->schedule_job( - strtotime( '+5 seconds' ), - 'wcpay_track_new_order', - [ 'order_id' => $order_id ] - ); - } else { - // Schedule an update action to send this information to the payment server. - $this->action_scheduler_service->schedule_job( - strtotime( '+5 seconds' ), - 'wcpay_track_update_order', - [ 'order_id' => $order_id ] - ); - } + /** + * Calls duplicate payment methods detection service to find duplicates. + * This method acts as a wrapper. The approach should be reverted once + * https://github.com/Automattic/woocommerce-payments/issues/7464 is resolved. + */ + public function find_duplicates() { + return $this->duplicate_payment_methods_detection_service->find_duplicates(); } /** - * Create a payment intent without confirming the intent. - * - * @param WC_Order $order - Order based on which to create intent. - * @param array $payment_methods - A list of allowed payment methods. Eg. card, card_present. - * @param string $capture_method - Controls when the funds will be captured from the customer's account ("automatic" or "manual"). - * It must be "manual" for in-person (terminal) payments. - * - * @param array $metadata - A list of intent metadata. - * @param string|null $customer_id - Customer id for intent. + * Get the recommended payment methods list. * - * @return array|WP_Error On success, an array containing info about the newly created intent. On failure, WP_Error object. + * @param string $country_code Optional. The business location country code. Provide a 2-letter ISO country code. + * If not provided, the account country will be used if the account is connected. + * Otherwise, the store's base country will be used. * - * @throws Exception - When an error occurs in intent creation. + * @return array List of recommended payment methods for the given country. + * Empty array if there are no recommendations available. + * Each item in the array should be an associative array with at least the following entries: + * - @string id: The payment method ID. + * - @string title: The payment method title/name. + * - @bool enabled: Whether the payment method is enabled. + * - @int order/priority: The order/priority of the payment method. */ - public function create_intent( WC_Order $order, array $payment_methods, string $capture_method = 'automatic', array $metadata = [], ?string $customer_id = null ) { - $currency = strtolower( $order->get_currency() ); - $converted_amount = WC_Payments_Utils::prepare_amount( $order->get_total(), $currency ); - $order_number = $order->get_order_number(); - if ( $order_number ) { - $metadata['order_number'] = $order_number; + public function get_recommended_payment_methods( string $country_code = '' ): array { + if ( empty( $country_code ) ) { + // If the account is connected, use the account country. + if ( $this->account->is_provider_connected() ) { + $country_code = $this->get_account_country(); + } else { + // If the account is not connected, use the store's base country. + $country_code = WC()->countries->get_base_country(); + } } - try { - $request = Create_Intention::create(); - $request->set_amount( $converted_amount ); - $request->set_customer( $customer_id ); - $request->set_currency_code( $currency ); - $request->set_metadata( $metadata ); - $request->set_payment_method_types( $payment_methods ); - $request->set_capture_method( $capture_method ); - $request->set_hook_args( $order ); - $intent = $request->send(); + return $this->account->get_recommended_payment_methods( $country_code ); + } - return [ - 'id' => ! empty( $intent ) ? $intent->get_id() : null, - ]; - } catch ( API_Exception $e ) { - return new WP_Error( - 'wcpay_intent_creation_error', - sprintf( - // translators: %s: the error message. - __( 'Intent creation failed with the following message: %s', 'woocommerce-payments' ), - $e->getMessage() ?? __( 'Unknown error', 'woocommerce-payments' ) - ), - [ 'status' => $e->get_http_code() ] - ); + /** + * Prepares the payment information object. + * + * @param WC_Order $order The order whose payment will be processed. + * @return Payment_Information An object, which describes the payment. + */ + protected function prepare_payment_information( $order ) { + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $payment_information = Payment_Information::from_payment_request( $_POST, $order, Payment_Type::SINGLE(), Payment_Initiated_By::CUSTOMER(), $this->get_capture_type(), $this->get_payment_method_to_use_for_intent() ); + $payment_information = $this->maybe_prepare_subscription_payment_information( $payment_information, $order->get_id() ); + + if ( ! empty( $_POST[ 'wc-' . static::GATEWAY_ID . '-new-payment-method' ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + // During normal orders the payment method is saved when the customer enters a new one and chooses to save it. + $payment_information->must_save_payment_method_to_store(); + } + + if ( $this->woopay_util->should_save_platform_customer() ) { + do_action( 'woocommerce_payments_save_user_in_woopay' ); + $payment_information->must_save_payment_method_to_platform(); } + + return $payment_information; } /** - * Create a setup intent when adding cards using the my account page. + * Manages customer details held on WCPay server for WordPress user associated with an order. * - * @return WC_Payments_API_Setup_Intention + * @param WC_Order $order WC Order object. + * @param array $options Additional options to apply. * - * @throws API_Exception - * @throws \WCPay\Core\Exceptions\Server\Request\Extend_Request_Exception - * @throws \WCPay\Core\Exceptions\Server\Request\Immutable_Parameter_Exception - * @throws \WCPay\Core\Exceptions\Server\Request\Invalid_Request_Parameter_Exception + * @return array First element is the new or updated WordPress user, the second element is the WCPay customer ID. */ - public function create_and_confirm_setup_intent() { - $payment_information = Payment_Information::from_payment_request( $_POST, null, null, null, null, $this->get_payment_method_to_use_for_intent() ); // phpcs:ignore WordPress.Security.NonceVerification - $should_save_in_platform_account = false; - - // phpcs:ignore WordPress.Security.NonceVerification.Missing - if ( ! empty( $_POST['save_payment_method_in_platform_account'] ) && filter_var( wp_unslash( $_POST['save_payment_method_in_platform_account'] ), FILTER_VALIDATE_BOOLEAN ) ) { - $should_save_in_platform_account = true; + protected function manage_customer_details_for_order( $order, $options = [] ) { + $user = $order->get_user(); + if ( false === $user ) { + $user = wp_get_current_user(); } - // Determine the customer adding the payment method, create one if we don't have one already. - $user = wp_get_current_user(); + // Determine the customer making the payment, create one if we don't have one already. $customer_id = $this->customer_service->get_customer_id_by_user_id( $user->ID ); + if ( null === $customer_id ) { - $customer_data = WC_Payments_Customer_Service::map_customer_data( null, new WC_Customer( $user->ID ) ); - $customer_id = $this->customer_service->create_customer_for_user( $user, $customer_data ); + $customer_data = WC_Payments_Customer_Service::map_customer_data( $order, new WC_Customer( $user->ID ) ); + // Create a new customer. + $customer_id = $this->customer_service->create_customer_for_user( $user, $customer_data ); + } else { + // Update the customer with order data async. + $this->update_customer_with_order_data( $order, $customer_id, WC_Payments::mode()->is_test(), $options['is_woopay'] ?? false ); } - $request = Create_And_Confirm_Setup_Intention::create(); - $request->set_customer( $customer_id ); - $request->set_payment_method( $payment_information->get_payment_method() ); - $request->assign_hook( 'wcpay_create_and_confirm_setup_intention_request' ); - $request->set_hook_args( $payment_information, $should_save_in_platform_account, false ); - return $request->send(); + return [ $user, $customer_id ]; } /** - * Handle AJAX request for creating a setup intent when adding cards using the my account page. + * Prepares Stripe metadata for a given order. The metadata later injected into intents, and + * used in transactions listing/details. If merchant connects an account to new store, listing/details + * keeps working even if orders are not available anymore - the metadata provides needed details. * - * @throws Add_Payment_Method_Exception - If nonce or setup intent is invalid. + * @param WC_Order $order Order being processed. + * @param Payment_Type $payment_type Enum stating whether payment is single or recurring. + * + * @return array Array of keyed metadata values. */ - public function create_setup_intent_ajax() { - try { - if ( ! check_ajax_referer( 'wcpay_create_setup_intent_nonce', false, false ) ) { - throw new Add_Payment_Method_Exception( - __( "We're not able to add this payment method. Please refresh the page and try again.", 'woocommerce-payments' ), - 'invalid_referrer' - ); - } - - if ( WC_Rate_Limiter::retried_too_soon( 'add_payment_method_' . get_current_user_id() ) ) { - throw new Add_Payment_Method_Exception( - __( 'You cannot add a new payment method so soon after the previous one. Please try again later.', 'woocommerce-payments' ), - 'retried_too_soon' - ); - } - - $setup_intent = $this->create_and_confirm_setup_intent(); - $setup_intent_output = [ - 'id' => $setup_intent->get_id(), - 'status' => $setup_intent->get_status(), - 'client_secret' => $setup_intent->get_client_secret(), - ]; - - wp_send_json_success( $setup_intent_output, 200 ); - } catch ( Exception $e ) { - // Send back error so it can be displayed to the customer. - wp_send_json_error( - [ - 'error' => [ - 'message' => WC_Payments_Utils::get_filtered_error_message( $e ), - ], - ], - WC_Payments_Utils::get_filtered_error_status_code( $e ) - ); - } + protected function get_metadata_from_order( $order, $payment_type ) { + $service = wcpay_get_container()->get( OrderService::class ); + return $service->get_payment_metadata( $order->get_id(), $payment_type ); } /** - * Returns a formatted token list for a user. + * Retrieve payment token from a subscription or order. * - * @param int $user_id The user ID. + * @param WC_Order $order Order or subscription object. + * + * @return null|WC_Payment_Token Last token associated with order or subscription. */ - protected function get_user_formatted_tokens_array( $user_id ) { - $tokens = WC_Payment_Tokens::get_tokens( - [ - 'user_id' => $user_id, - 'gateway_id' => self::GATEWAY_ID, - 'limit' => self::USER_FORMATTED_TOKENS_LIMIT, - ] - ); - - return array_map( - static function ( WC_Payment_Token $token ): array { - return [ - 'tokenId' => $token->get_id(), - 'paymentMethodId' => $token->get_token(), - 'isDefault' => $token->get_is_default(), - 'displayName' => $token->get_display_name(), - ]; - }, - array_values( $tokens ) - ); + protected function get_payment_token( $order ) { + $order_tokens = $order->get_payment_tokens(); + $token_id = end( $order_tokens ); + return ! $token_id ? null : WC_Payment_Tokens::get( $token_id ); } /** - * Checks whether the gateway is enabled. + * Get payment capture type from WCPay settings. * - * @return bool The result. + * @return Payment_Capture_Type MANUAL or AUTOMATIC depending on the settings. */ - public function is_enabled() { - return 'yes' === $this->get_option( 'enabled' ); + protected function get_capture_type() { + return 'yes' === $this->get_option( 'manual_capture' ) ? Payment_Capture_Type::MANUAL() : Payment_Capture_Type::AUTOMATIC(); } /** - * Disables gateway. + * Gets connected account business name. + * + * @param string $default_value Value to return when not connected or failed to fetch business name. + * + * @return string Business name or default value. */ - public function disable() { - $this->update_option( 'enabled', 'no' ); - } + protected function get_account_business_name( $default_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_business_name(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get account business name.' . $e ); + } - /** - * Enables gateway. - */ - public function enable() { - $this->update_option( 'enabled', 'yes' ); + return $default_value; } /** - * Returns the list of enabled payment method types for UPE. + * Gets connected account business url. * - * @return string[] + * @param string $default_value Value to return when not connected or failed to fetch business url. + * + * @return string Business url or default value. */ - public function get_upe_enabled_payment_method_ids() { - return $this->get_option( - 'upe_enabled_payment_method_ids', - [ - 'card', - ] - ); + protected function get_account_business_url( $default_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_business_url(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get account business URL.' . $e ); + } + + return $default_value; } /** - * Returns the list of statuses and capabilities available for UPE payment methods in the cached account. + * Gets connected account business address. * - * @return mixed[] The payment method statuses. + * @param array $default_value Value to return when not connected or failed to fetch business address. + * + * @return array Business address or default value. */ - public function get_upe_enabled_payment_method_statuses() { - $account_data = $this->account->get_cached_account_data(); - $capabilities = $account_data['capabilities'] ?? []; - $requirements = $account_data['capability_requirements'] ?? []; - $statuses = []; - - if ( $capabilities ) { - foreach ( $capabilities as $capability_id => $status ) { - $statuses[ $capability_id ] = [ - 'status' => $status, - 'requirements' => $requirements[ $capability_id ] ?? [], - ]; + protected function get_account_business_support_address( $default_value = [] ): array { + try { + if ( $this->is_connected() ) { + return $this->account->get_business_support_address(); } + } catch ( Exception $e ) { + Logger::error( 'Failed to get account business support address.' . $e ); } - return 0 === count( $statuses ) ? [ - 'card_payments' => [ - 'status' => 'active', - 'requirements' => [], - ], - ] : $statuses; + return $default_value; } /** - * Returns the mapping list between capability keys and payment type keys + * Gets connected account business support email. * - * @return string[] + * @param string $default_value Value to return when not connected or failed to fetch business support email. + * + * @return string Business support email or default value. */ - public function get_payment_method_capability_key_map(): array { - return $this->payment_method_capability_key_map; + protected function get_account_business_support_email( $default_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_business_support_email(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get business support email.' . $e ); + } + + return $default_value; } /** - * Updates the account cache with the new payment method status, until it gets fetched again from the server. + * Gets connected account business support phone. * - * @return void + * @param string $default_value Value to return when not connected or failed to fetch business support phone. + * + * @return string Business support phone or default value. */ - public function refresh_cached_account_data() { - $this->account->refresh_account_data(); + protected function get_account_business_support_phone( $default_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_business_support_phone(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get account business support phone.' . $e ); + } + + return $default_value; } /** - * Updates the cached account data. + * Gets connected account branding logo. * - * @param string $property Property to update. - * @param mixed $data Data to update. + * @param string $default_value Value to return when not connected or failed to fetch branding logo. + * + * @return string Business support branding logo or default value. */ - public function update_cached_account_data( $property, $data ) { - $this->account->update_account_data( $property, $data ); + protected function get_account_branding_logo( $default_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_branding_logo(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get account branding logo.' . $e ); + } + + return $default_value; } /** - * Returns the Stripe payment type of the selected payment method. + * Gets connected account branding icon. * - * @return string + * @param string $default_value Value to return when not connected or failed to fetch branding icon. + * + * @return string Business support branding icon or default value. */ - public function get_selected_stripe_payment_type_id() { - return $this->stripe_id; + protected function get_account_branding_icon( $default_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_branding_icon(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get account\'s branding icon.' . $e ); + } + + return $default_value; } /** - * Returns the list of enabled payment method types for UPE that are available based on the manual capture setting. + * Gets connected account branding primary color. * - * @return string[] + * @param string $default_value Value to return when not connected or failed to fetch branding primary color. + * + * @return string Business support branding primary color or default value. */ - public function get_upe_enabled_payment_method_ids_based_on_manual_capture() { - $automatic_capture = empty( $this->get_option( 'manual_capture' ) ) || $this->get_option( 'manual_capture' ) === 'no'; - if ( $automatic_capture ) { - return $this->get_upe_enabled_payment_method_ids(); + protected function get_account_branding_primary_color( $default_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_branding_primary_color(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get account\'s branding primary color.' . $e ); } - return array_intersect( $this->get_upe_enabled_payment_method_ids(), [ Payment_Method::CARD, Payment_Method::LINK ] ); + return $default_value; } /** - * Returns the list of enabled payment method types that will function with the current checkout. + * Gets connected account branding secondary color. * - * @param string $order_id optional Order ID. - * @param bool $force_currency_check optional Whether the currency check is required even if is_admin(). + * @param string $default_value Value to return when not connected or failed to fetch branding secondary color. * - * @return string[] + * @return string Business support branding secondary color or default value. */ - public function get_payment_method_ids_enabled_at_checkout( $order_id = null, $force_currency_check = false ) { - $upe_enabled_payment_methods = $this->get_upe_enabled_payment_method_ids_based_on_manual_capture(); - if ( is_wc_endpoint_url( 'order-pay' ) ) { - $force_currency_check = true; - } - - $enabled_payment_methods = []; - $active_payment_methods = $this->get_upe_enabled_payment_method_statuses(); - - foreach ( $upe_enabled_payment_methods as $payment_method_id ) { - $payment_method_capability_key = $this->payment_method_capability_key_map[ $payment_method_id ] ?? 'undefined_capability_key'; - if ( isset( $this->payment_methods[ $payment_method_id ] ) ) { - // When creating a payment intent, we need to ensure the currency is matching - // with the payment methods which are sent with the payment intent request, otherwise - // Stripe returns an error. - - // In order to allow payment methods to be displayed in admin pages (e.g. blocks editor), - // we need to skip the currency check (unless force_currency_check is true). - // force_currency_check = 0 is_admin = 0 -> skip_currency_check = 0. - // force_currency_check = 0 is_admin = 1 -> skip_currency_check = 1. - // force_currency_check = 1 is_admin = 0 -> skip_currency_check = 0. - // force_currency_check = 1 is_admin = 1 -> skip_currency_check = 0. - - $skip_currency_check = ! $force_currency_check && is_admin(); - $processing_payment_method = $this->payment_methods[ $payment_method_id ]; - if ( $processing_payment_method->is_enabled_at_checkout( $this->get_account_country(), $skip_currency_check ) && ( $skip_currency_check || $processing_payment_method->is_currency_valid( $this->get_account_domestic_currency(), $order_id ) ) ) { - $status = $active_payment_methods[ $payment_method_capability_key ]['status'] ?? null; - if ( 'active' === $status ) { - $enabled_payment_methods[] = $payment_method_id; - } - } + protected function get_account_branding_secondary_color( $default_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_branding_secondary_color(); } + } catch ( Exception $e ) { + Logger::error( 'Failed to get account\'s branding secondary color.' . $e ); } - // if credit card payment method is not enabled, we don't use stripe link. - if ( - ! in_array( CC_Payment_Method::PAYMENT_METHOD_STRIPE_ID, $enabled_payment_methods, true ) && - in_array( Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID, $enabled_payment_methods, true ) ) { - $enabled_payment_methods = array_filter( - $enabled_payment_methods, - static function ( $method ) { - return Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID !== $method; - } - ); - } - - return $enabled_payment_methods; + return $default_value; } /** - * Returns the list of enabled payment method types that will function with the current checkout filtered by fees. + * Gets connected account deposit schedule interval. * - * @param string $order_id optional Order ID. - * @param bool $force_currency_check optional Whether the currency check is required even if is_admin(). - * @return string[] + * @param string $empty_value Empty value to return when not connected or fails to fetch deposit schedule. + * + * @return string Interval or default value. */ - public function get_payment_method_ids_enabled_at_checkout_filtered_by_fees( $order_id = null, $force_currency_check = false ) { - $enabled_payment_methods = $this->get_payment_method_ids_enabled_at_checkout( $order_id, $force_currency_check ); - $methods_with_fees = array_keys( $this->account->get_fees() ); - - return array_values( array_intersect( $enabled_payment_methods, $methods_with_fees ) ); + protected function get_deposit_schedule_interval( string $empty_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_deposit_schedule_interval(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get deposit schedule interval.' . $e ); + } + return $empty_value; } /** - * Returns the list of available payment method types for UPE. - * See https://stripe.com/docs/stripe-js/payment-element#web-create-payment-intent for a complete list. + * Gets connected account deposit schedule weekly anchor. * - * @return string[] + * @param string $empty_value Empty value to return when not connected or fails to fetch deposit schedule weekly anchor. + * + * @return string Weekly anchor or default value. */ - public function get_upe_available_payment_methods() { - $available_methods = [ 'card' ]; - - /** - * FLAG: PAYMENT_METHODS_LIST - * As payment methods are converted to use definitions, they need to be removed from the list below. - */ - $available_methods[] = Becs_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - $available_methods[] = Bancontact_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - $available_methods[] = Eps_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - $available_methods[] = Ideal_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - $available_methods[] = Sepa_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - $available_methods[] = P24_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - $available_methods[] = Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - $available_methods[] = Affirm_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - $available_methods[] = Afterpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - $available_methods[] = Klarna_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - $available_methods[] = Multibanco_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - $available_methods[] = Grabpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID; - - // This gets all the registered payment method definitions. As new payment methods are converted from the legacy style, they need to be removed from the list above. - $payment_method_definitions = PaymentMethodDefinitionRegistry::instance()->get_all_payment_method_definitions(); - - foreach ( $payment_method_definitions as $definition_class ) { - $available_methods[] = $definition_class::get_id(); + protected function get_deposit_schedule_weekly_anchor( string $empty_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_deposit_schedule_weekly_anchor(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get deposit schedule weekly anchor.' . $e ); } - - $available_methods = array_values( - apply_filters( - 'wcpay_upe_available_payment_methods', - $available_methods - ) - ); - - $methods_with_fees = array_keys( $this->account->get_fees() ); - - return array_values( array_intersect( $available_methods, $methods_with_fees ) ); + return $empty_value; } /** - * Handle AJAX request for saving UPE appearance value to transient. + * Gets connected account deposit schedule monthly anchor. * - * @throws Exception - If nonce or setup intent is invalid. + * @param int|null $empty_value Empty value to return when not connected or fails to fetch deposit schedule monthly anchor. + * + * @return int|null Monthly anchor or default value. */ - public function save_upe_appearance_ajax() { + protected function get_deposit_schedule_monthly_anchor( $empty_value = null ) { try { - $is_nonce_valid = check_ajax_referer( 'wcpay_save_upe_appearance_nonce', false, false ); - if ( ! $is_nonce_valid ) { - throw new Exception( - __( 'Unable to update UPE appearance values at this time.', 'woocommerce-payments' ) - ); - } - - $elements_location = isset( $_POST['elements_location'] ) ? wc_clean( wp_unslash( $_POST['elements_location'] ) ) : null; - $appearance = isset( $_POST['appearance'] ) ? json_decode( wc_clean( wp_unslash( $_POST['appearance'] ) ) ) : null; - - $valid_locations = [ 'blocks_checkout', 'shortcode_checkout', 'bnpl_product_page', 'bnpl_classic_cart', 'bnpl_cart_block', 'add_payment_method' ]; - if ( ! $elements_location || ! in_array( $elements_location, $valid_locations, true ) ) { - throw new Exception( - __( 'Unable to update UPE appearance values at this time.', 'woocommerce-payments' ) - ); - } - - if ( in_array( $elements_location, [ 'blocks_checkout', 'shortcode_checkout' ], true ) ) { - $is_blocks_checkout = 'blocks_checkout' === $elements_location; - /** - * This filter is only called on "save" of the appearance, to avoid calling it on every page load. - * If you apply changes through this filter, you'll need to clear the transient data to see them at checkout. - * - * @deprecated 7.4.0 Use {@see 'wcpay_elements_appearance'} instead. - * @since 7.3.0 - */ - $appearance = apply_filters_deprecated( 'wcpay_upe_appearance', [ $appearance, $is_blocks_checkout ], '7.4.0', 'wcpay_elements_appearance' ); + if ( $this->is_connected() ) { + return $this->account->get_deposit_schedule_monthly_anchor(); } + } catch ( Exception $e ) { + Logger::error( 'Failed to get deposit schedule monthly anchor.' . $e ); + } + return null === $empty_value ? null : (int) $empty_value; + } - /** - * This filter is only called on "save" of the appearance, to avoid calling it on every page load. - * If you apply changes through this filter, you'll need to clear the transient data to see them at checkout. - * $elements_location can be 'blocks_checkout', 'shortcode_checkout', 'bnpl_product_page', 'bnpl_classic_cart', 'bnpl_cart_block', 'add_payment_method'. - * - * @since 7.4.0 - */ - $appearance = apply_filters( 'wcpay_elements_appearance', $appearance, $elements_location ); - - $appearance_transient = [ - 'shortcode_checkout' => self::UPE_APPEARANCE_TRANSIENT, - 'add_payment_method' => self::UPE_ADD_PAYMENT_METHOD_APPEARANCE_TRANSIENT, - 'blocks_checkout' => self::WC_BLOCKS_UPE_APPEARANCE_TRANSIENT, - 'bnpl_product_page' => self::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_TRANSIENT, - 'bnpl_classic_cart' => self::UPE_BNPL_CLASSIC_CART_APPEARANCE_TRANSIENT, - 'bnpl_cart_block' => self::UPE_BNPL_CART_BLOCK_APPEARANCE_TRANSIENT, - ][ $elements_location ]; - $appearance_theme_transient = [ - 'shortcode_checkout' => self::UPE_APPEARANCE_THEME_TRANSIENT, - 'add_payment_method' => self::UPE_ADD_PAYMENT_METHOD_APPEARANCE_THEME_TRANSIENT, - 'blocks_checkout' => self::WC_BLOCKS_UPE_APPEARANCE_THEME_TRANSIENT, - 'bnpl_product_page' => self::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_THEME_TRANSIENT, - 'bnpl_classic_cart' => self::UPE_BNPL_CLASSIC_CART_APPEARANCE_THEME_TRANSIENT, - 'bnpl_cart_block' => self::UPE_BNPL_CART_BLOCK_APPEARANCE_THEME_TRANSIENT, - ][ $elements_location ]; - - if ( null !== $appearance ) { - set_transient( $appearance_transient, $appearance, DAY_IN_SECONDS ); - set_transient( $appearance_theme_transient, $appearance->theme, DAY_IN_SECONDS ); + /** + * Gets connected account deposit delay days. + * + * @param int $default_value Value to return when not connected or fails to fetch deposit delay days. Default is 7 days. + * + * @return int number of days. + */ + protected function get_deposit_delay_days( int $default_value = 7 ): int { + try { + if ( $this->is_connected() ) { + return $this->account->get_deposit_delay_days() ?? $default_value; } + } catch ( Exception $e ) { + Logger::error( 'Failed to get deposit delay days.' . $e ); + } + return $default_value; + } - wp_send_json_success( $appearance, 200 ); + /** + * Gets connected account deposit status. + * + * @param string $empty_value Empty value to return when not connected or fails to fetch deposit status. + * + * @return string deposit status or default value. + */ + protected function get_deposit_status( string $empty_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_deposit_status(); + } } catch ( Exception $e ) { - // Send back error so it can be displayed to the customer. - wp_send_json_error( - [ - 'error' => [ - 'message' => WC_Payments_Utils::get_filtered_error_message( $e ), - ], - ], - WC_Payments_Utils::get_filtered_error_status_code( $e ) - ); + Logger::error( 'Failed to get deposit status.' . $e ); } + return $empty_value; } /** - * Clear the saved UPE appearance transient value. + * Gets connected account deposit restrictions. + * + * @param string $empty_value Empty value to return when not connected or fails to fetch deposit restrictions. + * + * @return string deposit restrictions or default value. */ - public function clear_upe_appearance_transient() { - delete_transient( self::UPE_APPEARANCE_TRANSIENT ); - delete_transient( self::WC_BLOCKS_UPE_APPEARANCE_TRANSIENT ); - delete_transient( self::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_TRANSIENT ); - delete_transient( self::UPE_BNPL_CLASSIC_CART_APPEARANCE_TRANSIENT ); - delete_transient( self::UPE_BNPL_CART_BLOCK_APPEARANCE_TRANSIENT ); - delete_transient( self::UPE_APPEARANCE_THEME_TRANSIENT ); - delete_transient( self::WC_BLOCKS_UPE_APPEARANCE_THEME_TRANSIENT ); - delete_transient( self::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_THEME_TRANSIENT ); - delete_transient( self::UPE_BNPL_CLASSIC_CART_APPEARANCE_THEME_TRANSIENT ); - delete_transient( self::UPE_BNPL_CART_BLOCK_APPEARANCE_THEME_TRANSIENT ); + protected function get_deposit_restrictions( string $empty_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_deposit_restrictions(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get deposit restrictions.' . $e ); + } + return $empty_value; } /** - * Returns true if the code returned from the API represents an error that should be rate-limited. + * Gets the completed deposit waiting period value. * - * @param string $error_code The error code returned from the API. + * @param bool $empty_value Empty value to return when not connected or fails to fetch the completed deposit waiting period value. * - * @return bool Whether the rate limiter should be bumped. + * @return bool The completed deposit waiting period value or default value. */ - protected function should_bump_rate_limiter( string $error_code ): bool { - return in_array( $error_code, [ 'card_declined', 'incorrect_number', 'incorrect_cvc' ], true ); + protected function get_deposit_completed_waiting_period( bool $empty_value = false ): bool { + try { + if ( $this->is_connected() ) { + return $this->account->get_deposit_completed_waiting_period(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get the deposit waiting period value.' . $e ); + } + return $empty_value; } /** - * Returns boolean for whether payment gateway supports saved payments. + * Gets the current fraud protection level value. * - * @return bool True, if gateway supports saved payments. False, otherwise. + * @return string The current fraud protection level. */ - public function should_support_saved_payments() { - return $this->is_enabled_for_saved_payments( $this->stripe_id ); + protected function get_current_protection_level() { + $this->maybe_refresh_fraud_protection_settings(); + return get_option( 'current_protection_level', 'basic' ); } /** - * Returns true when viewing payment methods page. + * Gets the advanced fraud protection level settings value. * - * @return bool + * @return array|string The advanced level fraud settings for the store, if not saved, the default ones. + * If there's a fetch error, it returns "error". */ - private function is_payment_methods_page() { - global $wp; - - $page_id = wc_get_page_id( 'myaccount' ); + protected function get_advanced_fraud_protection_settings() { + // Check if Stripe is connected. + if ( ! $this->is_connected() ) { + return []; + } - return ( $page_id && is_page( $page_id ) && ( isset( $wp->query_vars['payment-methods'] ) ) ); + $this->maybe_refresh_fraud_protection_settings(); + $transient_value = get_transient( 'wcpay_fraud_protection_settings' ); + return false === $transient_value ? 'error' : $transient_value; } /** - * Verifies that the proper intent is used to process the order. + * Checks if a fraud protection rule is enabled. * - * @param WC_Order $order The order object based on the order_id received from the request. - * @param string $intent_id_from_request The intent ID received from the request. + * @param string $rule The rule to check. * - * @return bool True if the proper intent is used to process the order, false otherwise. + * @return bool True if the rule is enabled, false otherwise. */ - public function is_proper_intent_used_with_order( $order, $intent_id_from_request ) { - $intent_id_attached_to_order = $this->order_service->get_intent_id_for_order( $order ); - if ( ! hash_equals( $intent_id_attached_to_order, $intent_id_from_request ) ) { - Logger::error( - sprintf( - 'Intent ID mismatch. Received in request: %1$s. Attached to order: %2$s. Order ID: %3$d', - $intent_id_from_request, - $intent_id_attached_to_order, - $order->get_id() - ) - ); + protected function is_fraud_rule_enabled( string $rule ): bool { + $settings = $this->get_advanced_fraud_protection_settings(); + + if ( ! is_array( $settings ) ) { return false; } - return true; - } - /** - * True if the request contains the values that indicates a redirection after a successful setup intent creation. - * - * @return bool - */ - public function is_setup_intent_success_creation_redirection() { - return ! empty( $_GET['setup_intent_client_secret'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended - ! empty( $_GET['setup_intent'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended - ! empty( $_GET['redirect_status'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended - 'succeeded' === $_GET['redirect_status']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + foreach ( $settings as $setting ) { + if ( $rule === $setting['key'] ) { + return true; + } + } + + return false; } /** - * Function to be used with array_filter - * to filter UPE payment methods that support saved payments + * Checks if the transaction was blocked by fraud rules. * - * @param string $payment_method_id Stripe payment method. + * @param Exception $e The exception to check. * - * @return bool + * @return bool True if the transaction was blocked by fraud rules, false otherwise. */ - public function is_enabled_for_saved_payments( $payment_method_id ) { - $payment_method = $this->get_selected_payment_method( $payment_method_id ); - if ( ! $payment_method ) { + protected function is_blocked_by_fraud_rules( Exception $e ): bool { + if ( ! ( $e instanceof API_Exception ) ) { return false; } - return $payment_method->is_reusable() - && ( is_admin() || $payment_method->is_currency_valid( $this->get_account_domestic_currency() ) ); + + $error_code = $e->get_error_code() ?? null; + $error_type = $e->get_error_type() ?? null; + + $blocked_by_fraud_rule = 'wcpay_blocked_by_fraud_rule' === $error_code; + + // Since the AVS mismatch is part of the advanced fraud prevention, we need to consider that as a blocked order. + $blocked_by_avs_mismatch = $this->is_blocked_by_avs_verification_fraud_rule( $error_code, $error_type ); + + return $blocked_by_fraud_rule || $blocked_by_avs_mismatch; } /** - * Move the email field to the top of the Checkout page. - * - * @param array $fields WooCommerce checkout fields. + * Checks the synchronicity of fraud protection settings with the server, and updates the local cache when needed. * - * @return array WooCommerce checkout fields. + * @return void */ - public function checkout_update_email_field_priority( $fields ) { - if ( is_checkout() || has_block( 'woocommerce/checkout' ) ) { - $is_link_enabled = in_array( - Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID, - \WC_Payments::get_gateway()->get_payment_method_ids_enabled_at_checkout_filtered_by_fees( null, true ), - true - ); + protected function maybe_refresh_fraud_protection_settings() { + // It'll be good to run this only once per call, because if it succeeds, the latter won't require + // to run again, and if it fails, it will fail on other calls too. + static $runonce = false; - if ( $is_link_enabled && isset( $fields['billing_email'] ) ) { - // Update the field priority. - $fields['billing_email']['priority'] = 1; + // If already ran this before on this call, return. + if ( $runonce ) { + return; + } - // Add extra `wcpay-checkout-email-field` class. - $fields['billing_email']['class'][] = 'wcpay-checkout-email-field'; + // Check if we have local cache available before pulling it from the server. + // If the transient exists, do nothing. + $cached_server_settings = get_transient( 'wcpay_fraud_protection_settings' ); - add_filter( 'woocommerce_form_field_email', [ $this, 'append_stripelink_button' ], 10, 4 ); + if ( false === $cached_server_settings ) { + // When both local and server values don't exist, we need to reset the protection level on both to "Basic". + $needs_reset = false; + + try { + // There's no cached ruleset, or the cache has expired. Try to fetch it from the server. + $latest_server_ruleset = $this->payments_api_client->get_latest_fraud_ruleset(); + if ( isset( $latest_server_ruleset['ruleset_config'] ) ) { + // Update the local cache from the server. + set_transient( 'wcpay_fraud_protection_settings', $latest_server_ruleset['ruleset_config'], DAY_IN_SECONDS ); + // Get the matching level for the ruleset, and set the option. + update_option( 'current_protection_level', Fraud_Risk_Tools::get_matching_protection_level( $latest_server_ruleset['ruleset_config'] ) ); + return; + } + // If the response doesn't contain a ruleset, probably there's an error. Grey out the form. + } catch ( API_Exception $ex ) { + if ( 'wcpay_fraud_ruleset_not_found' === $ex->get_error_code() ) { + // If fetching returned a 'wcpay_fraud_ruleset_not_found' exception, save the basic protection as the server ruleset, + // and update the client with the same config. + $needs_reset = true; + } + // If the exception isn't what we want, probably there's an error. Grey out the form. + } + + if ( $needs_reset ) { + // Set the Basic protection level as the default on both client and server. + $basic_protection_settings = Fraud_Risk_Tools::get_basic_protection_settings(); + $this->payments_api_client->save_fraud_ruleset( $basic_protection_settings ); + set_transient( 'wcpay_fraud_protection_settings', $basic_protection_settings, DAY_IN_SECONDS ); + update_option( 'current_protection_level', 'basic' ); } + + // Set the static flag to prevent duplicate calls to this method. + $runonce = true; } + } - return $fields; + /** + * Returns a formatted token list for a user. + * + * @param int $user_id The user ID. + */ + protected function get_user_formatted_tokens_array( $user_id ) { + $tokens = WC_Payment_Tokens::get_tokens( + [ + 'user_id' => $user_id, + 'gateway_id' => self::GATEWAY_ID, + 'limit' => self::USER_FORMATTED_TOKENS_LIMIT, + ] + ); + + return array_map( + static function ( WC_Payment_Token $token ): array { + return [ + 'tokenId' => $token->get_id(), + 'paymentMethodId' => $token->get_token(), + 'isDefault' => $token->get_is_default(), + 'displayName' => $token->get_display_name(), + ]; + }, + array_values( $tokens ) + ); } /** - * Append StripeLink button within email field for logged in users. + * Returns true if the code returned from the API represents an error that should be rate-limited. * - * @param string $field - HTML content within email field. - * @param string $key - Key. - * @param array $args - Arguments. - * @param string $value - Default value. + * @param string $error_code The error code returned from the API. * - * @return string $field - Updated email field content with the button appended. + * @return bool Whether the rate limiter should be bumped. */ - public function append_stripelink_button( $field, $key, $args, $value ) { - if ( 'billing_email' === $key ) { - $field = str_replace( '', '', $field ); - } - return $field; + protected function should_bump_rate_limiter( string $error_code ): bool { + return in_array( $error_code, [ 'card_declined', 'incorrect_number', 'incorrect_cvc' ], true ); } /** @@ -4391,170 +4415,165 @@ protected function get_selected_upe_payment_methods( string $selected_upe_paymen } /** - * Gets UPE_Payment_Method instance from ID. + * Modifies the create intent parameters when processing a payment. * - * @param string $payment_method_type Stripe payment method type ID. - * @return UPE_Payment_Method|false UPE payment method instance. - */ - public function get_selected_payment_method( $payment_method_type ) { - return WC_Payments::get_payment_method_by_id( $payment_method_type ); - } - - /** - * Return the payment method type from the payment method details. + * If the selected Stripe payment type is AFTERPAY, it updates the shipping data in the request. * - * @param array $payment_method_details Payment method details. - * @return string|null Payment method type or nothing. - */ - private function get_payment_method_type_from_payment_details( $payment_method_details ) { - return $payment_method_details['type'] ?? null; - } - - /** - * Get the payment method used with a setup intent. + * @param Create_And_Confirm_Intention $request The request object for creating and confirming intention. + * @param Payment_Information $payment_information The payment information object. + * @param WC_Order $order The order object. + * @throws Invalid_Address_Exception * - * @param WC_Payments_API_Setup_Intention $intent The PaymentIntent object. - * @param WC_Payment_Token $token The payment token. - * @return string|null The payment method type. + * @return void */ - private function get_payment_method_type_for_setup_intent( $intent, $token ) { - return 'wcpay_link' !== $token->get_type() ? $intent->get_payment_method_type() : Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + protected function modify_create_intent_parameters_when_processing_payment( Create_And_Confirm_Intention $request, Payment_Information $payment_information, WC_Order $order ): void { + if ( Payment_Method::AFTERPAY === $this->get_selected_stripe_payment_type_id() ) { + $this->handle_afterpay_shipping_requirement( $order, $request ); + } } + /** - * This function wraps WC_Payments::get_payment_method_map, useful for unit testing. + * Validate order_id received from the request vs value saved in the intent metadata. + * Throw an exception if they're not matched. * - * @return array Array of UPE_Payment_Method instances. + * @param WC_Order $order The received order to process. + * @param array $intent_metadata The metadata of attached intent to the order. + * + * @return void + * @throws Process_Payment_Exception */ - public function wc_payments_get_payment_method_map() { - return WC_Payments::get_payment_method_map(); + private function validate_order_id_received_vs_intent_meta_order_id( WC_Order $order, array $intent_metadata ): void { + $intent_meta_order_id_raw = $intent_metadata['order_id'] ?? ''; + $intent_meta_order_id = is_numeric( $intent_meta_order_id_raw ) ? intval( $intent_meta_order_id_raw ) : 0; + + if ( $order->get_id() !== $intent_meta_order_id ) { + Logger::error( + sprintf( + 'UPE Process Redirect Payment - Order ID mismatched. Received: %1$d. Intent Metadata Value: %2$d', + $order->get_id(), + $intent_meta_order_id + ) + ); + + throw new Process_Payment_Exception( + __( "We're not able to process this payment due to the order ID mismatch. Please try again later.", 'woocommerce-payments' ), + self::PROCESS_REDIRECT_ORDER_MISMATCH_ERROR_CODE + ); + } } /** - * Returns the payment methods for this gateway. + * Gets and formats payment request data. * - * @return array|UPE_Payment_Method[] + * @param \WP_REST_Request $request Request object. + * @return array */ - public function get_payment_methods() { - return $this->payment_methods; + private function get_request_payment_data( \WP_REST_Request $request ) { + static $payment_data = []; + if ( ! empty( $payment_data ) ) { + return $payment_data; + } + if ( ! empty( $request['payment_data'] ) ) { + foreach ( $request['payment_data'] as $data ) { + $payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] ); + } + } + + return $payment_data; } /** - * Returns the UPE payment method for the gateway. + * Get values for Stripe mandate_data parameter * - * @return UPE_Payment_Method + * @return array mandate_data values to use in request. */ - public function get_payment_method() { - return $this->payment_method; + private function get_mandate_data() { + return [ + 'customer_acceptance' => [ + 'type' => 'online', + 'online' => [ + 'ip_address' => WC_Geolocation::get_ip_address(), + 'user_agent' => 'WooCommerce Payments/' . WCPAY_VERSION_NUMBER . '; ' . get_bloginfo( 'url' ), + ], + ], + ]; } /** - * Returns Stripe payment method type ID. + * Gets the payment method type used for an order, if any + * + * @param WC_Order $order The order to get the payment method type for. * * @return string */ - public function get_stripe_id() { - return $this->stripe_id; - } + private function get_payment_method_type_for_order( $order ): string { + $payment_method_details = []; + if ( $this->order_service->get_payment_method_id_for_order( $order ) ) { + $payment_method_id = $this->order_service->get_payment_method_id_for_order( $order ); + $payment_method_details = $this->payments_api_client->get_payment_method( $payment_method_id ); + } elseif ( $this->order_service->get_intent_id_for_order( $order ) ) { + $payment_intent_id = $this->order_service->get_intent_id_for_order( $order ); - /** - * This function wraps WC_Payments::get_payment_gateway_by_id, useful for unit testing. - * - * @param string $payment_method_id Stripe payment method type ID. - * @return false|WC_Payment_Gateway_WCPay Matching UPE Payment Gateway instance. - */ - public function wc_payments_get_payment_gateway_by_id( $payment_method_id ) { - return WC_Payments::get_payment_gateway_by_id( $payment_method_id ); - } + $request = Get_Intention::create( $payment_intent_id ); + $request->set_hook_args( $order ); - /** - * This function wraps WC_Payments::get_payment_method_by_id, useful for unit testing. - * - * @param string $payment_method_id Stripe payment method type ID. - * @return false|UPE_Payment_Method Matching UPE Payment Method instance. - */ - public function wc_payments_get_payment_method_by_id( $payment_method_id ) { - return WC_Payments::get_payment_method_by_id( $payment_method_id ); + $payment_intent = $request->send(); + + $charge = $payment_intent ? $payment_intent->get_charge() : null; + $payment_method_details = $charge ? $charge->get_payment_method_details() : []; + } + + return $payment_method_details['type'] ?? 'unknown'; } /** - * Checks if UPE appearance theme is set and returns appropriate icon URL. + * Checks if the transaction was blocked by AVS verification fraud rule. * - * @return string + * @param string|null $error_code The error code to check. + * @param string|null $error_type The error type to check. + * + * @return bool True if the transaction was blocked by the AVS verification fraud rule, false otherwise. */ - public function get_theme_icon() { - $upe_appearance_theme = get_transient( self::UPE_APPEARANCE_THEME_TRANSIENT ); - if ( $upe_appearance_theme ) { - return 'night' === $upe_appearance_theme ? $this->payment_method->get_dark_icon() : $this->payment_method->get_icon(); - } - return $this->payment_method->get_icon(); + private function is_blocked_by_avs_verification_fraud_rule( ?string $error_code, ?string $error_type ): bool { + $is_avs_verification_rule_enabled = $this->is_fraud_rule_enabled( 'avs_verification' ); + $is_incorrect_zip_error = 'card_error' === $error_type && 'incorrect_zip' === $error_code; + + return $is_avs_verification_rule_enabled && $is_incorrect_zip_error; } /** - * Get the right method description if WooPay is eligible. + * Returns true when viewing payment methods page. * - * @return string + * @return bool */ - public function get_method_description() { - $description = sprintf( - /* translators: %1$s: WooPayments */ - __( - '%1$s gives your store flexibility to accept credit cards, debit cards, and Apple Pay. Enable popular local payment methods and other digital wallets like Google Pay to give customers even more choice.', - 'woocommerce-payments' - ), - 'WooPayments' - ); + private function is_payment_methods_page() { + global $wp; - if ( WooPay_Utilities::is_store_country_available() ) { - $description = sprintf( - /* translators: %s: WooPay, */ - __( - 'Payments made simple — including %s, a new express checkout feature.', - 'woocommerce-payments' - ), - 'WooPay' - ); - } + $page_id = wc_get_page_id( 'myaccount' ); - return $description; + return ( $page_id && is_page( $page_id ) && ( isset( $wp->query_vars['payment-methods'] ) ) ); } /** - * Calls duplicate payment methods detection service to find duplicates. - * This method acts as a wrapper. The approach should be reverted once - * https://github.com/Automattic/woocommerce-payments/issues/7464 is resolved. + * Return the payment method type from the payment method details. + * + * @param array $payment_method_details Payment method details. + * @return string|null Payment method type or nothing. */ - public function find_duplicates() { - return $this->duplicate_payment_methods_detection_service->find_duplicates(); + private function get_payment_method_type_from_payment_details( $payment_method_details ) { + return $payment_method_details['type'] ?? null; } /** - * Get the recommended payment methods list. - * - * @param string $country_code Optional. The business location country code. Provide a 2-letter ISO country code. - * If not provided, the account country will be used if the account is connected. - * Otherwise, the store's base country will be used. + * Get the payment method used with a setup intent. * - * @return array List of recommended payment methods for the given country. - * Empty array if there are no recommendations available. - * Each item in the array should be an associative array with at least the following entries: - * - @string id: The payment method ID. - * - @string title: The payment method title/name. - * - @bool enabled: Whether the payment method is enabled. - * - @int order/priority: The order/priority of the payment method. + * @param WC_Payments_API_Setup_Intention $intent The PaymentIntent object. + * @param WC_Payment_Token $token The payment token. + * @return string|null The payment method type. */ - public function get_recommended_payment_methods( string $country_code = '' ): array { - if ( empty( $country_code ) ) { - // If the account is connected, use the account country. - if ( $this->account->is_provider_connected() ) { - $country_code = $this->get_account_country(); - } else { - // If the account is not connected, use the store's base country. - $country_code = WC()->countries->get_base_country(); - } - } - - return $this->account->get_recommended_payment_methods( $country_code ); + private function get_payment_method_type_for_setup_intent( $intent, $token ) { + return 'wcpay_link' !== $token->get_type() ? $intent->get_payment_method_type() : Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID; } /** @@ -4631,23 +4650,4 @@ private function handle_afterpay_shipping_requirement( WC_Order $order, Create_A throw new Invalid_Address_Exception( __( 'A valid shipping address is required for Afterpay payments.', 'woocommerce-payments' ) ); } - - - /** - * Modifies the create intent parameters when processing a payment. - * - * If the selected Stripe payment type is AFTERPAY, it updates the shipping data in the request. - * - * @param Create_And_Confirm_Intention $request The request object for creating and confirming intention. - * @param Payment_Information $payment_information The payment information object. - * @param WC_Order $order The order object. - * @throws Invalid_Address_Exception - * - * @return void - */ - protected function modify_create_intent_parameters_when_processing_payment( Create_And_Confirm_Intention $request, Payment_Information $payment_information, WC_Order $order ): void { - if ( Payment_Method::AFTERPAY === $this->get_selected_stripe_payment_type_id() ) { - $this->handle_afterpay_shipping_requirement( $order, $request ); - } - } }