From 9db11ce5b1fd0f7dcec5c0f91091b597218a3537 Mon Sep 17 00:00:00 2001 From: Anne Mirasol Date: Fri, 13 Sep 2024 10:28:09 -0500 Subject: [PATCH 01/14] Set Klarna's preferred locale (#3428) * Set Klarna payments page preferred locale * Add changelog and readme entries * Add unit test * Fix misfiled entry in changelog and readme --- changelog.txt | 5 +- includes/class-wc-stripe-helper.php | 59 +++++++++++++++++++ .../class-wc-stripe-upe-payment-gateway.php | 13 ++++ readme.txt | 5 +- tests/phpunit/test-wc-stripe-helper.php | 45 ++++++++++++++ 5 files changed, 123 insertions(+), 4 deletions(-) diff --git a/changelog.txt b/changelog.txt index 028220970c..6508d274c6 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,11 +1,12 @@ *** Changelog *** = 8.8.0 - xxxx-xx-xx = -* Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. +* Tweak - Render the Klarna payment page in the store locale. +* Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. * Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. +* Fix - Fix empty error message for Express Payments when order creation fails. = 8.7.0 - xxxx-xx-xx = -* Fix - Fix empty error message for Express Payments when order creation fails. * Fix - Prevent duplicate failed-order emails from being sent. * Fix - Support custom name and description for Afterpay. * Fix - Link APM charge IDs in Order Details page to their Stripe dashboard payments page. diff --git a/includes/class-wc-stripe-helper.php b/includes/class-wc-stripe-helper.php index cec6f152de..175732503b 100644 --- a/includes/class-wc-stripe-helper.php +++ b/includes/class-wc-stripe-helper.php @@ -1533,4 +1533,63 @@ public static function get_transaction_url( $is_test_mode = false ) { return 'https://dashboard.stripe.com/payments/%s'; } + + /** + * Returns a supported locale for setting Klarna's "preferred_locale". + * While Stripe allows for localization of Klarna's payments page, it still + * limits the locale to the billing country's set of supported locales. For example, + * we cannot set the locale to "fr-FR" or "fr-US" if the billing country is "US". + * + * We compute our desired locale by combining the language tag from the store locale + * and the billing country. We return that if it is supported. + * + * @param string $store_locale The WooCommerce store locale. + * Expected format: WordPress locale format, e.g. "en" or "en_US". + * @param string $billing_country The billing country code. + * @return string|null The Klarna locale or null if not supported. + */ + public static function get_klarna_preferred_locale( $store_locale, $billing_country ) { + // From https://docs.stripe.com/payments/klarna/accept-a-payment?payments-ui-type=direct-api#supported-locales-and-currencies + $supported_locales = [ + 'AU' => [ 'en-AU' ], + 'AT' => [ 'de-AT', 'en-AT' ], + 'BE' => [ 'nl-BE', 'fr-BE', 'en-BE' ], + 'CA' => [ 'en-CA', 'fr-CA' ], + 'CZ' => [ 'en-CZ', 'cs-CZ' ], + 'DK' => [ 'da-DK', 'en-DK' ], + 'FI' => [ 'fi-FI', 'sv-FI', 'en-FI' ], + 'FR' => [ 'fr-FR', 'en-FR' ], + 'DE' => [ 'de-DE', 'en-DE' ], + 'GR' => [ 'en-GR', 'el-GR' ], + 'IE' => [ 'en-IE' ], + 'IT' => [ 'it-IT', 'en-IT' ], + 'NL' => [ 'nl-NL', 'en-NL' ], + 'NZ' => [ 'en-NZ' ], + 'NO' => [ 'nb-NO', 'en-NO' ], + 'PL' => [ 'pl-PL', 'en-PL' ], + 'PT' => [ 'pt-PT', 'en-PT' ], + 'RO' => [ 'ro-RO', 'en-RO' ], + 'ES' => [ 'es-ES', 'en-ES' ], + 'SE' => [ 'sv-SE', 'en-SE' ], + 'CH' => [ 'de-CH', 'fr-CH', 'it-CH', 'en-CH' ], + 'GB' => [ 'en-GB' ], + 'US' => [ 'en-US', 'es-US' ], + ]; + + $region = strtoupper( $billing_country ); + if ( ! isset( $supported_locales[ $region ] ) ) { + return null; + } + + // Get the language tag e.g. "en" for "en_US". + $lang = strtolower( explode( '_', $store_locale )[0] ); + $target_locale = $lang . '-' . $region; + + // Check if the target locale is supported. + if ( ! in_array( $target_locale, $supported_locales[ $region ], true ) ) { + return null; + } + + return $target_locale; + } } diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php index 5c50cfd5dd..72bec865c3 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -2042,6 +2042,19 @@ private function prepare_payment_information_from_request( WC_Order $order ) { 'client' => 'web', ], ]; + } elseif ( 'klarna' === $selected_payment_type ) { + $preferred_locale = WC_Stripe_Helper::get_klarna_preferred_locale( + get_locale(), + $order->get_billing_country() + ); + + if ( ! empty( $preferred_locale ) ) { + $payment_method_options = [ + 'klarna' => [ + 'preferred_locale' => $preferred_locale, + ], + ]; + } } // Add the updated preferred credit card brand when defined diff --git a/readme.txt b/readme.txt index 21d24e782f..a5d785d044 100644 --- a/readme.txt +++ b/readme.txt @@ -129,11 +129,12 @@ If you get stuck, you can ask for help in the Plugin Forum. == Changelog == = 8.8.0 - xxxx-xx-xx = -* Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. +* Tweak - Render the Klarna payment page in the store locale. +* Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. * Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. +* Fix - Fix empty error message for Express Payments when order creation fails. = 8.7.0 - xxxx-xx-xx = -* Fix - Fix empty error message for Express Payments when order creation fails. * Fix - Prevent duplicate failed-order emails from being sent. * Fix - Support custom name and description for Afterpay. * Fix - Link APM charge IDs in Order Details page to their Stripe dashboard payments page. diff --git a/tests/phpunit/test-wc-stripe-helper.php b/tests/phpunit/test-wc-stripe-helper.php index 0761f96dba..7bb583cb19 100644 --- a/tests/phpunit/test-wc-stripe-helper.php +++ b/tests/phpunit/test-wc-stripe-helper.php @@ -409,4 +409,49 @@ public function test_handle_main_stripe_settings() { $current_settings = WC_Stripe_Helper::get_stripe_settings(); $this->assertSame( [], $current_settings ); } + + /** + * Test for `get_klarna_preferred_locale`. + * @return void + */ + public function test_get_klarna_preferred_locale() { + // Language is supported for the region (same region) + $store_locale = 'en_US'; + $billing_country = 'US'; + $expected = 'en-US'; + $actual = WC_Stripe_Helper::get_klarna_preferred_locale( $store_locale, $billing_country ); + $this->assertSame( $expected, $actual ); + + // Language is supported for the region (different region) + $store_locale = 'en_US'; + $billing_country = 'DE'; + $expected = 'en-DE'; + $actual = WC_Stripe_Helper::get_klarna_preferred_locale( $store_locale, $billing_country ); + $this->assertSame( $expected, $actual ); + + // Language is supported for the region (different region) + $store_locale = 'es_ES'; + $billing_country = 'US'; + $expected = 'es-US'; + $actual = WC_Stripe_Helper::get_klarna_preferred_locale( $store_locale, $billing_country ); + $this->assertSame( $expected, $actual ); + + // Language is not supported for the region + $store_locale = 'fr_FR'; + $billing_country = 'US'; + $actual = WC_Stripe_Helper::get_klarna_preferred_locale( $store_locale, $billing_country ); + $this->assertNull( $actual ); + + // Region is not supported, with supported locale + $store_locale = 'pt_PT'; + $billing_country = 'BR'; + $actual = WC_Stripe_Helper::get_klarna_preferred_locale( $store_locale, $billing_country ); + $this->assertNull( $actual ); + + // Region is not supported, with non-supported locale + $store_locale = 'tl'; + $billing_country = 'PH'; + $actual = WC_Stripe_Helper::get_klarna_preferred_locale( $store_locale, $billing_country ); + $this->assertNull( $actual ); + } } From 2cc998c95260b976faae915b5c1e222cb902e9bf Mon Sep 17 00:00:00 2001 From: Diego Curbelo Date: Fri, 13 Sep 2024 15:18:03 -0300 Subject: [PATCH 02/14] Remove Stripe keys from the DB when uninstalling the plugin (#3385) * Update the options list to remove * Remove webhook data when uninstalling * Disable the gateway before removing the plugin --- changelog.txt | 1 + readme.txt | 1 + uninstall.php | 122 ++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 101 insertions(+), 23 deletions(-) diff --git a/changelog.txt b/changelog.txt index 6508d274c6..ab03cd2a78 100644 --- a/changelog.txt +++ b/changelog.txt @@ -32,6 +32,7 @@ * Update - Specify the JS Stripe API version as 2024-06-20. * Tweak - Use order ID from 'get_order_number' in stripe intent metadata. * Fix - Ensure payment tokens are detached from Stripe when a user is deleted, regardless of if the admin user has a Stripe account. +* Fix - Remove the Stripe OAuth Keys when uninstalling the plugin. * Fix - Address Klarna availability based on correct presentment currency rules. * Fix - Use correct ISO country code of United Kingdom in supported country and currency list of AliPay and WeChat. * Fix - Prevent duplicate order notes and emails being sent when purchasing subscription products with no initial payment. diff --git a/readme.txt b/readme.txt index a5d785d044..d0b85c06cb 100644 --- a/readme.txt +++ b/readme.txt @@ -160,6 +160,7 @@ If you get stuck, you can ask for help in the Plugin Forum. * Update - Specify the JS Stripe API version as 2024-06-20. * Tweak - Use order ID from 'get_order_number' in stripe intent metadata. * Fix - Ensure payment tokens are detached from Stripe when a user is deleted, regardless of if the admin user has a Stripe account. +* Fix - Remove the Stripe OAuth Keys when uninstalling the plugin. * Fix - Address Klarna availability based on correct presentment currency rules. * Fix - Use correct ISO country code of United Kingdom in supported country and currency list of AliPay and WeChat. * Fix - Prevent duplicate order notes and emails being sent when purchasing subscription products with no initial payment. diff --git a/uninstall.php b/uninstall.php index 71a22240e0..49409e678a 100644 --- a/uninstall.php +++ b/uninstall.php @@ -1,44 +1,120 @@ Date: Tue, 10 Sep 2024 17:48:04 +1000 Subject: [PATCH 03/14] Add an admin notice for SEPA subscriptions migrations after disabling legacy checkout (#3422) * Display an admin notice to inform users of the SEPA migration after disabling legacy checkout * Get any status * The next action should never be more than 3 hours so remove the else case * Add changelog entries * Display the notice for up to 3 days to limit the scope of the notice queries * Stop showing the notice after all subscriptions have been migrated * If admin re-enable legacy checkout and then later disable legacy checkout, we should make sure to reattempt the migrations * Update includes/migrations/class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php Co-authored-by: Matt Allan * Schedule jobs to run in 1 minute rather than 1 hour * Update notice to no longer include next update time case * Treat in-progress actions as $is_still_scheduling_jobs * Remove duplicated Paged param * Schedule individual SEPA updates to run in 2 minutes * Add a tool to enable users to restart the migration if needed * Replace "migrate" with "update" * Move variable around for slight performance improvement * Add changelog entry for new tool * Update includes/migrations/class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php Co-authored-by: Matt Allan --------- Co-authored-by: Matt Allan --- changelog.txt | 2 + ...ass-wc-rest-stripe-settings-controller.php | 6 + ...scriptions-repairer-legacy-sepa-tokens.php | 225 +++++++++++++++++- readme.txt | 2 + 4 files changed, 234 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index beba4b5d08..37a078c863 100644 --- a/changelog.txt +++ b/changelog.txt @@ -29,6 +29,8 @@ * Fix - Address Klarna availability based on correct presentment currency rules. * Fix - Use correct ISO country code of United Kingdom in supported country and currency list of AliPay and WeChat. * Fix - Prevent duplicate order notes and emails being sent when purchasing subscription products with no initial payment. +* Add - Display an admin notice on the WooCommerce > Subscriptions screen for tracking the progress of SEPA subscriptions migrations after the legacy checkout is disabled. +* Add - Introduce a new tool on the WooCommerce > Status > Tools screen to restart the legacy SEPA subscriptions update. = 8.6.1 - 2024-08-09 = * Tweak - Improves the wording of the invalid Stripe keys errors, instructing merchants to click the "Configure connection" button instead of manually setting the keys. diff --git a/includes/admin/class-wc-rest-stripe-settings-controller.php b/includes/admin/class-wc-rest-stripe-settings-controller.php index 75ff2882ff..2272c3b8cb 100644 --- a/includes/admin/class-wc-rest-stripe-settings-controller.php +++ b/includes/admin/class-wc-rest-stripe-settings-controller.php @@ -455,6 +455,12 @@ private function update_is_upe_enabled( WP_REST_Request $request ) { } $settings = WC_Stripe_Helper::get_stripe_settings(); + + // If the new UPE is enabled, we need to remove the flag to ensure legacy SEPA tokens are updated flag. + if ( $is_upe_enabled && ! WC_Stripe_Feature_Flags::is_upe_checkout_enabled() ) { + delete_option( 'woocommerce_stripe_subscriptions_legacy_sepa_tokens_updated' ); + } + $settings[ WC_Stripe_Feature_Flags::UPE_CHECKOUT_FEATURE_ATTRIBUTE_NAME ] = $is_upe_enabled ? 'yes' : 'disabled'; WC_Stripe_Helper::update_main_stripe_settings( $settings ); diff --git a/includes/migrations/class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php b/includes/migrations/class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php index 763342a559..b5aa70c38f 100644 --- a/includes/migrations/class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php +++ b/includes/migrations/class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php @@ -12,6 +12,20 @@ */ class WC_Stripe_Subscriptions_Repairer_Legacy_SEPA_Tokens extends WCS_Background_Repairer { + /** + * The transient key used to store the progress of the repair. + * + * @var string + */ + private $action_progress_transient = 'wc_stripe_legacy_sepa_tokens_repair_progress'; + + /** + * The transient key used to store whether we should display the notice to the user. + * + * @var string + */ + private $display_notice_transient = 'wc_stripe_legacy_sepa_tokens_repair_notice'; + /** * Constructor * @@ -26,6 +40,10 @@ public function __construct( WC_Logger_Interface $logger ) { // Repair subscriptions prior to renewal as a backstop. Hooked onto 0 to run before the actual renewal. add_action( 'woocommerce_scheduled_subscription_payment', [ $this, 'maybe_migrate_before_renewal' ], 0 ); + + add_action( 'admin_notices', [ $this, 'display_admin_notice' ] ); + + add_filter( 'woocommerce_debug_tools', [ $this, 'add_debug_tool' ] ); } /** @@ -48,6 +66,9 @@ public function maybe_update() { // This will be handled in the scheduled action. $this->schedule_repair(); + // Display the admin notice to inform the user that the repair is in progress. Limited to 3 days. + set_transient( $this->display_notice_transient, 'yes', 3 * DAY_IN_SECONDS ); + // Prevent the repair from being scheduled again. update_option( 'woocommerce_stripe_subscriptions_legacy_sepa_tokens_updated', 'yes' ); } @@ -67,11 +88,32 @@ public function repair_item( $subscription_id ) { $token_updater->maybe_update_subscription_legacy_payment_method( $subscription_id ); $this->log( sprintf( 'Successful migration of subscription #%1$d.', $subscription_id ) ); + + delete_transient( $this->action_progress_transient ); } catch ( \Exception $e ) { $this->log( $e->getMessage() ); } } + /** + * Schedules an individual action to migrate a subscription. + * + * Overrides the parent class function to make two changes: + * 1. Don't schedule an action if one already exists. + * 2. Schedules the migration to happen in two minutes instead of in one hour. + * 3. Delete the transient which stores the progress of the repair. + * + * @param int $item The ID of the subscription to migrate. + */ + protected function update_item( $item ) { + if ( ! as_next_scheduled_action( $this->repair_hook, [ 'repair_object' => $item ] ) ) { + as_schedule_single_action( gmdate( 'U' ) + ( 2 * MINUTE_IN_SECONDS ), $this->repair_hook, [ 'repair_object' => $item ] ); + } + + unset( $this->items_to_repair[ $item ] ); + delete_transient( $this->action_progress_transient ); + } + /** * Gets the batch of subscriptions using the Legacy SEPA payment method to be updated. * @@ -85,7 +127,6 @@ protected function get_items_to_repair( $page ) { 'return' => 'ids', 'type' => 'shop_subscription', 'posts_per_page' => 20, - 'paged' => $page, 'status' => 'any', 'paged' => $page, 'payment_method' => WC_Gateway_Stripe_Sepa::ID, @@ -140,4 +181,186 @@ public function maybe_migrate_before_renewal( $subscription_id ) { $token_updater->maybe_update_subscription_source( $subscription ); } } + + /** + * Displays an admin notice to inform the user that the repair is in progress. + * + * This notice is displayed on the Subscriptions list table page and includes information about the progress of the repair. + * What % of the repair is complete, or when the next scheduled action is expected to run. + */ + public function display_admin_notice() { + + if ( ! class_exists( 'WC_Subscriptions' ) || ! WC_Stripe_Feature_Flags::is_upe_checkout_enabled() ) { + return; + } + + // Only display this on the subscriptions list table page. + if ( ! $this->is_admin_subscriptions_list_table_screen() ) { + return; + } + + // The notice is only displayed for up to 3 days after disabling the setting. + $display_notice = get_transient( $this->display_notice_transient ) === 'yes'; + + if ( ! $display_notice ) { + return; + } + + // If there are no subscriptions to be migrated, remove the transient so we don't show the notice. + // Don't return early so we can show the notice at least once. + if ( ! $this->has_legacy_sepa_subscriptions() ) { + delete_transient( $this->display_notice_transient ); + } + + $action_progress = $this->get_scheduled_action_counts(); + + if ( ! $action_progress ) { + return; + } + + // If we're still in the process of scheduling jobs, show a note to the user. + if ( (bool) as_next_scheduled_action( $this->scheduled_hook ) ) { + // translators: %1$s: tag, %2$s: tag, %3$s: tag. %4$s: tag. + $progress = sprintf( __( '%1$sProgress: %2$s %3$sWe are still identifying all subscriptions that require updating.%4$s', 'woocommerce-gateway-stripe' ), '', '', '', '' ); + } else { + // All scheduled actions have run, so we're done. + if ( 0 === absint( $action_progress['pending'] ) ) { + // Remove the transient to prevent the notice from showing again. + delete_transient( $this->display_notice_transient ); + } + + // Calculate the percentage of completed actions. + $total_action_count = $action_progress['pending'] + $action_progress['complete']; + $compete_percentage = $total_action_count ? floor( ( $action_progress['complete'] / $total_action_count ) * 100 ) : 0; + + // translators: %1$s: tag, %2$s: tag, %3$s: percentage complete. + $progress = sprintf( __( '%1$sProgress: %2$s %3$s%% complete', 'woocommerce-gateway-stripe' ), '', '', $compete_percentage ); + } + + // Note: We're using a Subscriptions class to generate the admin notice, however, it's safe to use given the context of this class. + $notice = new WCS_Admin_Notice( 'notice notice-warning is-dismissible' ); + $notice->set_html_content( + '

' . esc_html__( 'SEPA subscription update in progress', 'woocommerce-gateway-stripe' ) . '

' . + '

' . __( "We are currently updating customer subscriptions that use the legacy Stripe SEPA Direct Debit payment method. During this update, you may notice that some subscriptions appear as manual renewals. Don't worry—renewals will continue to process as normal. Please be aware this process may take some time.", 'woocommerce-gateway-stripe' ) . '

' . + '

' . $progress . '

' + ); + + $notice->display(); + } + + /** + * Checks if the current screen is the subscriptions list table. + * + * @return bool True if the current screen is the subscriptions list table, false otherwise. + */ + private function is_admin_subscriptions_list_table_screen() { + if ( ! is_admin() || ! function_exists( 'get_current_screen' ) ) { + return false; + } + + $screen = get_current_screen(); + + if ( ! is_object( $screen ) ) { + return false; + } + + // Check if we are on the subscriptions list table page in a HPOS or WP_Post context. + return in_array( $screen->id, [ 'woocommerce_page_wc-orders--shop_subscription', 'edit-shop_subscription' ], true ); + } + + /** + * Fetches the number of pending and completed migration scheduled actions. + * + * @return array|bool The counts of pending and completed actions. False if the Action Scheduler store is not available. + */ + private function get_scheduled_action_counts() { + $action_counts = get_transient( $this->action_progress_transient ); + + // If the transient is not set, calculate the action counts. + if ( false === $action_counts ) { + $store = ActionScheduler::store(); + + if ( ! $store ) { + return false; + } + + $action_counts = [ + 'pending' => (int) $store->query_actions( + [ + 'hook' => $this->repair_hook, + 'status' => ActionScheduler_Store::STATUS_PENDING, + ], + 'count' + ), + 'complete' => (int) $store->query_actions( + [ + 'hook' => $this->repair_hook, + 'status' => ActionScheduler_Store::STATUS_COMPLETE, + ], + 'count' + ), + ]; + + set_transient( $this->action_progress_transient, $action_counts, 10 * MINUTE_IN_SECONDS ); + } + + return $action_counts; + } + + /** + * Registers the repair tool for the Legacy SEPA token migration. + * + * @param array $tools The existing repair tools. + * + * @return array The updated repair tools. + */ + public function add_debug_tool( $tools ) { + // We don't need to show the tool if the WooCommerce Subscriptions extension isn't active or the UPE checkout isn't enabled + if ( ! class_exists( 'WC_Subscriptions' ) || ! WC_Stripe_Feature_Flags::is_upe_checkout_enabled() ) { + return $tools; + } + + // Don't show the tool if the repair is already in progress or there are no subscriptions to migrate. + if ( (bool) as_next_scheduled_action( $this->scheduled_hook ) || (bool) as_next_scheduled_action( $this->repair_hook ) || ! $this->has_legacy_sepa_subscriptions() ) { + return $tools; + } + + $tools['stripe_legacy_sepa_tokens'] = [ + 'name' => __( 'Stripe Legacy SEPA Token Update', 'woocommerce-gateway-stripe' ), + 'desc' => __( 'This will restart the legacy Stripe SEPA update process.', 'woocommerce-gateway-stripe' ), + 'button' => __( 'Restart SEPA token update', 'woocommerce-gateway-stripe' ), + 'callback' => [ $this, 'restart_update' ], + ]; + + return $tools; + } + + /** + * Checks if there are subscriptions using the Legacy SEPA payment method. + * + * @return bool True if there are subscriptions using the Legacy SEPA payment method, false otherwise. + */ + private function has_legacy_sepa_subscriptions() { + $subscriptions = wc_get_orders( + [ + 'return' => 'ids', + 'type' => 'shop_subscription', + 'status' => 'any', + 'posts_per_page' => 1, + 'payment_method' => WC_Gateway_Stripe_Sepa::ID, + ] + ); + + return ! empty( $subscriptions ); + } + + /** + * Restarts the legacy token update process. + */ + public function restart_update() { + // Clear the option to allow the update to be scheduled again. + delete_option( 'woocommerce_stripe_subscriptions_legacy_sepa_tokens_updated' ); + + $this->maybe_update(); + } } diff --git a/readme.txt b/readme.txt index a17f5b53ce..d72a112807 100644 --- a/readme.txt +++ b/readme.txt @@ -157,5 +157,7 @@ If you get stuck, you can ask for help in the Plugin Forum. * Fix - Address Klarna availability based on correct presentment currency rules. * Fix - Use correct ISO country code of United Kingdom in supported country and currency list of AliPay and WeChat. * Fix - Prevent duplicate order notes and emails being sent when purchasing subscription products with no initial payment. +* Add - Display an admin notice on the WooCommerce > Subscriptions screen for tracking the progress of SEPA subscriptions migrations after the legacy checkout is disabled. +* Add - Introduce a new tool on the WooCommerce > Status > Tools screen to restart the legacy SEPA subscriptions update. [See changelog for all versions](https://raw.githubusercontent.com/woocommerce/woocommerce-gateway-stripe/trunk/changelog.txt). From 4758d8c6bbdcf9e5b0efd4a43379833761015089 Mon Sep 17 00:00:00 2001 From: Diego Curbelo Date: Fri, 13 Sep 2024 15:18:03 -0300 Subject: [PATCH 04/14] Remove Stripe keys from the DB when uninstalling the plugin (#3385) * Update the options list to remove * Remove webhook data when uninstalling * Disable the gateway before removing the plugin --- changelog.txt | 1 + readme.txt | 1 + uninstall.php | 122 ++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 101 insertions(+), 23 deletions(-) diff --git a/changelog.txt b/changelog.txt index 37a078c863..2d1481439d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -26,6 +26,7 @@ * Update - Specify the JS Stripe API version as 2024-06-20. * Tweak - Use order ID from 'get_order_number' in stripe intent metadata. * Fix - Ensure payment tokens are detached from Stripe when a user is deleted, regardless of if the admin user has a Stripe account. +* Fix - Remove the Stripe OAuth Keys when uninstalling the plugin. * Fix - Address Klarna availability based on correct presentment currency rules. * Fix - Use correct ISO country code of United Kingdom in supported country and currency list of AliPay and WeChat. * Fix - Prevent duplicate order notes and emails being sent when purchasing subscription products with no initial payment. diff --git a/readme.txt b/readme.txt index d72a112807..b386e53c2b 100644 --- a/readme.txt +++ b/readme.txt @@ -154,6 +154,7 @@ If you get stuck, you can ask for help in the Plugin Forum. * Update - Specify the JS Stripe API version as 2024-06-20. * Tweak - Use order ID from 'get_order_number' in stripe intent metadata. * Fix - Ensure payment tokens are detached from Stripe when a user is deleted, regardless of if the admin user has a Stripe account. +* Fix - Remove the Stripe OAuth Keys when uninstalling the plugin. * Fix - Address Klarna availability based on correct presentment currency rules. * Fix - Use correct ISO country code of United Kingdom in supported country and currency list of AliPay and WeChat. * Fix - Prevent duplicate order notes and emails being sent when purchasing subscription products with no initial payment. diff --git a/uninstall.php b/uninstall.php index 71a22240e0..49409e678a 100644 --- a/uninstall.php +++ b/uninstall.php @@ -1,44 +1,120 @@ Date: Tue, 10 Sep 2024 00:04:51 +0600 Subject: [PATCH 05/14] Fix undefined check of global variable (#3387) * get data from wcSettings on block checkout if 'wc_stripe_upe_params' is not loaded yet --- changelog.txt | 3 +++ client/stripe-utils/utils.js | 33 +++++++++++++++++++++++---------- readme.txt | 3 +++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/changelog.txt b/changelog.txt index 2d1481439d..4739f74ca3 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,8 @@ *** Changelog *** += 8.8.0 - xxxx-xx-xx = +* Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. + = 8.7.0 - xxxx-xx-xx = * Fix - Prevent duplicate failed-order emails from being sent. * Fix - Support custom name and description for Afterpay. diff --git a/client/stripe-utils/utils.js b/client/stripe-utils/utils.js index 736778ab0c..6eea1b78b5 100644 --- a/client/stripe-utils/utils.js +++ b/client/stripe-utils/utils.js @@ -1,4 +1,4 @@ -/* global wc_stripe_upe_params */ +/* global wc_stripe_upe_params, wc */ import { dispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { getAppearance } from '../styles/upe'; @@ -21,13 +21,24 @@ import { * @return {StripeServerData} Stripe server data. */ const getStripeServerData = () => { - // Classic checkout. + let data = null; + // eslint-disable-next-line camelcase - if ( ! wc_stripe_upe_params ) { + if ( typeof wc_stripe_upe_params !== 'undefined' ) { + data = wc_stripe_upe_params; // eslint-disable-line camelcase + } else if ( + typeof wc === 'object' && + typeof wc.wcSettings !== 'undefined' + ) { + // 'getSetting' has this data value on block checkout only. + data = wc.wcSettings?.getSetting( 'getSetting' ) || null; + } + + if ( ! data ) { throw new Error( 'Stripe initialization data is not available' ); } - // eslint-disable-next-line camelcase - return wc_stripe_upe_params; + + return data; }; const isNonFriendlyError = ( type ) => @@ -210,8 +221,8 @@ export { getStripeServerData, getErrorMessageForTypeAndCode }; */ export const isLinkEnabled = ( paymentMethodsConfig ) => { return ( - paymentMethodsConfig.link !== undefined && - paymentMethodsConfig.card !== undefined + paymentMethodsConfig?.link !== undefined && + paymentMethodsConfig?.card !== undefined ); }; @@ -505,16 +516,18 @@ export const getPaymentMethodName = ( paymentMethodType ) => { * @return {boolean} Whether the payment method is restricted to selected billing country. **/ export const isPaymentMethodRestrictedToLocation = ( upeElement ) => { - const paymentMethodsConfig = getStripeServerData()?.paymentMethodsConfig; + const paymentMethodsConfig = + getStripeServerData()?.paymentMethodsConfig || {}; const paymentMethodType = upeElement.dataset.paymentMethodType; - return !! paymentMethodsConfig[ paymentMethodType ].countries.length; + return !! paymentMethodsConfig[ paymentMethodType ]?.countries.length; }; /** * @param {Object} upeElement The selector of the DOM element of particular payment method to mount the UPE element to. **/ export const togglePaymentMethodForCountry = ( upeElement ) => { - const paymentMethodsConfig = getStripeServerData()?.paymentMethodsConfig; + const paymentMethodsConfig = + getStripeServerData()?.paymentMethodsConfig || {}; const paymentMethodType = upeElement.dataset.paymentMethodType; const supportedCountries = paymentMethodsConfig[ paymentMethodType ].countries; diff --git a/readme.txt b/readme.txt index b386e53c2b..7942b7e787 100644 --- a/readme.txt +++ b/readme.txt @@ -128,6 +128,9 @@ If you get stuck, you can ask for help in the Plugin Forum. == Changelog == += 8.8.0 - xxxx-xx-xx = +* Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. + = 8.7.0 - xxxx-xx-xx = * Fix - Prevent duplicate failed-order emails from being sent. * Fix - Support custom name and description for Afterpay. From b58b118b1ad2e0da516a5a45aaf133e9e7ca2ed4 Mon Sep 17 00:00:00 2001 From: Diego Curbelo Date: Fri, 13 Sep 2024 16:37:12 -0300 Subject: [PATCH 06/14] Update changelog and readme --- changelog.txt | 6 ++---- readme.txt | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/changelog.txt b/changelog.txt index 4739f74ca3..d923b2c48e 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,8 +1,5 @@ *** Changelog *** -= 8.8.0 - xxxx-xx-xx = -* Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. - = 8.7.0 - xxxx-xx-xx = * Fix - Prevent duplicate failed-order emails from being sent. * Fix - Support custom name and description for Afterpay. @@ -29,12 +26,13 @@ * Update - Specify the JS Stripe API version as 2024-06-20. * Tweak - Use order ID from 'get_order_number' in stripe intent metadata. * Fix - Ensure payment tokens are detached from Stripe when a user is deleted, regardless of if the admin user has a Stripe account. -* Fix - Remove the Stripe OAuth Keys when uninstalling the plugin. * Fix - Address Klarna availability based on correct presentment currency rules. * Fix - Use correct ISO country code of United Kingdom in supported country and currency list of AliPay and WeChat. * Fix - Prevent duplicate order notes and emails being sent when purchasing subscription products with no initial payment. * Add - Display an admin notice on the WooCommerce > Subscriptions screen for tracking the progress of SEPA subscriptions migrations after the legacy checkout is disabled. * Add - Introduce a new tool on the WooCommerce > Status > Tools screen to restart the legacy SEPA subscriptions update. +* Fix - Remove the Stripe OAuth Keys when uninstalling the plugin. +* Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. = 8.6.1 - 2024-08-09 = * Tweak - Improves the wording of the invalid Stripe keys errors, instructing merchants to click the "Configure connection" button instead of manually setting the keys. diff --git a/readme.txt b/readme.txt index 7942b7e787..14caf0848d 100644 --- a/readme.txt +++ b/readme.txt @@ -128,9 +128,6 @@ If you get stuck, you can ask for help in the Plugin Forum. == Changelog == -= 8.8.0 - xxxx-xx-xx = -* Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. - = 8.7.0 - xxxx-xx-xx = * Fix - Prevent duplicate failed-order emails from being sent. * Fix - Support custom name and description for Afterpay. @@ -157,11 +154,12 @@ If you get stuck, you can ask for help in the Plugin Forum. * Update - Specify the JS Stripe API version as 2024-06-20. * Tweak - Use order ID from 'get_order_number' in stripe intent metadata. * Fix - Ensure payment tokens are detached from Stripe when a user is deleted, regardless of if the admin user has a Stripe account. -* Fix - Remove the Stripe OAuth Keys when uninstalling the plugin. * Fix - Address Klarna availability based on correct presentment currency rules. * Fix - Use correct ISO country code of United Kingdom in supported country and currency list of AliPay and WeChat. * Fix - Prevent duplicate order notes and emails being sent when purchasing subscription products with no initial payment. * Add - Display an admin notice on the WooCommerce > Subscriptions screen for tracking the progress of SEPA subscriptions migrations after the legacy checkout is disabled. * Add - Introduce a new tool on the WooCommerce > Status > Tools screen to restart the legacy SEPA subscriptions update. +* Fix - Remove the Stripe OAuth Keys when uninstalling the plugin. +* Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. [See changelog for all versions](https://raw.githubusercontent.com/woocommerce/woocommerce-gateway-stripe/trunk/changelog.txt). From 95cbb1b6e4751c9b8af1ec3638ca31cdfaba7532 Mon Sep 17 00:00:00 2001 From: Diego Curbelo Date: Fri, 13 Sep 2024 16:40:32 -0300 Subject: [PATCH 07/14] Bump WC tested up to version --- woocommerce-gateway-stripe.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/woocommerce-gateway-stripe.php b/woocommerce-gateway-stripe.php index 2ce2cee142..e677efeb5b 100644 --- a/woocommerce-gateway-stripe.php +++ b/woocommerce-gateway-stripe.php @@ -10,7 +10,7 @@ * Requires at least: 6.4 * Tested up to: 6.6 * WC requires at least: 8.9 - * WC tested up to: 9.1 + * WC tested up to: 9.3 * Text Domain: woocommerce-gateway-stripe * Domain Path: /languages */ From 2788073b1750df4e5c5e46a0bc7486b93fdb63c2 Mon Sep 17 00:00:00 2001 From: Wesley Rosa Date: Mon, 16 Sep 2024 12:10:17 -0300 Subject: [PATCH 08/14] Fix Cash App Pay token reuse issues (with subscriptions) (#3263) * Fix Cash App Pay reuse of tokens * Fix cash app confirm intent call * Fix parsing of wallet URL partials * Removing additional log * Enable selecting Cash App when changing subscription payment methods * Possible redirect fix commented out * Changelog and readme updates * Uncommenting out wallet confirmation function updates * Fix tests * Updating docs related to the wallet redirect hash * Update includes/payment-methods/class-wc-stripe-upe-payment-gateway.php Co-authored-by: James Allan * Fix Cash App Pay token inclusion * Turning customer country available when changing a subscription payment method so Cash App can be selected * Fix redirect when using a new token * Removing unnecessary check when saving method to subscription * Revert unnecessary change * Adding specific unit tests * Re-including tests removed by mistake * Fix missing token information when subscribing using a saved token * Adding more doc blocks * Improving code readability * Fix lint issues * Changelog fix * Minor doc updates * Minor variable name change * Remove changelog entry that slipped into this branch --------- Co-authored-by: James Allan --- changelog.txt | 1 + client/classic/upe/deferred-intent.js | 6 +- client/classic/upe/payment-processing.js | 40 +++++++---- .../class-wc-stripe-intent-controller.php | 4 +- .../class-wc-stripe-upe-payment-gateway.php | 57 ++++++++++----- ...stripe-upe-payment-method-cash-app-pay.php | 36 +--------- readme.txt | 1 + .../phpunit/helpers/class-wc-helper-order.php | 9 ++- ...st-class-wc-stripe-upe-payment-gateway.php | 70 ++++++++++++++++--- 9 files changed, 145 insertions(+), 79 deletions(-) diff --git a/changelog.txt b/changelog.txt index ab03cd2a78..2b3ea46980 100644 --- a/changelog.txt +++ b/changelog.txt @@ -5,6 +5,7 @@ * Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. * Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. * Fix - Fix empty error message for Express Payments when order creation fails. +* Fix - Fix multiple issues related to the reuse of Cash App Pay tokens (as a saved payment method) when subscribing. = 8.7.0 - xxxx-xx-xx = * Fix - Prevent duplicate failed-order emails from being sent. diff --git a/client/classic/upe/deferred-intent.js b/client/classic/upe/deferred-intent.js index f1422db1ea..aee8f9d377 100644 --- a/client/classic/upe/deferred-intent.js +++ b/client/classic/upe/deferred-intent.js @@ -170,7 +170,8 @@ jQuery( function ( $ ) { function maybeConfirmVoucherOrWalletPayment() { if ( getStripeServerData()?.isOrderPay || - getStripeServerData()?.isCheckout + getStripeServerData()?.isCheckout || + getStripeServerData()?.isChangingPayment ) { if ( window.location.hash.startsWith( '#wc-stripe-voucher-' ) ) { confirmVoucherPayment( @@ -184,7 +185,8 @@ jQuery( function ( $ ) { ) { confirmWalletPayment( api, - getStripeServerData()?.isOrderPay + getStripeServerData()?.isOrderPay || + getStripeServerData()?.isChangingPayment ? $( '#order_review' ) : $( 'form.checkout' ) ); diff --git a/client/classic/upe/payment-processing.js b/client/classic/upe/payment-processing.js index 4611eda5b2..d635b75419 100644 --- a/client/classic/upe/payment-processing.js +++ b/client/classic/upe/payment-processing.js @@ -394,7 +394,7 @@ export const confirmVoucherPayment = async ( api, jQueryForm ) => { * * When processing a payment for a wallet payment method on the checkout or order pay page, * the process_payment_with_deferred_intent() function redirects the customer to a URL - * formatted with: #wc-stripe-wallet-:::. + * formatted with: #wc-stripe-wallet-::::. * * This function, which is hooked onto the hashchanged event, checks if the URL contains the data we need to process the wallet payment. * @@ -410,7 +410,7 @@ export const confirmWalletPayment = async ( api, jQueryForm ) => { } const partials = window.location.href.match( - /#wc-stripe-wallet-(.+):(.+):(.+):(.+)$/ + /#wc-stripe-wallet-(.+):(.+):(.+):(.+):(.+)$/ ); if ( ! partials ) { @@ -426,7 +426,7 @@ export const confirmWalletPayment = async ( api, jQueryForm ) => { ); const orderId = partials[ 1 ]; - const clientSecret = partials[ 3 ]; + const clientSecret = partials[ 4 ]; // Verify the request using the data added to the URL. if ( @@ -438,7 +438,8 @@ export const confirmWalletPayment = async ( api, jQueryForm ) => { } const paymentMethodType = partials[ 2 ]; - const returnURL = decodeURIComponent( partials[ 4 ] ); + const intentType = partials[ 3 ]; + const returnURL = decodeURIComponent( partials[ 5 ] ); try { // Confirm the payment to tell Stripe to display the modal to the customer. @@ -456,11 +457,19 @@ export const confirmWalletPayment = async ( api, jQueryForm ) => { } ); break; case 'cashapp': - confirmPayment = await api - .getStripe() - .confirmCashappPayment( clientSecret, { - return_url: returnURL, - } ); + if ( intentType === 'setup_intent' ) { + confirmPayment = await api + .getStripe() + .confirmCashappSetup( clientSecret, { + return_url: returnURL, + } ); + } else { + confirmPayment = await api + .getStripe() + .confirmCashappPayment( clientSecret, { + return_url: returnURL, + } ); + } break; default: // eslint-disable-next-line no-console @@ -472,15 +481,18 @@ export const confirmWalletPayment = async ( api, jQueryForm ) => { throw confirmPayment.error; } - if ( confirmPayment.paymentIntent.last_payment_error ) { - throw new Error( - confirmPayment.paymentIntent.last_payment_error.message - ); + const intentObject = + intentType === 'setup_intent' + ? confirmPayment.setupIntent + : confirmPayment.paymentIntent; + + if ( intentObject.last_payment_error ) { + throw new Error( intentObject.last_payment_error.message ); } // Do not redirect to the order received page if the modal is closed without payment. // Otherwise redirect to the order received page. - if ( confirmPayment.paymentIntent.status !== 'requires_action' ) { + if ( intentObject.status !== 'requires_action' ) { window.location.href = returnURL; } } catch ( error ) { diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index 57e9af1b82..7890e515e5 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -960,7 +960,7 @@ public function is_mandate_data_required( $selected_payment_type, $is_using_save * * @throws WC_Stripe_Exception If the create intent call returns with an error. * - * @return array + * @return stdClass */ public function create_and_confirm_setup_intent( $payment_information ) { $request = [ @@ -980,7 +980,7 @@ public function create_and_confirm_setup_intent( $payment_information ) { $request = $this->add_mandate_data( $request ); } - // For voucher payment methods type like Boleto, Oxxo & Multibanco, we shouldn't confirm the intent immediately as this is done on the front-end when displaying the voucher to the customer. + // For voucher payment methods type like Boleto, Oxxo, Multibanco, and Cash App, we shouldn't confirm the intent immediately as this is done on the front-end when displaying the voucher to the customer. // When the intent is confirmed, Stripe sends a webhook to the store which puts the order on-hold, which we only want to happen after successfully displaying the voucher. if ( $this->is_delayed_confirmation_required( $request['payment_method_types'] ) ) { $request['confirm'] = 'false'; diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php index 72bec865c3..682b662481 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -418,6 +418,14 @@ public function javascript_params() { $stripe_params['currency'] = $currency; if ( parent::is_valid_pay_for_order_endpoint() || $is_change_payment_method ) { + $order_id = absint( get_query_var( 'order-pay' ) ); + $order = wc_get_order( $order_id ); + + // Make billing country available for subscriptions as well, so country-restricted payment methods can be shown. + if ( is_a( $order, 'WC_Order' ) ) { + $stripe_params['customerData'] = [ 'billing_country' => $order->get_billing_country() ]; + } + if ( $this->is_subscriptions_enabled() && $is_change_payment_method ) { $stripe_params['isChangingPayment'] = true; $stripe_params['addPaymentReturnURL'] = wp_sanitize_redirect( esc_url_raw( home_url( add_query_arg( [] ) ) ) ); @@ -430,15 +438,13 @@ public function javascript_params() { return $stripe_params; } - $order_id = absint( get_query_var( 'order-pay' ) ); $stripe_params['orderId'] = $order_id; $stripe_params['isOrderPay'] = true; - $order = wc_get_order( $order_id ); + // Additional params for order pay page, when the order was successfully loaded. if ( is_a( $order, 'WC_Order' ) ) { $order_currency = $order->get_currency(); $stripe_params['currency'] = $order_currency; - $stripe_params['customerData'] = [ 'billing_country' => $order->get_billing_country() ]; $stripe_params['cartTotal'] = WC_Stripe_Helper::get_stripe_amount( $order->get_total(), $order_currency ); $stripe_params['orderReturnURL'] = esc_url_raw( add_query_arg( @@ -450,7 +456,6 @@ public function javascript_params() { $this->get_return_url( $order ) ) ); - } } elseif ( is_wc_endpoint_url( 'add-payment-method' ) ) { $stripe_params['cartTotal'] = 0; @@ -771,12 +776,13 @@ private function process_payment_with_deferred_intent( int $order_id ) { $this->validate_selected_payment_method_type( $payment_information, $order->get_billing_country() ); - $payment_needed = $this->is_payment_needed( $order->get_id() ); - $payment_method_id = $payment_information['payment_method']; - $payment_method_details = $payment_information['payment_method_details']; - $selected_payment_type = $payment_information['selected_payment_type']; - $upe_payment_method = $this->payment_methods[ $selected_payment_type ] ?? null; - $response_args = []; + $payment_needed = $this->is_payment_needed( $order->get_id() ); + $payment_method_id = $payment_information['payment_method']; + $payment_method_details = $payment_information['payment_method_details']; + $selected_payment_type = $payment_information['selected_payment_type']; + $is_using_saved_payment_method = $payment_information['is_using_saved_payment_method']; + $upe_payment_method = $this->payment_methods[ $selected_payment_type ] ?? null; + $response_args = []; // Make sure that we attach the payment method and the customer ID to the order meta data. $this->set_payment_method_id_for_order( $order, $payment_method_id ); @@ -794,7 +800,7 @@ private function process_payment_with_deferred_intent( int $order_id ) { $this->maybe_disallow_prepaid_card( $payment_method ); // Update saved payment method to include billing details. - if ( $payment_information['is_using_saved_payment_method'] ) { + if ( $is_using_saved_payment_method ) { $this->update_saved_payment_method( $payment_method_id, $order ); } @@ -807,7 +813,24 @@ private function process_payment_with_deferred_intent( int $order_id ) { // Create a payment intent, or update an existing one associated with the order. $payment_intent = $this->process_payment_intent_for_order( $order, $payment_information ); + } elseif ( $is_using_saved_payment_method && 'cashapp' === $selected_payment_type ) { + // If the payment method is Cash App Pay, the order has no cost, and a saved payment method is used, mark the order as paid. + $this->maybe_update_source_on_subscription_order( + $order, + (object) [ + 'payment_method' => $payment_information['payment_method'], + 'customer' => $payment_information['customer'], + ], + $this->get_upe_gateway_id_for_order( $upe_payment_method ) + ); + $order->payment_complete(); + + return [ + 'result' => 'success', + 'redirect' => $this->get_return_url( $order ), + ]; } else { + // Create a setup intent, or update an existing one associated with the order. $payment_intent = $this->process_setup_intent_for_order( $order, $payment_information ); } @@ -819,7 +842,7 @@ private function process_payment_with_deferred_intent( int $order_id ) { $payment_method_details, $selected_payment_type ); - } elseif ( $payment_information['is_using_saved_payment_method'] ) { + } elseif ( $is_using_saved_payment_method ) { $this->maybe_update_source_on_subscription_order( $order, (object) [ @@ -857,11 +880,12 @@ private function process_payment_with_deferred_intent( int $order_id ) { rawurlencode( $redirect ) ); } elseif ( isset( $payment_intent->payment_method_types ) && count( array_intersect( [ 'wechat_pay', 'cashapp' ], $payment_intent->payment_method_types ) ) !== 0 ) { - // For Wallet payment method types (CashApp/WeChat Pay), redirect the customer to a URL hash formatted #wc-stripe-wallet-{order_id}:{payment_method_type}:{client_secret}:{redirect_url} to confirm the intent which also displays the modal. + // For Wallet payment method types (CashApp/WeChat Pay), redirect the customer to a URL hash formatted #wc-stripe-wallet-{order_id}:{payment_method_type}:{payment_intent_type}:{client_secret}:{redirect_url} to confirm the intent which also displays the modal. $redirect = sprintf( - '#wc-stripe-wallet-%s:%s:%s:%s', + '#wc-stripe-wallet-%s:%s:%s:%s:%s', $order_id, $payment_information['selected_payment_type'], + $payment_intent->object, $payment_intent->client_secret, rawurlencode( $redirect ) ); @@ -1613,10 +1637,7 @@ public function set_payment_method_title_for_order( $order, $payment_method_type $order->save(); // Update the subscription's purchased in this order with the payment method ID. - if ( isset( $this->payment_methods[ $payment_method_type ] ) ) { - $payment_method_instance = $this->payment_methods[ $payment_method_type ]; - $this->update_subscription_payment_method_from_order( $order, $this->get_upe_gateway_id_for_order( $payment_method_instance ) ); - } + $this->update_subscription_payment_method_from_order( $order, $this->get_upe_gateway_id_for_order( $payment_method ) ); } /** diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-method-cash-app-pay.php b/includes/payment-methods/class-wc-stripe-upe-payment-method-cash-app-pay.php index 430eba5111..e872d19111 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-method-cash-app-pay.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-method-cash-app-pay.php @@ -40,14 +40,6 @@ public function __construct() { // Cash App Pay supports subscriptions. Init subscriptions so it can process subscription payments. $this->maybe_init_subscriptions(); - /** - * Cash App Pay is incapable of processing zero amount payments with saved payment methods. - * - * This is because setup intents with a saved payment method (token) fail. While we wait for a solution to this issue, we - * disable customer's changing the payment method to Cash App Pay as that would result in a $0 set up intent. - */ - $this->supports = array_diff( $this->supports, [ 'subscription_payment_method_change_customer' ] ); - add_filter( 'woocommerce_thankyou_order_received_text', [ $this, 'order_received_text_for_wallet_failure' ], 10, 2 ); } @@ -71,28 +63,6 @@ public function get_retrievable_type() { return $this->get_id(); } - /** - * Determines whether Cash App Pay is enabled at checkout. - * - * @param int $order_id The order ID. - * @param string $account_domestic_currency The account's default currency. - * - * @return bool Whether Cash App Pay is enabled at checkout. - */ - public function is_enabled_at_checkout( $order_id = null, $account_domestic_currency = null ) { - /** - * Cash App Pay is incapable of processing zero amount payments with saved payment methods. - * - * This is because setup intents with a saved payment method (token) fail. While we wait for a solution to this issue, we - * disable Cash App Pay for zero amount orders. - */ - if ( ! is_add_payment_method_page() && $this->get_current_order_amount() <= 0 ) { - return false; - } - - return parent::is_enabled_at_checkout( $order_id, $account_domestic_currency ); - } - /** * Creates a Cash App Pay payment token for the customer. * @@ -130,10 +100,10 @@ public function order_received_text_for_wallet_failure( $text, $order ) { $redirect_status = wc_clean( wp_unslash( $_GET['redirect_status'] ) ); } if ( $order && $this->id === $order->get_payment_method() && 'failed' === $redirect_status ) { - $text = '

'; + $text = '

'; $text .= esc_html( 'Unfortunately your order cannot be processed as the payment method has declined your transaction. Please attempt your purchase again.' ); - $text .= '

'; - $text .= '

'; + $text .= '

'; + $text .= '

'; $text .= '' . esc_html( 'Pay' ) . ''; if ( is_user_logged_in() ) { $text .= '' . esc_html( 'My account' ) . ''; diff --git a/readme.txt b/readme.txt index d0b85c06cb..5850861e2e 100644 --- a/readme.txt +++ b/readme.txt @@ -133,6 +133,7 @@ If you get stuck, you can ask for help in the Plugin Forum. * Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. * Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. * Fix - Fix empty error message for Express Payments when order creation fails. +* Fix - Fix multiple issues related to the reuse of Cash App Pay tokens (as a saved payment method) when subscribing. = 8.7.0 - xxxx-xx-xx = * Fix - Prevent duplicate failed-order emails from being sent. diff --git a/tests/phpunit/helpers/class-wc-helper-order.php b/tests/phpunit/helpers/class-wc-helper-order.php index cfa4bd587d..ffd9674392 100644 --- a/tests/phpunit/helpers/class-wc-helper-order.php +++ b/tests/phpunit/helpers/class-wc-helper-order.php @@ -40,10 +40,11 @@ public static function delete_order( $order_id ) { * * @param int $customer_id The ID of the customer the order is for. * @param WC_Product $product The product to add to the order. + * @param array $order_props Order properties. * * @return WC_Order */ - public static function create_order( $customer_id = 1, $product = null ) { + public static function create_order( $customer_id = 1, $product = null, $order_props = [] ) { if ( ! is_a( $product, 'WC_Product' ) ) { $product = WC_Helper_Product::create_simple_product(); @@ -115,6 +116,12 @@ public static function create_order( $customer_id = 1, $product = null ) { $order->set_cart_tax( 0 ); $order->set_shipping_tax( 0 ); $order->set_total( 50 ); // 4 x $10 simple helper product + + // Additional order properties. + foreach ( $order_props as $key => $value ) { + $order->{"set_$key"}( $value ); + } + $order->save(); return $order; diff --git a/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php b/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php index 04581d7299..67e1f24169 100644 --- a/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php +++ b/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php @@ -155,7 +155,7 @@ public function set_up() { ); $this->mock_gateway->intent_controller = $this->getMockBuilder( WC_Stripe_Intent_Controller::class ) - ->setMethods( [ 'create_and_confirm_payment_intent', 'update_and_confirm_payment_intent' ] ) + ->setMethods( [ 'create_and_confirm_payment_intent', 'update_and_confirm_payment_intent', 'create_and_confirm_setup_intent' ] ) ->getMock(); $this->mock_stripe_customer = $this->getMockBuilder( WC_Stripe_Customer::class ) @@ -468,10 +468,16 @@ public function test_process_payment_deferred_intent_with_required_action_return /** * Test Wallet checkout process_payment flow with deferred intent. + * + * @param string $payment_method Payment method to test. + * @param bool $free_order Whether the order is free. + * @param bool $saved_token Whether the payment method is saved. + * @dataProvider provide_process_payment_deferred_intent_with_required_action_for_wallet_returns_valid_response + * @throws WC_Data_Exception When setting order payment method fails. */ - public function test_process_payment_deferred_intent_with_required_action_for_wallet_returns_valid_response() { + public function test_process_payment_deferred_intent_with_required_action_for_wallet_returns_valid_response( $payment_method, $free_order = false, $saved_token = false ) { $customer_id = 'cus_mock'; - $order = WC_Helper_Order::create_order(); + $order = WC_Helper_Order::create_order( 1, null, [ 'total' => $free_order ? 0 : 50 ] ); $order_id = $order->get_id(); // Set payment gateway. @@ -482,6 +488,7 @@ public function test_process_payment_deferred_intent_with_required_action_for_wa $mock_intent = (object) wp_parse_args( [ 'status' => 'requires_action', + 'object' => 'payment_intent', 'data' => [ (object) [ 'id' => $order_id, @@ -490,7 +497,7 @@ public function test_process_payment_deferred_intent_with_required_action_for_wa ], ], 'payment_method' => 'pm_mock', - 'payment_method_types' => [ 'wechat_pay' ], + 'payment_method_types' => [ $payment_method ], 'charges' => (object) [ 'total_count' => 0, // Intents requiring SCA verification respond with no charges. 'data' => [], @@ -501,16 +508,30 @@ public function test_process_payment_deferred_intent_with_required_action_for_wa // Set the appropriate POST flag to trigger a deferred intent request. $_POST = [ - 'payment_method' => 'stripe_wechat_pay', + 'payment_method' => 'stripe_' . $payment_method, 'wc-stripe-payment-method' => 'pm_mock', 'wc-stripe-is-deferred-intent' => '1', ]; + if ( $saved_token ) { + $token = WC_Helper_Token::create_token( 'pm_mock' ); + $token->set_gateway_id( 'stripe_' . $payment_method ); + $token->save(); + + $_POST[ 'wc-stripe_' . $payment_method . '-payment-token' ] = (string) $token->get_id(); + } + $this->mock_gateway->intent_controller - ->expects( $this->once() ) + ->expects( $free_order ? $this->never() : $this->once() ) ->method( 'create_and_confirm_payment_intent' ) ->willReturn( $mock_intent ); + $create_and_confirm_setup_intent_num_calls = $free_order && ! ( $saved_token && 'cashapp' === $payment_method ) ? 1 : 0; + $this->mock_gateway->intent_controller + ->expects( $this->exactly( $create_and_confirm_setup_intent_num_calls ) ) + ->method( 'create_and_confirm_setup_intent' ) + ->willReturn( $mock_intent ); + $this->mock_gateway ->expects( $this->once() ) ->method( 'get_stripe_customer_id' ) @@ -518,19 +539,50 @@ public function test_process_payment_deferred_intent_with_required_action_for_wa // We only use this when handling mandates. $this->mock_gateway - ->expects( $this->exactly( 2 ) ) + ->expects( $saved_token ? $this->never() : ( $free_order ? $this->once() : $this->exactly( 2 ) ) ) ->method( 'get_latest_charge_from_intent' ) ->willReturn( null ); $this->mock_gateway - ->expects( $this->never() ) + ->expects( $saved_token ? $this->once() : $this->never() ) ->method( 'update_saved_payment_method' ); $response = $this->mock_gateway->process_payment( $order_id ); $return_url = self::MOCK_RETURN_URL; + if ( $saved_token ) { + $expected_redirect_url = '/' . self::MOCK_RETURN_URL . '/'; + } else { + $expected_redirect_url = "/#wc-stripe-wallet-{$order_id}:{$payment_method}:{$mock_intent->object}:{$mock_intent->client_secret}:{$return_url}/"; + } + $this->assertEquals( 'success', $response['result'] ); - $this->assertMatchesRegularExpression( "/#wc-stripe-wallet-{$order_id}:wechat_pay:{$mock_intent->client_secret}:{$return_url}/", $response['redirect'] ); + $this->assertMatchesRegularExpression( $expected_redirect_url, $response['redirect'] ); + } + + /** + * Provider for `test_process_payment_deferred_intent_with_required_action_for_wallet_returns_valid_response`. + * + * @return array + */ + public function provide_process_payment_deferred_intent_with_required_action_for_wallet_returns_valid_response() { + return [ + 'wechat pay / default amount' => [ + 'payment method' => 'wechat_pay', + ], + 'cashapp / default amount' => [ + 'payment method' => 'cashapp', + ], + 'cashapp / free' => [ + 'payment method' => 'cashapp', + 'free order' => true, + ], + 'cashapp / free / saved token' => [ + 'payment method' => 'cashapp', + 'free order' => true, + 'saved token' => true, + ], + ]; } /** From 97112f2ee2c2a272a3a2493f9541de786f1278f0 Mon Sep 17 00:00:00 2001 From: Wesley Rosa Date: Mon, 16 Sep 2024 16:25:20 -0300 Subject: [PATCH 09/14] UPE banner for APM enabled merchants (#3433) * UPE banner for APM enabled merchants * Fix payment method list override * Changelog and readme entries * Changing the dummy text for the new banner * Adding specific unit tests * Updating promotional surface with the final copy * Fix minor typo --- changelog.txt | 1 + .../promotional-banner-section.test.js | 21 ++++++ .../promotional-banner-section.js | 73 ++++++++++++++++++- readme.txt | 1 + 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/changelog.txt b/changelog.txt index 2b3ea46980..531ef26cd0 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,7 @@ * Fix - Fix multiple issues related to the reuse of Cash App Pay tokens (as a saved payment method) when subscribing. = 8.7.0 - xxxx-xx-xx = +* Add - Introduces a new promotional surface to encourage merchants with the legacy checkout experience and APMs enabled to use the new checkout experience. * Fix - Prevent duplicate failed-order emails from being sent. * Fix - Support custom name and description for Afterpay. * Fix - Link APM charge IDs in Order Details page to their Stripe dashboard payments page. diff --git a/client/settings/payment-settings/__tests__/promotional-banner-section.test.js b/client/settings/payment-settings/__tests__/promotional-banner-section.test.js index 841d367316..cd15ff6d3c 100644 --- a/client/settings/payment-settings/__tests__/promotional-banner-section.test.js +++ b/client/settings/payment-settings/__tests__/promotional-banner-section.test.js @@ -3,6 +3,7 @@ import React from 'react'; import { screen, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import PromotionalBannerSection from '../promotional-banner-section'; +import { useEnabledPaymentMethodIds } from 'wcstripe/data'; jest.mock( '@wordpress/data' ); @@ -10,6 +11,11 @@ jest.mock( 'wcstripe/data/account', () => ( { useAccount: jest.fn(), } ) ); +jest.mock( 'wcstripe/data', () => ( { + useEnabledPaymentMethodIds: jest.fn().mockReturnValue( [ [ 'card' ] ] ), + useTestMode: jest.fn().mockReturnValue( [ false ] ), +} ) ); + const noticesDispatch = { createErrorNotice: jest.fn(), createSuccessNotice: jest.fn(), @@ -68,4 +74,19 @@ describe( 'PromotionalBanner', () => { screen.queryByTestId( 're-connect-account-banner' ) ).toBeInTheDocument(); } ); + + it( 'Display the APM version of the new checkout experience promotional surface when any APM is enabled', () => { + useEnabledPaymentMethodIds.mockReturnValue( [ [ 'card', 'ideal' ] ] ); + + render( + + ); + + expect( + screen.queryByTestId( 'new-checkout-apms-banner' ) + ).toBeInTheDocument(); + } ); } ); diff --git a/client/settings/payment-settings/promotional-banner-section.js b/client/settings/payment-settings/promotional-banner-section.js index e01c6fdef6..1a793c0c60 100644 --- a/client/settings/payment-settings/promotional-banner-section.js +++ b/client/settings/payment-settings/promotional-banner-section.js @@ -1,14 +1,15 @@ import { __ } from '@wordpress/i18n'; import { useDispatch } from '@wordpress/data'; import { React } from 'react'; -import { Card, Button } from '@wordpress/components'; +import { Card, Button, ExternalLink } from '@wordpress/components'; import styled from '@emotion/styled'; +import interpolateComponents from 'interpolate-components'; import CardBody from '../card-body'; import bannerIllustration from './banner-illustration.svg'; import bannerIllustrationReConnect from './banner-illustration-re-connect.svg'; import Pill from 'wcstripe/components/pill'; import { recordEvent } from 'wcstripe/tracking'; -import { useTestMode } from 'wcstripe/data'; +import { useEnabledPaymentMethodIds, useTestMode } from 'wcstripe/data'; const NewPill = styled( Pill )` border-color: #674399; @@ -66,6 +67,9 @@ const PromotionalBannerSection = ( { 'core/notices' ); const [ isTestModeEnabled ] = useTestMode(); + const [ enabledPaymentMethodIds ] = useEnabledPaymentMethodIds(); + const hasAPMEnabled = + enabledPaymentMethodIds.filter( ( e ) => e !== 'card' ).length > 0; const handleButtonClick = () => { const callback = async () => { @@ -159,6 +163,65 @@ const PromotionalBannerSection = ( { ); + const NewCheckoutExperienceAPMsBanner = () => ( + + + + + { __( 'New', 'woocommerce-gateway-stripe' ) } + +

+ { __( + 'Enable the new Stripe checkout to continue accepting non-card payments', + 'woocommerce-gateway-stripe' + ) } +

+

+ { interpolateComponents( { + mixedString: __( + 'Stripe will end support for non-card payment methods in the {{StripeLegacyLink}}legacy checkout on October 29, 2024{{/StripeLegacyLink}}. To continue accepting non-card payments, you must enable the new checkout experience or remove non-card payment methods from your checkout to avoid payment disruptions.', + 'woocommerce-gateway-stripe' + ), + components: { + StripeLegacyLink: ( + + ), + }, + } ) } +

+ + + + + + + + { __( + 'Enable the new checkout', + 'woocommerce-gateway-stripe' + ) } + + + { __( 'Dismiss', 'woocommerce-gateway-stripe' ) } + + + + ); + const NewCheckoutExperienceBanner = () => ( @@ -215,7 +278,11 @@ const PromotionalBannerSection = ( { if ( isConnectedViaOAuth === false ) { BannerContent = ; } else if ( ! isUpeEnabled ) { - BannerContent = ; + if ( hasAPMEnabled ) { + BannerContent = ; + } else { + BannerContent = ; + } } return ( diff --git a/readme.txt b/readme.txt index 5850861e2e..ec42af24dd 100644 --- a/readme.txt +++ b/readme.txt @@ -136,6 +136,7 @@ If you get stuck, you can ask for help in the Plugin Forum. * Fix - Fix multiple issues related to the reuse of Cash App Pay tokens (as a saved payment method) when subscribing. = 8.7.0 - xxxx-xx-xx = +* Add - Introduces a new promotional surface to encourage merchants with the legacy checkout experience and APMs enabled to use the new checkout experience. * Fix - Prevent duplicate failed-order emails from being sent. * Fix - Support custom name and description for Afterpay. * Fix - Link APM charge IDs in Order Details page to their Stripe dashboard payments page. From ac0a45cb3bff23670f4ef39204f57c1595f664bd Mon Sep 17 00:00:00 2001 From: Wesley Rosa Date: Mon, 16 Sep 2024 16:25:20 -0300 Subject: [PATCH 10/14] UPE banner for APM enabled merchants (#3433) * UPE banner for APM enabled merchants * Fix payment method list override * Changelog and readme entries * Changing the dummy text for the new banner * Adding specific unit tests * Updating promotional surface with the final copy * Fix minor typo --- changelog.txt | 1 + .../promotional-banner-section.test.js | 21 ++++++ .../promotional-banner-section.js | 73 ++++++++++++++++++- readme.txt | 1 + 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/changelog.txt b/changelog.txt index d923b2c48e..46df8fed12 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,7 @@ *** Changelog *** = 8.7.0 - xxxx-xx-xx = +* Add - Introduces a new promotional surface to encourage merchants with the legacy checkout experience and APMs enabled to use the new checkout experience. * Fix - Prevent duplicate failed-order emails from being sent. * Fix - Support custom name and description for Afterpay. * Fix - Link APM charge IDs in Order Details page to their Stripe dashboard payments page. diff --git a/client/settings/payment-settings/__tests__/promotional-banner-section.test.js b/client/settings/payment-settings/__tests__/promotional-banner-section.test.js index 841d367316..cd15ff6d3c 100644 --- a/client/settings/payment-settings/__tests__/promotional-banner-section.test.js +++ b/client/settings/payment-settings/__tests__/promotional-banner-section.test.js @@ -3,6 +3,7 @@ import React from 'react'; import { screen, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import PromotionalBannerSection from '../promotional-banner-section'; +import { useEnabledPaymentMethodIds } from 'wcstripe/data'; jest.mock( '@wordpress/data' ); @@ -10,6 +11,11 @@ jest.mock( 'wcstripe/data/account', () => ( { useAccount: jest.fn(), } ) ); +jest.mock( 'wcstripe/data', () => ( { + useEnabledPaymentMethodIds: jest.fn().mockReturnValue( [ [ 'card' ] ] ), + useTestMode: jest.fn().mockReturnValue( [ false ] ), +} ) ); + const noticesDispatch = { createErrorNotice: jest.fn(), createSuccessNotice: jest.fn(), @@ -68,4 +74,19 @@ describe( 'PromotionalBanner', () => { screen.queryByTestId( 're-connect-account-banner' ) ).toBeInTheDocument(); } ); + + it( 'Display the APM version of the new checkout experience promotional surface when any APM is enabled', () => { + useEnabledPaymentMethodIds.mockReturnValue( [ [ 'card', 'ideal' ] ] ); + + render( + + ); + + expect( + screen.queryByTestId( 'new-checkout-apms-banner' ) + ).toBeInTheDocument(); + } ); } ); diff --git a/client/settings/payment-settings/promotional-banner-section.js b/client/settings/payment-settings/promotional-banner-section.js index e01c6fdef6..1a793c0c60 100644 --- a/client/settings/payment-settings/promotional-banner-section.js +++ b/client/settings/payment-settings/promotional-banner-section.js @@ -1,14 +1,15 @@ import { __ } from '@wordpress/i18n'; import { useDispatch } from '@wordpress/data'; import { React } from 'react'; -import { Card, Button } from '@wordpress/components'; +import { Card, Button, ExternalLink } from '@wordpress/components'; import styled from '@emotion/styled'; +import interpolateComponents from 'interpolate-components'; import CardBody from '../card-body'; import bannerIllustration from './banner-illustration.svg'; import bannerIllustrationReConnect from './banner-illustration-re-connect.svg'; import Pill from 'wcstripe/components/pill'; import { recordEvent } from 'wcstripe/tracking'; -import { useTestMode } from 'wcstripe/data'; +import { useEnabledPaymentMethodIds, useTestMode } from 'wcstripe/data'; const NewPill = styled( Pill )` border-color: #674399; @@ -66,6 +67,9 @@ const PromotionalBannerSection = ( { 'core/notices' ); const [ isTestModeEnabled ] = useTestMode(); + const [ enabledPaymentMethodIds ] = useEnabledPaymentMethodIds(); + const hasAPMEnabled = + enabledPaymentMethodIds.filter( ( e ) => e !== 'card' ).length > 0; const handleButtonClick = () => { const callback = async () => { @@ -159,6 +163,65 @@ const PromotionalBannerSection = ( { ); + const NewCheckoutExperienceAPMsBanner = () => ( + + + + + { __( 'New', 'woocommerce-gateway-stripe' ) } + +

+ { __( + 'Enable the new Stripe checkout to continue accepting non-card payments', + 'woocommerce-gateway-stripe' + ) } +

+

+ { interpolateComponents( { + mixedString: __( + 'Stripe will end support for non-card payment methods in the {{StripeLegacyLink}}legacy checkout on October 29, 2024{{/StripeLegacyLink}}. To continue accepting non-card payments, you must enable the new checkout experience or remove non-card payment methods from your checkout to avoid payment disruptions.', + 'woocommerce-gateway-stripe' + ), + components: { + StripeLegacyLink: ( + + ), + }, + } ) } +

+
+ + + +
+ + + { __( + 'Enable the new checkout', + 'woocommerce-gateway-stripe' + ) } + + + { __( 'Dismiss', 'woocommerce-gateway-stripe' ) } + + +
+ ); + const NewCheckoutExperienceBanner = () => ( @@ -215,7 +278,11 @@ const PromotionalBannerSection = ( { if ( isConnectedViaOAuth === false ) { BannerContent = ; } else if ( ! isUpeEnabled ) { - BannerContent = ; + if ( hasAPMEnabled ) { + BannerContent = ; + } else { + BannerContent = ; + } } return ( diff --git a/readme.txt b/readme.txt index 14caf0848d..31f289dd74 100644 --- a/readme.txt +++ b/readme.txt @@ -129,6 +129,7 @@ If you get stuck, you can ask for help in the Plugin Forum. == Changelog == = 8.7.0 - xxxx-xx-xx = +* Add - Introduces a new promotional surface to encourage merchants with the legacy checkout experience and APMs enabled to use the new checkout experience. * Fix - Prevent duplicate failed-order emails from being sent. * Fix - Support custom name and description for Afterpay. * Fix - Link APM charge IDs in Order Details page to their Stripe dashboard payments page. From cd54ee8a33e53d35cc1f0d21fcfae53a6cd80080 Mon Sep 17 00:00:00 2001 From: Wesley Rosa Date: Mon, 16 Sep 2024 17:46:33 -0300 Subject: [PATCH 11/14] woorelease: Product version bump update --- changelog.txt | 2 +- package-lock.json | 2 +- package.json | 2 +- readme.txt | 4 ++-- woocommerce-gateway-stripe.php | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/changelog.txt b/changelog.txt index 46df8fed12..f81254b546 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,6 @@ *** Changelog *** -= 8.7.0 - xxxx-xx-xx = += 8.7.0 - 2024-09-16 = * Add - Introduces a new promotional surface to encourage merchants with the legacy checkout experience and APMs enabled to use the new checkout experience. * Fix - Prevent duplicate failed-order emails from being sent. * Fix - Support custom name and description for Afterpay. diff --git a/package-lock.json b/package-lock.json index d8061ef358..ee1482c3e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "woocommerce-gateway-stripe", - "version": "8.6.1", + "version": "8.7.0", "lockfileVersion": 2, "requires": true, "packages": { diff --git a/package.json b/package.json index 270ca44a5e..0ebe592003 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "woocommerce-gateway-stripe", "title": "WooCommerce Gateway Stripe", - "version": "8.6.1", + "version": "8.7.0", "license": "GPL-3.0", "homepage": "http://wordpress.org/plugins/woocommerce-gateway-stripe/", "repository": { diff --git a/readme.txt b/readme.txt index 31f289dd74..ae82454bc8 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: credit card, stripe, apple pay, payment request, google pay, sepa, bancont Requires at least: 6.4 Tested up to: 6.6 Requires PHP: 7.4 -Stable tag: 8.6.1 +Stable tag: 8.7.0 License: GPLv3 License URI: https://www.gnu.org/licenses/gpl-3.0.html Attributions: thorsten-stripe @@ -128,7 +128,7 @@ If you get stuck, you can ask for help in the Plugin Forum. == Changelog == -= 8.7.0 - xxxx-xx-xx = += 8.7.0 - 2024-09-16 = * Add - Introduces a new promotional surface to encourage merchants with the legacy checkout experience and APMs enabled to use the new checkout experience. * Fix - Prevent duplicate failed-order emails from being sent. * Fix - Support custom name and description for Afterpay. diff --git a/woocommerce-gateway-stripe.php b/woocommerce-gateway-stripe.php index e677efeb5b..9c9e5cdf81 100644 --- a/woocommerce-gateway-stripe.php +++ b/woocommerce-gateway-stripe.php @@ -5,7 +5,7 @@ * Description: Take credit card payments on your store using Stripe. * Author: WooCommerce * Author URI: https://woocommerce.com/ - * Version: 8.6.1 + * Version: 8.7.0 * Requires Plugins: woocommerce * Requires at least: 6.4 * Tested up to: 6.6 @@ -22,7 +22,7 @@ /** * Required minimums and constants */ -define( 'WC_STRIPE_VERSION', '8.6.1' ); // WRCS: DEFINED_VERSION. +define( 'WC_STRIPE_VERSION', '8.7.0' ); // WRCS: DEFINED_VERSION. define( 'WC_STRIPE_MIN_PHP_VER', '7.3.0' ); define( 'WC_STRIPE_MIN_WC_VER', '7.4' ); define( 'WC_STRIPE_FUTURE_MIN_WC_VER', '7.5' ); From 62827e1ed2e67ab0f1178f27578c3337c3e7586a Mon Sep 17 00:00:00 2001 From: Mayisha <33387139+Mayisha@users.noreply.github.com> Date: Tue, 17 Sep 2024 02:55:11 +0600 Subject: [PATCH 12/14] Create backend classes for express checkout element (#3429) * create 'WC_Stripe_Express_Checkout_Element' class * register script for shortcode checkout * move ajax functions to separate class * move helper functions to a separate class * include and initialize express checkout classes * make functions public in the helper class --- .../express-checkout/express-checkout.js | 6 +- client/entrypoints/express-checkout/index.js | 1 + ...c-stripe-express-checkout-ajax-handler.php | 310 +++++ ...ass-wc-stripe-express-checkout-element.php | 352 +++++ ...lass-wc-stripe-express-checkout-helper.php | 1185 +++++++++++++++++ .../class-wc-stripe-payment-request.php | 5 + webpack.config.js | 1 + woocommerce-gateway-stripe.php | 26 +- 8 files changed, 1878 insertions(+), 8 deletions(-) create mode 100644 client/entrypoints/express-checkout/index.js create mode 100644 includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php create mode 100644 includes/payment-methods/class-wc-stripe-express-checkout-element.php create mode 100644 includes/payment-methods/class-wc-stripe-express-checkout-helper.php diff --git a/client/blocks/express-checkout/express-checkout.js b/client/blocks/express-checkout/express-checkout.js index 345758acce..297fda1092 100644 --- a/client/blocks/express-checkout/express-checkout.js +++ b/client/blocks/express-checkout/express-checkout.js @@ -1,4 +1,4 @@ -/* global wc_stripe_payment_request_params */ +/* global wc_stripe_express_checkout_params */ import React from 'react'; import { Elements, ExpressCheckoutElement } from '@stripe/react-stripe-js'; @@ -13,8 +13,8 @@ export const ExpressCheckout = ( props ) => { const buttonOptions = { buttonType: { - googlePay: wc_stripe_payment_request_params.button.type, - applePay: wc_stripe_payment_request_params.button.type, + googlePay: wc_stripe_express_checkout_params.button.type, + applePay: wc_stripe_express_checkout_params.button.type, }, }; diff --git a/client/entrypoints/express-checkout/index.js b/client/entrypoints/express-checkout/index.js new file mode 100644 index 0000000000..f69a9256ff --- /dev/null +++ b/client/entrypoints/express-checkout/index.js @@ -0,0 +1 @@ +// express checkout element integration for shortcode goes here. diff --git a/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php b/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php new file mode 100644 index 0000000000..ade4b01326 --- /dev/null +++ b/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php @@ -0,0 +1,310 @@ +express_checkout_helper = $express_checkout_helper; + } + + /** + * Initialize hooks. + * + * @return void + */ + public function init() { + add_action( 'wc_ajax_wc_stripe_get_cart_details', [ $this, 'ajax_get_cart_details' ] ); + add_action( 'wc_ajax_wc_stripe_get_shipping_options', [ $this, 'ajax_get_shipping_options' ] ); + add_action( 'wc_ajax_wc_stripe_update_shipping_method', [ $this, 'ajax_update_shipping_method' ] ); + add_action( 'wc_ajax_wc_stripe_create_order', [ $this, 'ajax_create_order' ] ); + add_action( 'wc_ajax_wc_stripe_add_to_cart', [ $this, 'ajax_add_to_cart' ] ); + add_action( 'wc_ajax_wc_stripe_get_selected_product_data', [ $this, 'ajax_get_selected_product_data' ] ); + add_action( 'wc_ajax_wc_stripe_clear_cart', [ $this, 'ajax_clear_cart' ] ); + add_action( 'wc_ajax_wc_stripe_log_errors', [ $this, 'ajax_log_errors' ] ); + } + + /** + * Get cart details. + */ + public function ajax_get_cart_details() { + check_ajax_referer( 'wc-stripe-express-checkout', 'security' ); + + if ( ! defined( 'WOOCOMMERCE_CART' ) ) { + define( 'WOOCOMMERCE_CART', true ); + } + + WC()->cart->calculate_totals(); + + $currency = get_woocommerce_currency(); + + // Set mandatory payment details. + $data = [ + 'shipping_required' => WC()->cart->needs_shipping(), + 'order_data' => [ + 'currency' => strtolower( $currency ), + 'country_code' => substr( get_option( 'woocommerce_default_country' ), 0, 2 ), + ], + ]; + + $data['order_data'] += $this->express_checkout_helper->build_display_items(); + + wp_send_json( $data ); + } + + + /** + * Adds the current product to the cart. Used on product detail page. + * + * @return array $data Results of adding the product to the cart. + */ + public function ajax_add_to_cart() { + check_ajax_referer( 'wc-stripe-add-to-cart', 'security' ); + + if ( ! defined( 'WOOCOMMERCE_CART' ) ) { + define( 'WOOCOMMERCE_CART', true ); + } + + WC()->shipping->reset_shipping(); + + $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0; + $qty = ! isset( $_POST['qty'] ) ? 1 : absint( $_POST['qty'] ); + $product = wc_get_product( $product_id ); + $product_type = $product->get_type(); + + // First empty the cart to prevent wrong calculation. + WC()->cart->empty_cart(); + + if ( ( 'variable' === $product_type || 'variable-subscription' === $product_type ) && isset( $_POST['attributes'] ) ) { + $attributes = wc_clean( wp_unslash( $_POST['attributes'] ) ); + + $data_store = WC_Data_Store::load( 'product' ); + $variation_id = $data_store->find_matching_product_variation( $product, $attributes ); + + WC()->cart->add_to_cart( $product->get_id(), $qty, $variation_id, $attributes ); + } + + if ( in_array( $product_type, [ 'simple', 'variation', 'subscription', 'subscription_variation' ], true ) ) { + WC()->cart->add_to_cart( $product->get_id(), $qty ); + } + + WC()->cart->calculate_totals(); + + $data = []; + $data += $this->express_checkout_helper->build_display_items(); + $data['result'] = 'success'; + + // @phpstan-ignore-next-line (return statement is added) + wp_send_json( $data ); + } + + /** + * Clears cart. + */ + public function ajax_clear_cart() { + check_ajax_referer( 'wc-stripe-clear-cart', 'security' ); + + WC()->cart->empty_cart(); + exit; + } + + /** + * Get shipping options. + * + * @see WC_Cart::get_shipping_packages(). + * @see WC_Shipping::calculate_shipping(). + * @see WC_Shipping::get_packages(). + */ + public function ajax_get_shipping_options() { + check_ajax_referer( 'wc-stripe-express-checkout-shipping', 'security' ); + + $shipping_address = filter_input_array( + INPUT_POST, + [ + 'country' => FILTER_SANITIZE_SPECIAL_CHARS, + 'state' => FILTER_SANITIZE_SPECIAL_CHARS, + 'postcode' => FILTER_SANITIZE_SPECIAL_CHARS, + 'city' => FILTER_SANITIZE_SPECIAL_CHARS, + 'address' => FILTER_SANITIZE_SPECIAL_CHARS, + 'address_2' => FILTER_SANITIZE_SPECIAL_CHARS, + ] + ); + $product_view_options = filter_input_array( INPUT_POST, [ 'is_product_page' => FILTER_SANITIZE_SPECIAL_CHARS ] ); + $should_show_itemized_view = ! isset( $product_view_options['is_product_page'] ) ? true : filter_var( $product_view_options['is_product_page'], FILTER_VALIDATE_BOOLEAN ); + + $data = $this->express_checkout_helper->get_shipping_options( $shipping_address, $should_show_itemized_view ); + wp_send_json( $data ); + } + + /** + * Update shipping method. + */ + public function ajax_update_shipping_method() { + check_ajax_referer( 'wc-stripe-update-shipping-method', 'security' ); + + if ( ! defined( 'WOOCOMMERCE_CART' ) ) { + define( 'WOOCOMMERCE_CART', true ); + } + + $shipping_methods = filter_input( INPUT_POST, 'shipping_method', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ); + $this->express_checkout_helper->update_shipping_method( $shipping_methods ); + + WC()->cart->calculate_totals(); + + $product_view_options = filter_input_array( INPUT_POST, [ 'is_product_page' => FILTER_SANITIZE_SPECIAL_CHARS ] ); + $should_show_itemized_view = ! isset( $product_view_options['is_product_page'] ) ? true : filter_var( $product_view_options['is_product_page'], FILTER_VALIDATE_BOOLEAN ); + + $data = []; + $data += $this->express_checkout_helper->build_display_items( $should_show_itemized_view ); + $data['result'] = 'success'; + + wp_send_json( $data ); + } + + /** + * Gets the selected product data. + * + * @return array $data The selected product data. + */ + public function ajax_get_selected_product_data() { + check_ajax_referer( 'wc-stripe-get-selected-product-data', 'security' ); + + try { // @phpstan-ignore-line (return statement is added) + $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0; + $qty = ! isset( $_POST['qty'] ) ? 1 : apply_filters( 'woocommerce_add_to_cart_quantity', absint( $_POST['qty'] ), $product_id ); + $addon_value = isset( $_POST['addon_value'] ) ? max( floatval( $_POST['addon_value'] ), 0 ) : 0; + $product = wc_get_product( $product_id ); + $variation_id = null; + + if ( ! is_a( $product, 'WC_Product' ) ) { + /* translators: 1) The product Id */ + throw new Exception( sprintf( __( 'Product with the ID (%1$s) cannot be found.', 'woocommerce-gateway-stripe' ), $product_id ) ); + } + + if ( in_array( $product->get_type(), [ 'variable', 'variable-subscription' ], true ) && isset( $_POST['attributes'] ) ) { + $attributes = wc_clean( wp_unslash( $_POST['attributes'] ) ); + + $data_store = WC_Data_Store::load( 'product' ); + $variation_id = $data_store->find_matching_product_variation( $product, $attributes ); + + if ( ! empty( $variation_id ) ) { + $product = wc_get_product( $variation_id ); + } + } + + if ( $this->express_checkout_helper->is_invalid_subscription_product( $product, true ) ) { + throw new Exception( __( 'The chosen subscription product is not supported.', 'woocommerce-gateway-stripe' ) ); + } + + // Force quantity to 1 if sold individually and check for existing item in cart. + if ( $product->is_sold_individually() ) { + $qty = apply_filters( 'wc_stripe_payment_request_add_to_cart_sold_individually_quantity', 1, $qty, $product_id, $variation_id ); + } + + if ( ! $product->has_enough_stock( $qty ) ) { + /* translators: 1) product name 2) quantity in stock */ + throw new Exception( sprintf( __( 'You cannot add that amount of "%1$s"; to the cart because there is not enough stock (%2$s remaining).', 'woocommerce-gateway-stripe' ), $product->get_name(), wc_format_stock_quantity_for_display( $product->get_stock_quantity(), $product ) ) ); + } + + $total = $qty * $this->express_checkout_helper->get_product_price( $product ) + $addon_value; + + $quantity_label = 1 < $qty ? ' (x' . $qty . ')' : ''; + + $data = []; + $items = []; + + $items[] = [ + 'label' => $product->get_name() . $quantity_label, + 'amount' => WC_Stripe_Helper::get_stripe_amount( $total ), + ]; + + if ( wc_tax_enabled() ) { + $items[] = [ + 'label' => __( 'Tax', 'woocommerce-gateway-stripe' ), + 'amount' => 0, + 'pending' => true, + ]; + } + + if ( wc_shipping_enabled() && $product->needs_shipping() ) { + $items[] = [ + 'label' => __( 'Shipping', 'woocommerce-gateway-stripe' ), + 'amount' => 0, + 'pending' => true, + ]; + + $data['shippingOptions'] = [ + 'id' => 'pending', + 'label' => __( 'Pending', 'woocommerce-gateway-stripe' ), + 'detail' => '', + 'amount' => 0, + ]; + } + + $data['displayItems'] = $items; + $data['total'] = [ + 'label' => $this->express_checkout_helper->get_total_label(), + 'amount' => WC_Stripe_Helper::get_stripe_amount( $total ), + ]; + + $data['requestShipping'] = ( wc_shipping_enabled() && $product->needs_shipping() ); + $data['currency'] = strtolower( get_woocommerce_currency() ); + $data['country_code'] = substr( get_option( 'woocommerce_default_country' ), 0, 2 ); + + wp_send_json( $data ); + } catch ( Exception $e ) { + wp_send_json( [ 'error' => wp_strip_all_tags( $e->getMessage() ) ] ); + } + } + + /** + * Create order. Security is handled by WC. + */ + public function ajax_create_order() { + if ( WC()->cart->is_empty() ) { + wp_send_json_error( __( 'Empty cart', 'woocommerce-gateway-stripe' ) ); + } + + if ( ! defined( 'WOOCOMMERCE_CHECKOUT' ) ) { + define( 'WOOCOMMERCE_CHECKOUT', true ); + } + + // Normalizes billing and shipping state values. + $this->express_checkout_helper->normalize_state(); + + // In case the state is required, but is missing, add a more descriptive error notice. + $this->express_checkout_helper->validate_state(); + + WC()->checkout()->process_checkout(); + + die( 0 ); + } + + /** + * Log errors coming from express checkout elements + */ + public function ajax_log_errors() { + check_ajax_referer( 'wc-stripe-log-errors', 'security' ); + + $errors = isset( $_POST['errors'] ) ? wc_clean( wp_unslash( $_POST['errors'] ) ) : ''; + + WC_Stripe_Logger::log( $errors ); + + exit; + } +} diff --git a/includes/payment-methods/class-wc-stripe-express-checkout-element.php b/includes/payment-methods/class-wc-stripe-express-checkout-element.php new file mode 100644 index 0000000000..7e41cf8cf3 --- /dev/null +++ b/includes/payment-methods/class-wc-stripe-express-checkout-element.php @@ -0,0 +1,352 @@ +stripe_settings = WC_Stripe_Helper::get_stripe_settings(); + + $this->express_checkout_helper = $express_checkout_helper; + $this->express_checkout_ajax_handler = $express_checkout_ajax_handler; + $this->express_checkout_ajax_handler->init(); + } + + /** + * Initialize hooks. + * + * @return void + */ + public function init() { + // Check if ECE feature flag is enabled. + if ( ! WC_Stripe_Feature_Flags::is_stripe_ece_enabled() ) { + return; + } + + // Checks if Stripe Gateway is enabled. + if ( empty( $this->stripe_settings ) || ( isset( $this->stripe_settings['enabled'] ) && 'yes' !== $this->stripe_settings['enabled'] ) ) { + return; + } + + // Don't initiate this class if express checkout element is disabled. + if ( ! $this->express_checkout_helper->is_express_checkout_enabled() ) { + return; + } + + // Don't load for change payment method page. + if ( isset( $_GET['change_payment_method'] ) ) { + return; + } + + add_action( 'template_redirect', [ $this, 'set_session' ] ); + add_action( 'template_redirect', [ $this, 'handle_express_checkout_redirect' ] ); + + add_action( 'wp_enqueue_scripts', [ $this, 'scripts' ] ); + + add_action( 'woocommerce_after_add_to_cart_form', [ $this, 'display_express_checkout_button_html' ], 1 ); + add_action( 'woocommerce_proceed_to_checkout', [ $this, 'display_express_checkout_button_html' ], 25 ); + add_action( 'woocommerce_checkout_before_customer_details', [ $this, 'display_express_checkout_button_html' ], 1 ); + + add_filter( 'woocommerce_gateway_title', [ $this, 'filter_gateway_title' ], 10, 2 ); + add_action( 'woocommerce_checkout_order_processed', [ $this, 'add_order_meta' ], 10, 2 ); + add_filter( 'woocommerce_login_redirect', [ $this, 'get_login_redirect_url' ], 10, 3 ); + add_filter( 'woocommerce_registration_redirect', [ $this, 'get_login_redirect_url' ], 10, 3 ); + } + + /** + * Get this instance. + * + * @return class + */ + public static function instance() { + return self::$_this; + } + + /** + * Sets the WC customer session if one is not set. + * This is needed so nonces can be verified by AJAX Request. + * + * @return void + */ + public function set_session() { + if ( ! $this->express_checkout_helper->is_product() || ( isset( WC()->session ) && WC()->session->has_session() ) ) { + return; + } + + WC()->session->set_customer_session_cookie( true ); + } + + /** + * Handles express checkout redirect when the redirect dialog "Continue" button is clicked. + */ + public function handle_express_checkout_redirect() { + if ( + ! empty( $_GET['wc_stripe_express_checkout_redirect_url'] ) + && ! empty( $_GET['_wpnonce'] ) + && wp_verify_nonce( $_GET['_wpnonce'], 'wc-stripe-set-redirect-url' ) // @codingStandardsIgnoreLine + ) { + $url = rawurldecode( esc_url_raw( wp_unslash( $_GET['wc_stripe_express_checkout_redirect_url'] ) ) ); + // Sets a redirect URL cookie for 10 minutes, which we will redirect to after authentication. + // Users will have a 10 minute timeout to login/create account, otherwise redirect URL expires. + wc_setcookie( 'wc_stripe_express_checkout_redirect_url', $url, time() + MINUTE_IN_SECONDS * 10 ); + // Redirects to "my-account" page. + wp_safe_redirect( get_permalink( get_option( 'woocommerce_myaccount_page_id' ) ) ); + exit; + } + } + + /** + * Returns the login redirect URL. + * + * @param string $redirect Default redirect URL. + * @return string Redirect URL. + */ + public function get_login_redirect_url( $redirect ) { + $url = esc_url_raw( wp_unslash( isset( $_COOKIE['wc_stripe_express_checkout_redirect_url'] ) ? $_COOKIE['wc_stripe_express_checkout_redirect_url'] : '' ) ); + + if ( empty( $url ) ) { + return $redirect; + } + wc_setcookie( 'wc_stripe_express_checkout_redirect_url', null ); + + return $url; + } + + /** + * Returns the JavaScript configuration object used for any pages with express checkout element. + * + * @return array The settings used for the Stripe express checkout element in JavaScript. + */ + public function javascript_params() { + $needs_shipping = 'no'; + if ( ! is_null( WC()->cart ) && WC()->cart->needs_shipping() ) { + $needs_shipping = 'yes'; + } + + return [ + 'ajax_url' => WC_AJAX::get_endpoint( '%%endpoint%%' ), + 'stripe' => [ + 'allow_prepaid_card' => apply_filters( 'wc_stripe_allow_prepaid_card', true ) ? 'yes' : 'no', + 'locale' => WC_Stripe_Helper::convert_wc_locale_to_stripe_locale( get_locale() ), + 'is_link_enabled' => WC_Stripe_UPE_Payment_Method_Link::is_link_enabled(), + 'is_express_checkout_enabled' => $this->express_checkout_helper->is_express_checkout_enabled(), + ], + 'nonce' => [ + 'payment' => wp_create_nonce( 'wc-stripe-express-checkout-element' ), + 'shipping' => wp_create_nonce( 'wc-stripe-express-checkout-element-shipping' ), + 'update_shipping' => wp_create_nonce( 'wc-stripe-update-shipping-method' ), + 'checkout' => wp_create_nonce( 'woocommerce-process_checkout' ), + 'add_to_cart' => wp_create_nonce( 'wc-stripe-add-to-cart' ), + 'get_selected_product_data' => wp_create_nonce( 'wc-stripe-get-selected-product-data' ), + 'log_errors' => wp_create_nonce( 'wc-stripe-log-errors' ), + 'clear_cart' => wp_create_nonce( 'wc-stripe-clear-cart' ), + ], + 'i18n' => [ + 'no_prepaid_card' => __( 'Sorry, we\'re not accepting prepaid cards at this time.', 'woocommerce-gateway-stripe' ), + /* translators: Do not translate the [option] placeholder */ + 'unknown_shipping' => __( 'Unknown shipping option "[option]".', 'woocommerce-gateway-stripe' ), + ], + 'checkout' => [ + 'url' => wc_get_checkout_url(), + 'currency_code' => strtolower( get_woocommerce_currency() ), + 'country_code' => substr( get_option( 'woocommerce_default_country' ), 0, 2 ), + 'needs_shipping' => $needs_shipping, + // Defaults to 'required' to match how core initializes this option. + 'needs_payer_phone' => 'required' === get_option( 'woocommerce_checkout_phone_field', 'required' ), + ], + 'button' => $this->express_checkout_helper->get_button_settings(), + 'login_confirmation' => $this->express_checkout_helper->get_login_confirmation_settings(), + 'is_product_page' => $this->express_checkout_helper->is_product(), + 'product' => $this->express_checkout_helper->get_product_data(), + ]; + } + + /** + * Load scripts and styles. + */ + public function scripts() { + // If page is not supported, bail. + if ( ! $this->express_checkout_helper->is_page_supported() ) { + return; + } + + if ( ! $this->express_checkout_helper->should_show_express_checkout_button() ) { + return; + } + + $asset_path = WC_STRIPE_PLUGIN_PATH . '/build/express_checkout.asset.php'; + $version = WC_STRIPE_VERSION; + $dependencies = []; + if ( file_exists( $asset_path ) ) { + $asset = require $asset_path; + $version = is_array( $asset ) && isset( $asset['version'] ) + ? $asset['version'] + : $version; + $dependencies = is_array( $asset ) && isset( $asset['dependencies'] ) + ? $asset['dependencies'] + : $dependencies; + } + + wp_register_script( 'stripe', 'https://js.stripe.com/v3/', '', '3.0', true ); + wp_register_script( + 'wc_stripe_express_checkout', + WC_STRIPE_PLUGIN_URL . '/build/express_checkout.js', + array_merge( [ 'jquery', 'stripe' ], $dependencies ), + $version, + true + ); + + wp_enqueue_style( + 'wc_stripe_express_checkout_style', + WC_STRIPE_PLUGIN_URL . '/build/express_checkout.css', + [], + $version + ); + + wp_localize_script( + 'wc_stripe_express_checkout', + 'wc_stripe_express_checkout_params', + apply_filters( + 'wc_stripe_express_checkout_params', + $this->javascript_params() + ) + ); + + wp_enqueue_script( 'wc_stripe_express_checkout' ); + } + + /** + * Add needed order meta + * + * @param integer $order_id The order ID. + * @param array $posted_data The posted data from checkout form. + * + * @return void + */ + public function add_order_meta( $order_id, $posted_data ) { + if ( empty( $_POST['express_checkout_type'] ) || ! isset( $_POST['payment_method'] ) || 'stripe' !== $_POST['payment_method'] ) { + return; + } + + $order = wc_get_order( $order_id ); + + $express_checkout_type = wc_clean( wp_unslash( $_POST['express_checkout_type'] ) ); + + if ( 'apple_pay' === $express_checkout_type ) { + $order->set_payment_method_title( 'Apple Pay (Stripe)' ); + $order->save(); + } elseif ( 'google_pay' === $express_checkout_type ) { + $order->set_payment_method_title( 'Google Pay (Stripe)' ); + $order->save(); + } + } + + /** + * Filters the gateway title to reflect express checkout type + */ + public function filter_gateway_title( $title, $id ) { + global $theorder; + + // If $theorder is empty (i.e. non-HPOS), fallback to using the global post object. + if ( empty( $theorder ) && ! empty( $GLOBALS['post']->ID ) ) { + $theorder = wc_get_order( $GLOBALS['post']->ID ); + } + + if ( ! is_object( $theorder ) ) { + return $title; + } + + $method_title = $theorder->get_payment_method_title(); + + if ( 'stripe' === $id && ! empty( $method_title ) ) { + if ( 'Apple Pay (Stripe)' === $method_title + || 'Google Pay (Stripe)' === $method_title + ) { + return $method_title; + } + } + + return $title; + } + + /** + * Display the express checkout button. + */ + public function display_express_checkout_button_html() { + $gateways = WC()->payment_gateways->get_available_payment_gateways(); + + if ( ! isset( $gateways['stripe'] ) ) { + return; + } + + if ( ! $this->express_checkout_helper->is_page_supported() ) { + return; + } + + if ( ! $this->express_checkout_helper->should_show_express_checkout_button() ) { + return; + } + + ?> + + display_express_checkout_button_separator_html(); + } + + /** + * Display express checkout button separator. + */ + public function display_express_checkout_button_separator_html() { + if ( ! is_checkout() && ! is_wc_endpoint_url( 'order-pay' ) ) { + return; + } + + if ( is_checkout() && ! in_array( 'checkout', $this->express_checkout_helper->get_button_locations(), true ) ) { + return; + } + + ?> + + stripe_settings = WC_Stripe_Helper::get_stripe_settings(); + $this->testmode = ( ! empty( $this->stripe_settings['testmode'] ) && 'yes' === $this->stripe_settings['testmode'] ) ? true : false; + $this->total_label = ! empty( $this->stripe_settings['statement_descriptor'] ) ? WC_Stripe_Helper::clean_statement_descriptor( $this->stripe_settings['statement_descriptor'] ) : ''; + + $this->total_label = str_replace( "'", '', $this->total_label ) . apply_filters( 'wc_stripe_payment_request_total_label_suffix', ' (via WooCommerce)' ); + + } + + /** + * Checks whether authentication is required for checkout. + * + * @return bool + */ + public function is_authentication_required() { + // If guest checkout is disabled and account creation upon checkout is not possible, authentication is required. + if ( 'no' === get_option( 'woocommerce_enable_guest_checkout', 'yes' ) && ! $this->is_account_creation_possible() ) { + return true; + } + // If cart contains subscription and account creation upon checkout is not posible, authentication is required. + if ( $this->has_subscription_product() && ! $this->is_account_creation_possible() ) { + return true; + } + + return false; + } + + /** + * Checks whether account creation is possible upon checkout. + * + * @return bool + */ + public function is_account_creation_possible() { + // If automatically generate username/password are disabled, we can not include any of those fields, + // during express checkout. So account creation is not possible. + return ( + 'yes' === get_option( 'woocommerce_enable_signup_and_login_from_checkout', 'no' ) && + 'yes' === get_option( 'woocommerce_registration_generate_username', 'yes' ) && + 'yes' === get_option( 'woocommerce_registration_generate_password', 'yes' ) + ); + } + + /** + * Gets the button type. + * + * @return string + */ + public function get_button_type() { + return isset( $this->stripe_settings['payment_request_button_type'] ) ? $this->stripe_settings['payment_request_button_type'] : 'default'; + } + + /** + * Gets the button theme. + * + * @return string + */ + public function get_button_theme() { + return isset( $this->stripe_settings['payment_request_button_theme'] ) ? $this->stripe_settings['payment_request_button_theme'] : 'dark'; + } + + /** + * Gets the button height. + * + * @return string + */ + public function get_button_height() { + $height = isset( $this->stripe_settings['payment_request_button_size'] ) ? $this->stripe_settings['payment_request_button_size'] : 'default'; + if ( 'small' === $height ) { + return '40'; + } + + if ( 'large' === $height ) { + return '56'; + } + + return '48'; + } + + /** + * Gets total label. + * + * @return string + */ + public function get_total_label() { + return $this->total_label; + } + + /** + * Gets the product total price. + * + * @param object $product WC_Product_* object. + * @return integer Total price. + */ + public function get_product_price( $product ) { + $product_price = $product->get_price(); + // Add subscription sign-up fees to product price. + if ( in_array( $product->get_type(), [ 'subscription', 'subscription_variation' ] ) && class_exists( 'WC_Subscriptions_Product' ) ) { + $product_price = $product->get_price() + WC_Subscriptions_Product::get_sign_up_fee( $product ); + } + + return $product_price; + } + + /** + * Gets the product data for the currently viewed page + * + * @return mixed Returns false if not on a product page, the product information otherwise. + */ + public function get_product_data() { + if ( ! $this->is_product() ) { + return false; + } + + $product = $this->get_product(); + $variation_id = 0; + + if ( in_array( $product->get_type(), [ 'variable', 'variable-subscription' ], true ) ) { + $variation_attributes = $product->get_variation_attributes(); + $attributes = []; + + foreach ( $variation_attributes as $attribute_name => $attribute_values ) { + $attribute_key = 'attribute_' . sanitize_title( $attribute_name ); + + // Passed value via GET takes precedence, then POST, otherwise get the default value for given attribute + if ( isset( $_GET[ $attribute_key ] ) ) { + $attributes[ $attribute_key ] = wc_clean( wp_unslash( $_GET[ $attribute_key ] ) ); + } elseif ( isset( $_POST[ $attribute_key ] ) ) { + $attributes[ $attribute_key ] = wc_clean( wp_unslash( $_POST[ $attribute_key ] ) ); + } else { + $attributes[ $attribute_key ] = $product->get_variation_default_attribute( $attribute_name ); + } + } + + $data_store = WC_Data_Store::load( 'product' ); + $variation_id = $data_store->find_matching_product_variation( $product, $attributes ); + + if ( ! empty( $variation_id ) ) { + $product = wc_get_product( $variation_id ); + } + } + + $data = []; + $items = []; + + $items[] = [ + 'label' => $product->get_name(), + 'amount' => WC_Stripe_Helper::get_stripe_amount( $this->get_product_price( $product ) ), + ]; + + if ( wc_tax_enabled() ) { + $items[] = [ + 'label' => __( 'Tax', 'woocommerce-gateway-stripe' ), + 'amount' => 0, + 'pending' => true, + ]; + } + + if ( wc_shipping_enabled() && $product->needs_shipping() ) { + $items[] = [ + 'label' => __( 'Shipping', 'woocommerce-gateway-stripe' ), + 'amount' => 0, + 'pending' => true, + ]; + + $data['shippingOptions'] = [ + 'id' => 'pending', + 'label' => __( 'Pending', 'woocommerce-gateway-stripe' ), + 'detail' => '', + 'amount' => 0, + ]; + } + + $data['displayItems'] = $items; + $data['total'] = [ + 'label' => apply_filters( 'wc_stripe_payment_request_total_label', $this->total_label ), + 'amount' => WC_Stripe_Helper::get_stripe_amount( $this->get_product_price( $product ) ), + ]; + + $data['requestShipping'] = ( wc_shipping_enabled() && $product->needs_shipping() && 0 !== wc_get_shipping_method_count( true ) ); + $data['currency'] = strtolower( get_woocommerce_currency() ); + $data['country_code'] = substr( get_option( 'woocommerce_default_country' ), 0, 2 ); + + // On product page load, if there's a variation already selected, check if it's supported. + $data['validVariationSelected'] = ! empty( $variation_id ) ? $this->is_product_supported( $product ) : true; + + return apply_filters( 'wc_stripe_payment_request_product_data', $data, $product ); + } + + /** + * Normalizes postal code in case of redacted data from Apple Pay. + * + * @param string $postcode Postal code. + * @param string $country Country. + */ + public function get_normalized_postal_code( $postcode, $country ) { + /** + * Currently, Apple Pay truncates the UK and Canadian postal codes to the first 4 and 3 characters respectively + * when passing it back from the shippingcontactselected object. This causes WC to invalidate + * the postal code and not calculate shipping zones correctly. + */ + if ( 'GB' === $country ) { + // Replaces a redacted string with something like LN10***. + return str_pad( preg_replace( '/\s+/', '', $postcode ), 7, '*' ); + } + if ( 'CA' === $country ) { + // Replaces a redacted string with something like L4Y***. + return str_pad( preg_replace( '/\s+/', '', $postcode ), 6, '*' ); + } + + return $postcode; + } + + /** + * Checks to make sure product type is supported. + * + * @return array + */ + public function supported_product_types() { + return apply_filters( + 'wc_stripe_payment_request_supported_types', + [ + 'simple', + 'variable', + 'variation', + 'subscription', + 'variable-subscription', + 'subscription_variation', + 'booking', + 'bundle', + 'composite', + ] + ); + } + + /** + * Checks the cart to see if all items are allowed to be used. + * + * @return boolean + */ + public function allowed_items_in_cart() { + // Pre Orders compatibility where we don't support charge upon release. + if ( $this->is_pre_order_item_in_cart() && $this->is_pre_order_product_charged_upon_release( $this->get_pre_order_product_from_cart() ) ) { + return false; + } + + // If the cart is not available we don't have any unsupported products in the cart, so we + // return true. This can happen e.g. when loading the cart or checkout blocks in Gutenberg. + if ( is_null( WC()->cart ) ) { + return true; + } + + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + $_product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key ); + + if ( ! in_array( $_product->get_type(), $this->supported_product_types() ) ) { + return false; + } + + // Subscriptions with a trial period that need shipping are not supported. + if ( $this->is_invalid_subscription_product( $_product ) ) { + return false; + } + } + + // We don't support multiple packages with express checkout buttons because we can't offer + // a good UX. + $packages = WC()->cart->get_shipping_packages(); + if ( 1 < count( $packages ) ) { + return false; + } + + return true; + } + + /** + * Returns true if the given product is a subscription that cannot be purchased with express checkout buttons. + * + * Invalid subscription products include those with: + * - a free trial that requires shipping (synchronised subscriptions with a delayed first payment are considered to have a free trial) + * - a synchronised subscription with no upfront payment and is virtual (this limitation only applies to the product page as we cannot calculate totals correctly) + * + * If the product is a variable subscription, this function will return true if all of its variations have a trial and require shipping. + * + * @since 7.8.0 + * + * @param WC_Product|null $product Product object. + * @param boolean $is_product_page_request Whether this is a request from the product page. + * + * @return boolean + */ + public function is_invalid_subscription_product( $product, $is_product_page_request = false ) { + if ( ! class_exists( 'WC_Subscriptions_Product' ) || ! class_exists( 'WC_Subscriptions_Synchroniser' ) || ! WC_Subscriptions_Product::is_subscription( $product ) ) { + return false; + } + + $is_invalid = true; + + if ( $product->get_type() === 'variable-subscription' ) { + $products = $product->get_available_variations( 'object' ); + } else { + $products = [ $product ]; + } + + foreach ( $products as $product ) { + $needs_shipping = $product->needs_shipping(); + $is_synced = WC_Subscriptions_Synchroniser::is_product_synced( $product ); + $is_payment_upfront = WC_Subscriptions_Synchroniser::is_payment_upfront( $product ); + $has_trial_period = WC_Subscriptions_Product::get_trial_length( $product ) > 0; + + if ( $is_product_page_request && $is_synced && ! $is_payment_upfront && ! $needs_shipping ) { + /** + * This condition prevents the purchase of virtual synced subscription products with no upfront costs via express checkout buttons from the product page. + * + * The main issue is that calling $product->get_price() on a synced subscription does not take into account a mock trial period or prorated price calculations + * until the product is in the cart. This means that the totals passed to express checkout element are incorrect when purchasing from the product page. + * Another part of the problem is because the product is virtual this stops the Stripe PaymentRequest API from triggering the necessary `shippingaddresschange` event + * which is when we call WC()->cart->calculate_totals(); which would fix the totals. + * + * The fix here is to not allow virtual synced subscription products with no upfront costs to be purchased via express checkout buttons on the product page. + */ + continue; + } elseif ( $is_synced && ! $is_payment_upfront && $needs_shipping ) { + continue; + } elseif ( $has_trial_period && $needs_shipping ) { + continue; + } else { + // If we made it this far, the product is valid. Break out of the foreach and return early as we only care about invalid cases. + $is_invalid = false; + break; + } + } + + return $is_invalid; + } + + /** + * Checks whether cart contains a subscription product or this is a subscription product page. + * + * @return boolean + */ + public function has_subscription_product() { + if ( ! class_exists( 'WC_Subscriptions_Product' ) ) { + return false; + } + + if ( $this->is_product() ) { + $product = $this->get_product(); + if ( WC_Subscriptions_Product::is_subscription( $product ) ) { + return true; + } + } elseif ( WC_Stripe_Helper::has_cart_or_checkout_on_current_page() ) { + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + $_product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key ); + if ( WC_Subscriptions_Product::is_subscription( $_product ) ) { + return true; + } + } + } + + return false; + } + + /** + * Checks if this is a product page or content contains a product_page shortcode. + * + * @return boolean + */ + public function is_product() { + return is_product() || wc_post_content_has_shortcode( 'product_page' ); + } + + /** + * Get product from product page or product_page shortcode. + * + * @return WC_Product Product object. + */ + public function get_product() { + global $post; + + if ( is_product() ) { + return wc_get_product( $post->ID ); + } elseif ( wc_post_content_has_shortcode( 'product_page' ) ) { + // Get id from product_page shortcode. + preg_match( '/\[product_page id="(?\d+)"\]/', $post->post_content, $shortcode_match ); + + if ( ! isset( $shortcode_match['id'] ) ) { + return false; + } + + return wc_get_product( $shortcode_match['id'] ); + } + + return false; + } + + /** + * Returns true if the current page supports Express Checkout Buttons, false otherwise. + * + * @return boolean True if the current page is supported, false otherwise. + */ + public function is_page_supported() { + return $this->is_product() + || WC_Stripe_Helper::has_cart_or_checkout_on_current_page() + || is_wc_endpoint_url( 'order-pay' ); + } + + /** + * Returns true if express checkout elements are supported on the current page, false + * otherwise. + * + * @return boolean True if express checkout elements are supported on current page, false otherwise + */ + public function should_show_express_checkout_button() { + // Bail if account is not connected. + if ( ! WC_Stripe::get_instance()->connect->is_connected() ) { + WC_Stripe_Logger::log( 'Account is not connected.' ); + return false; + } + + // If no SSL bail. + if ( ! $this->testmode && ! is_ssl() ) { + WC_Stripe_Logger::log( 'Stripe Express Checkout live mode requires SSL.' ); + return false; + } + + // Don't show if on the cart or checkout page, or if page contains the cart or checkout + // shortcodes, with items in the cart that aren't supported. + if ( + WC_Stripe_Helper::has_cart_or_checkout_on_current_page() + && ! $this->allowed_items_in_cart() + ) { + return false; + } + + // Don't show on cart if disabled. + if ( is_cart() && ! $this->should_show_ece_on_cart_page() ) { + return false; + } + + // Don't show on checkout if disabled. + if ( is_checkout() && ! $this->should_show_ece_on_checkout_page() ) { + return false; + } + + // Don't show if product page PRB is disabled. + if ( $this->is_product() && ! $this->should_show_ece_on_product_pages() ) { + return false; + } + + // Don't show if product on current page is not supported. + if ( $this->is_product() && ! $this->is_product_supported( $this->get_product() ) ) { + return false; + } + + if ( $this->is_product() && in_array( $this->get_product()->get_type(), [ 'variable', 'variable-subscription' ], true ) ) { + $stock_availability = array_column( $this->get_product()->get_available_variations(), 'is_in_stock' ); + // Don't show if all product variations are out-of-stock. + if ( ! in_array( true, $stock_availability, true ) ) { + return false; + } + } + + return true; + } + + /** + * Returns true if express checkout buttons are enabled on the cart page, false + * otherwise. + * + * @return boolean True if express checkout buttons are enabled on the cart page, false otherwise + */ + public function should_show_ece_on_cart_page() { + $should_show_on_cart_page = in_array( 'cart', $this->get_button_locations(), true ); + + return apply_filters( + 'wc_stripe_show_payment_request_on_cart', + $should_show_on_cart_page + ); + } + + /** + * Returns true if express checkout buttons are enabled on the checkout page, false + * otherwise. + * + * @return boolean True if express checkout buttons are enabled on the checkout page, false otherwise + */ + public function should_show_ece_on_checkout_page() { + global $post; + + $should_show_on_checkout_page = in_array( 'checkout', $this->get_button_locations(), true ); + + return apply_filters( + 'wc_stripe_show_payment_request_on_checkout', + $should_show_on_checkout_page, + $post + ); + } + + /** + * Returns true if express checkout buttons are enabled on product pages, false + * otherwise. + * + * @return boolean True if express checkout buttons are enabled on product pages, false otherwise + */ + public function should_show_ece_on_product_pages() { + global $post; + + $should_show_on_product_page = in_array( 'product', $this->get_button_locations(), true ); + + // Note the negation because if the filter returns `true` that means we should hide the PRB. + return ! apply_filters( + 'wc_stripe_hide_payment_request_on_product_page', + ! $should_show_on_product_page, + $post + ); + } + + /** + * Returns true if a the provided product is supported, false otherwise. + * + * @param WC_Product $param The product that's being checked for support. + * + * @return boolean True if the provided product is supported, false otherwise. + */ + public function is_product_supported( $product ) { + if ( ! is_object( $product ) || ! in_array( $product->get_type(), $this->supported_product_types() ) ) { + return false; + } + + // Trial subscriptions with shipping are not supported. + if ( $this->is_invalid_subscription_product( $product, true ) ) { + return false; + } + + // Pre Orders charge upon release not supported. + if ( $this->is_pre_order_product_charged_upon_release( $product ) ) { + return false; + } + + // Composite products are not supported on the product page. + if ( class_exists( 'WC_Composite_Products' ) && function_exists( 'is_composite_product' ) && is_composite_product() ) { + return false; + } + + // File upload addon not supported + if ( class_exists( 'WC_Product_Addons_Helper' ) ) { + $product_addons = WC_Product_Addons_Helper::get_product_addons( $product->get_id() ); + foreach ( $product_addons as $addon ) { + if ( 'file_upload' === $addon['type'] ) { + return false; + } + } + } + + return true; + } + + /** + * Gets shipping options available for specified shipping address + * + * @param array $shipping_address Shipping address. + * @param boolean $itemized_display_items Indicates whether to show subtotals or itemized views. + * + * @return array Shipping options data. + * + * phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag + */ + public function get_shipping_options( $shipping_address, $itemized_display_items = false ) { + try { + // Set the shipping options. + $data = []; + + // Remember current shipping method before resetting. + $chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods', [] ); + $this->calculate_shipping( apply_filters( 'wc_stripe_payment_request_shipping_posted_values', $shipping_address ) ); + + $packages = WC()->shipping->get_packages(); + $shipping_rate_ids = []; + + if ( ! empty( $packages ) && WC()->customer->has_calculated_shipping() ) { + foreach ( $packages as $package_key => $package ) { + if ( empty( $package['rates'] ) ) { + throw new Exception( __( 'Unable to find shipping method for address.', 'woocommerce-gateway-stripe' ) ); + } + + foreach ( $package['rates'] as $key => $rate ) { + if ( in_array( $rate->id, $shipping_rate_ids, true ) ) { + // The express checkout will try to load indefinitely if there are duplicate shipping + // option IDs. + throw new Exception( __( 'Unable to provide shipping options for express checkout.', 'woocommerce-gateway-stripe' ) ); + } + $shipping_rate_ids[] = $rate->id; + $data['shipping_options'][] = [ + 'id' => $rate->id, + 'label' => $rate->label, + 'detail' => '', + 'amount' => WC_Stripe_Helper::get_stripe_amount( $rate->cost ), + ]; + } + } + } else { + throw new Exception( __( 'Unable to find shipping method for address.', 'woocommerce-gateway-stripe' ) ); + } + + // The first shipping option is automatically applied on the client. + // Keep chosen shipping method by sorting shipping options if the method still available for new address. + // Fallback to the first available shipping method. + if ( isset( $data['shipping_options'][0] ) ) { + if ( isset( $chosen_shipping_methods[0] ) ) { + $chosen_method_id = $chosen_shipping_methods[0]; + $compare_shipping_options = function ( $a, $b ) use ( $chosen_method_id ) { + if ( $a['id'] === $chosen_method_id ) { + return -1; + } + + if ( $b['id'] === $chosen_method_id ) { + return 1; + } + + return 0; + }; + usort( $data['shipping_options'], $compare_shipping_options ); + } + + $first_shipping_method_id = $data['shipping_options'][0]['id']; + $this->update_shipping_method( [ $first_shipping_method_id ] ); + } + + WC()->cart->calculate_totals(); + + $this->maybe_restore_recurring_chosen_shipping_methods( $chosen_shipping_methods ); + + $data += $this->build_display_items( $itemized_display_items ); + $data['result'] = 'success'; + } catch ( Exception $e ) { + $data += $this->build_display_items( $itemized_display_items ); + $data['result'] = 'invalid_shipping_address'; + } + + return $data; + } + + /** + * Updates shipping method in WC session + * + * @param array $shipping_methods Array of selected shipping methods ids. + */ + public function update_shipping_method( $shipping_methods ) { + $chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods' ); + + if ( is_array( $shipping_methods ) ) { + foreach ( $shipping_methods as $i => $value ) { + $chosen_shipping_methods[ $i ] = wc_clean( $value ); + } + } + + WC()->session->set( 'chosen_shipping_methods', $chosen_shipping_methods ); + } + + /** + * Normalizes billing and shipping state fields. + */ + public function normalize_state() { + $billing_country = ! empty( $_POST['billing_country'] ) ? wc_clean( wp_unslash( $_POST['billing_country'] ) ) : ''; + $shipping_country = ! empty( $_POST['shipping_country'] ) ? wc_clean( wp_unslash( $_POST['shipping_country'] ) ) : ''; + $billing_state = ! empty( $_POST['billing_state'] ) ? wc_clean( wp_unslash( $_POST['billing_state'] ) ) : ''; + $shipping_state = ! empty( $_POST['shipping_state'] ) ? wc_clean( wp_unslash( $_POST['shipping_state'] ) ) : ''; + + // Due to a bug in Apple Pay, the "Region" part of a Hong Kong address is delivered in + // `shipping_postcode`, so we need some special case handling for that. According to + // our sources at Apple Pay people will sometimes use the district or even sub-district + // for this value. As such we check against all regions, districts, and sub-districts + // with both English and Mandarin spelling. + // + // @reykjalin: The check here is quite elaborate in an attempt to make sure this doesn't break once + // Apple Pay fixes the bug that causes address values to be in the wrong place. Because of that the + // algorithm becomes: + // 1. Use the supplied state if it's valid (in case Apple Pay bug is fixed) + // 2. Use the value supplied in the postcode if it's a valid HK region (equivalent to a WC state). + // 3. Fall back to the value supplied in the state. This will likely cause a validation error, in + // which case a merchant can reach out to us so we can either: 1) add whatever the customer used + // as a state to our list of valid states; or 2) let them know the customer must spell the state + // in some way that matches our list of valid states. + // + // @reykjalin: This HK specific sanitazation *should be removed* once Apple Pay fix + // the address bug. More info on that in pc4etw-bY-p2. + if ( 'HK' === $billing_country ) { + include_once WC_STRIPE_PLUGIN_PATH . '/includes/constants/class-wc-stripe-hong-kong-states.php'; + + if ( ! WC_Stripe_Hong_Kong_States::is_valid_state( strtolower( $billing_state ) ) ) { + $billing_postcode = ! empty( $_POST['billing_postcode'] ) ? wc_clean( wp_unslash( $_POST['billing_postcode'] ) ) : ''; + if ( WC_Stripe_Hong_Kong_States::is_valid_state( strtolower( $billing_postcode ) ) ) { + $billing_state = $billing_postcode; + } + } + } + if ( 'HK' === $shipping_country ) { + include_once WC_STRIPE_PLUGIN_PATH . '/includes/constants/class-wc-stripe-hong-kong-states.php'; + + if ( ! WC_Stripe_Hong_Kong_States::is_valid_state( strtolower( $shipping_state ) ) ) { + $shipping_postcode = ! empty( $_POST['shipping_postcode'] ) ? wc_clean( wp_unslash( $_POST['shipping_postcode'] ) ) : ''; + if ( WC_Stripe_Hong_Kong_States::is_valid_state( strtolower( $shipping_postcode ) ) ) { + $shipping_state = $shipping_postcode; + } + } + } + + // Finally we normalize the state value we want to process. + if ( $billing_state && $billing_country ) { + $_POST['billing_state'] = $this->get_normalized_state( $billing_state, $billing_country ); + } + + if ( $shipping_state && $shipping_country ) { + $_POST['shipping_state'] = $this->get_normalized_state( $shipping_state, $shipping_country ); + } + } + + /** + * Checks if given state is normalized. + * + * @param string $state State. + * @param string $country Two-letter country code. + * + * @return bool Whether state is normalized or not. + */ + public function is_normalized_state( $state, $country ) { + $wc_states = WC()->countries->get_states( $country ); + return ( + is_array( $wc_states ) && + in_array( $state, array_keys( $wc_states ), true ) + ); + } + + /** + * Sanitize string for comparison. + * + * @param string $string String to be sanitized. + * + * @return string The sanitized string. + */ + public function sanitize_string( $string ) { + return trim( wc_strtolower( remove_accents( $string ) ) ); + } + + /** + * Get normalized state from express checkout API dropdown list of states. + * + * @param string $state Full state name or state code. + * @param string $country Two-letter country code. + * + * @return string Normalized state or original state input value. + */ + public function get_normalized_state_from_pr_states( $state, $country ) { + // Include Payment Request API State list for compatibility with WC countries/states. + include_once WC_STRIPE_PLUGIN_PATH . '/includes/constants/class-wc-stripe-payment-request-button-states.php'; + $pr_states = WC_Stripe_Payment_Request_Button_States::STATES; + + if ( ! isset( $pr_states[ $country ] ) ) { + return $state; + } + + foreach ( $pr_states[ $country ] as $wc_state_abbr => $pr_state ) { + $sanitized_state_string = $this->sanitize_string( $state ); + // Checks if input state matches with Payment Request state code (0), name (1) or localName (2). + if ( + ( ! empty( $pr_state[0] ) && $sanitized_state_string === $this->sanitize_string( $pr_state[0] ) ) || + ( ! empty( $pr_state[1] ) && $sanitized_state_string === $this->sanitize_string( $pr_state[1] ) ) || + ( ! empty( $pr_state[2] ) && $sanitized_state_string === $this->sanitize_string( $pr_state[2] ) ) + ) { + return $wc_state_abbr; + } + } + + return $state; + } + + /** + * Get normalized state from WooCommerce list of translated states. + * + * @param string $state Full state name or state code. + * @param string $country Two-letter country code. + * + * @return string Normalized state or original state input value. + */ + public function get_normalized_state_from_wc_states( $state, $country ) { + $wc_states = WC()->countries->get_states( $country ); + + if ( is_array( $wc_states ) ) { + foreach ( $wc_states as $wc_state_abbr => $wc_state_value ) { + if ( preg_match( '/' . preg_quote( $wc_state_value, '/' ) . '/i', $state ) ) { + return $wc_state_abbr; + } + } + } + + return $state; + } + + /** + * Gets the normalized state/county field because in some + * cases, the state/county field is formatted differently from + * what WC is expecting and throws an error. An example + * for Ireland, the county dropdown in Chrome shows "Co. Clare" format. + * + * @param string $state Full state name or an already normalized abbreviation. + * @param string $country Two-letter country code. + * + * @return string Normalized state abbreviation. + */ + public function get_normalized_state( $state, $country ) { + // If it's empty or already normalized, skip. + if ( ! $state || $this->is_normalized_state( $state, $country ) ) { + return $state; + } + + // Try to match state from the Payment Request API list of states. + $state = $this->get_normalized_state_from_pr_states( $state, $country ); + + // If it's normalized, return. + if ( $this->is_normalized_state( $state, $country ) ) { + return $state; + } + + // If the above doesn't work, fallback to matching against the list of translated + // states from WooCommerce. + return $this->get_normalized_state_from_wc_states( $state, $country ); + } + + /** + * The express checkout API provides its own validation for the address form. + * For some countries, it might not provide a state field, so we need to return a more descriptive + * error message, indicating that the express checkout button is not supported for that country. + */ + public function validate_state() { + $wc_checkout = WC_Checkout::instance(); + $posted_data = $wc_checkout->get_posted_data(); + $checkout_fields = $wc_checkout->get_checkout_fields(); + $countries = WC()->countries->get_countries(); + + $is_supported = true; + // Checks if billing state is missing and is required. + if ( ! empty( $checkout_fields['billing']['billing_state']['required'] ) && '' === $posted_data['billing_state'] ) { + $is_supported = false; + } + + // Checks if shipping state is missing and is required. + if ( WC()->cart->needs_shipping_address() && ! empty( $checkout_fields['shipping']['shipping_state']['required'] ) && '' === $posted_data['shipping_state'] ) { + $is_supported = false; + } + + if ( ! $is_supported ) { + wc_add_notice( + sprintf( + /* translators: 1) country. */ + __( 'The Express Checkout button is not supported in %1$s because some required fields couldn\'t be verified. Please proceed to the checkout page and try again.', 'woocommerce-gateway-stripe' ), + isset( $countries[ $posted_data['billing_country'] ] ) ? $countries[ $posted_data['billing_country'] ] : $posted_data['billing_country'] + ), + 'error' + ); + } + } + + /** + * Calculate and set shipping method. + * + * @param array $address Shipping address. + */ + protected function calculate_shipping( $address = [] ) { + $country = $address['country']; + $state = $address['state']; + $postcode = $address['postcode']; + $city = $address['city']; + $address_1 = $address['address']; + $address_2 = $address['address_2']; + + // Normalizes state to calculate shipping zones. + $state = $this->get_normalized_state( $state, $country ); + + // Normalizes postal code in case of redacted data from Apple Pay. + $postcode = $this->get_normalized_postal_code( $postcode, $country ); + + WC()->shipping->reset_shipping(); + + if ( $postcode && WC_Validation::is_postcode( $postcode, $country ) ) { + $postcode = wc_format_postcode( $postcode, $country ); + } + + if ( $country ) { + WC()->customer->set_location( $country, $state, $postcode, $city ); + WC()->customer->set_shipping_location( $country, $state, $postcode, $city ); + } else { + WC()->customer->set_billing_address_to_base(); + WC()->customer->set_shipping_address_to_base(); + } + + WC()->customer->set_calculated_shipping( true ); + WC()->customer->save(); + + $packages = []; + + $packages[0]['contents'] = WC()->cart->get_cart(); + $packages[0]['contents_cost'] = 0; + $packages[0]['applied_coupons'] = WC()->cart->applied_coupons; + $packages[0]['user']['ID'] = get_current_user_id(); + $packages[0]['destination']['country'] = $country; + $packages[0]['destination']['state'] = $state; + $packages[0]['destination']['postcode'] = $postcode; + $packages[0]['destination']['city'] = $city; + $packages[0]['destination']['address'] = $address_1; + $packages[0]['destination']['address_2'] = $address_2; + + foreach ( WC()->cart->get_cart() as $item ) { + if ( $item['data']->needs_shipping() ) { + if ( isset( $item['line_total'] ) ) { + $packages[0]['contents_cost'] += $item['line_total']; + } + } + } + + $packages = apply_filters( 'woocommerce_cart_shipping_packages', $packages ); + + WC()->shipping->calculate_shipping( $packages ); + } + + /** + * The settings for the `button` attribute. + * + * @return array + */ + public function get_button_settings() { + $button_type = $this->get_button_type(); + return [ + 'type' => $button_type, + 'theme' => $this->get_button_theme(), + 'height' => $this->get_button_height(), + // Default format is en_US. + 'locale' => apply_filters( 'wc_stripe_payment_request_button_locale', substr( get_locale(), 0, 2 ) ), + ]; + } + + /** + * Builds the shippings methods to pass to express checkout elements. + */ + protected function build_shipping_methods( $shipping_methods ) { + if ( empty( $shipping_methods ) ) { + return []; + } + + $shipping = []; + + foreach ( $shipping_methods as $method ) { + $shipping[] = [ + 'id' => $method['id'], + 'label' => $method['label'], + 'detail' => '', + 'amount' => WC_Stripe_Helper::get_stripe_amount( $method['amount']['value'] ), + ]; + } + + return $shipping; + } + + /** + * Builds the line items to pass to express checkout elements. + */ + protected function build_display_items( $itemized_display_items = false ) { + if ( ! defined( 'WOOCOMMERCE_CART' ) ) { + define( 'WOOCOMMERCE_CART', true ); + } + + $items = []; + $lines = []; + $subtotal = 0; + $discounts = 0; + $display_items = ! apply_filters( 'wc_stripe_payment_request_hide_itemization', true ) || $itemized_display_items; + + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + $subtotal += $cart_item['line_subtotal']; + $amount = $cart_item['line_subtotal']; + $quantity_label = 1 < $cart_item['quantity'] ? ' (x' . $cart_item['quantity'] . ')' : ''; + $product_name = $cart_item['data']->get_name(); + + $lines[] = [ + 'label' => $product_name . $quantity_label, + 'amount' => WC_Stripe_Helper::get_stripe_amount( $amount ), + ]; + } + + if ( $display_items ) { + $items = array_merge( $items, $lines ); + } else { + // Default show only subtotal instead of itemization. + + $items[] = [ + 'label' => 'Subtotal', + 'amount' => WC_Stripe_Helper::get_stripe_amount( $subtotal ), + ]; + } + + $applied_coupons = array_values( WC()->cart->get_coupon_discount_totals() ); + + foreach ( $applied_coupons as $amount ) { + $discounts += (float) $amount; + } + + $discounts = wc_format_decimal( $discounts, WC()->cart->dp ); + $tax = wc_format_decimal( WC()->cart->tax_total + WC()->cart->shipping_tax_total, WC()->cart->dp ); + $shipping = wc_format_decimal( WC()->cart->shipping_total, WC()->cart->dp ); + $items_total = wc_format_decimal( WC()->cart->cart_contents_total, WC()->cart->dp ) + $discounts; + $order_total = WC()->cart->get_total( false ); + + if ( wc_tax_enabled() ) { + $items[] = [ + 'label' => esc_html( __( 'Tax', 'woocommerce-gateway-stripe' ) ), + 'amount' => WC_Stripe_Helper::get_stripe_amount( $tax ), + ]; + } + + if ( WC()->cart->needs_shipping() ) { + $items[] = [ + 'label' => esc_html( __( 'Shipping', 'woocommerce-gateway-stripe' ) ), + 'amount' => WC_Stripe_Helper::get_stripe_amount( $shipping ), + ]; + } + + if ( WC()->cart->has_discount() ) { + $items[] = [ + 'label' => esc_html( __( 'Discount', 'woocommerce-gateway-stripe' ) ), + 'amount' => WC_Stripe_Helper::get_stripe_amount( $discounts ), + ]; + } + + $cart_fees = WC()->cart->get_fees(); + + // Include fees and taxes as display items. + foreach ( $cart_fees as $key => $fee ) { + $items[] = [ + 'label' => $fee->name, + 'amount' => WC_Stripe_Helper::get_stripe_amount( $fee->amount ), + ]; + } + + return [ + 'displayItems' => $items, + 'total' => [ + 'label' => $this->total_label, + 'amount' => max( 0, apply_filters( 'woocommerce_stripe_calculated_total', WC_Stripe_Helper::get_stripe_amount( $order_total ), $order_total, WC()->cart ) ), + 'pending' => false, + ], + ]; + } + + /** + * Settings array for the user authentication dialog and redirection. + * + * @return array + */ + public function get_login_confirmation_settings() { + if ( is_user_logged_in() || ! $this->is_authentication_required() ) { + return false; + } + + /* translators: The text encapsulated in `**` can be replaced with "Apple Pay" or "Google Pay". Please translate this text, but don't remove the `**`. */ + $message = __( 'To complete your transaction with **the selected payment method**, you must log in or create an account with our site.', 'woocommerce-gateway-stripe' ); + $redirect_url = add_query_arg( + [ + '_wpnonce' => wp_create_nonce( 'wc-stripe-set-redirect-url' ), + 'wc_stripe_express_checkout_redirect_url' => rawurlencode( home_url( add_query_arg( [] ) ) ), // Current URL to redirect to after login. + ], + home_url() + ); + + return [ + 'message' => $message, + 'redirect_url' => wp_sanitize_redirect( esc_url_raw( $redirect_url ) ), + ]; + } + + /** + * Pages where the express checkout buttons should be displayed. + * + * @return array + */ + public function get_button_locations() { + // If the locations have not been set return the default setting. + if ( ! isset( $this->stripe_settings['payment_request_button_locations'] ) ) { + return [ 'product', 'cart' ]; + } + + // If all locations are removed through the settings UI the location config will be set to + // an empty string "". If that's the case (and if the settings are not an array for any + // other reason) we should return an empty array. + if ( ! is_array( $this->stripe_settings['payment_request_button_locations'] ) ) { + return []; + } + + return $this->stripe_settings['payment_request_button_locations']; + } + + /** + * Returns whether Stripe express checkout element is enabled. + * + * This option defines whether Apple Pay and Google Pay buttons are enabled. + * + * @return boolean + */ + public function is_express_checkout_enabled() { + return isset( $this->stripe_settings['payment_request'] ) && 'yes' === $this->stripe_settings['payment_request']; + } + + /** + * Restores the shipping methods previously chosen for each recurring cart after shipping was reset and recalculated + * during the express checkout get_shipping_options flow. + * + * When the cart contains multiple subscriptions with different billing periods, customers are able to select different shipping + * methods for each subscription, however, this is not supported when purchasing with Apple Pay and Google Pay as it's + * only concerned about handling the initial purchase. + * + * In order to avoid Woo Subscriptions's `WC_Subscriptions_Cart::validate_recurring_shipping_methods` throwing an error, we need to restore + * the previously chosen shipping methods for each recurring cart. + * + * This function needs to be called after `WC()->cart->calculate_totals()` is run, otherwise `WC()->cart->recurring_carts` won't exist yet. + * + * @param array $previous_chosen_methods The previously chosen shipping methods. + */ + public function maybe_restore_recurring_chosen_shipping_methods( $previous_chosen_methods = [] ) { + if ( empty( WC()->cart->recurring_carts ) || ! method_exists( 'WC_Subscriptions_Cart', 'get_recurring_shipping_package_key' ) ) { + return; + } + + $chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods', [] ); + + foreach ( WC()->cart->recurring_carts as $recurring_cart_key => $recurring_cart ) { + foreach ( $recurring_cart->get_shipping_packages() as $recurring_cart_package_index => $recurring_cart_package ) { + if ( class_exists( 'WC_Subscriptions_Cart' ) ) { + $package_key = WC_Subscriptions_Cart::get_recurring_shipping_package_key( $recurring_cart_key, $recurring_cart_package_index ); + + // If the recurring cart package key is found in the previous chosen methods, but not in the current chosen methods, restore it. + if ( isset( $previous_chosen_methods[ $package_key ] ) && ! isset( $chosen_shipping_methods[ $package_key ] ) ) { + $chosen_shipping_methods[ $package_key ] = $previous_chosen_methods[ $package_key ]; + } + } + } + } + + WC()->session->set( 'chosen_shipping_methods', $chosen_shipping_methods ); + } +} diff --git a/includes/payment-methods/class-wc-stripe-payment-request.php b/includes/payment-methods/class-wc-stripe-payment-request.php index 669167fb76..bca2a2801d 100644 --- a/includes/payment-methods/class-wc-stripe-payment-request.php +++ b/includes/payment-methods/class-wc-stripe-payment-request.php @@ -84,6 +84,11 @@ public function __construct() { add_action( 'woocommerce_stripe_updated', [ $this, 'migrate_button_size' ] ); + // Check if ECE feature flag is enabled. + if ( WC_Stripe_Feature_Flags::is_stripe_ece_enabled() ) { + return; + } + // Checks if Stripe Gateway is enabled. if ( empty( $this->stripe_settings ) || ( isset( $this->stripe_settings['enabled'] ) && 'yes' !== $this->stripe_settings['enabled'] ) ) { return; diff --git a/webpack.config.js b/webpack.config.js index e7ab20c2fc..2dd3ff25eb 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -63,5 +63,6 @@ module.exports = { upe_blocks: './client/blocks/upe/index.js', upe_settings: './client/settings/index.js', payment_gateways: './client/entrypoints/payment-gateways/index.js', + express_checkout: './client/entrypoints/express-checkout/index.js', }, }; diff --git a/woocommerce-gateway-stripe.php b/woocommerce-gateway-stripe.php index 2ce2cee142..3d4f42f1f3 100644 --- a/woocommerce-gateway-stripe.php +++ b/woocommerce-gateway-stripe.php @@ -131,6 +131,13 @@ public static function get_instance() { */ public $payment_request_configuration; + /** + * Stripe Express Checkout configurations. + * + * @var WC_Stripe_Express_Checkout_Element + */ + public $express_checkout_configuration; + /** * Stripe Account. * @@ -237,6 +244,9 @@ public function init() { require_once dirname( __FILE__ ) . '/includes/payment-methods/class-wc-gateway-stripe-boleto.php'; require_once dirname( __FILE__ ) . '/includes/payment-methods/class-wc-gateway-stripe-oxxo.php'; require_once dirname( __FILE__ ) . '/includes/payment-methods/class-wc-stripe-payment-request.php'; + require_once dirname( __FILE__ ) . '/includes/payment-methods/class-wc-stripe-express-checkout-element.php'; + require_once dirname( __FILE__ ) . '/includes/payment-methods/class-wc-stripe-express-checkout-helper.php'; + require_once dirname( __FILE__ ) . '/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php'; require_once dirname( __FILE__ ) . '/includes/compat/class-wc-stripe-woo-compat-utils.php'; require_once dirname( __FILE__ ) . '/includes/connect/class-wc-stripe-connect.php'; require_once dirname( __FILE__ ) . '/includes/connect/class-wc-stripe-connect-api.php'; @@ -250,10 +260,16 @@ public function init() { require_once dirname( __FILE__ ) . '/includes/class-wc-stripe-account.php'; new Allowed_Payment_Request_Button_Types_Update(); - $this->api = new WC_Stripe_Connect_API(); - $this->connect = new WC_Stripe_Connect( $this->api ); - $this->payment_request_configuration = new WC_Stripe_Payment_Request(); - $this->account = new WC_Stripe_Account( $this->connect, 'WC_Stripe_API' ); + $this->api = new WC_Stripe_Connect_API(); + $this->connect = new WC_Stripe_Connect( $this->api ); + $this->payment_request_configuration = new WC_Stripe_Payment_Request(); + $this->account = new WC_Stripe_Account( $this->connect, 'WC_Stripe_API' ); + + // Express checkout configurations. + $express_checkout_helper = new WC_Stripe_Express_Checkout_Helper(); + $express_checkout_ajax_handler = new WC_Stripe_Express_Checkout_Ajax_Handler( $express_checkout_helper ); + $this->express_checkout_configuration = new WC_Stripe_Express_Checkout_Element( $express_checkout_ajax_handler, $express_checkout_helper ); + $this->express_checkout_configuration->init(); $intent_controller = new WC_Stripe_Intent_Controller(); $intent_controller->init_hooks(); @@ -829,7 +845,7 @@ function woocommerce_gateway_stripe_woocommerce_block_support() { 'woocommerce_blocks_payment_method_type_registration', function( Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry $payment_method_registry ) { // I noticed some incompatibility with WP 5.x and WC 5.3 when `_wcstripe_feature_upe_settings` is enabled. - if ( ! class_exists( 'WC_Stripe_Payment_Request' ) ) { + if ( ! class_exists( 'WC_Stripe_Payment_Request' ) || ! class_exists( 'WC_Stripe_Express_Checkout_Element' ) ) { return; } From 7f20b587b53f7cd822e7b186608943588a73ef9e Mon Sep 17 00:00:00 2001 From: Anne Mirasol Date: Mon, 16 Sep 2024 18:57:20 -0500 Subject: [PATCH 13/14] Fix address fields mapping for Google Pay and UAE addresses (#3432) * Fix address fields mapping for Google Pay and UAE * Add readme and changelog entries --- changelog.txt | 1 + .../class-wc-stripe-payment-request.php | 34 +++++++++++++++++++ readme.txt | 1 + 3 files changed, 36 insertions(+) diff --git a/changelog.txt b/changelog.txt index f1a0709488..8245274230 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,7 @@ *** Changelog *** = 8.8.0 - xxxx-xx-xx = +* Fix - Fix Google Pay address fields mapping for UAE addresses. * Tweak - Render the Klarna payment page in the store locale. * Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. * Fix - Fix empty error message for Express Payments when order creation fails. diff --git a/includes/payment-methods/class-wc-stripe-payment-request.php b/includes/payment-methods/class-wc-stripe-payment-request.php index bca2a2801d..660e6ea406 100644 --- a/includes/payment-methods/class-wc-stripe-payment-request.php +++ b/includes/payment-methods/class-wc-stripe-payment-request.php @@ -1698,6 +1698,8 @@ public function ajax_create_order() { define( 'WOOCOMMERCE_CHECKOUT', true ); } + $this->fix_address_fields_mapping(); + // Normalizes billing and shipping state values. $this->normalize_state(); @@ -2071,4 +2073,36 @@ private function maybe_restore_recurring_chosen_shipping_methods( $previous_chos WC()->session->set( 'chosen_shipping_methods', $chosen_shipping_methods ); } + + /** + * Performs special mapping for address fields for specific contexts. + */ + private function fix_address_fields_mapping() { + $billing_country = ! empty( $_POST['billing_country'] ) ? wc_clean( wp_unslash( $_POST['billing_country'] ) ) : ''; + $shipping_country = ! empty( $_POST['shipping_country'] ) ? wc_clean( wp_unslash( $_POST['shipping_country'] ) ) : ''; + + // For UAE, Google Pay stores the emirate in "region", which gets mapped to the "state" field, + // but WooCommerce expects it in the "city" field. + if ( 'AE' === $billing_country ) { + $billing_state = ! empty( $_POST['billing_state'] ) ? wc_clean( wp_unslash( $_POST['billing_state'] ) ) : ''; + $billing_city = ! empty( $_POST['billing_city'] ) ? wc_clean( wp_unslash( $_POST['billing_city'] ) ) : ''; + + // Move the state (emirate) to the city field. + if ( empty( $billing_city ) && ! empty( $billing_state ) ) { + $_POST['billing_city'] = $billing_state; + $_POST['billing_state'] = ''; + } + } + + if ( 'AE' === $shipping_country ) { + $shipping_state = ! empty( $_POST['shipping_state'] ) ? wc_clean( wp_unslash( $_POST['shipping_state'] ) ) : ''; + $shipping_city = ! empty( $_POST['shipping_city'] ) ? wc_clean( wp_unslash( $_POST['shipping_city'] ) ) : ''; + + // Move the state (emirate) to the city field. + if ( empty( $shipping_city ) && ! empty( $shipping_state ) ) { + $_POST['shipping_city'] = $shipping_state; + $_POST['shipping_state'] = ''; + } + } + } } diff --git a/readme.txt b/readme.txt index ec29486b6f..3ebd0ca25b 100644 --- a/readme.txt +++ b/readme.txt @@ -129,6 +129,7 @@ If you get stuck, you can ask for help in the Plugin Forum. == Changelog == = 8.8.0 - xxxx-xx-xx = +* Fix - Fix Google Pay address fields mapping for UAE addresses. * Tweak - Render the Klarna payment page in the store locale. * Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. * Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. From a592a7632db912a7e7bb95573ad6fe05be5cc098 Mon Sep 17 00:00:00 2001 From: Anne Mirasol Date: Thu, 19 Sep 2024 10:18:04 -0500 Subject: [PATCH 14/14] Fix mandate creation for subscriptions and saved payment methods (#3438) * Fix mandate creation for subscriptions and saved payment methods When creating the payment intent and the order has a subscription, we want to set `setup_future_usage` to `off_session`. We are already doing this when the user is not using a saved card. We want this same behavior even when using saved cards. * Add changelog and readme entries * Add unit test --- changelog.txt | 1 + .../class-wc-stripe-intent-controller.php | 2 +- .../class-wc-stripe-upe-payment-gateway.php | 1 + readme.txt | 1 + .../test-wc-stripe-intent-controller.php | 57 +++++++++++++++++++ 5 files changed, 61 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index 8245274230..595b4f7aa6 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,7 @@ *** Changelog *** = 8.8.0 - xxxx-xx-xx = +* Fix - Fix mandate creation for subscriptions and saved payment methods. * Fix - Fix Google Pay address fields mapping for UAE addresses. * Tweak - Render the Klarna payment page in the store locale. * Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index 7890e515e5..2e72ead89a 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -925,7 +925,7 @@ private function build_base_payment_intent_request_params( $payment_information $request['return_url'] = $payment_information['return_url']; } - if ( $payment_information['save_payment_method_to_store'] ) { + if ( $payment_information['save_payment_method_to_store'] || ! empty( $payment_information['has_subscription'] ) ) { $request['setup_future_usage'] = 'off_session'; } diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php index 682b662481..60a5122bce 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -2046,6 +2046,7 @@ private function prepare_payment_information_from_request( WC_Order $order ) { 'token' => $token, 'return_url' => $this->get_return_url_for_redirect( $order, $save_payment_method_to_store ), 'use_stripe_sdk' => 'true', // We want to use the SDK to handle next actions via the client payment elements. See https://docs.stripe.com/api/setup_intents/create#create_setup_intent-use_stripe_sdk + 'has_subscription' => $this->has_subscription( $order->get_id() ), ]; // Use the dynamic + short statement descriptor if enabled and it's a card payment. diff --git a/readme.txt b/readme.txt index 3ebd0ca25b..135af2e8ff 100644 --- a/readme.txt +++ b/readme.txt @@ -129,6 +129,7 @@ If you get stuck, you can ask for help in the Plugin Forum. == Changelog == = 8.8.0 - xxxx-xx-xx = +* Fix - Fix mandate creation for subscriptions and saved payment methods. * Fix - Fix Google Pay address fields mapping for UAE addresses. * Tweak - Render the Klarna payment page in the store locale. * Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. diff --git a/tests/phpunit/test-wc-stripe-intent-controller.php b/tests/phpunit/test-wc-stripe-intent-controller.php index bc28c6cc6b..ef7cd56412 100644 --- a/tests/phpunit/test-wc-stripe-intent-controller.php +++ b/tests/phpunit/test-wc-stripe-intent-controller.php @@ -210,4 +210,61 @@ public function provide_test_update_and_confirm_payment_intent() { ], ]; } + + /** + * Test for setting the `setup_future_usage` parameter in the + * create_and_confirm_payment_intent intent creation request. + */ + public function test_intent_creation_request_setup_future_usage() { + $payment_information = [ + 'amount' => 100, + 'capture_method' => 'automattic', + 'currency' => 'USD', + 'customer' => 'cus_mock', + 'level3' => [ + 'line_items' => [ + [ + 'product_code' => 'ABC123', + 'product_description' => 'Test Product', + 'unit_cost' => 100, + 'quantity' => 1, + ], + ], + ], + 'metadata' => [ '_stripe_metadata' => '123' ], + 'order' => $this->order, + 'payment_method' => 'pm_mock', + 'shipping' => [], + 'selected_payment_type' => 'card', + 'payment_method_types' => [ 'card' ], + 'is_using_saved_payment_method' => false, + ]; + + $payment_information['save_payment_method_to_store'] = true; + $payment_information['has_subscription'] = false; + $this->check_setup_future_usage_off_session( $payment_information ); + + // If order has subscription, setup_future_usage should be off_session, + // regardless of save_payment_method_to_store, which may be false + // if using an already saved payment method. + $payment_information['save_payment_method_to_store'] = false; + $payment_information['has_subscription'] = true; + $this->check_setup_future_usage_off_session( $payment_information ); + } + + private function check_setup_future_usage_off_session( $payment_information ) { + $test_request = function ( $preempt, $parsed_args, $url ) { + $this->assertEquals( 'off_session', $parsed_args['body']['setup_future_usage'] ); + + return [ + 'response' => 200, + 'headers' => [ 'Content-Type' => 'application/json' ], + 'body' => json_encode( [] ), + ]; + }; + + add_filter( 'pre_http_request', $test_request, 10, 3 ); + + $this->mock_controller->create_and_confirm_payment_intent( $payment_information ); + } }