diff --git a/src/inc/sift-object-validator.php b/src/inc/sift-object-validator.php index f1b6cfb..5f60211 100644 --- a/src/inc/sift-object-validator.php +++ b/src/inc/sift-object-validator.php @@ -686,6 +686,21 @@ class SiftObjectValidator { '$other', ); + const ORDER_STATUSES = array( + '$approved', + '$canceled', + '$held', + '$fulfilled', + '$returned', + ); + + const CANCELLATION_REASONS = array( + '$payment_risk', + '$abuse', + '$policy', + '$other', + ); + /** * This is the main validation function. * @@ -1523,4 +1538,48 @@ public static function validate_update_password( $data ) { } return true; } + + /** + * Validate the order status event. + * + * @param array $data The event to validate. + * + * @return true + * @throws \Exception If the event is invalid. + */ + public static function validate_order_status( array $data ) { + $validator_map = array( + '$user_id' => array( __CLASS__, 'validate_id' ), + '$order_id' => 'is_string', + '$order_status' => self::ORDER_STATUSES, + '$reason' => self::CANCELLATION_REASONS, + '$source' => array( '$automated', '$manual_review' ), + '$analyst' => 'is_string', + '$webhook_id' => 'is_string', + '$description' => 'is_string', + '$browser' => array( __CLASS__, 'validate_browser' ), + '$app' => array( __CLASS__, 'validate_app' ), + '$brand_name' => 'is_string', + '$site_country' => array( __CLASS__, 'validate_country_code' ), + '$site_domain' => 'is_string', + ); + + try { + static::validate( $data, $validator_map ); + // Required fields for order status: $user_id, $order_id, $order_status + if ( empty( $data['$user_id'] ) ) { + throw new \Exception( 'missing $user_id' ); + } + if ( empty( $data['$order_id'] ) ) { + throw new \Exception( 'missing $order_id' ); + } + if ( empty( $data['$order_status'] ) ) { + throw new \Exception( 'missing $order_status' ); + } + } catch ( \Exception $e ) { + throw new \Exception( 'Invalid $order_status event: ' . esc_html( $e->getMessage() ) ); + } + + return true; + } } diff --git a/src/inc/woocommerce-actions.php b/src/inc/woocommerce-actions.php index 0a89d1b..6113e4e 100644 --- a/src/inc/woocommerce-actions.php +++ b/src/inc/woocommerce-actions.php @@ -13,6 +13,16 @@ class Events { public static $to_send = array(); + const SUPPORTED_STATUS_CHANGES = array( + 'pending', + 'processing', + 'on-hold', + 'completed', + 'cancelled', + 'refunded', + 'failed', + ); + /** * Set up the integration hooks for messages we want to send to Sift. * @@ -30,9 +40,20 @@ public static function hooks() { add_action( 'woocommerce_checkout_order_processed', array( static::class, 'create_order' ), 100, 3 ); add_action( 'woocommerce_new_order', array( static::class, 'add_session_info' ), 100 ); - add_action( 'woocommerce_order_status_changed', array( static::class, 'change_order_status' ), 100 ); add_action( 'post_updated', array( static::class, 'update_order' ), 100 ); + /** + * We need to break this out into separate actions so we have the $status_transition available. + * + * This limits the number of supported status transitions so if we have an unsupported transition we need to + * log it. + */ + foreach ( self::SUPPORTED_STATUS_CHANGES as $status ) { + add_action( 'woocommerce_order_status_' . $status, array( static::class, 'change_order_status' ), 100, 3 ); + } + // For unsupported actions. + add_action( 'woocommerce_order_status_changed', array( static::class, 'maybe_log_change_order_status' ), 100, 3 ); + /** * This action merged in to WooCommerce and shipped via 8.8.0 * https://github.com/woocommerce/woocommerce/pull/45146 @@ -512,16 +533,89 @@ public static function create_order( string $order_id, array $posted_data, \WC_O */ public static function add_session_info( string $order_id ) {} + /** + * Log error for unsupported status changes. + * + * @param string $order_id Order ID. + * @param string $from From status. + * @param string $to To status. + * + * @return void + */ + public static function maybe_log_change_order_status( string $order_id, string $from, string $to ) { + if ( ! in_array( $to, self::SUPPORTED_STATUS_CHANGES, true ) ) { + wc_get_logger()->error( + sprintf( + 'Unsupported status change from %s to %s for order %s.', + $from, + $to, + $order_id + ) + ); + } + } + /** * Adds the event for the order status update * * @link https://developers.sift.com/docs/curl/events-api/reserved-events/order-status * - * @param string $order_id Order ID. + * @param string $order_id Order ID. + * @param \WC_Order $order The order object. + * @param array $status_transition Status transition data. + * type: array. * * @return void */ - public static function change_order_status( string $order_id ) {} + public static function change_order_status( string $order_id, \WC_Order $order, array $status_transition ) { + if ( ! in_array( $status_transition['to'], self::SUPPORTED_STATUS_CHANGES, true ) ) { + self::maybe_log_change_order_status( $order_id, $status_transition['from'], $status_transition['to'] ); + return; + } + + $properties = array( + '$user_id' => (string) $order->get_user_id(), + '$order_id' => (string) $order_id, + '$source' => $status_transition['manual'] ? '$manual_review' : '$automated', + '$description' => $status_transition['note'], + '$browser' => self::get_client_browser(), + '$site_country' => wc_get_base_location()['country'], + '$site_domain' => wp_parse_url( site_url(), PHP_URL_HOST ), + '$ip' => self::get_client_ip(), + '$time' => intval( 1000 * microtime( true ) ), + ); + + // Add the $order_status property (based on the status transition). + switch ( $status_transition['to'] ) { + case 'pending': + case 'processing': + case 'on-hold': + $properties['$order_status'] = '$held'; + break; + case 'completed': + $properties['$order_status'] = '$fulfilled'; + break; + case 'cancelled': + case 'refunded': + case 'failed': + $properties['$order_status'] = '$canceled'; + break; + } + + // For manual reviews add the user as the `$analyst`. + if ( $status_transition['manual'] ?? false ) { + $properties['$analyst'] = wp_get_current_user()->user_login; + } + + try { + SiftObjectValidator::validate_order_status( $properties ); + } catch ( \Exception $e ) { + wc_get_logger()->error( esc_html( $e->getMessage() ) ); + return; + } + + self::add( '$order_status', $properties ); + } /** * Adds event for order update diff --git a/tests/EventTest.php b/tests/EventTest.php index d9eefba..dbbb671 100644 --- a/tests/EventTest.php +++ b/tests/EventTest.php @@ -89,7 +89,7 @@ private static function create_simple_product() { * * @return array */ - private static function array_dot( mixed $multidimensional_array ) { + protected static function array_dot( mixed $multidimensional_array ) { $flat = []; $it = new RecursiveIteratorIterator( new RecursiveArrayIterator( $multidimensional_array ) ); foreach ( $it as $leaf ) { diff --git a/tests/OrderStatusEventTest.php b/tests/OrderStatusEventTest.php new file mode 100644 index 0000000..26b749f --- /dev/null +++ b/tests/OrderStatusEventTest.php @@ -0,0 +1,86 @@ +factory()->user->create(); + wp_set_current_user( $user_id ); + + $_REQUEST['woocommerce-process-checkout-nonce'] = wp_create_nonce( 'woocommerce-process_checkout' ); + add_filter( 'woocommerce_checkout_fields', fn() => [], 10, 0 ); + add_filter( 'woocommerce_cart_needs_payment', '__return_false' ); + + // Act + WC()->cart->add_to_cart( static::$product_id ); + $co = WC_Checkout::instance(); + $co->process_checkout(); + + // Assert + static::fail_on_error_logged(); + $events = static::assertOrderStatusEventTriggered( + [ + '$source' => '$automated', + '$order_status' => '$held', + ] + ); + + // Let's manually change the status of the order by cancelling it. + $order_id = $events[0]['properties.$order_id']; + $order = wc_get_order( $order_id ); + $order->update_status( 'cancelled', '', true ); + static::fail_on_error_logged(); + static::assertOrderStatusEventTriggered( + [ + '$source' => '$manual_review', + '$order_status' => '$canceled', + ] + ); + + // Let's try an unsupported status. + $gold_status_filter = fn( $statuses ) => array_merge( $statuses, [ 'wc-gold' => 'Gold' ] ); + add_filter( 'wc_order_statuses', $gold_status_filter ); + $order->update_status( 'gold', '', true ); + static::assertNotEmpty( static::$errors, 'No error logged for unsupported status' ); + + // Clean up + remove_filter( 'wc_order_statuses', $gold_status_filter ); + wp_delete_user( $user_id ); + } + + /** + * Assert $order_status event is triggered. + * + * @param array $props Event properties. + * + * @return array Return the matching events. + */ + public static function assertOrderStatusEventTriggered( array $props = [] ) { + $filters = [ 'event' => '$order_status' ]; + if ( ! empty( $props ) ) { + $filters = array_merge( $filters, static::array_dot( [ 'properties' => $props ] ) ); + } + $events = static::filter_events( $filters ); + static::assertGreaterThanOrEqual( 1, count( $events ), 'No $order_status event found' ); + return $events; + } +} diff --git a/tests/SiftApi/Validate_OrderStatus_Test.php b/tests/SiftApi/Validate_OrderStatus_Test.php new file mode 100644 index 0000000..66e937d --- /dev/null +++ b/tests/SiftApi/Validate_OrderStatus_Test.php @@ -0,0 +1,57 @@ + null ] ), + 'missing $user_id' + ); + } + + public function test_order_id_required() { + static::assert_invalid_argument_exception( + static::modify_data( [ '$order_id' => null ] ), + 'missing $order_id' + ); + } + + public function test_order_status_required() { + static::assert_invalid_argument_exception( + static::modify_data( [ '$order_status' => null ] ), + 'missing $order_status' + ); + } + + public function test_app_browser_set() { + $data = static::load_json(); + static::assert_invalid_argument_exception( + $data, + 'Cannot have both $app and $browser' + ); + } + + public function test_site_country() { + static::assert_invalid_argument_exception( + static::modify_data( [ '$site_country' => 'US1' ] ), + '$site_country: must be an ISO 3166 country code' + ); + } +} diff --git a/tests/SiftApi/fixtures/order-status.json b/tests/SiftApi/fixtures/order-status.json new file mode 100644 index 0000000..a4aecc8 --- /dev/null +++ b/tests/SiftApi/fixtures/order-status.json @@ -0,0 +1,31 @@ +{ + "$user_id" : "billy_jones_301", + "$order_id" : "ORDER-28168441", + "$order_status" : "$canceled", + + "$reason" : "$payment_risk", + "$source" : "$manual_review", + "$analyst" : "someone@your-site.com", + "$webhook_id" : "3ff1082a4aea8d0c58e3643ddb7a5bb87ffffeb2492dca33", + "$description" : "Canceling because multiple fraudulent users on device", + "$ip" : "54.208.214.78", + + + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + }, + + "$app" : { + + "$os" : "iOS", + "$os_version" : "10.1.3", + "$device_manufacturer" : "Apple", + "$device_model" : "iPhone 4,2", + "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name" : "Calculator", + "$app_version" : "3.2.7", + "$client_language" : "en-US" + } +}