Skip to content
Open
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
73 changes: 66 additions & 7 deletions facebook-commerce-events-tracker.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ class WC_Facebookcommerce_EventsTracker {
/** @var array array with epnding events */
private $pending_events = array();

/**
* IDs of orders that were saved without items, keyed by order ID.
*
* They are reported at the end of the request, once the integration that created
* them has finished adding items and totals.
*
* @var array<int, true>
*/
private $deferred_purchase_order_ids = array();

/** @var AAMSettings aam settings instance, used to filter advanced matching fields*/
private $aam_settings;

Expand Down Expand Up @@ -341,6 +351,9 @@ private function add_hooks() {
add_action( 'woocommerce_process_shop_order_meta', array( $this, 'inject_purchase_event' ), 20 );
add_action( 'woocommerce_checkout_update_order_meta', array( $this, 'inject_purchase_event' ), 30 );
add_action( 'woocommerce_thankyou', array( $this, 'inject_purchase_event' ), 40 );
// Orders created without items are reported at the end of the request, once the
// integration that created them has added items and totals.
add_action( 'shutdown', array( $this, 'inject_deferred_purchase_events' ), 5 );

// Offline Purchase events, reported once a point-of-sale order is paid.
add_action( 'woocommerce_order_status_changed', array( $this, 'inject_offline_purchase_event_on_status_change' ), 10 );
Expand All @@ -354,9 +367,10 @@ private function add_hooks() {
add_filter( 'wpforms_ajax_submit_redirect', array( $this, 'inject_wpforms_lead_event_ajax' ), 20, 3 );
add_action( 'wp_footer', array( $this, 'inject_wpforms_ajax_listener' ), 20 );

// Prevent stale Purchase tracking flags from being copied from subscriptions
// onto renewal orders (which would make inject_purchase_event() skip CAPI).
// New subscription-generated orders must not inherit Purchase tracking flags.
add_filter( 'wc_subscriptions_renewal_order_data', array( $this, 'exclude_purchase_tracking_meta_from_renewal_orders' ), 10, 3 );
add_filter( 'wc_subscriptions_resubscribe_order_data', array( $this, 'exclude_purchase_tracking_meta_from_renewal_orders' ), 10, 3 );
add_filter( 'wc_subscriptions_parent_data', array( $this, 'exclude_purchase_tracking_meta_from_renewal_orders' ), 10, 3 );

// Flush pending events on shutdown
add_action( 'shutdown', array( $this, 'send_pending_events' ) );
Expand Down Expand Up @@ -1355,18 +1369,18 @@ public function inject_initiate_checkout_event() {

/**
* Excludes Meta Purchase tracking keys when Woo Subscriptions copies subscription meta
* to renewal orders.
* to renewal, resubscribe or parent orders.
*
* Without this, renewal orders may inherit `_meta_purchase_tracked_server` and
* Without this, these orders may inherit `_meta_purchase_tracked_server` and
* `_meta_event_id`, causing inject_purchase_event() to treat them as already sent.
*
* @param array $data copied data keyed by meta key.
* @param WC_Order $to_object target order (renewal order).
* @param WC_Order $to_object target order.
* @param WC_Order $from_object source object (subscription).
* @return array
*/
public function exclude_purchase_tracking_meta_from_renewal_orders( $data, $to_object, $from_object ) {
// Guard to only affect subscription -> renewal order copies.
// Guard to only affect subscription -> order copies.
if ( ! is_a( $to_object, 'WC_Order' ) || 'shop_order' !== $to_object->get_type() ) {
return $data;
}
Expand Down Expand Up @@ -1875,6 +1889,14 @@ private function track_web_purchase_event( \WC_Order $order ) {
$valid_purchase_order_states = array( 'processing', 'completed', 'on-hold', 'pending' );
$order_id = $order->get_id();

// Some integrations save an empty order first and add its items and totals
// afterwards. Report it at the end of the request instead of now, when a
// Purchase would have no contents.
if ( 'woocommerce_new_order' === current_action() && ! $this->order_has_items( $order ) ) {
$this->deferred_purchase_order_ids[ $order_id ] = true;
return;
}

$hook_name = current_action();

// Determine if this is a browser or server event.
Expand Down Expand Up @@ -1985,6 +2007,43 @@ private function track_web_purchase_event( \WC_Order $order ) {
$this->inject_subscribe_event( $order_id );
}

/**
* Reports orders that were created without items once the request completes.
*
* Subscription plugins and similar integrations save an empty order first and add
* its items and totals afterwards, sometimes across several saves. Waiting for the
* end of the request reports the order from its persisted state, whichever plugin
* created it and however it filled it.
*/
public function inject_deferred_purchase_events() {
$order_ids = array_keys( $this->deferred_purchase_order_ids );
$this->deferred_purchase_order_ids = array();

foreach ( $order_ids as $order_id ) {
$order = wc_get_order( $order_id );

// Orders that are still empty, or were removed meanwhile, have nothing to report.
if ( ! $order instanceof \WC_Order || ! $this->order_has_items( $order ) ) {
continue;
}

$this->inject_purchase_event( $order_id, $order );
}
}

/**
* Determines whether an order has any items yet.
*
* Line items are checked first so orders built in memory, such as checkout orders,
* do not trigger an extra read of the other item types.
*
* @param WC_Order $order Order object.
* @return bool
*/
private function order_has_items( $order ) {
return (bool) $order->get_items() || (bool) $order->get_items( array( 'fee', 'shipping', 'tax', 'coupon' ) );
}

/**
* Triggers a Purchase event when checkout is completed.
*
Expand All @@ -2000,7 +2059,7 @@ private function track_web_purchase_event( \WC_Order $order ) {
* @internal
*
* @param int $order_id Order identifier.
* @param WC_Order|null $order Original order supplied by woocommerce_new_order, if available.
* @param WC_Order|null $order Order object supplied by the caller, if available.
*/
public function inject_purchase_event( $order_id, $order = null ) {

Expand Down
28 changes: 24 additions & 4 deletions tests/Unit/Events/FacebookCommerceEventsTrackerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ private function remove_purchase_hooks(): void {
remove_action( 'woocommerce_process_shop_order_meta', array( $this->instance, 'inject_purchase_event' ), 20 );
remove_action( 'woocommerce_checkout_update_order_meta', array( $this->instance, 'inject_purchase_event' ), 30 );
remove_action( 'woocommerce_thankyou', array( $this->instance, 'inject_purchase_event' ), 40 );
remove_action( 'shutdown', array( $this->instance, 'inject_deferred_purchase_events' ), 5 );
}

/**
Expand Down Expand Up @@ -610,8 +611,12 @@ public function test_inject_purchase_event_sends_capi_for_server_context(): void
$this->instance = $this->create_tracker_with_pixel_enabled();
$this->remove_purchase_hooks();

// Create a valid order with processing status
// Create a valid order with processing status and a line item.
$product = new \WC_Product_Simple();
$product->set_regular_price( 100 );
$product->save();
$order = wc_create_order();
$order->add_product( $product, 1 );
$order->set_status( 'processing' );
$order->set_total( 100 );
$order->save();
Expand Down Expand Up @@ -691,8 +696,12 @@ public function test_inject_purchase_event_sends_single_capi_for_full_checkout_f

// Server hook first, fired by WooCommerce while it creates the order (as in a
// real checkout). Tracking metadata is saved once that save has finished.
$product = new \WC_Product_Simple();
$product->set_regular_price( 100 );
$product->save();
add_action( 'woocommerce_new_order', array( $this->instance, 'inject_purchase_event' ), 10, 2 );
$order = new \WC_Order();
$order->add_product( $product, 1 );
$order->set_status( 'processing' );
$order->set_total( 100 );
$order->save();
Expand All @@ -716,11 +725,13 @@ public function test_inject_purchase_event_sends_single_capi_for_full_checkout_f
}

/**
* Test that renewal-order meta copy excludes Purchase tracking keys.
* Test that subscription-generated orders do not inherit Purchase tracking keys.
*
* @dataProvider subscription_order_copy_hooks
* @param string $hook Subscription order data-copy filter.
* @covers WC_Facebookcommerce_EventsTracker::exclude_purchase_tracking_meta_from_renewal_orders
*/
public function test_exclude_purchase_tracking_meta_from_renewal_orders_removes_tracking_keys(): void {
public function test_exclude_purchase_tracking_meta_from_renewal_orders_removes_tracking_keys( string $hook ): void {
$this->instance = $this->create_tracker_with_pixel_enabled();

$data = array(
Expand All @@ -742,14 +753,23 @@ public function test_exclude_purchase_tracking_meta_from_renewal_orders_removes_
->getMock();
$from_subscription->method( 'get_type' )->willReturn( 'shop_subscription' );

$result = $this->instance->exclude_purchase_tracking_meta_from_renewal_orders( $data, $to_order, $from_subscription );
$result = apply_filters( $hook, $data, $to_order, $from_subscription );

$this->assertArrayNotHasKey( '_meta_purchase_tracked_server', $result );
$this->assertArrayNotHasKey( '_meta_purchase_tracked_browser', $result );
$this->assertArrayNotHasKey( '_meta_event_id', $result );
$this->assertArrayHasKey( '_subscription_renewal', $result );
}

/** @return array */
public function subscription_order_copy_hooks(): array {
return array(
'renewal' => array( 'wc_subscriptions_renewal_order_data' ),
'resubscribe' => array( 'wc_subscriptions_resubscribe_order_data' ),
'parent' => array( 'wc_subscriptions_parent_data' ),
);
}

/**
* Test that renewal-order meta copy leaves data unchanged for non-subscription sources.
*
Expand Down
139 changes: 139 additions & 0 deletions tests/Unit/Events/PurchaseOrderCreationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,145 @@ public function test_after_save_callback_removes_itself(): void {
$this->assertSame( $before, $count_callbacks() );
}

/**
* Subscription plugins and similar integrations save an empty order before adding its items.
* The order is reported at the end of the request, from its persisted state.
*
* @dataProvider order_storage
* @param string $hpos Whether to enable HPOS.
*/
public function test_empty_order_is_reported_at_request_end_once_items_exist( string $hpos ): void {
update_option( 'woocommerce_custom_orders_table_enabled', $hpos );
update_option( 'woocommerce_custom_orders_table_data_sync_enabled', 'no' );
$order = new \WC_Order();
$order->set_status( 'pending' );
$order->save();
$order->set_customer_note( 'Still empty' );
$order->save();

$this->assertCount( 0, $this->tracker->get_tracked_events() );
$this->assertFalse( $order->meta_exists( '_meta_purchase_tracked_server' ) );
$this->assertFalse( $order->meta_exists( '_meta_event_id' ) );

$source = $this->build_order();
foreach ( $source->get_items() as $item ) {
$order->add_item( $item );
}
$order->set_currency( $source->get_currency() );
$order->set_total( $source->get_total() );
$order->save();
$this->assertCount( 0, $this->tracker->get_tracked_events(), 'Nothing is reported until the request ends.' );

$this->tracker->inject_deferred_purchase_events();
$this->assert_complete_purchase( $order );
$event_id = wc_get_order( $order->get_id() )->get_meta( '_meta_event_id' );
$this->assertNotEmpty( $event_id );
$this->assertNotEmpty( wc_get_order( $order->get_id() )->get_meta( '_meta_purchase_tracked_server' ) );

// A second flush, later saves and the thank-you page keep the same event and do not
// resend CAPI, including after the transient has expired.
$this->tracker->inject_deferred_purchase_events();
delete_transient( '_wc_' . facebook_for_woocommerce()->get_id() . '_purchase_tracked_' . $order->get_id() . '_server' );
$order->save();
ob_start();
try {
do_action( 'woocommerce_thankyou', $order->get_id() );
} finally {
ob_end_clean();
}
$this->assertCount( 1, $this->tracker->get_tracked_events() );
$this->assertSame( $event_id, wc_get_order( $order->get_id() )->get_meta( '_meta_event_id' ) );
}

/**
* Woo Subscriptions inserts the items directly, and WP Swings saves the order before
* calculating totals. The persisted state at request end is what gets reported.
*
* @dataProvider order_storage
* @param string $hpos Whether to enable HPOS.
*/
public function test_directly_inserted_items_and_late_totals_are_reported_from_the_persisted_order( string $hpos ): void {
update_option( 'woocommerce_custom_orders_table_enabled', $hpos );
update_option( 'woocommerce_custom_orders_table_data_sync_enabled', 'no' );
$order = new \WC_Order();
$order->set_status( 'pending' );
$order->set_currency( 'USD' );
$order->save();

foreach ( $this->build_order()->get_items() as $item ) {
$item->set_order_id( $order->get_id() );
$item->save();
}

// An intermediate save with items but no totals yet, as WP Swings does through its meta helper.
$intermediate = wc_get_order( $order->get_id() );
$intermediate->update_meta_data( '_qa_renewal', 'yes' );
$intermediate->save();
$this->assertSame( '0.00', $intermediate->get_total() );

$fresh = wc_get_order( $order->get_id() );
$fresh->calculate_totals();
$fresh->save();
$this->assertCount( 0, $this->tracker->get_tracked_events() );

$this->tracker->inject_deferred_purchase_events();
$this->assert_complete_purchase( $fresh );
$this->assertNotEmpty( wc_get_order( $order->get_id() )->get_meta( '_meta_purchase_tracked_server' ) );
}

/**
* @dataProvider purchase_statuses
* @param string $status Order status.
* @param int $count Expected events.
*/
public function test_deferred_order_keeps_existing_status_eligibility( string $status, int $count ): void {
$order = new \WC_Order();
$order->set_status( $status );
$order->save();

foreach ( $this->build_order()->get_items() as $item ) {
$order->add_item( $item );
}
$order->set_currency( 'USD' );
$order->set_total( 45 );
$order->save();
$this->assertCount( 0, $this->tracker->get_tracked_events() );

$this->tracker->inject_deferred_purchase_events();
$this->assertCount( $count, $this->tracker->get_tracked_events() );
}

/** Only orders that were created without items are reported at request end. */
public function test_orders_excluded_at_creation_are_not_reported_at_request_end(): void {
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
$order = $this->build_order();
$order->save();
$this->assertCount( 0, $this->tracker->get_tracked_events() );

wp_set_current_user( 0 );
$order->set_status( 'processing' );
$order->save();
$this->tracker->inject_deferred_purchase_events();
$this->assertCount( 0, $this->tracker->get_tracked_events() );
$this->assertFalse( wc_get_order( $order->get_id() )->meta_exists( '_meta_purchase_tracked_server' ) );
}

/** Orders that are still empty, or were deleted, by the end of the request are skipped. */
public function test_orders_still_empty_or_deleted_at_request_end_are_skipped(): void {
$empty = new \WC_Order();
$empty->set_status( 'pending' );
$empty->save();

$deleted = new \WC_Order();
$deleted->set_status( 'pending' );
$deleted->save();
$deleted->delete( true );

$this->tracker->inject_deferred_purchase_events();
$this->assertCount( 0, $this->tracker->get_tracked_events() );
$this->assertFalse( wc_get_order( $empty->get_id() )->meta_exists( '_meta_purchase_tracked_server' ) );
}

/**
* Blocks creates a draft first and fires new_order on the pending transition.
*
Expand Down
Loading