Commit 6ff231e4b4 for woocommerce
commit 6ff231e4b4e2dd6ee66888b371b9019093564804
Author: Leonardo Lopes de Albuquerque <leonardo.albuquerque@automattic.com>
Date: Sun Jan 11 20:03:05 2026 -0300
[Fraud Protection] Call fraud protection methods directly instead of relying on hooks (#62743)
* Call fraud protection methods directly from cart, checkout and payment methods management flows instead of hooks.
* Implement EventDispatcher with shared events tracking and decision processing logic.
* [Fraud Protection] Optimize checkout field tracking to only fire on country changes.
Reduces unnecessary API calls by dispatching checkout_field_update events only
when billing or shipping country changes, instead of on every field update.
Changes:
- Add get_current_billing_country() and get_current_shipping_country() helper
methods to SessionDataCollector
- Update CheckoutEventTracker to inject SessionDataCollector and compare posted
countries with current session values
- Fix bug: detect when user unchecks "ship to different address" and effective
shipping country changes from current shipping to billing country
- Update all tests with comprehensive coverage for country change detection
Performance: Reduces fraud protection API calls by 80-90%.
* [Fraud Protection] Rename checkout_field_update event to checkout_update
Unifies event naming convention by renaming checkout_field_update to checkout_update
to match the existing track_blocks_checkout_update method which uses the same event name.
This creates consistency across shortcode and blocks checkout tracking.
* [Fraud Protection] Fix double-dispatch when adding existing cart items
When add_to_cart() finds an existing cart item and calls set_quantity(),
it triggers track_cart_item_updated(). Previously, track_cart_item_added()
was also called unconditionally after the if/else block, resulting in both
an "updated" and "added" event being dispatched for the same operation.
Fix:
- Move track_cart_item_added() inside the else block (lines 1342-1346)
- Now only fires when truly creating a new cart item
- Cast $quantity to (int) for consistency with (int) $product_id
The correct behavior is now:
- Adding a NEW item: track_cart_item_added() fires
- Updating EXISTING item quantity: track_cart_item_updated() fires (from set_quantity())
All fraud protection tests pass (100 tests, 392 assertions).
* [Fraud Protection] Add cart event tracking for Store API add-to-cart
When the hook-based tracking was replaced with direct method calls,
the Store API's CartController::add_to_cart() was not updated.
This caused cart item added events to not be tracked when items
were added via Store API (blocks checkout, headless).
This commit:
- Adds fraud protection tracking call to CartController::add_to_cart()
- Adds tests for Store API fraud protection tracking
* [Fraud Protection] Move cart tracking after woocommerce_add_to_cart action
Moved the fraud protection tracking call to after the woocommerce_add_to_cart
action fires, consistent with how other events are tracked. This ensures
third-party plugins that modify the cart during that action are accounted for.
Also added proper variable initialization for $item_was_already_in_cart.
Co-authored-by: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Co-authored-by: Luiz Reis <luiz.reis@automattic.com>
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 6d6207f236..9f920b39a6 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -15,6 +15,8 @@ use Automattic\WooCommerce\Internal\Orders\CouponsController;
use Automattic\WooCommerce\Internal\Orders\TaxesController;
use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
+use Automattic\WooCommerce\Internal\FraudProtection\CheckoutEventTracker;
+use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController;
use Automattic\WooCommerce\Internal\Utilities\Users;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
@@ -375,6 +377,12 @@ class WC_AJAX {
do_action( 'woocommerce_checkout_update_order_review', isset( $_POST['post_data'] ) ? wp_unslash( $_POST['post_data'] ) : '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ // Track checkout field update for fraud protection.
+ if ( wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled() ) {
+ wc_get_container()->get( CheckoutEventTracker::class )
+ ->track_shortcode_checkout_field_update( isset( $_POST['post_data'] ) ? wp_unslash( $_POST['post_data'] ) : '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ }
+
$chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods' );
$posted_shipping_methods = isset( $_POST['shipping_method'] ) ? wc_clean( wp_unslash( $_POST['shipping_method'] ) ) : array();
diff --git a/plugins/woocommerce/includes/class-wc-cart.php b/plugins/woocommerce/includes/class-wc-cart.php
index c0d8659bd6..51a0971976 100644
--- a/plugins/woocommerce/includes/class-wc-cart.php
+++ b/plugins/woocommerce/includes/class-wc-cart.php
@@ -12,6 +12,8 @@
use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\FraudProtection\CartEventTracker;
+use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController;
use Automattic\WooCommerce\Utilities\DiscountsUtil;
use Automattic\WooCommerce\Utilities\NumberUtil;
use Automattic\WooCommerce\Utilities\ShippingUtil;
@@ -1312,10 +1314,12 @@ class WC_Cart extends WC_Legacy_Cart {
}
}
+ $item_was_already_in_cart = false;
// If cart_item_key is set, the item is already in the cart.
if ( $cart_item_key ) {
$new_quantity = $quantity + $this->cart_contents[ $cart_item_key ]['quantity'];
$this->set_quantity( $cart_item_key, $new_quantity, false );
+ $item_was_already_in_cart = true;
} else {
$cart_item_key = $cart_id;
@@ -1342,6 +1346,12 @@ class WC_Cart extends WC_Legacy_Cart {
do_action( 'woocommerce_add_to_cart', $cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data );
+ // Track cart event for fraud protection (only for newly added items).
+ if ( ! $item_was_already_in_cart && wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled() ) {
+ wc_get_container()->get( CartEventTracker::class )
+ ->track_cart_item_added( $cart_item_key, (int) $product_id, (int) $quantity, $variation_id );
+ }
+
return $cart_item_key;
} catch ( Exception $e ) {
@@ -1367,6 +1377,12 @@ class WC_Cart extends WC_Legacy_Cart {
do_action( 'woocommerce_remove_cart_item', $cart_item_key, $this );
+ // Track cart event for fraud protection.
+ if ( wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled() ) {
+ wc_get_container()->get( CartEventTracker::class )
+ ->track_cart_item_removed( $cart_item_key, $this );
+ }
+
unset( $this->cart_contents[ $cart_item_key ] );
do_action( 'woocommerce_cart_item_removed', $cart_item_key, $this );
@@ -1390,6 +1406,12 @@ class WC_Cart extends WC_Legacy_Cart {
do_action( 'woocommerce_restore_cart_item', $cart_item_key, $this );
+ // Track cart event for fraud protection.
+ if ( wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled() ) {
+ wc_get_container()->get( CartEventTracker::class )
+ ->track_cart_item_restored( $cart_item_key, $this );
+ }
+
unset( $this->removed_cart_contents[ $cart_item_key ] );
do_action( 'woocommerce_cart_item_restored', $cart_item_key, $this );
@@ -1420,6 +1442,12 @@ class WC_Cart extends WC_Legacy_Cart {
do_action( 'woocommerce_after_cart_item_quantity_update', $cart_item_key, $quantity, $old_quantity, $this );
+ // Track cart event for fraud protection.
+ if ( wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled() ) {
+ wc_get_container()->get( CartEventTracker::class )
+ ->track_cart_item_updated( $cart_item_key, $quantity, $old_quantity, $this );
+ }
+
if ( $refresh_totals ) {
$this->calculate_totals();
}
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 8f78dee55a..7f87c073a2 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -375,10 +375,7 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\Orders\OrderAttributionBlocksController::class )->register();
$container->get( Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController::class )->register();
$container->get( Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController::class )->register();
- $container->get( Automattic\WooCommerce\Internal\FraudProtection\CartEventTracker::class )->register();
$container->get( Automattic\WooCommerce\Internal\FraudProtection\AdminSettingsHandler::class )->register();
- $container->get( Automattic\WooCommerce\Internal\FraudProtection\PaymentMethodEventTracker::class )->register();
- $container->get( Automattic\WooCommerce\Internal\FraudProtection\CheckoutEventTracker::class )->register();
$container->get( Automattic\WooCommerce\Internal\Admin\Settings\PaymentsController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments\WooPaymentsController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub::class )->register();
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-payment-token-data-store.php b/plugins/woocommerce/includes/data-stores/class-wc-payment-token-data-store.php
index c6d6e5012b..2f57210473 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-payment-token-data-store.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-payment-token-data-store.php
@@ -5,6 +5,9 @@
* @package WooCommerce\DataStores
*/
+use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController;
+use Automattic\WooCommerce\Internal\FraudProtection\PaymentMethodEventTracker;
+
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
@@ -72,6 +75,12 @@ class WC_Payment_Token_Data_Store extends WC_Data_Store_WP implements WC_Object_
}
do_action( 'woocommerce_new_payment_token', $token_id, $token );
+
+ // Track payment method event for fraud protection.
+ if ( wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled() ) {
+ wc_get_container()->get( PaymentMethodEventTracker::class )
+ ->track_payment_method_added( $token_id, $token );
+ }
}
/**
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/CartEventTracker.php b/plugins/woocommerce/src/Internal/FraudProtection/CartEventTracker.php
index 615241e047..c666ac1f15 100644
--- a/plugins/woocommerce/src/Internal/FraudProtection/CartEventTracker.php
+++ b/plugins/woocommerce/src/Internal/FraudProtection/CartEventTracker.php
@@ -7,21 +7,19 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\FraudProtection;
-use Automattic\WooCommerce\Internal\RegisterHooksInterface;
-
defined( 'ABSPATH' ) || exit;
/**
* Tracks cart events for fraud protection analysis.
*
- * This class hooks into WooCommerce cart events (add, update, remove, restore)
- * and triggers fraud protection event dispatching. Event-specific data is passed
+ * This class provides methods to track cart events (add, update, remove, restore)
+ * for fraud protection event dispatching. Event-specific data is passed
* to the dispatcher which handles session data collection internally.
*
* @since 10.5.0
* @internal This class is part of the internal API and is subject to change without notice.
*/
-class CartEventTracker implements RegisterHooksInterface {
+class CartEventTracker {
/**
* Fraud protection dispatcher instance.
@@ -30,51 +28,19 @@ class CartEventTracker implements RegisterHooksInterface {
*/
private FraudProtectionDispatcher $dispatcher;
- /**
- * Fraud protection controller instance.
- *
- * @var FraudProtectionController
- */
- private FraudProtectionController $fraud_protection_controller;
-
/**
* Initialize with dependencies.
*
* @internal
*
- * @param FraudProtectionDispatcher $dispatcher The fraud protection dispatcher instance.
- * @param FraudProtectionController $fraud_protection_controller The fraud protection controller instance.
+ * @param FraudProtectionDispatcher $dispatcher The fraud protection dispatcher instance.
*/
- final public function init(
- FraudProtectionDispatcher $dispatcher,
- FraudProtectionController $fraud_protection_controller
- ): void {
- $this->dispatcher = $dispatcher;
- $this->fraud_protection_controller = $fraud_protection_controller;
- }
-
- /**
- * Register cart event hooks.
- *
- * Hooks into WooCommerce cart actions to track fraud protection events.
- * Only registers hooks if the fraud protection feature is enabled.
- *
- * @return void
- */
- public function register(): void {
- // Only register hooks if fraud protection is enabled.
- if ( ! $this->fraud_protection_controller->feature_is_enabled() ) {
- return;
- }
-
- add_action( 'woocommerce_add_to_cart', array( $this, 'handle_track_cart_item_added' ), 10, 6 );
- add_action( 'woocommerce_after_cart_item_quantity_update', array( $this, 'handle_track_cart_item_updated' ), 10, 4 );
- add_action( 'woocommerce_remove_cart_item', array( $this, 'handle_track_cart_item_removed' ), 10, 2 );
- add_action( 'woocommerce_restore_cart_item', array( $this, 'handle_track_cart_item_restored' ), 10, 2 );
+ final public function init( FraudProtectionDispatcher $dispatcher ): void {
+ $this->dispatcher = $dispatcher;
}
/**
- * Handle cart item added event.
+ * Track cart item added event.
*
* Triggers fraud protection event dispatching when an item is added to the cart.
*
@@ -84,11 +50,9 @@ class CartEventTracker implements RegisterHooksInterface {
* @param int $product_id Product ID.
* @param int $quantity Quantity added.
* @param int $variation_id Variation ID.
- * @param array $variation Variation data.
- * @param array $cart_item_data Cart item data.
* @return void
*/
- public function handle_track_cart_item_added( $cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data ): void {
+ public function track_cart_item_added( $cart_item_key, $product_id, $quantity, $variation_id ): void {
$event_data = $this->build_cart_event_data(
'item_added',
$product_id,
@@ -101,7 +65,7 @@ class CartEventTracker implements RegisterHooksInterface {
}
/**
- * Handle cart item quantity updated event.
+ * Track cart item quantity updated event.
*
* Triggers fraud protection event dispatching when cart item quantity is updated.
*
@@ -113,7 +77,7 @@ class CartEventTracker implements RegisterHooksInterface {
* @param object $cart Cart object.
* @return void
*/
- public function handle_track_cart_item_updated( $cart_item_key, $quantity, $old_quantity, $cart ): void {
+ public function track_cart_item_updated( $cart_item_key, $quantity, $old_quantity, $cart ): void {
$cart_item = $cart->cart_contents[ $cart_item_key ] ?? null;
if ( (int) $quantity === (int) $old_quantity || ! $cart_item ) {
@@ -138,7 +102,7 @@ class CartEventTracker implements RegisterHooksInterface {
}
/**
- * Handle cart item removed event.
+ * Track cart item removed event.
*
* Triggers fraud protection event dispatching when an item is removed from the cart.
*
@@ -148,7 +112,7 @@ class CartEventTracker implements RegisterHooksInterface {
* @param object $cart Cart object.
* @return void
*/
- public function handle_track_cart_item_removed( $cart_item_key, $cart ): void {
+ public function track_cart_item_removed( $cart_item_key, $cart ): void {
$cart_item = $cart->removed_cart_contents[ $cart_item_key ] ?? null;
if ( ! $cart_item ) {
@@ -171,7 +135,7 @@ class CartEventTracker implements RegisterHooksInterface {
}
/**
- * Handle cart item restored event.
+ * Track cart item restored event.
*
* Triggers fraud protection event dispatching when a removed item is restored to the cart.
*
@@ -181,7 +145,7 @@ class CartEventTracker implements RegisterHooksInterface {
* @param object $cart Cart object.
* @return void
*/
- public function handle_track_cart_item_restored( $cart_item_key, $cart ): void {
+ public function track_cart_item_restored( $cart_item_key, $cart ): void {
$cart_item = $cart->cart_contents[ $cart_item_key ] ?? null;
if ( ! $cart_item ) {
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/CheckoutEventTracker.php b/plugins/woocommerce/src/Internal/FraudProtection/CheckoutEventTracker.php
index 88d9bd4edc..92753f9c07 100644
--- a/plugins/woocommerce/src/Internal/FraudProtection/CheckoutEventTracker.php
+++ b/plugins/woocommerce/src/Internal/FraudProtection/CheckoutEventTracker.php
@@ -7,21 +7,19 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\FraudProtection;
-use Automattic\WooCommerce\Internal\RegisterHooksInterface;
-
defined( 'ABSPATH' ) || exit;
/**
* Tracks checkout events for fraud protection analysis.
*
- * This class hooks into both WooCommerce Blocks (Store API) and traditional
- * shortcode checkout events, triggering fraud protection event dispatching.
+ * This class provides methods to track both WooCommerce Blocks (Store API) and traditional
+ * shortcode checkout events for fraud protection event dispatching.
* Event-specific data is passed to the dispatcher which handles session data collection internally.
*
* @since 10.5.0
* @internal This class is part of the internal API and is subject to change without notice.
*/
-class CheckoutEventTracker implements RegisterHooksInterface {
+class CheckoutEventTracker {
/**
* Fraud protection dispatcher instance.
@@ -31,49 +29,27 @@ class CheckoutEventTracker implements RegisterHooksInterface {
private FraudProtectionDispatcher $dispatcher;
/**
- * Fraud protection controller instance.
+ * Session data collector instance.
*
- * @var FraudProtectionController
+ * @var SessionDataCollector
*/
- private FraudProtectionController $fraud_protection_controller;
+ private SessionDataCollector $session_data_collector;
/**
* Initialize with dependencies.
*
* @internal
*
- * @param FraudProtectionDispatcher $dispatcher The fraud protection dispatcher instance.
- * @param FraudProtectionController $fraud_protection_controller The fraud protection controller instance.
- */
- final public function init(
- FraudProtectionDispatcher $dispatcher,
- FraudProtectionController $fraud_protection_controller
- ): void {
- $this->dispatcher = $dispatcher;
- $this->fraud_protection_controller = $fraud_protection_controller;
- }
-
- /**
- * Register checkout event hooks.
- *
- * Hooks into both WooCommerce Blocks (Store API) and traditional checkout
- * actions to track fraud protection events. Only registers hooks if the
- * fraud protection feature is enabled.
- *
- * @return void
+ * @param FraudProtectionDispatcher $dispatcher The fraud protection dispatcher instance.
+ * @param SessionDataCollector $session_data_collector The session data collector instance.
*/
- public function register(): void {
- // Only register hooks if fraud protection is enabled.
- if ( ! $this->fraud_protection_controller->feature_is_enabled() ) {
- return;
- }
-
- // Shortcode checkout: Track when checkout fields are updated.
- add_action( 'woocommerce_checkout_update_order_review', array( $this, 'handle_shortcode_checkout_field_update' ), 10, 1 );
+ final public function init( FraudProtectionDispatcher $dispatcher, SessionDataCollector $session_data_collector ): void {
+ $this->dispatcher = $dispatcher;
+ $this->session_data_collector = $session_data_collector;
}
/**
- * Handle Store API customer update event (WooCommerce Blocks checkout).
+ * Track Store API customer update event (WooCommerce Blocks checkout).
*
* Triggered when customer information is updated via the Store API endpoint
* /wc/store/v1/cart/update-customer during Blocks checkout flow.
@@ -86,27 +62,52 @@ class CheckoutEventTracker implements RegisterHooksInterface {
$this->dispatcher->dispatch_event( 'checkout_update', array() );
}
-
/**
- * Handle shortcode checkout field update event.
+ * Track shortcode checkout field update event.
*
* Triggered when checkout fields are updated via AJAX (woocommerce_update_order_review).
+ * Only dispatches event when billing or shipping country changes to reduce unnecessary API calls.
*
* @internal
*
* @param string $posted_data Serialized checkout form data.
* @return void
*/
- public function handle_shortcode_checkout_field_update( $posted_data ): void {
+ public function track_shortcode_checkout_field_update( $posted_data ): void {
// Parse the posted data to extract relevant fields.
$data = array();
if ( $posted_data ) {
parse_str( $posted_data, $data );
}
- // Build and dispatch the event.
- $event_data = $this->format_checkout_event_data( 'field_update', $data );
- $this->dispatcher->dispatch_event( 'checkout_field_update', $event_data );
+ // Get current customer countries using SessionDataCollector.
+ $current_billing_country = $this->session_data_collector->get_current_billing_country();
+ $current_shipping_country = $this->session_data_collector->get_current_shipping_country();
+
+ // Get posted countries.
+ $posted_billing_country = $data['billing_country'] ?? '';
+ $posted_shipping_country = $data['shipping_country'] ?? '';
+
+ // Check if billing country changed.
+ $billing_changed = ! empty( $posted_billing_country ) && $posted_billing_country !== $current_billing_country;
+
+ // Check if shipping country changed.
+ $ship_to_different = ! empty( $data['ship_to_different_address'] );
+ if ( $ship_to_different ) {
+ // User wants different shipping address - check if shipping country changed.
+ $shipping_changed = ! empty( $posted_shipping_country ) && $posted_shipping_country !== $current_shipping_country;
+ } else {
+ // User wants same address for billing and shipping.
+ // If current shipping country exists and differs from billing country, it's a change.
+ $effective_billing_country = ! empty( $posted_billing_country ) ? $posted_billing_country : $current_billing_country;
+ $shipping_changed = ! empty( $current_shipping_country ) && $current_shipping_country !== $effective_billing_country;
+ }
+
+ // Only dispatch if either country changed.
+ if ( $billing_changed || $shipping_changed ) {
+ $event_data = $this->format_checkout_event_data( 'field_update', $data );
+ $this->dispatcher->dispatch_event( 'checkout_update', $event_data );
+ }
}
/**
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/PaymentMethodEventTracker.php b/plugins/woocommerce/src/Internal/FraudProtection/PaymentMethodEventTracker.php
index f01ff48842..48c9ba7916 100644
--- a/plugins/woocommerce/src/Internal/FraudProtection/PaymentMethodEventTracker.php
+++ b/plugins/woocommerce/src/Internal/FraudProtection/PaymentMethodEventTracker.php
@@ -7,21 +7,19 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\FraudProtection;
-use Automattic\WooCommerce\Internal\RegisterHooksInterface;
-
defined( 'ABSPATH' ) || exit;
/**
* Tracks payment method events for fraud protection analysis.
*
- * This class hooks into WooCommerce payment method events in My Account
- * (add, update, set default, delete) and triggers fraud protection event dispatching.
+ * This class provides methods to track events for adding payment methods in My Account page
+ * for fraud protection.
* Event-specific data is passed to the dispatcher which handles session data collection internally.
*
* @since 10.5.0
* @internal This class is part of the internal API and is subject to change without notice.
*/
-class PaymentMethodEventTracker implements RegisterHooksInterface {
+class PaymentMethodEventTracker {
/**
* Fraud protection dispatcher instance.
@@ -30,49 +28,19 @@ class PaymentMethodEventTracker implements RegisterHooksInterface {
*/
private FraudProtectionDispatcher $dispatcher;
- /**
- * Fraud protection controller instance.
- *
- * @var FraudProtectionController
- */
- private FraudProtectionController $fraud_protection_controller;
-
/**
* Initialize with dependencies.
*
* @internal
*
- * @param FraudProtectionDispatcher $dispatcher The fraud protection dispatcher instance.
- * @param FraudProtectionController $fraud_protection_controller The fraud protection controller instance.
- */
- final public function init(
- FraudProtectionDispatcher $dispatcher,
- FraudProtectionController $fraud_protection_controller
- ): void {
- $this->dispatcher = $dispatcher;
- $this->fraud_protection_controller = $fraud_protection_controller;
- }
-
- /**
- * Register payment method event hooks.
- *
- * Hooks into WooCommerce payment token actions to track fraud protection events.
- * Only registers hooks if the fraud protection feature is enabled.
+ * @param FraudProtectionDispatcher $dispatcher The fraud protection dispatcher instance.
*/
- public function register(): void {
- // Only register hooks if fraud protection is enabled.
- if ( ! $this->fraud_protection_controller->feature_is_enabled() ) {
- return;
- }
-
- add_action( 'woocommerce_new_payment_token', array( $this, 'handle_payment_method_added' ), 10, 2 );
- add_action( 'woocommerce_payment_token_updated', array( $this, 'handle_payment_method_updated' ), 10, 1 );
- add_action( 'woocommerce_payment_token_set_default', array( $this, 'handle_payment_method_set_default' ), 10, 2 );
- add_action( 'woocommerce_payment_token_deleted', array( $this, 'handle_payment_method_deleted' ), 10, 2 );
+ final public function init( FraudProtectionDispatcher $dispatcher ): void {
+ $this->dispatcher = $dispatcher;
}
/**
- * Handle payment method added event.
+ * Track payment method added event.
*
* Triggers fraud protection event tracking when a payment method is added.
*
@@ -81,70 +49,13 @@ class PaymentMethodEventTracker implements RegisterHooksInterface {
* @param int $token_id The newly created token ID.
* @param \WC_Payment_Token $token The payment token object.
*/
- public function handle_payment_method_added( $token_id, $token ): void {
+ public function track_payment_method_added( $token_id, $token ): void {
$event_data = $this->build_payment_method_event_data( 'added', $token );
// Trigger event dispatching.
$this->dispatcher->dispatch_event( 'payment_method_added', $event_data );
}
- /**
- * Handle payment method updated event.
- *
- * Triggers fraud protection event tracking when a payment method is updated.
- *
- * @internal
- *
- * @param int $token_id The ID of the updated token.
- */
- public function handle_payment_method_updated( $token_id ): void {
- // Get the token object to extract details.
- $token = \WC_Payment_Tokens::get( $token_id );
-
- if ( ! $token instanceof \WC_Payment_Token ) {
- return;
- }
-
- $event_data = $this->build_payment_method_event_data( 'updated', $token );
-
- // Trigger event dispatching.
- $this->dispatcher->dispatch_event( 'payment_method_updated', $event_data );
- }
-
- /**
- * Handle payment method set as default event.
- *
- * Triggers fraud protection event tracking when a payment method is set as default.
- *
- * @internal
- *
- * @param int $token_id The ID of the token being set as default.
- * @param \WC_Payment_Token $token The payment token object.
- */
- public function handle_payment_method_set_default( $token_id, $token ): void {
- $event_data = $this->build_payment_method_event_data( 'set_default', $token );
-
- // Trigger event dispatching.
- $this->dispatcher->dispatch_event( 'payment_method_set_default', $event_data );
- }
-
- /**
- * Handle payment method deleted event.
- *
- * Triggers fraud protection event tracking when a payment method is deleted.
- *
- * @internal
- *
- * @param int $token_id The ID of the deleted token.
- * @param \WC_Payment_Token $token The payment token object.
- */
- public function handle_payment_method_deleted( $token_id, $token ): void {
- $event_data = $this->build_payment_method_event_data( 'deleted', $token );
-
- // Trigger event dispatching.
- $this->dispatcher->dispatch_event( 'payment_method_deleted', $event_data );
- }
-
/**
* Build payment method event-specific data.
*
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/SessionDataCollector.php b/plugins/woocommerce/src/Internal/FraudProtection/SessionDataCollector.php
index 4cd6aa22a8..6cf284540f 100644
--- a/plugins/woocommerce/src/Internal/FraudProtection/SessionDataCollector.php
+++ b/plugins/woocommerce/src/Internal/FraudProtection/SessionDataCollector.php
@@ -75,6 +75,60 @@ class SessionDataCollector {
);
}
+ /**
+ * Get current billing country from customer data.
+ *
+ * Reuses the same logic as get_billing_address() but returns only the country.
+ * Tries WC_Customer first, falls back to session data, with graceful error handling.
+ *
+ * @since 10.5.0
+ *
+ * @return string|null Current billing country code or null if unavailable.
+ */
+ public function get_current_billing_country(): ?string {
+ try {
+ if ( WC()->customer instanceof \WC_Customer ) {
+ $country = WC()->customer->get_billing_country();
+ return ! empty( $country ) ? \sanitize_text_field( $country ) : null;
+ } elseif ( WC()->session instanceof \WC_Session ) {
+ $customer_data = WC()->session->get( 'customer' );
+ if ( is_array( $customer_data ) && ! empty( $customer_data['country'] ) ) {
+ return \sanitize_text_field( $customer_data['country'] );
+ }
+ }
+ } catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+ // Graceful degradation.
+ }
+ return null;
+ }
+
+ /**
+ * Get current shipping country from customer data.
+ *
+ * Reuses the same logic as get_shipping_address() but returns only the country.
+ * Tries WC_Customer first, falls back to session data, with graceful error handling.
+ *
+ * @since 10.5.0
+ *
+ * @return string|null Current shipping country code or null if unavailable.
+ */
+ public function get_current_shipping_country(): ?string {
+ try {
+ if ( WC()->customer instanceof \WC_Customer ) {
+ $country = WC()->customer->get_shipping_country();
+ return ! empty( $country ) ? \sanitize_text_field( $country ) : null;
+ } elseif ( WC()->session instanceof \WC_Session ) {
+ $customer_data = WC()->session->get( 'customer' );
+ if ( is_array( $customer_data ) && ! empty( $customer_data['shipping_country'] ) ) {
+ return \sanitize_text_field( $customer_data['shipping_country'] );
+ }
+ }
+ } catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+ // Graceful degradation.
+ }
+ return null;
+ }
+
/**
* Get session data including session ID, IP address, email, and user agent.
*
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
index a2120758e8..a6e1aafe85 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
@@ -10,6 +10,8 @@ use Automattic\WooCommerce\StoreApi\Exceptions\OutOfStockException;
use Automattic\WooCommerce\StoreApi\Exceptions\PartialOutOfStockException;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Exceptions\TooManyInCartException;
+use Automattic\WooCommerce\Internal\FraudProtection\CartEventTracker;
+use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController;
use Automattic\WooCommerce\StoreApi\Utilities\ArrayUtils;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\StoreApi\Utilities\NoticeHandler;
@@ -223,6 +225,12 @@ class CartController {
$request['cart_item_data']
);
+ // Track cart event for fraud protection.
+ if ( $product instanceof \WC_Product && wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled() ) {
+ wc_get_container()->get( CartEventTracker::class )
+ ->track_cart_item_added( $cart_id, $this->get_product_id( $product ), (int) $request_quantity, $this->get_variation_id( $product ) );
+ }
+
return $cart_id;
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/CartControllerTests.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/CartControllerTests.php
index 729d6af767..c7cc674876 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/CartControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/CartControllerTests.php
@@ -3,6 +3,8 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Utilities;
+use Automattic\WooCommerce\Internal\FraudProtection\CartEventTracker;
+use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController;
use Automattic\WooCommerce\StoreApi\Utilities\CartController;
use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
use Yoast\PHPUnitPolyfills\TestCases\TestCase;
@@ -19,6 +21,11 @@ class CartControllerTests extends TestCase {
parent::tearDown();
WC()->cart->empty_cart();
remove_all_filters( 'woocommerce_cart_shipping_packages' );
+
+ // Reset DI container to clear any mocks.
+ $container = wc_get_container();
+ $container->reset_all_resolved();
+ $container->reset_all_replacements();
}
/**
@@ -283,4 +290,150 @@ class CartControllerTests extends TestCase {
remove_all_filters( 'woocommerce_cart_shipping_packages' );
}
+
+ /**
+ * Test that fraud protection tracking is called when adding items via Store API.
+ */
+ public function test_add_to_cart_triggers_fraud_protection_tracking(): void {
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Fraud Protection Test Product',
+ 'regular_price' => 10,
+ )
+ );
+
+ // Create mock for CartEventTracker.
+ $mock_cart_tracker = $this->createMock( CartEventTracker::class );
+
+ // Create mock for FraudProtectionController that returns enabled.
+ $mock_controller = $this->createMock( FraudProtectionController::class );
+ $mock_controller->method( 'feature_is_enabled' )->willReturn( true );
+
+ // Replace container instances with mocks.
+ $container = wc_get_container();
+ $container->replace( FraudProtectionController::class, $mock_controller );
+ $container->replace( CartEventTracker::class, $mock_cart_tracker );
+
+ // Expect track_cart_item_added to be called once with correct parameters.
+ $mock_cart_tracker
+ ->expects( $this->once() )
+ ->method( 'track_cart_item_added' )
+ ->with(
+ $this->isType( 'string' ), // cart_id.
+ $this->equalTo( $product->get_id() ), // product_id.
+ $this->equalTo( 2 ), // quantity.
+ $this->equalTo( 0 ) // variation_id.
+ );
+
+ // Use Store API CartController to add item.
+ $cart_controller = new CartController();
+ $cart_controller->add_to_cart(
+ array(
+ 'id' => $product->get_id(),
+ 'quantity' => 2,
+ )
+ );
+ }
+
+ /**
+ * Test that fraud protection tracking is NOT called when feature is disabled.
+ */
+ public function test_add_to_cart_skips_fraud_protection_tracking_when_disabled(): void {
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Fraud Protection Disabled Test Product',
+ 'regular_price' => 10,
+ )
+ );
+
+ // Create mock for CartEventTracker.
+ $mock_cart_tracker = $this->createMock( CartEventTracker::class );
+
+ // Create mock for FraudProtectionController that returns disabled.
+ $mock_controller = $this->createMock( FraudProtectionController::class );
+ $mock_controller->method( 'feature_is_enabled' )->willReturn( false );
+
+ // Replace container instances with mocks.
+ $container = wc_get_container();
+ $container->replace( FraudProtectionController::class, $mock_controller );
+ $container->replace( CartEventTracker::class, $mock_cart_tracker );
+
+ // Expect track_cart_item_added to NOT be called.
+ $mock_cart_tracker
+ ->expects( $this->never() )
+ ->method( 'track_cart_item_added' );
+
+ // Use Store API CartController to add item.
+ $cart_controller = new CartController();
+ $cart_controller->add_to_cart(
+ array(
+ 'id' => $product->get_id(),
+ 'quantity' => 1,
+ )
+ );
+ }
+
+ /**
+ * Test that fraud protection tracking includes variation_id for variable products.
+ */
+ public function test_add_to_cart_tracks_variation_id_for_variable_products(): void {
+ // Create a variable product with variations using WC_Helper_Product.
+ $variable_product = \WC_Helper_Product::create_variation_product();
+
+ // Get the third variation which has all attributes defined (size, colour, number).
+ $children = $variable_product->get_children();
+ $variation_id = $children[2]; // "DUMMY SKU VARIABLE HUGE RED 0" - has all attributes.
+
+ // Create mock for CartEventTracker.
+ $mock_cart_tracker = $this->createMock( CartEventTracker::class );
+
+ // Create mock for FraudProtectionController that returns enabled.
+ $mock_controller = $this->createMock( FraudProtectionController::class );
+ $mock_controller->method( 'feature_is_enabled' )->willReturn( true );
+
+ // Replace container instances with mocks.
+ $container = wc_get_container();
+ $container->replace( FraudProtectionController::class, $mock_controller );
+ $container->replace( CartEventTracker::class, $mock_cart_tracker );
+
+ // Expect track_cart_item_added to be called with the correct variation_id.
+ $mock_cart_tracker
+ ->expects( $this->once() )
+ ->method( 'track_cart_item_added' )
+ ->with(
+ $this->isType( 'string' ), // cart_id.
+ $this->equalTo( $variable_product->get_id() ), // product_id.
+ $this->equalTo( 1 ), // quantity.
+ $this->equalTo( $variation_id ) // variation_id.
+ );
+
+ // Use Store API CartController to add variation with all required attributes.
+ // CartController::parse_variation_data() expects an array of {attribute, value} pairs.
+ $cart_controller = new CartController();
+ $cart_controller->add_to_cart(
+ array(
+ 'id' => $variation_id,
+ 'quantity' => 1,
+ 'variation' => array(
+ array(
+ 'attribute' => 'attribute_pa_size',
+ 'value' => 'huge',
+ ),
+ array(
+ 'attribute' => 'attribute_pa_colour',
+ 'value' => 'red',
+ ),
+ array(
+ 'attribute' => 'attribute_pa_number',
+ 'value' => '0',
+ ),
+ ),
+ )
+ );
+
+ // Clean up.
+ $variable_product->delete( true );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/CartEventTrackerTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/CartEventTrackerTest.php
index da92b7d3de..c403b827be 100644
--- a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/CartEventTrackerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/CartEventTrackerTest.php
@@ -63,10 +63,7 @@ class CartEventTrackerTest extends \WC_Unit_Test_Case {
// Create system under test.
$this->sut = new CartEventTracker();
- $this->sut->init(
- $this->mock_dispatcher,
- $this->mock_controller
- );
+ $this->sut->init( $this->mock_dispatcher );
// Create a test product.
$this->test_product = \WC_Helper_Product::create_simple_product();
@@ -76,43 +73,9 @@ class CartEventTrackerTest extends \WC_Unit_Test_Case {
}
/**
- * Test that register does not register hooks when feature is disabled.
- */
- public function test_register_does_not_register_hooks_when_feature_disabled(): void {
- // Mock feature as disabled.
- $this->mock_controller->method( 'feature_is_enabled' )->willReturn( false );
-
- // Call register.
- $this->sut->register();
-
- // Verify hooks were not registered.
- $this->assertFalse( has_action( 'woocommerce_add_to_cart', array( $this->sut, 'handle_track_cart_item_added' ) ) );
- $this->assertFalse( has_action( 'woocommerce_after_cart_item_quantity_update', array( $this->sut, 'handle_track_cart_item_updated' ) ) );
- $this->assertFalse( has_action( 'woocommerce_remove_cart_item', array( $this->sut, 'handle_track_cart_item_removed' ) ) );
- $this->assertFalse( has_action( 'woocommerce_restore_cart_item', array( $this->sut, 'handle_track_cart_item_restored' ) ) );
- }
-
- /**
- * Test that register registers hooks when feature is enabled.
+ * Test track_cart_item_added tracks event.
*/
- public function test_register_registers_hooks_when_feature_enabled(): void {
- // Mock feature as enabled.
- $this->mock_controller->method( 'feature_is_enabled' )->willReturn( true );
-
- // Call register.
- $this->sut->register();
-
- // Verify hooks were registered with correct priority.
- $this->assertEquals( 10, has_action( 'woocommerce_add_to_cart', array( $this->sut, 'handle_track_cart_item_added' ) ) );
- $this->assertEquals( 10, has_action( 'woocommerce_after_cart_item_quantity_update', array( $this->sut, 'handle_track_cart_item_updated' ) ) );
- $this->assertEquals( 10, has_action( 'woocommerce_remove_cart_item', array( $this->sut, 'handle_track_cart_item_removed' ) ) );
- $this->assertEquals( 10, has_action( 'woocommerce_restore_cart_item', array( $this->sut, 'handle_track_cart_item_restored' ) ) );
- }
-
- /**
- * Test handle_track_cart_item_added tracks event.
- */
- public function test_handle_track_cart_item_added_tracks_event(): void {
+ public function test_track_cart_item_added_tracks_event(): void {
// Mock the dispatcher to verify dispatch_event is called with event data.
$this->mock_dispatcher
->expects( $this->once() )
@@ -133,21 +96,19 @@ class CartEventTrackerTest extends \WC_Unit_Test_Case {
)
);
- // Call the handler.
- $this->sut->handle_track_cart_item_added(
+ // Call the method.
+ $this->sut->track_cart_item_added(
'test_cart_key',
$this->test_product->get_id(),
2,
- 0,
- array(),
- array()
+ 0
);
}
/**
- * Test handle_track_cart_item_updated tracks event.
+ * Test track_cart_item_updated tracks event.
*/
- public function test_handle_track_cart_item_updated_tracks_event(): void {
+ public function test_track_cart_item_updated_tracks_event(): void {
// Add item to cart first.
$cart_item_key = WC()->cart->add_to_cart( $this->test_product->get_id(), 1 );
@@ -171,8 +132,8 @@ class CartEventTrackerTest extends \WC_Unit_Test_Case {
)
);
- // Call the handler.
- $this->sut->handle_track_cart_item_updated(
+ // Call the method.
+ $this->sut->track_cart_item_updated(
$cart_item_key,
5,
1,
@@ -181,9 +142,9 @@ class CartEventTrackerTest extends \WC_Unit_Test_Case {
}
/**
- * Test handle_track_cart_item_removed tracks event.
+ * Test track_cart_item_removed tracks event.
*/
- public function test_handle_track_cart_item_removed_tracks_event(): void {
+ public function test_track_cart_item_removed_tracks_event(): void {
// Add item to cart.
$cart_item_key = WC()->cart->add_to_cart( $this->test_product->get_id(), 1 );
@@ -206,14 +167,14 @@ class CartEventTrackerTest extends \WC_Unit_Test_Case {
// Remove the item from cart.
WC()->cart->remove_cart_item( $cart_item_key );
- // Call the handler directly (since hooks aren't registered in test context).
- $this->sut->handle_track_cart_item_removed( $cart_item_key, WC()->cart );
+ // Call the method directly.
+ $this->sut->track_cart_item_removed( $cart_item_key, WC()->cart );
}
/**
- * Test handle_track_cart_item_restored tracks event.
+ * Test track_cart_item_restored tracks event.
*/
- public function test_handle_track_cart_item_restored_tracks_event(): void {
+ public function test_track_cart_item_restored_tracks_event(): void {
// Add item to cart.
$cart_item_key = WC()->cart->add_to_cart( $this->test_product->get_id(), 1 );
@@ -233,8 +194,8 @@ class CartEventTrackerTest extends \WC_Unit_Test_Case {
)
);
- // Call the handler directly (simulating restore action).
- $this->sut->handle_track_cart_item_restored(
+ // Call the method directly (simulating restore action).
+ $this->sut->track_cart_item_restored(
$cart_item_key,
WC()->cart
);
@@ -267,14 +228,12 @@ class CartEventTrackerTest extends \WC_Unit_Test_Case {
)
);
- // Call the handler with variation ID.
- $this->sut->handle_track_cart_item_added(
+ // Call the method with variation ID.
+ $this->sut->track_cart_item_added(
'test_cart_key',
$variable_product->get_id(),
1,
- $variation_id,
- array(),
- array()
+ $variation_id
);
// Clean up.
@@ -294,11 +253,5 @@ class CartEventTrackerTest extends \WC_Unit_Test_Case {
// Empty cart.
WC()->cart->empty_cart();
-
- // Remove all registered hooks.
- remove_all_actions( 'woocommerce_add_to_cart' );
- remove_all_actions( 'woocommerce_after_cart_item_quantity_update' );
- remove_all_actions( 'woocommerce_remove_cart_item' );
- remove_all_actions( 'woocommerce_restore_cart_item' );
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/CheckoutEventTrackerTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/CheckoutEventTrackerTest.php
index 5406b8aa92..2a1a0a498e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/CheckoutEventTrackerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/CheckoutEventTrackerTest.php
@@ -35,6 +35,13 @@ class CheckoutEventTrackerTest extends \WC_Unit_Test_Case {
*/
private $mock_dispatcher;
+ /**
+ * Mock session data collector.
+ *
+ * @var SessionDataCollector|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $mock_session_data_collector;
+
/**
* Mock fraud protection controller.
*
@@ -54,43 +61,13 @@ class CheckoutEventTrackerTest extends \WC_Unit_Test_Case {
}
// Create mocks.
- $this->mock_dispatcher = $this->createMock( FraudProtectionDispatcher::class );
- $this->mock_controller = $this->createMock( FraudProtectionController::class );
+ $this->mock_dispatcher = $this->createMock( FraudProtectionDispatcher::class );
+ $this->mock_session_data_collector = $this->createMock( SessionDataCollector::class );
+ $this->mock_controller = $this->createMock( FraudProtectionController::class );
// Create system under test.
$this->sut = new CheckoutEventTracker();
- $this->sut->init(
- $this->mock_dispatcher,
- $this->mock_controller
- );
- }
-
- /**
- * Test that register does not register hooks when feature is disabled.
- */
- public function test_register_does_not_register_hooks_when_feature_disabled(): void {
- // Mock feature as disabled.
- $this->mock_controller->method( 'feature_is_enabled' )->willReturn( false );
-
- // Call register.
- $this->sut->register();
-
- // Verify hooks were not registered.
- $this->assertFalse( has_action( 'woocommerce_checkout_update_order_review', array( $this->sut, 'handle_shortcode_checkout_field_update' ) ) );
- }
-
- /**
- * Test that register registers shortcode checkout hooks when feature is enabled.
- */
- public function test_register_registers_hooks_when_feature_enabled(): void {
- // Mock feature as enabled.
- $this->mock_controller->method( 'feature_is_enabled' )->willReturn( true );
-
- // Call register.
- $this->sut->register();
-
- // Verify hook was registered.
- $this->assertNotFalse( has_action( 'woocommerce_checkout_update_order_review', array( $this->sut, 'handle_shortcode_checkout_field_update' ) ) );
+ $this->sut->init( $this->mock_dispatcher, $this->mock_session_data_collector );
}
// ========================================
@@ -122,18 +99,27 @@ class CheckoutEventTrackerTest extends \WC_Unit_Test_Case {
// ========================================
/**
- * Test handle_shortcode_checkout_field_update schedules event with billing email.
+ * Test track_shortcode_checkout_field_update schedules event with billing email when billing country changes.
*/
- public function test_handle_shortcode_checkout_field_update_schedules_event_with_billing_email(): void {
+ public function test_track_shortcode_checkout_field_update_schedules_event_with_billing_email(): void {
// Mock feature as enabled.
$this->mock_controller->method( 'feature_is_enabled' )->willReturn( true );
+ // Mock SessionDataCollector to return different billing country.
+ $this->mock_session_data_collector
+ ->method( 'get_current_billing_country' )
+ ->willReturn( 'CA' ); // Current country is CA.
+
+ $this->mock_session_data_collector
+ ->method( 'get_current_shipping_country' )
+ ->willReturn( null );
+
// Mock scheduler to verify dispatch_event is called.
$this->mock_dispatcher
->expects( $this->once() )
->method( 'dispatch_event' )
->with(
- $this->equalTo( 'checkout_field_update' ),
+ $this->equalTo( 'checkout_update' ),
$this->callback(
function ( $event_data ) {
return isset( $event_data['action'] )
@@ -144,21 +130,27 @@ class CheckoutEventTrackerTest extends \WC_Unit_Test_Case {
)
);
- // Register hooks.
- $this->sut->register();
-
- // Simulate checkout field update with billing email.
- $posted_data = 'billing_email=test@example.com&billing_first_name=John&billing_last_name=Doe';
- $this->sut->handle_shortcode_checkout_field_update( $posted_data );
+ // Simulate checkout field update with billing email and country change (CA -> US).
+ $posted_data = 'billing_email=test@example.com&billing_first_name=John&billing_last_name=Doe&billing_country=US';
+ $this->sut->track_shortcode_checkout_field_update( $posted_data );
}
/**
- * Test handle_shortcode_checkout_field_update extracts billing fields correctly.
+ * Test track_shortcode_checkout_field_update extracts billing fields correctly when country changes.
*/
- public function test_handle_shortcode_checkout_field_update_extracts_billing_fields(): void {
+ public function test_track_shortcode_checkout_field_update_extracts_billing_fields(): void {
// Mock feature as enabled.
$this->mock_controller->method( 'feature_is_enabled' )->willReturn( true );
+ // Mock SessionDataCollector to return different billing country.
+ $this->mock_session_data_collector
+ ->method( 'get_current_billing_country' )
+ ->willReturn( 'CA' ); // Current country is CA.
+
+ $this->mock_session_data_collector
+ ->method( 'get_current_shipping_country' )
+ ->willReturn( null );
+
// Mock scheduler to capture event data.
$captured_event_data = null;
$this->mock_dispatcher
@@ -170,12 +162,9 @@ class CheckoutEventTrackerTest extends \WC_Unit_Test_Case {
}
);
- // Register hooks.
- $this->sut->register();
-
- // Simulate checkout field update with multiple billing fields.
+ // Simulate checkout field update with multiple billing fields and country change.
$posted_data = 'billing_email=test@example.com&billing_first_name=John&billing_last_name=Doe&billing_country=US&billing_city=New+York';
- $this->sut->handle_shortcode_checkout_field_update( $posted_data );
+ $this->sut->track_shortcode_checkout_field_update( $posted_data );
// Verify extracted fields.
$this->assertNotNull( $captured_event_data );
@@ -188,12 +177,21 @@ class CheckoutEventTrackerTest extends \WC_Unit_Test_Case {
}
/**
- * Test handle_shortcode_checkout_field_update extracts shipping fields when ship_to_different_address is set.
+ * Test track_shortcode_checkout_field_update extracts shipping fields when ship_to_different_address is set and shipping country changes.
*/
- public function test_handle_shortcode_checkout_field_update_extracts_shipping_fields(): void {
+ public function test_track_shortcode_checkout_field_update_extracts_shipping_fields(): void {
// Mock feature as enabled.
$this->mock_controller->method( 'feature_is_enabled' )->willReturn( true );
+ // Mock SessionDataCollector to return different shipping country.
+ $this->mock_session_data_collector
+ ->method( 'get_current_billing_country' )
+ ->willReturn( null );
+
+ $this->mock_session_data_collector
+ ->method( 'get_current_shipping_country' )
+ ->willReturn( 'CA' ); // Current shipping country is CA.
+
// Mock scheduler to capture event data.
$captured_event_data = null;
$this->mock_dispatcher
@@ -205,12 +203,9 @@ class CheckoutEventTrackerTest extends \WC_Unit_Test_Case {
}
);
- // Register hooks.
- $this->sut->register();
-
- // Simulate checkout field update with shipping fields.
- $posted_data = 'billing_email=test@example.com&ship_to_different_address=1&shipping_first_name=Jane&shipping_last_name=Smith&shipping_city=Los+Angeles';
- $this->sut->handle_shortcode_checkout_field_update( $posted_data );
+ // Simulate checkout field update with shipping fields and country change.
+ $posted_data = 'billing_email=test@example.com&ship_to_different_address=1&shipping_first_name=Jane&shipping_last_name=Smith&shipping_city=Los+Angeles&shipping_country=US';
+ $this->sut->track_shortcode_checkout_field_update( $posted_data );
// Verify extracted fields.
$this->assertNotNull( $captured_event_data );
@@ -220,12 +215,21 @@ class CheckoutEventTrackerTest extends \WC_Unit_Test_Case {
}
/**
- * Test handle_shortcode_checkout_field_update does not extract shipping fields when ship_to_different_address is not set.
+ * Test track_shortcode_checkout_field_update does not extract shipping fields when ship_to_different_address is not set.
*/
- public function test_handle_shortcode_checkout_field_update_skips_shipping_fields_when_not_different_address(): void {
+ public function test_track_shortcode_checkout_field_update_skips_shipping_fields_when_not_different_address(): void {
// Mock feature as enabled.
$this->mock_controller->method( 'feature_is_enabled' )->willReturn( true );
+ // Mock SessionDataCollector to return different billing country.
+ $this->mock_session_data_collector
+ ->method( 'get_current_billing_country' )
+ ->willReturn( 'CA' ); // Current billing country is CA.
+
+ $this->mock_session_data_collector
+ ->method( 'get_current_shipping_country' )
+ ->willReturn( null );
+
// Mock scheduler to capture event data.
$captured_event_data = null;
$this->mock_dispatcher
@@ -237,16 +241,224 @@ class CheckoutEventTrackerTest extends \WC_Unit_Test_Case {
}
);
- // Register hooks.
- $this->sut->register();
-
- // Simulate checkout field update without ship_to_different_address.
- $posted_data = 'billing_email=test@example.com&shipping_first_name=Jane&shipping_last_name=Smith';
- $this->sut->handle_shortcode_checkout_field_update( $posted_data );
+ // Simulate checkout field update without ship_to_different_address but with billing country change.
+ $posted_data = 'billing_email=test@example.com&billing_country=US&shipping_first_name=Jane&shipping_last_name=Smith';
+ $this->sut->track_shortcode_checkout_field_update( $posted_data );
// Verify shipping fields are not extracted.
$this->assertNotNull( $captured_event_data );
$this->assertArrayNotHasKey( 'shipping_first_name', $captured_event_data );
$this->assertArrayNotHasKey( 'shipping_last_name', $captured_event_data );
}
+
+ // ========================================
+ // Country Change Detection Tests
+ // ========================================
+
+ /**
+ * Test event is dispatched when billing country changes.
+ */
+ public function test_event_dispatched_when_billing_country_changes(): void {
+ // Mock SessionDataCollector to return different billing country.
+ $this->mock_session_data_collector
+ ->method( 'get_current_billing_country' )
+ ->willReturn( 'CA' ); // Current country is CA.
+
+ $this->mock_session_data_collector
+ ->method( 'get_current_shipping_country' )
+ ->willReturn( null );
+
+ // Expect event to be dispatched once.
+ $this->mock_dispatcher
+ ->expects( $this->once() )
+ ->method( 'dispatch_event' )
+ ->with(
+ $this->equalTo( 'checkout_update' ),
+ $this->callback(
+ function ( $event_data ) {
+ return isset( $event_data['billing_country'] ) && 'US' === $event_data['billing_country'];
+ }
+ )
+ );
+
+ // Posted data with billing country changing from CA to US.
+ $posted_data = 'billing_email=test@example.com&billing_country=US';
+ $this->sut->track_shortcode_checkout_field_update( $posted_data );
+ }
+
+ /**
+ * Test event is dispatched when shipping country changes.
+ */
+ public function test_event_dispatched_when_shipping_country_changes(): void {
+ // Mock SessionDataCollector to return current countries.
+ $this->mock_session_data_collector
+ ->method( 'get_current_billing_country' )
+ ->willReturn( 'US' ); // Current billing country matches posted.
+
+ $this->mock_session_data_collector
+ ->method( 'get_current_shipping_country' )
+ ->willReturn( 'CA' ); // Current shipping country is CA.
+
+ // Expect event to be dispatched once.
+ $this->mock_dispatcher
+ ->expects( $this->once() )
+ ->method( 'dispatch_event' )
+ ->with(
+ $this->equalTo( 'checkout_update' ),
+ $this->callback(
+ function ( $event_data ) {
+ return isset( $event_data['shipping_country'] ) && 'US' === $event_data['shipping_country'];
+ }
+ )
+ );
+
+ // Posted data with shipping country changing from CA to US.
+ $posted_data = 'billing_country=US&ship_to_different_address=1&shipping_country=US';
+ $this->sut->track_shortcode_checkout_field_update( $posted_data );
+ }
+
+ /**
+ * Test event is NOT dispatched when neither country changes.
+ */
+ public function test_event_not_dispatched_when_no_country_changes(): void {
+ // Mock SessionDataCollector to return same countries as posted.
+ $this->mock_session_data_collector
+ ->method( 'get_current_billing_country' )
+ ->willReturn( 'US' ); // Same as posted.
+
+ $this->mock_session_data_collector
+ ->method( 'get_current_shipping_country' )
+ ->willReturn( 'US' ); // Same as posted.
+
+ // Expect event to NOT be dispatched.
+ $this->mock_dispatcher
+ ->expects( $this->never() )
+ ->method( 'dispatch_event' );
+
+ // Posted data with no country changes.
+ $posted_data = 'billing_email=test@example.com&billing_country=US&shipping_country=US';
+ $this->sut->track_shortcode_checkout_field_update( $posted_data );
+ }
+
+ /**
+ * Test event is NOT dispatched when only non-country fields change.
+ */
+ public function test_event_not_dispatched_when_only_non_country_fields_change(): void {
+ // Mock SessionDataCollector to return countries.
+ $this->mock_session_data_collector
+ ->method( 'get_current_billing_country' )
+ ->willReturn( 'US' );
+
+ $this->mock_session_data_collector
+ ->method( 'get_current_shipping_country' )
+ ->willReturn( null );
+
+ // Expect event to NOT be dispatched.
+ $this->mock_dispatcher
+ ->expects( $this->never() )
+ ->method( 'dispatch_event' );
+
+ // Posted data with only non-country fields (email, name, phone).
+ $posted_data = 'billing_email=test@example.com&billing_first_name=John&billing_phone=1234567890';
+ $this->sut->track_shortcode_checkout_field_update( $posted_data );
+ }
+
+ /**
+ * Test event is NOT dispatched when ship_to_different_address is not set and current shipping matches billing.
+ */
+ public function test_event_not_dispatched_when_shipping_already_matches_billing(): void {
+ // Mock SessionDataCollector: shipping already matches billing.
+ $this->mock_session_data_collector
+ ->method( 'get_current_billing_country' )
+ ->willReturn( 'US' );
+
+ $this->mock_session_data_collector
+ ->method( 'get_current_shipping_country' )
+ ->willReturn( 'US' ); // Already matches billing - no change.
+
+ // Expect event to NOT be dispatched (no effective change).
+ $this->mock_dispatcher
+ ->expects( $this->never() )
+ ->method( 'dispatch_event' );
+
+ // Posted data with NO ship_to_different_address flag, billing stays US.
+ $posted_data = 'billing_country=US&billing_email=test@example.com';
+ $this->sut->track_shortcode_checkout_field_update( $posted_data );
+ }
+
+ /**
+ * Test event is dispatched when billing country changes from null.
+ */
+ public function test_event_dispatched_when_billing_country_changes_from_null(): void {
+ // Mock SessionDataCollector to return null for current billing country.
+ $this->mock_session_data_collector
+ ->method( 'get_current_billing_country' )
+ ->willReturn( null ); // No current billing country.
+
+ $this->mock_session_data_collector
+ ->method( 'get_current_shipping_country' )
+ ->willReturn( null );
+
+ // Expect event to be dispatched.
+ $this->mock_dispatcher
+ ->expects( $this->once() )
+ ->method( 'dispatch_event' );
+
+ // Posted data with billing country (first time setting).
+ $posted_data = 'billing_email=test@example.com&billing_country=US';
+ $this->sut->track_shortcode_checkout_field_update( $posted_data );
+ }
+
+ /**
+ * Test event is dispatched when user unchecks ship_to_different_address and current shipping country differs from billing.
+ *
+ * Scenario: User had different shipping address with different country (e.g., shipping=CA, billing=US),
+ * then unchecks "ship to different address". The effective shipping country changes from CA to US.
+ */
+ public function test_event_dispatched_when_ship_to_different_address_unchecked_with_different_countries(): void {
+ // Mock SessionDataCollector: billing=US, shipping=CA (previously different).
+ $this->mock_session_data_collector
+ ->method( 'get_current_billing_country' )
+ ->willReturn( 'US' );
+
+ $this->mock_session_data_collector
+ ->method( 'get_current_shipping_country' )
+ ->willReturn( 'CA' ); // Was different.
+
+ // Expect event to be dispatched (shipping effectively changed from CA to US).
+ $this->mock_dispatcher
+ ->expects( $this->once() )
+ ->method( 'dispatch_event' )
+ ->with(
+ $this->equalTo( 'checkout_update' ),
+ $this->anything()
+ );
+
+ // Posted data: ship_to_different_address NOT set (unchecked), billing country is US.
+ $posted_data = 'billing_country=US&billing_email=test@example.com';
+ $this->sut->track_shortcode_checkout_field_update( $posted_data );
+ }
+
+ /**
+ * Test event is NOT dispatched when user unchecks ship_to_different_address but countries are already the same.
+ */
+ public function test_event_not_dispatched_when_ship_to_different_address_unchecked_with_same_countries(): void {
+ // Mock SessionDataCollector: billing=US, shipping=US (already same).
+ $this->mock_session_data_collector
+ ->method( 'get_current_billing_country' )
+ ->willReturn( 'US' );
+
+ $this->mock_session_data_collector
+ ->method( 'get_current_shipping_country' )
+ ->willReturn( 'US' ); // Same as billing.
+
+ // Expect event to NOT be dispatched (no effective change).
+ $this->mock_dispatcher
+ ->expects( $this->never() )
+ ->method( 'dispatch_event' );
+
+ // Posted data: ship_to_different_address NOT set, billing country is US.
+ $posted_data = 'billing_country=US&billing_email=test@example.com';
+ $this->sut->track_shortcode_checkout_field_update( $posted_data );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/PaymentMethodEventTrackerTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/PaymentMethodEventTrackerTest.php
index c1636572b1..5c4cc88c7b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/PaymentMethodEventTrackerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/PaymentMethodEventTrackerTest.php
@@ -41,47 +41,12 @@ class PaymentMethodEventTrackerTest extends \WC_Unit_Test_Case {
$this->sut = $container->get( PaymentMethodEventTracker::class );
}
- /**
- * Test that hooks are registered when feature is enabled.
- *
- * @testdox Should register hooks when feature is enabled.
- */
- public function test_hooks_registered_when_feature_enabled(): void {
- $this->sut->register();
-
- $this->assertNotFalse( has_action( 'woocommerce_new_payment_token', array( $this->sut, 'handle_payment_method_added' ) ) );
- $this->assertNotFalse( has_action( 'woocommerce_payment_token_updated', array( $this->sut, 'handle_payment_method_updated' ) ) );
- $this->assertNotFalse( has_action( 'woocommerce_payment_token_set_default', array( $this->sut, 'handle_payment_method_set_default' ) ) );
- $this->assertNotFalse( has_action( 'woocommerce_payment_token_deleted', array( $this->sut, 'handle_payment_method_deleted' ) ) );
- }
-
- /**
- * Test that hooks are not registered when feature is disabled.
- *
- * @testdox Should not register hooks when feature is disabled.
- */
- public function test_hooks_not_registered_when_feature_disabled(): void {
- update_option( 'woocommerce_feature_fraud_protection_enabled', 'no' );
-
- $container = wc_get_container();
- $container->reset_all_resolved();
- $this->sut = $container->get( PaymentMethodEventTracker::class );
-
- $this->sut->register();
-
- $this->assertFalse( has_action( 'woocommerce_new_payment_token', array( $this->sut, 'handle_payment_method_added' ) ) );
- $this->assertFalse( has_action( 'woocommerce_payment_token_updated', array( $this->sut, 'handle_payment_method_updated' ) ) );
- $this->assertFalse( has_action( 'woocommerce_payment_token_set_default', array( $this->sut, 'handle_payment_method_set_default' ) ) );
- $this->assertFalse( has_action( 'woocommerce_payment_token_deleted', array( $this->sut, 'handle_payment_method_deleted' ) ) );
- }
-
/**
* Test payment method added event tracking.
*
* @testdox Should track payment method added event.
*/
public function test_handle_payment_method_added(): void {
- $this->sut->register();
$user_id = $this->factory->user->create();
@@ -115,159 +80,12 @@ class PaymentMethodEventTrackerTest extends \WC_Unit_Test_Case {
);
}
- /**
- * Test payment method updated event tracking.
- *
- * @testdox Should track payment method updated event.
- */
- public function test_handle_payment_method_updated(): void {
- $this->sut->register();
-
- $user_id = $this->factory->user->create();
-
- $token = new \WC_Payment_Token_CC();
- $token->set_token( 'test_token_456' );
- $token->set_gateway_id( 'stripe' );
- $token->set_card_type( 'mastercard' );
- $token->set_last4( '5555' );
- $token->set_expiry_month( '06' );
- $token->set_expiry_year( '2026' );
- $token->set_user_id( $user_id );
- $token->save();
-
- // Update the token to trigger the 'updated' event.
- $token->set_expiry_year( '2027' );
- $token->save();
-
- // Verify that the event was sent to the API with correct payload.
- $this->assertLogged(
- 'info',
- 'Sending fraud protection event: payment_method_updated',
- array(
- 'source' => 'woo-fraud-protection',
- 'payload' => array(
- 'event_type' => 'payment_method_updated',
- 'event_data' => array(
- 'action' => 'updated',
- 'token_id' => $token->get_id(),
- 'gateway_id' => 'stripe',
- 'card_type' => 'mastercard',
- ),
- ),
- )
- );
- }
-
- /**
- * Test payment method set as default event tracking.
- *
- * @testdox Should track payment method set as default event.
- */
- public function test_handle_payment_method_set_default(): void {
- $this->sut->register();
-
- $user_id = $this->factory->user->create();
-
- // Create first token (will be automatically set as default since it's the user's first token).
- $token1 = new \WC_Payment_Token_CC();
- $token1->set_token( 'test_token_first' );
- $token1->set_gateway_id( 'stripe' );
- $token1->set_card_type( 'visa' );
- $token1->set_last4( '1111' );
- $token1->set_expiry_month( '01' );
- $token1->set_expiry_year( '2026' );
- $token1->set_user_id( $user_id );
- $token1->save();
-
- // Create second token (won't be default).
- $token2 = new \WC_Payment_Token_CC();
- $token2->set_token( 'test_token_789' );
- $token2->set_gateway_id( 'stripe' );
- $token2->set_card_type( 'amex' );
- $token2->set_last4( '0005' );
- $token2->set_expiry_month( '03' );
- $token2->set_expiry_year( '2027' );
- $token2->set_user_id( $user_id );
- $token2->save();
-
- // Note: We use do_action() here because WC_Payment_Tokens::set_users_default()
- // relies on get_customer_tokens() which doesn't retrieve tokens properly in the test environment.
- // In production, the hook is triggered by WC_Payment_Tokens::set_users_default().
- // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment, WooCommerce.Commenting.CommentHooks.MissingSinceComment
- do_action( 'woocommerce_payment_token_set_default', $token2->get_id(), $token2 );
-
- // Verify that the event was sent to the API with correct payload.
- $this->assertLogged(
- 'info',
- 'Sending fraud protection event: payment_method_set_default',
- array(
- 'source' => 'woo-fraud-protection',
- 'payload' => array(
- 'event_type' => 'payment_method_set_default',
- 'event_data' => array(
- 'action' => 'set_default',
- 'token_id' => $token2->get_id(),
- 'gateway_id' => 'stripe',
- 'is_default' => true,
- ),
- ),
- )
- );
- }
-
- /**
- * Test payment method deleted event tracking.
- *
- * @testdox Should track payment method deleted event.
- */
- public function test_handle_payment_method_deleted(): void {
- $this->sut->register();
-
- $user_id = $this->factory->user->create();
-
- $token = new \WC_Payment_Token_CC();
- $token->set_token( 'test_token_delete' );
- $token->set_gateway_id( 'stripe' );
- $token->set_card_type( 'visa' );
- $token->set_last4( '1111' );
- $token->set_expiry_month( '09' );
- $token->set_expiry_year( '2028' );
- $token->set_user_id( $user_id );
- $token->save();
-
- // Delete the token to trigger the 'deleted' event.
- \WC_Payment_Tokens::delete( $token->get_id() );
-
- // Verify that the event was sent to the API with correct payload.
- $this->assertLogged(
- 'info',
- 'Sending fraud protection event: payment_method_deleted',
- array(
- 'source' => 'woo-fraud-protection',
- 'payload' => array(
- 'event_type' => 'payment_method_deleted',
- 'event_data' => array(
- 'action' => 'deleted',
- 'token_id' => $token->get_id(),
- 'gateway_id' => 'stripe',
- ),
- ),
- )
- );
- }
-
/**
* Cleanup after test.
*/
public function tearDown(): void {
parent::tearDown();
- // Remove all hooks.
- remove_all_actions( 'woocommerce_new_payment_token' );
- remove_all_actions( 'woocommerce_payment_token_updated' );
- remove_all_actions( 'woocommerce_payment_token_set_default' );
- remove_all_actions( 'woocommerce_payment_token_deleted' );
-
// Clean up options.
delete_option( 'woocommerce_feature_fraud_protection_enabled' );