Skip to content
This repository has been archived by the owner on Nov 13, 2024. It is now read-only.

Add $order_status event #10

Merged
merged 3 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/inc/sift-object-validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
}
100 changes: 97 additions & 3 deletions src/inc/woocommerce-actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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
Expand Down Expand Up @@ -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<string $from, string $to, string $note, boolean $manual>.
*
* @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
Expand Down
2 changes: 1 addition & 1 deletion tests/EventTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
86 changes: 86 additions & 0 deletions tests/OrderStatusEventTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php
/**
* Class OrderStatusEventTest
*
* @package Sift_Decisions
*/

require_once 'EventTest.php';

// phpcs:disable Universal.Arrays.DisallowShortArraySyntax.Found, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound

use WPCOMSpecialProjects\SiftDecisions\WooCommerce_Actions\Events;

/**
* Test case.
*/
class OrderStatusEventTest extends EventTest {
/**
* Test that the $create_order event is triggered.
*
* @return void
*/
public function test_change_order_status() {
// Arrange
// - create a user and log them in
$user_id = $this->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;
}
}
57 changes: 57 additions & 0 deletions tests/SiftApi/Validate_OrderStatus_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php declare( strict_types = 1 );

// phpcs:disable

namespace SiftApi;

use WPCOMSpecialProjects\SiftDecisions\Sift\SiftObjectValidator;

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

require_once 'SiftObjectValidatorTest.php';

class Validate_OrderStatus_Test extends SiftObjectValidatorTest {
protected static ?string $fixture_name = 'order-status.json';

protected static function validator( $data ) {
return SiftObjectValidator::validate_order_status( $data );
}

public function test_user_id_required() {
static::assert_invalid_argument_exception(
static::modify_data( [ '$user_id' => 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'
);
}
}
31 changes: 31 additions & 0 deletions tests/SiftApi/fixtures/order-status.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"$user_id" : "billy_jones_301",
"$order_id" : "ORDER-28168441",
"$order_status" : "$canceled",

"$reason" : "$payment_risk",
"$source" : "$manual_review",
"$analyst" : "[email protected]",
"$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"
}
}
Loading