diff --git a/changelog/rpp-6679-factor-flags b/changelog/rpp-6679-factor-flags new file mode 100644 index 00000000000..60d7f3c7a0a --- /dev/null +++ b/changelog/rpp-6679-factor-flags @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Adding factor flags to control when to enter the new payment process. diff --git a/includes/class-database-cache.php b/includes/class-database-cache.php index 641efaf08a6..08a22ab66b8 100644 --- a/includes/class-database-cache.php +++ b/includes/class-database-cache.php @@ -13,11 +13,22 @@ * A class for caching data as an option in the database. */ class Database_Cache { - const ACCOUNT_KEY = 'wcpay_account_data'; - const ONBOARDING_FIELDS_DATA_KEY = 'wcpay_onboarding_fields_data'; - const BUSINESS_TYPES_KEY = 'wcpay_business_types_data'; - const CURRENCIES_KEY = 'wcpay_multi_currency_cached_currencies'; - const CUSTOMER_CURRENCIES_KEY = 'wcpay_multi_currency_customer_currencies'; + const ACCOUNT_KEY = 'wcpay_account_data'; + const ONBOARDING_FIELDS_DATA_KEY = 'wcpay_onboarding_fields_data'; + const BUSINESS_TYPES_KEY = 'wcpay_business_types_data'; + const CURRENCIES_KEY = 'wcpay_multi_currency_cached_currencies'; + const CUSTOMER_CURRENCIES_KEY = 'wcpay_multi_currency_customer_currencies'; + const PAYMENT_PROCESS_FACTORS_KEY = 'wcpay_payment_process_factors'; + + /** + * Refresh during AJAX calls is avoided, but white-listing + * a key here will allow the refresh to happen. + * + * @var string[] + */ + const AJAX_ALLOWED_KEYS = [ + self::PAYMENT_PROCESS_FACTORS_KEY, + ]; /** * Payment methods cache key prefix. Used in conjunction with the customer_id to cache a customer's payment methods. @@ -216,7 +227,10 @@ private function should_refresh_cache( string $key, $cache_contents, callable $v } // Do not refresh if doing ajax or the refresh has been disabled (running an AS job). - if ( defined( 'DOING_CRON' ) || wp_doing_ajax() || $this->refresh_disabled ) { + if ( + defined( 'DOING_CRON' ) + || ( wp_doing_ajax() && ! in_array( $key, self::AJAX_ALLOWED_KEYS, true ) ) + || $this->refresh_disabled ) { return false; } @@ -330,6 +344,9 @@ private function get_ttl( string $key, array $cache_contents ): int { case self::CONNECT_INCENTIVE_KEY: $ttl = $cache_contents['data']['ttl'] ?? HOUR_IN_SECONDS * 6; break; + case self::PAYMENT_PROCESS_FACTORS_KEY: + $ttl = 2 * HOUR_IN_SECONDS; + break; default: // Default to 24h. $ttl = DAY_IN_SECONDS; diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 5f3912b4b1d..754629e0ec2 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -42,6 +42,8 @@ use WCPay\Session_Rate_Limiter; use WCPay\Tracker; use WCPay\Internal\Service\PaymentProcessingService; +use WCPay\Internal\Payment\Factor; +use WCPay\Internal\Payment\Router; /** * Gateway class for WooPayments @@ -706,6 +708,111 @@ public function payment_fields() { do_action( 'wc_payments_add_payment_fields' ); } + /** + * Checks whether the new payment process should be used to pay for a given order. + * + * @param WC_Order $order Order that's being paid. + * @return bool + */ + public function should_use_new_process( WC_Order $order ) { + $order_id = $order->get_id(); + + // The new process us under active development, and not ready for production yet. + if ( ! WC_Payments::mode()->is_dev() ) { + return false; + } + + // This array will contain all factors, present during checkout. + $factors = [ + /** + * The new payment process is a factor itself. + * Even if no other factors are present, this will make entering + * the new payment process possible only if this factor is allowed. + */ + Factor::NEW_PAYMENT_PROCESS(), + ]; + + // If there is a token in the request, we're using a saved PM. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $using_saved_payment_method = ! empty( Payment_Information::get_token_from_request( $_POST ) ); + if ( $using_saved_payment_method ) { + $factors[] = Factor::USE_SAVED_PM(); + } + + // The PM should be saved when chosen, or when it's a recurrent payment, but not if already saved. + $save_payment_method = ! $using_saved_payment_method && ( + // phpcs:ignore WordPress.Security.NonceVerification.Missing + ! empty( $_POST[ 'wc-' . static::GATEWAY_ID . '-new-payment-method' ] ) + || $this->is_payment_recurring( $order_id ) + ); + if ( $save_payment_method ) { + $factors[] = Factor::SAVE_PM(); + } + + // In case amount is 0 and we're not saving the payment method, we won't be using intents and can confirm the order payment. + if ( + apply_filters( + 'wcpay_confirm_without_payment_intent', + $order->get_total() <= 0 && ! $save_payment_method + ) + ) { + $factors[] = Factor::NO_PAYMENT(); + } + + // Subscription (both WCPay and WCSubs) if when the order contains one. + if ( function_exists( 'wcs_order_contains_subscription' ) && wcs_order_contains_subscription( $order_id ) ) { + $factors[] = Factor::SUBSCRIPTION_SIGNUP(); + } + + // WooPay might change how payment fields were loaded. + if ( + $this->woopay_util->should_enable_woopay( $this ) + && $this->woopay_util->should_enable_woopay_on_cart_or_checkout() + ) { + $factors[] = Factor::WOOPAY_ENABLED(); + } + + // WooPay payments are indicated by the platform checkout intent. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( isset( $_POST['platform-checkout-intent'] ) ) { + $factors[] = Factor::WOOPAY_PAYMENT(); + } + + // Check whether the customer is signining up for a WCPay subscription. + if ( + function_exists( 'wcs_order_contains_subscription' ) + && wcs_order_contains_subscription( $order_id ) + && WC_Payments_Features::is_wcpay_subscriptions_enabled() + && ! $this->is_subscriptions_plugin_active() + ) { + $factors[] = Factor::WCPAY_SUBSCRIPTION_SIGNUP(); + } + + if ( $this instanceof UPE_Split_Payment_Gateway ) { + $factors[] = Factor::DEFERRED_INTENT_SPLIT_UPE(); + } + + if ( defined( 'WCPAY_PAYMENT_REQUEST_CHECKOUT' ) && WCPAY_PAYMENT_REQUEST_CHECKOUT ) { + $factors[] = Factor::PAYMENT_REQUEST(); + } + + $router = wcpay_get_container()->get( Router::class ); + return $router->should_use_new_payment_process( $factors ); + } + + /** + * Checks whether the new payment process should be entered, + * and if the answer is yes, uses it and returns the result. + * + * @param WC_Order $order Order that needs payment. + * @return array|null Array if processed, null if the new process is not supported. + */ + public function new_process_payment( WC_Order $order ) { + // Important: No factors are provided here, they were meant just for `Feature`. + $service = wcpay_get_container()->get( PaymentProcessingService::class ); + return $service->process_payment( $order->get_id() ); + } + /** * Process the payment for a given order. * @@ -716,14 +823,13 @@ public function payment_fields() { * @throws Exception Error processing the payment. */ public function process_payment( $order_id ) { + $order = wc_get_order( $order_id ); - if ( defined( 'WCPAY_NEW_PROCESS' ) && true === WCPAY_NEW_PROCESS ) { - $new_process = wcpay_get_container()->get( PaymentProcessingService::class ); - return $new_process->process_payment( $order_id ); + // Use the new payment process if allowed. + if ( $this->should_use_new_process( $order ) ) { + return $this->new_process_payment( $order ); } - $order = wc_get_order( $order_id ); - try { if ( 20 < strlen( $order->get_billing_phone() ) ) { throw new Process_Payment_Exception( diff --git a/includes/class-wc-payments-payment-request-button-handler.php b/includes/class-wc-payments-payment-request-button-handler.php index 04f8b53aec6..56179967d20 100644 --- a/includes/class-wc-payments-payment-request-button-handler.php +++ b/includes/class-wc-payments-payment-request-button-handler.php @@ -1379,6 +1379,10 @@ public function ajax_create_order() { define( 'WOOCOMMERCE_CHECKOUT', true ); } + if ( ! defined( 'WCPAY_PAYMENT_REQUEST_CHECKOUT' ) ) { + define( 'WCPAY_PAYMENT_REQUEST_CHECKOUT', true ); + } + // In case the state is required, but is missing, add a more descriptive error notice. $this->validate_state(); diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index c80b1f19664..b77b3589e0a 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -336,6 +336,7 @@ public static function init() { include_once __DIR__ . '/core/server/request/trait-use-test-mode-only-when-dev-mode.php'; include_once __DIR__ . '/core/server/request/class-generic.php'; include_once __DIR__ . '/core/server/request/class-get-intention.php'; + include_once __DIR__ . '/core/server/request/class-get-payment-process-factors.php'; include_once __DIR__ . '/core/server/request/class-create-intention.php'; include_once __DIR__ . '/core/server/request/class-update-intention.php'; include_once __DIR__ . '/core/server/request/class-capture-intention.php'; diff --git a/includes/core/server/class-request.php b/includes/core/server/class-request.php index 97e2162ab51..51c1d82e5fa 100644 --- a/includes/core/server/class-request.php +++ b/includes/core/server/class-request.php @@ -101,43 +101,44 @@ abstract class Request { * @var string[] */ private $route_list = [ - WC_Payments_API_Client::ACCOUNTS_API => 'accounts', - WC_Payments_API_Client::CAPABILITIES_API => 'accounts/capabilities', - WC_Payments_API_Client::WOOPAY_ACCOUNTS_API => 'accounts/platform_checkout', - WC_Payments_API_Client::WOOPAY_COMPATIBILITY_API => 'woopay/compatibility', - WC_Payments_API_Client::APPLE_PAY_API => 'apple_pay', - WC_Payments_API_Client::CHARGES_API => 'charges', - WC_Payments_API_Client::CONN_TOKENS_API => 'terminal/connection_tokens', - WC_Payments_API_Client::TERMINAL_LOCATIONS_API => 'terminal/locations', - WC_Payments_API_Client::CUSTOMERS_API => 'customers', - WC_Payments_API_Client::CURRENCY_API => 'currency', - WC_Payments_API_Client::INTENTIONS_API => 'intentions', - WC_Payments_API_Client::REFUNDS_API => 'refunds', - WC_Payments_API_Client::DEPOSITS_API => 'deposits', - WC_Payments_API_Client::TRANSACTIONS_API => 'transactions', - WC_Payments_API_Client::DISPUTES_API => 'disputes', - WC_Payments_API_Client::FILES_API => 'files', - WC_Payments_API_Client::ONBOARDING_API => 'onboarding', - WC_Payments_API_Client::TIMELINE_API => 'timeline', - WC_Payments_API_Client::PAYMENT_METHODS_API => 'payment_methods', - WC_Payments_API_Client::SETUP_INTENTS_API => 'setup_intents', - WC_Payments_API_Client::TRACKING_API => 'tracking', - WC_Payments_API_Client::PRODUCTS_API => 'products', - WC_Payments_API_Client::PRICES_API => 'products/prices', - WC_Payments_API_Client::INVOICES_API => 'invoices', - WC_Payments_API_Client::SUBSCRIPTIONS_API => 'subscriptions', - WC_Payments_API_Client::SUBSCRIPTION_ITEMS_API => 'subscriptions/items', - WC_Payments_API_Client::READERS_CHARGE_SUMMARY => 'reader-charges/summary', - WC_Payments_API_Client::TERMINAL_READERS_API => 'terminal/readers', + WC_Payments_API_Client::ACCOUNTS_API => 'accounts', + WC_Payments_API_Client::CAPABILITIES_API => 'accounts/capabilities', + WC_Payments_API_Client::WOOPAY_ACCOUNTS_API => 'accounts/platform_checkout', + WC_Payments_API_Client::WOOPAY_COMPATIBILITY_API => 'woopay/compatibility', + WC_Payments_API_Client::APPLE_PAY_API => 'apple_pay', + WC_Payments_API_Client::CHARGES_API => 'charges', + WC_Payments_API_Client::CONN_TOKENS_API => 'terminal/connection_tokens', + WC_Payments_API_Client::TERMINAL_LOCATIONS_API => 'terminal/locations', + WC_Payments_API_Client::CUSTOMERS_API => 'customers', + WC_Payments_API_Client::CURRENCY_API => 'currency', + WC_Payments_API_Client::INTENTIONS_API => 'intentions', + WC_Payments_API_Client::REFUNDS_API => 'refunds', + WC_Payments_API_Client::DEPOSITS_API => 'deposits', + WC_Payments_API_Client::TRANSACTIONS_API => 'transactions', + WC_Payments_API_Client::DISPUTES_API => 'disputes', + WC_Payments_API_Client::FILES_API => 'files', + WC_Payments_API_Client::ONBOARDING_API => 'onboarding', + WC_Payments_API_Client::TIMELINE_API => 'timeline', + WC_Payments_API_Client::PAYMENT_METHODS_API => 'payment_methods', + WC_Payments_API_Client::SETUP_INTENTS_API => 'setup_intents', + WC_Payments_API_Client::TRACKING_API => 'tracking', + WC_Payments_API_Client::PAYMENT_PROCESS_CONFIG_API => 'payment_process_config', + WC_Payments_API_Client::PRODUCTS_API => 'products', + WC_Payments_API_Client::PRICES_API => 'products/prices', + WC_Payments_API_Client::INVOICES_API => 'invoices', + WC_Payments_API_Client::SUBSCRIPTIONS_API => 'subscriptions', + WC_Payments_API_Client::SUBSCRIPTION_ITEMS_API => 'subscriptions/items', + WC_Payments_API_Client::READERS_CHARGE_SUMMARY => 'reader-charges/summary', + WC_Payments_API_Client::TERMINAL_READERS_API => 'terminal/readers', WC_Payments_API_Client::MINIMUM_RECURRING_AMOUNT_API => 'subscriptions/minimum_amount', - WC_Payments_API_Client::CAPITAL_API => 'capital', - WC_Payments_API_Client::WEBHOOK_FETCH_API => 'webhook/failed_events', - WC_Payments_API_Client::DOCUMENTS_API => 'documents', - WC_Payments_API_Client::VAT_API => 'vat', - WC_Payments_API_Client::LINKS_API => 'links', - WC_Payments_API_Client::AUTHORIZATIONS_API => 'authorizations', - WC_Payments_API_Client::FRAUD_OUTCOMES_API => 'fraud_outcomes', - WC_Payments_API_Client::FRAUD_RULESET_API => 'fraud_ruleset', + WC_Payments_API_Client::CAPITAL_API => 'capital', + WC_Payments_API_Client::WEBHOOK_FETCH_API => 'webhook/failed_events', + WC_Payments_API_Client::DOCUMENTS_API => 'documents', + WC_Payments_API_Client::VAT_API => 'vat', + WC_Payments_API_Client::LINKS_API => 'links', + WC_Payments_API_Client::AUTHORIZATIONS_API => 'authorizations', + WC_Payments_API_Client::FRAUD_OUTCOMES_API => 'fraud_outcomes', + WC_Payments_API_Client::FRAUD_RULESET_API => 'fraud_ruleset', ]; /** diff --git a/includes/core/server/request/class-get-payment-process-factors.php b/includes/core/server/request/class-get-payment-process-factors.php new file mode 100644 index 00000000000..a5e230ffa83 --- /dev/null +++ b/includes/core/server/request/class-get-payment-process-factors.php @@ -0,0 +1,32 @@ +addShared( PaymentProcessingService::class ); + $container->addShared( Router::class ) + ->addArgument( Database_Cache::class ); + $container->addShared( ExampleService::class ); $container->addShared( ExampleServiceWithDependencies::class ) ->addArgument( ExampleService::class ) diff --git a/src/Internal/Payment/Factor.php b/src/Internal/Payment/Factor.php new file mode 100644 index 00000000000..594683f67a4 --- /dev/null +++ b/src/Internal/Payment/Factor.php @@ -0,0 +1,134 @@ +database_cache = $database_cache; + } + + /** + * Checks whether a given payment should use the new payment process. + * + * @param Factor[] $factors Factors, describing the type and conditions of the payment. + * @return bool + * @psalm-suppress MissingThrowsDocblock + */ + public function should_use_new_payment_process( array $factors ): bool { + $allowed_factors = $this->get_allowed_factors(); + + foreach ( $factors as $present_factor ) { + if ( ! in_array( $present_factor, $allowed_factors, true ) ) { + return false; + } + } + + return true; + } + + /** + * Returns all factors, which can be handled by the new payment process. + * + * @return Factor[] + */ + public function get_allowed_factors() { + // Might be false if loading failed. + $cached = $this->get_cached_factors(); + $all_factors = is_array( $cached ) ? $cached : []; + $allowed = []; + + foreach ( ( $all_factors ?? [] ) as $key => $enabled ) { + if ( $enabled ) { + $allowed[] = Factor::$key(); + } + } + + $allowed = apply_filters( 'wcpay_new_payment_process_enabled_factors', $allowed ); + return $allowed; + } + + /** + * Checks if cached data is valid. + * + * @psalm-suppress MissingThrowsDocblock + * @param mixed $cache The cached data. + * @return bool + */ + public function is_valid_cache( $cache ): bool { + return is_array( $cache ) && isset( $cache[ Factor::NEW_PAYMENT_PROCESS()->get_value() ] ); + } + + /** + * Gets and chaches all factors, which can be handled by the new payment process. + * + * @param bool $force_refresh Forces data to be fetched from the server, rather than using the cache. + * @return array Factors, or an empty array. + */ + private function get_cached_factors( bool $force_refresh = false ) { + $factors = $this->database_cache->get_or_add( + Database_Cache::PAYMENT_PROCESS_FACTORS_KEY, + function () { + try { + $request = Get_Payment_Process_Factors::create(); + $response = $request->send( 'wcpay_get_payment_process_factors' ); + return $response->to_array(); + } catch ( API_Exception $e ) { + // Return false to signal retrieval error. + return false; + } + }, + [ $this, 'is_valid_cache' ], + $force_refresh + ); + + return $factors ?? []; + } +} diff --git a/tests/unit/bootstrap.php b/tests/unit/bootstrap.php index 9c18c491e0b..48ba5bcdf7d 100755 --- a/tests/unit/bootstrap.php +++ b/tests/unit/bootstrap.php @@ -142,7 +142,11 @@ function wcpay_get_test_container() { $container = $GLOBALS['wcpay_container'] ?? null; if ( ! $container instanceof Container ) { - throw new Exception( 'Tests require the WCPay dependency container to be set up.' ); + if ( is_null( $container ) ) { + $container = wcpay_get_container(); + } else { + throw new Exception( 'Tests require the WCPay dependency container to be set up.' ); + } } // Load the property through reflection. diff --git a/tests/unit/src/ContainerTest.php b/tests/unit/src/ContainerTest.php index 5198d815358..370e9d08311 100644 --- a/tests/unit/src/ContainerTest.php +++ b/tests/unit/src/ContainerTest.php @@ -73,6 +73,18 @@ protected function setUp(): void { $this->test_sut = wcpay_get_test_container(); } + /** + * Cleans up global replacements after the class. + * + * Without this, other `src` tests will fail. + */ + public static function tearDownAfterClass(): void { + parent::tearDownAfterClass(); + + $GLOBALS['wcpay_container'] = null; + $GLOBALS['wcpay_test_container'] = null; + } + /** * Tests the `wcpay_get_container` function. */ diff --git a/tests/unit/src/Internal/Payment/FactorTest.php b/tests/unit/src/Internal/Payment/FactorTest.php new file mode 100644 index 00000000000..768718d860b --- /dev/null +++ b/tests/unit/src/Internal/Payment/FactorTest.php @@ -0,0 +1,45 @@ +assertEquals( $factors, $result ); + } +} diff --git a/tests/unit/src/Internal/Payment/RouterTest.php b/tests/unit/src/Internal/Payment/RouterTest.php new file mode 100644 index 00000000000..818bf8c33e4 --- /dev/null +++ b/tests/unit/src/Internal/Payment/RouterTest.php @@ -0,0 +1,274 @@ +mock_db_cache = $this->createMock( Database_Cache::class ); + $this->sut = new Router( $this->mock_db_cache ); + } + + /** + * Tests that the router returns false if a factor is **not present** in the account cache. + */ + public function test_should_use_new_payment_process_returns_false_with_missing_factor() { + $this->mock_db_cache_factors( [] ); + + $result = $this->sut->should_use_new_payment_process( [ Factor::USE_SAVED_PM() ] ); + $this->assertFalse( $result ); + } + + /** + * Tests that the router returns false if a factor is **false** in the account cache. + */ + public function test_should_use_new_payment_process_returns_false_with_unavailable_factor() { + $this->mock_db_cache_factors( [ Factor::USE_SAVED_PM => false ] ); + + $result = $this->sut->should_use_new_payment_process( [ Factor::USE_SAVED_PM() ] ); + $this->assertFalse( $result ); + } + + /** + * Tests that the router returns true when a factor is both present, and true in the account cache. + */ + public function test_should_use_new_payment_process_returns_true_with_available_factor() { + $this->mock_db_cache_factors( [ Factor::USE_SAVED_PM => true ] ); + + $result = $this->sut->should_use_new_payment_process( [ Factor::USE_SAVED_PM() ] ); + $this->assertTrue( $result ); + } + + /** + * Tests that the router handles multiple flags properly, + * and returns false in case any of them is not available. + */ + public function test_should_use_new_payment_process_with_multiple_factors_returns_false() { + $this->mock_db_cache_factors( + [ + Factor::USE_SAVED_PM => true, + Factor::SUBSCRIPTION_SIGNUP => false, + Factor::WOOPAY_ENABLED => true, + Factor::PAYMENT_REQUEST => false, + ] + ); + + $result = $this->sut->should_use_new_payment_process( + [ + Factor::USE_SAVED_PM(), + Factor::SUBSCRIPTION_SIGNUP(), + Factor::WOOPAY_ENABLED(), + ] + ); + $this->assertFalse( $result ); + } + + /** + * Tests that the router handles multiple flags properly, + * and returns true when all factors are present. + */ + public function test_should_use_new_payment_process_with_multiple_factors_returns_true() { + $this->mock_db_cache_factors( + [ + Factor::USE_SAVED_PM => true, + Factor::SUBSCRIPTION_SIGNUP => true, + Factor::WOOPAY_ENABLED => true, + Factor::PAYMENT_REQUEST => false, + ] + ); + + $result = $this->sut->should_use_new_payment_process( + [ + Factor::USE_SAVED_PM(), + Factor::SUBSCRIPTION_SIGNUP(), + Factor::WOOPAY_ENABLED(), + ] + ); + $this->assertTrue( $result ); + } + + /** + * Check that `get_allowed_factors` returns the factors, provided by the cache. + */ + public function test_get_allowed_factors_returns_factors() { + $cached_factors = [ + Factor::SAVE_PM => true, + Factor::SUBSCRIPTION_SIGNUP => false, + ]; + $processed_factors = [ Factor::SAVE_PM() ]; + + $this->mock_db_cache_factors( $cached_factors, false ); + + $result = $this->sut->get_allowed_factors(); + + $this->assertIsArray( $result ); + $this->assertSame( $processed_factors, $result ); + } + + /** + * Ensures that `get_allowed_factors` returns an array, even with broken cache. + */ + public function test_get_allowed_factors_returns_empty_array() { + // Return nothing to force an empty array. + $this->mock_db_cache_factors( null, false ); + + $result = $this->sut->get_allowed_factors(); + + $this->assertIsArray( $result ); + $this->assertEmpty( $result ); + } + + /** + * Confirms that `get_allowed_factors` allows filters to work. + */ + public function test_get_allowed_factors_allows_filters() { + $cached_factors = [ + Factor::SAVE_PM => true, + Factor::SUBSCRIPTION_SIGNUP => false, + ]; + $replaced_factors = [ + Factor::NO_PAYMENT(), + ]; + $this->mock_db_cache_factors( $cached_factors, false ); + + $filter_cb = function() use ( $replaced_factors ) { + return $replaced_factors; + }; + add_filter( 'wcpay_new_payment_process_enabled_factors', $filter_cb ); + + $result = $this->sut->get_allowed_factors(); + + $this->assertIsArray( $result ); + $this->assertSame( $replaced_factors, $result ); + + remove_filter( 'wcpay_new_payment_process_enabled_factors', $filter_cb ); + } + + /** + * Verify that `is_valid_cache` returns false with a non-array. + */ + public function test_is_valid_cache_requires_array() { + $this->assertFalse( $this->sut->is_valid_cache( false ) ); + } + + /** + * Verify that `is_valid_cache` returns false with incorrect arrays. + */ + public function test_is_valid_cache_requires_base_factor() { + $cache = [ Factor::NO_PAYMENT => true ]; + $this->assertFalse( $this->sut->is_valid_cache( $cache ) ); + } + + /** + * Verify that `is_valid_cache` accepts well-formed data. + */ + public function test_is_valid_cache_with_well_formed_data() { + $cache = [ Factor::NEW_PAYMENT_PROCESS => true ]; + $this->assertTrue( $this->sut->is_valid_cache( $cache ) ); + } + + /** + * + */ + public function test_get_cached_factors_populates_cache() { + $request_response = [ + Factor::NEW_PAYMENT_PROCESS => true, + ]; + $processed_factors = [ Factor::NEW_PAYMENT_PROCESS() ]; + + $this->mock_wcpay_request( Get_Payment_Process_Factors::class, 1, null, $request_response ); + + $this->mock_db_cache->expects( $this->once() ) + ->method( 'get_or_add' ) + ->with( + Database_Cache::PAYMENT_PROCESS_FACTORS_KEY, + $this->callback( + function ( $cb ) use ( $request_response ) { + return $request_response === $cb(); + } + ), + [ $this->sut, 'is_valid_cache' ], + false + ) + ->willReturn( $request_response ); + + $result = $this->sut->get_allowed_factors(); + $this->assertSame( $processed_factors, $result ); + } + + /** + * Ensures that a server error would handle exceptions correctly. + */ + public function test_get_cached_factors_handles_exceptions() { + $generator = function( $cb ) { + $this->mock_wcpay_request( Get_Payment_Process_Factors::class ) + ->expects( $this->once() ) + ->method( 'format_response' ) + ->will( $this->throwException( new API_Exception( 'Does not work', 'forced', 1234 ) ) ); + + $result = $cb(); + return false === $result; + }; + + $this->mock_db_cache->expects( $this->once() ) + ->method( 'get_or_add' ) + ->with( + Database_Cache::PAYMENT_PROCESS_FACTORS_KEY, + $this->callback( $generator ) + ) + ->willReturn( false ); + + $this->assertEmpty( $this->sut->get_allowed_factors() ); + } + + /** + * Simulates specific factors, being returned by `Database_Cache`. + * + * @param array|null $factors The factors to simulate. + * @param bool $add_base Whether to add the base `NEW_PAYMENT_PROCESS` factor. + */ + private function mock_db_cache_factors( array $factors = null, bool $add_base = true ) { + if ( $add_base && ! isset( $factors[ Factor::NEW_PAYMENT_PROCESS ] ) ) { + $factors[ Factor::NEW_PAYMENT_PROCESS ] = true; + } + + $this->mock_db_cache->expects( $this->once() ) + ->method( 'get_or_add' ) + ->with( Database_Cache::PAYMENT_PROCESS_FACTORS_KEY ) + ->willreturn( $factors ); + } +} diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index f39f4dea3ed..6ef8e0a5f83 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -21,6 +21,9 @@ use WCPay\Exceptions\Amount_Too_Small_Exception; use WCPay\Exceptions\API_Exception; use WCPay\Fraud_Prevention\Fraud_Prevention_Service; +use WCPay\Internal\Payment\Factor; +use WCPay\Internal\Payment\Router; +use WCPay\Internal\Service\PaymentProcessingService; use WCPay\Payment_Information; use WCPay\WooPay\WooPay_Utilities; use WCPay\Session_Rate_Limiter; @@ -211,6 +214,20 @@ public function tear_down() { // Fall back to an US store. update_option( 'woocommerce_store_postcode', '94110' ); $this->wcpay_gateway->update_option( 'saved_cards', 'yes' ); + + // Some tests simulate payment method parameters. + $payment_method_keys = [ + 'payment_method', + 'wc-woocommerce_payments-payment-token', + 'wc-woocommerce_payments-new-payment-method', + ]; + foreach ( $payment_method_keys as $key ) { + // phpcs:disable WordPress.Security.NonceVerification.Missing + if ( isset( $_POST[ $key ] ) ) { + unset( $_POST[ $key ] ); + } + // phpcs:enable WordPress.Security.NonceVerification.Missing + } } public function test_attach_exchange_info_to_order_with_no_conversion() { @@ -2342,6 +2359,287 @@ public function test_no_payment_is_processed_for_woopay_preflight_check_request( $response = $mock_wcpay_gateway->process_payment( $order->get_id() ); } + public function test_should_use_new_process_requires_dev_mode() { + $mock_router = $this->createMock( Router::class ); + wcpay_get_test_container()->replace( Router::class, $mock_router ); + + $order = WC_Helper_Order::create_order(); + + // Assert: The router is never called. + $mock_router->expects( $this->never() ) + ->method( 'should_use_new_payment_process' ); + + $this->assertFalse( $this->wcpay_gateway->should_use_new_process( $order ) ); + } + + public function test_should_use_new_process_returns_false_if_feature_unavailable() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $mock_router = $this->createMock( Router::class ); + wcpay_get_test_container()->replace( Router::class, $mock_router ); + + $order = WC_Helper_Order::create_order(); + + // Assert: Feature returns false. + $mock_router->expects( $this->once() ) + ->method( 'should_use_new_payment_process' ) + ->willReturn( false ); + + // Act: Call the method. + $result = $this->wcpay_gateway->should_use_new_process( $order ); + $this->assertFalse( $result ); + } + + public function test_should_use_new_process_uses_the_new_process() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $mock_router = $this->createMock( Router::class ); + $mock_service = $this->createMock( PaymentProcessingService::class ); + $order = WC_Helper_Order::create_order(); + + wcpay_get_test_container()->replace( Router::class, $mock_router ); + wcpay_get_test_container()->replace( PaymentProcessingService::class, $mock_service ); + + // Assert: Feature returns false. + $mock_router->expects( $this->once() ) + ->method( 'should_use_new_payment_process' ) + ->willReturn( true ); + + // Act: Call the method. + $result = $this->wcpay_gateway->should_use_new_process( $order ); + $this->assertTrue( $result ); + } + + public function test_should_use_new_process_adds_base_factor() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order( 1, 0 ); + + $this->expect_router_factor( Factor::NEW_PAYMENT_PROCESS(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_positive_no_payment() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order( 1, 0 ); + + $this->expect_router_factor( Factor::NO_PAYMENT(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_negative_no_payment() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + $order->set_total( 10 ); + $order->save(); + + $this->expect_router_factor( Factor::NO_PAYMENT(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_negative_no_payment_when_saving_pm() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order( 1, 0 ); + + // Simulate a payment method being saved to force payment processing. + $_POST['wc-woocommerce_payments-new-payment-method'] = 'pm_XYZ'; + + $this->expect_router_factor( Factor::NO_PAYMENT(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_positive_use_saved_pm() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + $token = WC_Helper_Token::create_token( 'pm_XYZ' ); + + // Simulate that a saved token is being used. + $_POST['payment_method'] = 'woocommerce_payments'; + $_POST['wc-woocommerce_payments-payment-token'] = $token->get_id(); + + $this->expect_router_factor( Factor::USE_SAVED_PM(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_negative_use_saved_pm() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + // Simulate that a saved token is being used. + $_POST['payment_method'] = 'woocommerce_payments'; + $_POST['wc-woocommerce_payments-payment-token'] = 'new'; + + $this->expect_router_factor( Factor::USE_SAVED_PM(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_positive_save_pm() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + $_POST['wc-woocommerce_payments-new-payment-method'] = '1'; + + $this->expect_router_factor( Factor::SAVE_PM(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_positive_save_pm_for_subscription() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + WC_Subscriptions::$wcs_order_contains_subscription = '__return_true'; + + $this->expect_router_factor( Factor::SAVE_PM(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_negative_save_pm() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + $token = WC_Helper_Token::create_token( 'pm_XYZ' ); + + // Simulate that a saved token is being used. + $_POST['wc-woocommerce_payments-new-payment-method'] = '1'; + $_POST['payment_method'] = 'woocommerce_payments'; + $_POST['wc-woocommerce_payments-payment-token'] = $token->get_id(); + + $this->expect_router_factor( Factor::SAVE_PM(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_positive_subscription_signup() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + WC_Subscriptions::$wcs_order_contains_subscription = '__return_true'; + + $this->expect_router_factor( Factor::SUBSCRIPTION_SIGNUP(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_negative_subscription_signup() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + WC_Subscriptions::$wcs_order_contains_subscription = '__return_false'; + + $this->expect_router_factor( Factor::SUBSCRIPTION_SIGNUP(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_positive_woopay_payment() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + $_POST['platform-checkout-intent'] = 'pi_ZYX'; + + $this->expect_router_factor( Factor::WOOPAY_PAYMENT(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_negative_woopay_payment() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing + unset( $_POST['platform-checkout-intent'] ); + + $this->expect_router_factor( Factor::WOOPAY_PAYMENT(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + /** + * Testing the positive WCPay subscription signup factor is not possible, + * as the check relies on the existence of the `WC_Subscriptions` class + * through an un-mockable method, and the class simply exists. + */ + public function test_should_use_new_process_determines_negative_wcpay_subscription_signup() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + WC_Subscriptions::$wcs_order_contains_subscription = '__return_true'; + add_filter( 'wcpay_is_wcpay_subscriptions_enabled', '__return_true' ); + + $this->expect_router_factor( Factor::WCPAY_SUBSCRIPTION_SIGNUP(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_new_process_payment() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $mock_service = $this->createMock( PaymentProcessingService::class ); + $mock_router = $this->createMock( Router::class ); + $order = WC_Helper_Order::create_order(); + $mock_response = [ 'success' => 'maybe' ]; + + wcpay_get_test_container()->replace( PaymentProcessingService::class, $mock_service ); + wcpay_get_test_container()->replace( Router::class, $mock_router ); + + $mock_router->expects( $this->once() ) + ->method( 'should_use_new_payment_process' ) + ->willReturn( true ); + + // Assert: The new service is called. + $mock_service->expects( $this->once() ) + ->method( 'process_payment' ) + ->with( $order->get_id() ) + ->willReturn( $mock_response ); + + $result = $this->wcpay_gateway->process_payment( $order->get_id() ); + $this->assertSame( $mock_response, $result ); + } + + /** + * Sets up the expectation for a certain factor for the new payment + * process to be either set or unset. + * + * @param Factor $factor_name Factor constant. + * @param bool $value Expected value. + */ + private function expect_router_factor( $factor_name, $value ) { + $mock_router = $this->createMock( Router::class ); + wcpay_get_test_container()->replace( Router::class, $mock_router ); + + $checker = function( $factors ) use ( $factor_name, $value ) { + $is_in_array = in_array( $factor_name, $factors, true ); + return $value ? $is_in_array : ! $is_in_array; + }; + + $mock_router->expects( $this->once() ) + ->method( 'should_use_new_payment_process' ) + ->with( $this->callback( $checker ) ); + } + /** * Mocks Fraud_Prevention_Service. *