Commit 8e33afaf2e0 for woocommerce
commit 8e33afaf2e00767344a4490be2f3da3d4681fbc2
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Tue Mar 24 11:48:01 2026 +0300
Add fulfillments tracking code (#60205)
* Add fulfillments tracking code
* Add changefile(s) from automation for the following project(s): woocommerce, woocommerce/client/admin
* Add changefile(s) from automation for the following project(s): woocommerce, woocommerce/client/admin
* Wire up remaining fulfillment telemetry events and add tests
- Add track_fulfillment_modal_opened method and rename JS event to
fulfillment_modal_opened to match the spec
- Wire up track_fulfillment_tracking_added in REST controller create
and update flows via maybe_track_tracking_added helper
- Add url_generated param to tracking lookup attempt event
- Hook fulfillment email template save actions to track customization
- Add determine_tracking_entry_method helper for mapping shipping
option meta to entry method values
- Add comprehensive docblock comments to all tracker methods
- Add FulfillmentsTrackerTest (21 tests) covering entry method logic
and method signatures
- Add REST controller tests for tracking wiring and request source
- Add FulfillmentsManager test for email template tracking hooks
* Fix PHPStan and lint errors in fulfillment tracking code
- Cast $order->get_item_count() to int for track_fulfillment_creation
- Default nullable get_status() to 'unfulfilled' for tracker calls
- Simplify catch-block action to 'update' since $next_state is not
available when an exception is thrown before assignment
- Add @phpstan-ignore for WP_REST_Request generics on new method
* Fix TypeError: cast Exception::getCode() to string for tracker calls
Exception::getCode() returns int, but track_fulfillment_validation_error
expects string. Cast to (string) in all three catch blocks.
* Fix array alignment in FulfillmentsTrackerTest data provider
* Address PR review comments
- Fix legacy order edit page detection by also checking for 'post'
query param in isOrderDetailsPage (index.tsx)
- Guard get_item_count() against malformed _items metadata by
validating each entry is an array with a numeric qty (Fulfillment.php)
- Move filter tracking call after validation and add tracking to the
legacy orders list query path (FulfillmentsRenderer.php)
- Fix $item_count docblock to reflect it is total quantity, not
unique line item count (FulfillmentsTracker.php)
- Fix test fixture meta key from _shipment_provider to
_shipping_provider to match the Fulfillment model accessor
* Fix merge conflict style errors
* Use typed accessors instead of raw get_meta for fulfillment properties
- Replace get_meta('_shipment_provider') with get_shipping_provider()
in FulfillmentsRenderer, fixing the meta key mismatch
- Revert unnecessary defensive checks in get_item_count() since items
are validated before being written
- Update FulfillmentsRendererTest to use set_tracking_number(),
set_tracking_url(), and set_shipping_provider() typed setters
* Address code review feedback for fulfillment telemetry
- Add @since 10.7.0 annotation to public get_item_count() method
- Inline $action variable in update_fulfillment catch blocks
- Capture fulfillment status before delete() call for accurate tracking
* Gate fulfillment filter tracking on explicit filter submission
Only track fulfillment_filter_used when the user explicitly clicks
the Filter button (filter_action present), not on pagination, sorting,
or page refresh where fulfillment_status persists in the URL.
* Address telemetry review feedback and fix fulfillment change tracking
- Fix blank provider name for auto-lookup by adding FulfillmentUtils::resolve_provider_name()
- Fix empty changed_fields by migrating Fulfillment setters to set_prop/get_prop and
adding meta snapshot-based change detection via get_changes() override
- Fix @since tags on FulfillmentsTracker (10.1.0 -> 10.7.0) and add to all public methods
- Replace data store snapshot_tracked_state/compute_changed_props with Fulfillment::get_changes()
- Simplify determine_tracking_entry_method to 2 params (ui_manual replaces custom/select split)
- Gate fulfillment_tracking_added on actual tracking meta changes during updates
- Update woocommerce_fulfillment_after_update hook to pass $changes and $previous_status
- Fix test flakiness: by-ref parameter errors, stale DB table flag, feature flag pollution
- Add tests for get_changes(), resolve_provider_name, and updated hook signature
* Align shipment provider meta key to _shipment_provider across backend
The frontend uses _shipment_provider but the backend Fulfillment model
was reading _shipping_provider, causing the provider to always be empty
for UI-created fulfillments. Rename the meta key, model methods, and all
references to use _shipment_provider consistently.
* Add return type to Fulfillment::apply_changes() for PHPStan
---------
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
Co-authored-by: Ayush Pahwa <ayush.pahwa@automattic.com>
diff --git a/plugins/woocommerce/changelog/60205-add-60040-telemetry-for-fulfillments b/plugins/woocommerce/changelog/60205-add-60040-telemetry-for-fulfillments
new file mode 100644
index 00000000000..9fa509700f6
--- /dev/null
+++ b/plugins/woocommerce/changelog/60205-add-60040-telemetry-for-fulfillments
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add telemetry for fulfillment feature adoption and usage tracking
\ No newline at end of file
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/store.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/store.ts
index 790870aac1d..2dcea287db8 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/store.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/store.ts
@@ -113,6 +113,10 @@ const publicActions = {
),
method: 'POST',
data: fulfillment,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WC-Fulfillments-UI': 'true',
+ },
} );
if ( ! saved.id ) {
throw new Error( 'Fulfillment ID is missing in response' );
@@ -153,6 +157,10 @@ const publicActions = {
),
method: 'PUT',
data: fulfillment,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WC-Fulfillments-UI': 'true',
+ },
} );
if ( ! updated.id ) {
throw new Error( 'Fulfillment ID is missing in response' );
@@ -183,6 +191,10 @@ const publicActions = {
{ notify_customer: notifyCustomer }
),
method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WC-Fulfillments-UI': 'true',
+ },
} );
dispatch.deleteFulfillmentRecord( orderId, fulfillmentId );
} catch ( error: unknown ) {
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/index.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/index.tsx
index 4b31afaef43..263fa432fd5 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/index.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/index.tsx
@@ -4,6 +4,7 @@
import React, { useCallback, useLayoutEffect, useState } from 'react';
import { createRoot } from '@wordpress/element';
import { getQuery } from '@woocommerce/navigation';
+import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
@@ -14,6 +15,10 @@ import FulfillmentDrawer from './components/user-interface/fulfillment-drawer/fu
function FulfillmentsController() {
const [ isOpen, setIsOpen ] = useState( false );
const [ orderId, setOrderId ] = useState< number | null >( null );
+ const query = getQuery();
+ const isOrderDetailsPage =
+ Object.prototype.hasOwnProperty.call( query, 'id' ) ||
+ Object.prototype.hasOwnProperty.call( query, 'post' );
const deselectOrderRow = useCallback( () => {
document.querySelectorAll( '.type-shop_order' ).forEach( ( row ) => {
@@ -30,6 +35,20 @@ function FulfillmentsController() {
[ deselectOrderRow ]
);
+ const openFulfillmentDrawer = useCallback(
+ ( id: number ) => {
+ setOrderId( id );
+ setIsOpen( true );
+ recordEvent( 'fulfillment_modal_opened', {
+ source: isOrderDetailsPage
+ ? 'order_detail_page'
+ : 'orders_list',
+ order_id: id,
+ } );
+ },
+ [ setOrderId, setIsOpen, isOrderDetailsPage ]
+ );
+
useLayoutEffect( () => {
const handleClick = ( e: Event ) => {
const target = e.target as HTMLElement;
@@ -42,8 +61,7 @@ function FulfillmentsController() {
e.preventDefault();
e.stopPropagation();
selectOrderRow( button );
- setOrderId( id );
- setIsOpen( true );
+ openFulfillmentDrawer( id );
}
}
};
@@ -53,10 +71,7 @@ function FulfillmentsController() {
return () => {
document.body.removeEventListener( 'click', handleClick );
};
- }, [ selectOrderRow ] );
-
- const query = getQuery();
- const isOrderDetailsPage = query.hasOwnProperty( 'id' );
+ }, [ selectOrderRow, openFulfillmentDrawer ] );
return (
<FulfillmentDrawer
diff --git a/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md b/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
index ddf17abd4a0..ba9e3b64d2f 100644
--- a/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
+++ b/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
@@ -53,24 +53,30 @@ function update_inventory_system( $fulfillment ) {
Fired after a fulfillment is successfully updated in the database.
-**File:** `src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php:262`
+**File:** `src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php`
**Parameters:**
- `$data` (Fulfillment) - The updated fulfillment object
-- `$changed_props` (array) - List of tracked property keys that changed (e.g. `'status'`, `'items'`, `'_tracking_number'`, `'_tracking_url'`, `'_shipping_provider'`)
-- `$old_state` (array) - Snapshot of tracked property values before the update
+- `$changes` (array) - The changes that were applied, as returned by `Fulfillment::get_changes()` before save. Core data properties (e.g. `status`, `is_fulfilled`) sit at the top level; meta-based changes (e.g. `_tracking_number`, `_shipment_provider`, `_items`) are nested under the `meta_data` key.
+- `$previous_status` (string) - The fulfillment status before the update (e.g. `'unfulfilled'`)
-**Purpose:** Allows plugins to perform actions after a fulfillment is updated. Only tracked properties (status, items, tracking number, tracking URL, shipping provider) are compared; changes to other metadata do not appear in `$changed_props`.
+**Purpose:** Allows plugins to perform actions after a fulfillment is updated. All core data and metadata changes are included in `$changes`.
```php
add_action( 'woocommerce_fulfillment_after_update', 'sync_fulfillment_changes', 10, 3 );
-function sync_fulfillment_changes( $fulfillment, $changed_props, $old_state ) {
- // Only sync when tracking info changes
- if ( in_array( '_tracking_number', $changed_props, true ) ) {
+function sync_fulfillment_changes( $fulfillment, $changes, $previous_status ) {
+ // Check if tracking info changed
+ $meta_changes = $changes['meta_data'] ?? array();
+ if ( array_key_exists( '_tracking_number', $meta_changes ) ) {
// Sync tracking to external service
}
+
+ // Check if status changed
+ if ( array_key_exists( 'status', $changes ) ) {
+ // Status changed from $previous_status to $changes['status']
+ }
}
```
diff --git a/plugins/woocommerce/client/admin/docs/features/fulfillments-rest-api.md b/plugins/woocommerce/client/admin/docs/features/fulfillments-rest-api.md
index 29a6d1be903..3f53c4162c2 100644
--- a/plugins/woocommerce/client/admin/docs/features/fulfillments-rest-api.md
+++ b/plugins/woocommerce/client/admin/docs/features/fulfillments-rest-api.md
@@ -69,7 +69,7 @@ Authorization: Basic <base64_encoded_credentials>
},
{
"id": 3,
- "key": "_shipping_provider",
+ "key": "_shipment_provider",
"value": "ups"
}
]
@@ -124,7 +124,7 @@ Authorization: Basic <base64_encoded_credentials>
"value": "1Z999AA1234567890"
},
{
- "key": "_shipping_provider",
+ "key": "_shipment_provider",
"value": "ups"
},
{
@@ -168,7 +168,7 @@ Authorization: Basic <base64_encoded_credentials>
},
{
"id": 5,
- "key": "_shipping_provider",
+ "key": "_shipment_provider",
"value": "ups"
},
{
@@ -221,7 +221,7 @@ Authorization: Basic <base64_encoded_credentials>
},
{
"id": 4,
- "key": "_shipping_provider",
+ "key": "_shipment_provider",
"value": "ups"
}
]
@@ -277,7 +277,7 @@ Authorization: Basic <base64_encoded_credentials>
"value": "1Z999AA1234567890"
},
{
- "key": "_shipping_provider",
+ "key": "_shipment_provider",
"value": "ups"
},
{
@@ -321,7 +321,7 @@ Authorization: Basic <base64_encoded_credentials>
},
{
"id": 5,
- "key": "_shipping_provider",
+ "key": "_shipment_provider",
"value": "ups"
},
{
@@ -411,7 +411,7 @@ Authorization: Basic <base64_encoded_credentials>
},
{
"id": 5,
- "key": "_shipping_provider",
+ "key": "_shipment_provider",
"value": "ups"
},
{
@@ -464,7 +464,7 @@ Authorization: Basic <base64_encoded_credentials>
"value": "1Z999AA9876543210"
},
{
- "key": "_shipping_provider",
+ "key": "_shipment_provider",
"value": "fedex"
},
{
@@ -499,7 +499,7 @@ Authorization: Basic <base64_encoded_credentials>
},
{
"id": 5,
- "key": "_shipping_provider",
+ "key": "_shipment_provider",
"value": "fedex"
},
{
@@ -647,12 +647,12 @@ Metadata objects have the following structure:
### Optional Private Metadata Keys
- `_tracking_number` - Shipment tracking number
-- `_shipping_provider` - Shipping provider key (ups, fedex, dhl, etc.)
+- `_shipment_provider` - Shipment provider key (ups, fedex, dhl, etc.)
- `_tracking_url` - URL to track the shipment
- `_is_locked` - Whether the fulfillment is locked for merchant modification
- `_lock_message` - What to show as the lock message for a locked fulfillment
-**Note:** The tracking number, shipping provider, and tracking URL metadata keys have typed convenience methods on the `Fulfillment` object: `get_tracking_number()` / `set_tracking_number()`, `get_shipping_provider()` / `set_shipping_provider()`, and `get_tracking_url()` / `set_tracking_url()`. These are recommended over direct `get_meta()` / `update_meta_data()` calls for better type safety and IDE autocompletion.
+**Note:** The tracking number, shipping provider, and tracking URL metadata keys have typed convenience methods on the `Fulfillment` object: `get_tracking_number()` / `set_tracking_number()`, `get_shipment_provider()` / `set_shipment_provider()`, and `get_tracking_url()` / `set_tracking_url()`. These are recommended over direct `get_meta()` / `update_meta_data()` calls for better type safety and IDE autocompletion.
### Items Structure
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
index 924f6565e5c..94d5bb29d7b 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
@@ -24,11 +24,6 @@ if ( ! defined( 'ABSPATH' ) ) {
*/
class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data_Store_Interface, FulfillmentsDataStoreInterface {
- /**
- * Tracked meta keys for detecting meaningful fulfillment property changes.
- */
- private const TRACKED_META_KEYS = array( '_tracking_number', '_tracking_url', '_shipping_provider' );
-
/**
* Method to create a new fulfillment in the database.
*
@@ -162,6 +157,7 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
$data->set_id( (int) $fulfillment_data['fulfillment_id'] );
$data->read_meta_data( true );
$data->set_object_read( true );
+ $data->snapshot_meta();
}
/**
@@ -188,9 +184,6 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
$this->validate_items( $data );
- // Snapshot tracked properties before the update so we can detect changes.
- $old_state = $this->snapshot_tracked_state( $data_id );
-
/**
* Filter to modify the fulfillment data before it is updated.
*
@@ -221,6 +214,11 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
global $wpdb;
+ // Capture changes and previous status before set_date_updated (which always
+ // changes) and before apply_changes resets the tracking.
+ $changes = $data->get_changes();
+ $previous_status = $data->get_data()['status'] ?? 'unfulfilled';
+
$data->set_date_updated( current_time( 'mysql' ) );
$wpdb->update(
@@ -253,20 +251,19 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
$data->set_object_read( true );
if ( ! doing_action( 'woocommerce_fulfillment_after_update' ) ) {
- $changed_props = $this->compute_changed_props( $data, $old_state );
-
/**
* Action to perform after a fulfillment is updated.
*
- * @param Fulfillment $data The fulfillment object that was updated.
- * @param array $changed_props List of tracked property keys that changed
- * (e.g. 'status', 'items', '_tracking_number').
- * @param array $old_state Snapshot of tracked property values before the update.
+ * @param Fulfillment $data The fulfillment object that was updated.
+ * @param array $changes The changes that were applied, as returned by
+ * Fulfillment::get_changes() before save. Core data
+ * props at top level, meta changes under 'meta_data'.
+ * @param string $previous_status The fulfillment status before the update.
*
* @since 10.1.0
- * @since 10.7.0 Added $changed_props and $old_state parameters.
+ * @since 10.7.0 Added $changes and $previous_status parameters.
*/
- do_action( 'woocommerce_fulfillment_after_update', $data, $changed_props, $old_state );
+ do_action( 'woocommerce_fulfillment_after_update', $data, $changes, $previous_status );
}
if ( $is_fulfill_action && ! doing_action( 'woocommerce_fulfillment_after_fulfill' ) ) {
@@ -281,58 +278,6 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
}
}
- /**
- * Snapshot tracked properties of a fulfillment from the database.
- *
- * @param int $fulfillment_id The fulfillment ID.
- * @return array The snapshot of tracked property values.
- */
- private function snapshot_tracked_state( int $fulfillment_id ): array {
- $old = new Fulfillment( (string) $fulfillment_id );
-
- $state = array(
- 'status' => $old->get_status() ?? 'unfulfilled',
- 'items' => $old->get_items(),
- );
-
- foreach ( self::TRACKED_META_KEYS as $key ) {
- $value = $old->get_meta( $key, true );
- $state[ $key ] = is_string( $value ) ? $value : '';
- }
-
- return $state;
- }
-
- /**
- * Compute which tracked properties changed between the old state and the updated fulfillment.
- *
- * @param Fulfillment $fulfillment The updated fulfillment object.
- * @param array $old_state The previous state snapshot.
- * @return array List of changed property keys (e.g. 'status', 'items', '_tracking_number').
- */
- private function compute_changed_props( Fulfillment $fulfillment, array $old_state ): array {
- $changed = array();
-
- $new_status = $fulfillment->get_status() ?? 'unfulfilled';
- if ( $new_status !== $old_state['status'] ) {
- $changed[] = 'status';
- }
-
- if ( $fulfillment->get_items() !== $old_state['items'] ) {
- $changed[] = 'items';
- }
-
- foreach ( self::TRACKED_META_KEYS as $key ) {
- $new_value = $fulfillment->get_meta( $key, true );
- $new_value = is_string( $new_value ) ? $new_value : '';
- if ( $new_value !== $old_state[ $key ] ) {
- $changed[] = $key;
- }
- }
-
- return $changed;
- }
-
/**
* Method to delete a fulfillment from the database.
*
@@ -614,6 +559,7 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
// Read the metadata for the fulfillment.
$fulfillment->read_meta_data( true );
+ $fulfillment->snapshot_meta();
$fulfillments[] = $fulfillment;
}
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/Fulfillment.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/Fulfillment.php
index 1960986852a..31fd8f9def9 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/Fulfillment.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/Fulfillment.php
@@ -22,6 +22,30 @@ defined( 'ABSPATH' ) || exit;
* @since 10.1.0
*/
class Fulfillment extends \WC_Data {
+
+ /**
+ * Core data for this object. Name/value pairs.
+ *
+ * @var array
+ */
+ protected $data = array(
+ 'id' => 0,
+ 'entity_type' => null,
+ 'entity_id' => null,
+ 'status' => null,
+ 'is_fulfilled' => false,
+ 'date_updated' => null,
+ 'date_deleted' => null,
+ );
+
+ /**
+ * Snapshot of meta values taken after the object is read from the database.
+ * Used by get_changes() to detect meta-based field changes.
+ *
+ * @var array<string, mixed>
+ */
+ private $meta_snapshot = array();
+
/**
* Fulfillment constructor. Loads fulfillment data.
*
@@ -51,6 +75,74 @@ class Fulfillment extends \WC_Data {
}
}
+ /**
+ * Capture a snapshot of all current meta values.
+ *
+ * Called by the data store after reading so that get_changes() can detect
+ * meta modifications alongside core data property changes.
+ *
+ * @since 10.7.0
+ */
+ public function snapshot_meta(): void {
+ $this->meta_snapshot = array();
+ foreach ( $this->get_meta_data() as $meta ) {
+ $this->meta_snapshot[ $meta->key ] = $meta->value;
+ }
+ }
+
+ /**
+ * Return data changes including meta-based field changes.
+ *
+ * Core data props are tracked by set_prop(); meta-based fields are detected
+ * by comparing current meta values against the snapshot taken on read.
+ *
+ * @since 10.7.0
+ *
+ * @return array
+ */
+ public function get_changes(): array {
+ $changes = parent::get_changes();
+
+ $current_meta = array();
+ foreach ( $this->get_meta_data() as $meta ) {
+ $current_meta[ $meta->key ] = $meta->value;
+ }
+
+ $meta_changes = array();
+
+ // Detect changed or added meta.
+ foreach ( $current_meta as $key => $value ) {
+ if ( ! array_key_exists( $key, $this->meta_snapshot ) || $this->meta_snapshot[ $key ] !== $value ) {
+ $meta_changes[ $key ] = $value;
+ }
+ }
+
+ // Detect deleted meta.
+ foreach ( $this->meta_snapshot as $key => $value ) {
+ if ( ! array_key_exists( $key, $current_meta ) ) {
+ $meta_changes[ $key ] = null;
+ }
+ }
+
+ if ( ! empty( $meta_changes ) ) {
+ $changes['meta_data'] = $meta_changes;
+ }
+
+ return $changes;
+ }
+
+ /**
+ * Merge changes with data, clear changes, and refresh the meta snapshot.
+ *
+ * @since 10.7.0
+ *
+ * @return void
+ */
+ public function apply_changes(): void {
+ parent::apply_changes();
+ $this->snapshot_meta();
+ }
+
/**
* Get the fulfillment ID.
*
@@ -76,7 +168,7 @@ class Fulfillment extends \WC_Data {
* @return string|null Entity type.
*/
public function get_entity_type(): ?string {
- return $this->data['entity_type'] ?? null;
+ return $this->get_prop( 'entity_type' );
}
/**
@@ -85,7 +177,7 @@ class Fulfillment extends \WC_Data {
* @param class-string|null $entity_type Entity type.
*/
public function set_entity_type( ?string $entity_type ): void {
- $this->data['entity_type'] = $entity_type;
+ $this->set_prop( 'entity_type', $entity_type );
}
/**
@@ -94,7 +186,7 @@ class Fulfillment extends \WC_Data {
* @return string|null Entity ID.
*/
public function get_entity_id(): ?string {
- return $this->data['entity_id'] ?? null;
+ return $this->get_prop( 'entity_id' );
}
/**
@@ -103,7 +195,7 @@ class Fulfillment extends \WC_Data {
* @param string|null $entity_id Entity ID.
*/
public function set_entity_id( ?string $entity_id ): void {
- $this->data['entity_id'] = $entity_id;
+ $this->set_prop( 'entity_id', $entity_id );
}
/**
@@ -123,8 +215,7 @@ class Fulfillment extends \WC_Data {
}
// Set the fulfillment status.
$this->set_is_fulfilled( $statuses[ $status ]['is_fulfilled'] ?? false );
- // Set the status in the data array.
- $this->data['status'] = $status;
+ $this->set_prop( 'status', $status );
}
/**
@@ -133,7 +224,7 @@ class Fulfillment extends \WC_Data {
* @return string|null Fulfillment status.
*/
public function get_status(): ?string {
- return $this->data['status'] ?? null;
+ return $this->get_prop( 'status' );
}
/**
@@ -144,7 +235,7 @@ class Fulfillment extends \WC_Data {
* @return void
*/
private function set_is_fulfilled( bool $is_fulfilled ): void {
- $this->data['is_fulfilled'] = $is_fulfilled;
+ $this->set_prop( 'is_fulfilled', $is_fulfilled );
}
/**
@@ -153,7 +244,7 @@ class Fulfillment extends \WC_Data {
* @return bool Whether the fulfillment is fulfilled.
*/
public function get_is_fulfilled(): bool {
- return $this->data['is_fulfilled'] ?? false;
+ return (bool) $this->get_prop( 'is_fulfilled' );
}
/**
@@ -198,7 +289,7 @@ class Fulfillment extends \WC_Data {
* @return string|null Date updated.
*/
public function get_date_updated(): ?string {
- return $this->data['date_updated'] ?? null;
+ return $this->get_prop( 'date_updated' );
}
/**
@@ -207,7 +298,7 @@ class Fulfillment extends \WC_Data {
* @param string|null $date_updated Date updated.
*/
public function set_date_updated( ?string $date_updated ): void {
- $this->data['date_updated'] = $date_updated;
+ $this->set_prop( 'date_updated', $date_updated );
}
/**
@@ -232,7 +323,7 @@ class Fulfillment extends \WC_Data {
* @return string|null Date deleted.
*/
public function get_date_deleted(): ?string {
- return $this->data['date_deleted'] ?? null;
+ return $this->get_prop( 'date_deleted' );
}
/**
@@ -242,7 +333,7 @@ class Fulfillment extends \WC_Data {
* @return void
*/
public function set_date_deleted( ?string $date_deleted ): void {
- $this->data['date_deleted'] = $date_deleted;
+ $this->set_prop( 'date_deleted', $date_deleted );
}
/**
@@ -264,6 +355,24 @@ class Fulfillment extends \WC_Data {
$this->update_meta_data( '_items', array_values( $items ) );
}
+ /**
+ * Get the item count for the fulfillment.
+ *
+ * This method calculates the total quantity of items in the fulfillment.
+ *
+ * @since 10.7.0
+ * @return int Total quantity of items in the fulfillment.
+ */
+ public function get_item_count(): int {
+ return array_reduce(
+ $this->get_items(),
+ function ( int $carry, array $item ) {
+ return $carry + (int) $item['qty'];
+ },
+ 0
+ );
+ }
+
/**
* Get the tracking number.
*
@@ -291,13 +400,13 @@ class Fulfillment extends \WC_Data {
}
/**
- * Get the shipping provider.
+ * Get the shipment provider.
*
* @since 10.7.0
- * @return string|null Shipping provider slug.
+ * @return string|null Shipment provider slug.
*/
- public function get_shipping_provider(): ?string {
- $value = $this->get_meta( '_shipping_provider', true );
+ public function get_shipment_provider(): ?string {
+ $value = $this->get_meta( '_shipment_provider', true );
if ( ! is_scalar( $value ) ) {
return null;
}
@@ -307,13 +416,13 @@ class Fulfillment extends \WC_Data {
}
/**
- * Set the shipping provider.
+ * Set the shipment provider.
*
* @since 10.7.0
- * @param string $shipping_provider Shipping provider slug.
+ * @param string $shipment_provider Shipment provider slug.
*/
- public function set_shipping_provider( string $shipping_provider ): void {
- $this->update_meta_data( '_shipping_provider', $shipping_provider );
+ public function set_shipment_provider( string $shipment_provider ): void {
+ $this->update_meta_data( '_shipment_provider', $shipment_provider );
}
/**
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
index 57aeb0b7c45..775cbe5a400 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
@@ -92,12 +92,13 @@ class FulfillmentOrderNotes {
* tracking number, tracking URL, shipping provider). If the status
* changed, a dedicated status change note is added instead.
*
- * @param Fulfillment $fulfillment The fulfillment object.
- * @param array $changed_props List of tracked property keys that changed.
- * @param array $old_state Snapshot of tracked property values before the update.
+ * @param Fulfillment $fulfillment The fulfillment object (post-update).
+ * @param array $changes Changes as returned by Fulfillment::get_changes() before
+ * save. Core data props at top level, meta under 'meta_data'.
+ * @param string $previous_status The fulfillment status before the update.
*/
- public function add_fulfillment_updated_note( Fulfillment $fulfillment, array $changed_props = array(), array $old_state = array() ): void {
- if ( empty( $changed_props ) ) {
+ public function add_fulfillment_updated_note( Fulfillment $fulfillment, array $changes = array(), string $previous_status = 'unfulfilled' ): void {
+ if ( empty( $changes ) ) {
return;
}
@@ -107,10 +108,9 @@ class FulfillmentOrderNotes {
}
// If status changed, add a dedicated status change note.
- if ( in_array( 'status', $changed_props, true ) ) {
- $old_status = $old_state['status'] ?? 'unfulfilled';
- $new_status = $fulfillment->get_status() ?? 'unfulfilled';
- $this->add_fulfillment_status_changed_note( $fulfillment, $order, $old_status, $new_status );
+ if ( array_key_exists( 'status', $changes ) ) {
+ $new_status = $changes['status'] ?? 'unfulfilled';
+ $this->add_fulfillment_status_changed_note( $fulfillment, $order, $previous_status, $new_status );
return;
}
@@ -324,7 +324,7 @@ class FulfillmentOrderNotes {
*/
private function format_tracking( Fulfillment $fulfillment ): string {
$tracking_number = $fulfillment->get_tracking_number();
- $shipping_provider = $fulfillment->get_shipping_provider();
+ $shipping_provider = $fulfillment->get_shipment_provider();
$tracking_url = $fulfillment->get_tracking_url();
if ( null === $tracking_number ) {
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentUtils.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentUtils.php
index ecc4580544a..a88afb2225b 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentUtils.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentUtils.php
@@ -729,4 +729,30 @@ class FulfillmentUtils {
}
return intval( $tracking_number[11] ) === $check;
}
+
+ /**
+ * Resolve the provider name for a fulfillment.
+ *
+ * For custom providers ("other"), the display name from _provider_name meta is used.
+ * For known providers, the slug from _shipment_provider meta is preferred, but the
+ * display name is used as a fallback when the slug is empty (e.g., when auto-lookup
+ * identified the provider but did not set the slug).
+ *
+ * @since 10.7.0
+ *
+ * @param Fulfillment $fulfillment The fulfillment object.
+ *
+ * @return string The resolved provider name.
+ */
+ public static function resolve_provider_name( Fulfillment $fulfillment ): string {
+ $shipment_provider = $fulfillment->get_shipment_provider() ?? '';
+ $provider_name = $fulfillment->get_meta( '_provider_name', true );
+ $provider_name = ! empty( $provider_name ) ? (string) $provider_name : '';
+
+ if ( 'other' === $shipment_provider ) {
+ return $provider_name;
+ }
+
+ return ! empty( $shipment_provider ) ? $shipment_provider : $provider_name;
+ }
}
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
index 8e9999b4787..ab27e5d7bff 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
@@ -93,8 +93,11 @@ class FulfillmentsController {
global $wpdb;
if ( get_option( 'woocommerce_fulfillments_db_tables_created', false ) ) {
- // The tables already exist, no need to create them again.
- return;
+ // Verify the tables actually exist (the option may be stale after DB resets in tests).
+ $table_exists = $wpdb->get_var( "SHOW TABLES LIKE '{$wpdb->prefix}wc_order_fulfillments'" );
+ if ( $table_exists ) {
+ return;
+ }
}
// Drop the tables if they exist, to ensure a clean slate.
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
index cd43d4f4d9c..abf7e6c84b6 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
@@ -38,6 +38,7 @@ class FulfillmentsManager {
$this->init_fulfillment_status_hooks();
$this->init_refund_hooks();
+ $this->init_email_template_tracking_hooks();
$this->init_order_deletion_hooks();
if ( ! $this->fulfillment_order_notes ) {
@@ -69,6 +70,29 @@ class FulfillmentsManager {
add_action( 'woocommerce_delete_order_refund', array( $this, 'update_fulfillment_status_after_refund_deleted' ), 10, 1 );
}
+ /**
+ * Initialize hooks to track when fulfillment email templates are customized.
+ *
+ * Hooks into the WooCommerce email settings save action for each fulfillment email type
+ * so we can track when merchants customize these templates.
+ */
+ private function init_email_template_tracking_hooks(): void {
+ $fulfillment_email_ids = array(
+ 'customer_fulfillment_created',
+ 'customer_fulfillment_updated',
+ 'customer_fulfillment_deleted',
+ );
+
+ foreach ( $fulfillment_email_ids as $email_id ) {
+ add_action(
+ 'woocommerce_update_options_email_' . $email_id,
+ function () use ( $email_id ) {
+ FulfillmentsTracker::track_fulfillment_email_template_customized( $email_id );
+ }
+ );
+ }
+ }
+
/**
* Initialize order deletion hooks.
*
@@ -483,6 +507,14 @@ class FulfillmentsManager {
$results['possibilities'] = $possibilities; // Include all possibilities for reference.
}
+ if ( isset( $results['shipping_provider'] ) ) {
+ // Record the tracking lookup attempt with url_generated indicating if a tracking URL was constructed.
+ FulfillmentsTracker::track_fulfillment_tracking_lookup_attempt( 'success', $results['shipping_provider'], ! empty( $results['tracking_url'] ) );
+ } else {
+ // If no provider could parse the tracking number, record a failure.
+ FulfillmentsTracker::track_fulfillment_tracking_lookup_attempt( 'not_found', '', false );
+ }
+
return $results;
}
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
index 29cdb79cc08..bfddf24fc85 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
@@ -175,7 +175,7 @@ class FulfillmentsRenderer {
private function render_shipment_provider_column_row_data( WC_Order $order, array $fulfillments ) {
$providers = array();
foreach ( $fulfillments as $fulfillment ) {
- $providers[] = $fulfillment->get_meta( '_shipment_provider' ) ?? null;
+ $providers[] = $fulfillment->get_shipment_provider();
}
$providers = array_filter(
@@ -256,6 +256,7 @@ class FulfillmentsRenderer {
*/
public function handle_fulfillment_bulk_actions( $redirect_to, $action, $post_ids ) {
if ( 'fulfill' === $action ) {
+ FulfillmentsTracker::track_fulfillment_bulk_action_used( 'fulfill_orders', count( $post_ids ) );
foreach ( $post_ids as $post_id ) {
$order = wc_get_order( $post_id );
if ( ! $order ) {
@@ -469,6 +470,10 @@ class FulfillmentsRenderer {
// Ensure the fulfillment status is one of the allowed values.
if ( FulfillmentUtils::is_valid_order_fulfillment_status( $fulfillment_status ) ) {
+ // Only track when the filter is explicitly submitted, not on pagination/refresh.
+ if ( isset( $_GET['filter_action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
+ FulfillmentsTracker::track_fulfillment_filter_used( 'fulfillment_status', $fulfillment_status );
+ }
$meta_query = FulfillmentUtils::get_order_fulfillment_status_meta_query( $fulfillment_status );
if ( ! empty( $meta_query ) ) {
if ( ! isset( $args['meta_query'] ) ) {
@@ -497,6 +502,10 @@ class FulfillmentsRenderer {
$status = sanitize_text_field( wp_unslash( $_GET['fulfillment_status'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
// Ensure the fulfillment status is one of the allowed values.
if ( FulfillmentUtils::is_valid_order_fulfillment_status( $status ) ) {
+ // Only track when the filter is explicitly submitted, not on pagination/refresh.
+ if ( isset( $_GET['filter_action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
+ FulfillmentsTracker::track_fulfillment_filter_used( 'fulfillment_status', $status );
+ }
$query->set(
'meta_query',
'no_fulfillments' === $status ?
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsTracker.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsTracker.php
new file mode 100644
index 00000000000..8112108e3d4
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsTracker.php
@@ -0,0 +1,384 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Admin\Features\Fulfillments;
+
+use WC_Tracks;
+
+/**
+ * FulfillmentsTracker class.
+ *
+ * Centralizes all telemetry for the Fulfillments feature. Every tracked event is recorded via
+ * WC_Tracks::record_event() which sends it to the analytics pipeline with a "wcadmin_" prefix.
+ *
+ * Tracked events (WOOPLUG-5197):
+ *
+ * 1. Core Funnel (Adoption):
+ * - fulfillment_modal_opened : Merchant opens the fulfillment editor drawer. (Frontend only)
+ * - fulfillment_created : A new fulfillment is saved. (REST controller)
+ * - fulfillment_updated : An existing fulfillment is modified. (REST controller)
+ * - fulfillment_deleted : A fulfillment is deleted. (REST controller)
+ *
+ * 2. Tracking Information (Usage Patterns):
+ * - fulfillment_tracking_added : Tracking info is attached to a fulfillment. (REST controller)
+ * - fulfillment_tracking_lookup_attempted : Tracking number auto-lookup is attempted. (FulfillmentsManager)
+ *
+ * 3. Efficiency / Power-User:
+ * - fulfillment_bulk_action_used : Bulk fulfill/unfulfill from the orders list. (FulfillmentsRenderer)
+ * - fulfillment_filter_used : Orders list filtered by fulfillment status/provider. (FulfillmentsRenderer)
+ *
+ * 4. Customer Communication:
+ * - fulfillment_notification_sent : A fulfillment email is queued to the customer. (REST controller)
+ * - fulfillment_email_template_customized : Merchant saves fulfillment email template settings. (FulfillmentsManager)
+ *
+ * 5. Friction / Errors:
+ * - fulfillment_validation_error : A create/update/delete action fails validation. (REST controller)
+ *
+ * @since 10.7.0
+ */
+class FulfillmentsTracker {
+
+ // ──────────────────────────────────────────────
+ // 1. Core Funnel: Fulfillment Creation & Management
+ // ──────────────────────────────────────────────
+
+ /**
+ * Track when a merchant opens the fulfillment editor modal/sidebar.
+ *
+ * Tracked from: Frontend (JS recordEvent).
+ * Measures: Feature discoverability and adoption.
+ *
+ * @since 10.7.0
+ *
+ * @param string $source Where the modal was opened from ("orders_list" or "order_detail_page").
+ * @param int $order_id The ID of the order being viewed.
+ *
+ * @return void
+ */
+ public static function track_fulfillment_modal_opened( string $source, int $order_id ): void {
+ WC_Tracks::record_event(
+ 'fulfillment_modal_opened',
+ array(
+ 'source' => $source,
+ 'order_id' => $order_id,
+ )
+ );
+ }
+
+ /**
+ * Track when a new fulfillment is successfully saved.
+ *
+ * Tracked from: OrderFulfillmentsRestController::create_fulfillment().
+ * Measures: Core adoption; whether merchants create full vs. partial shipments.
+ *
+ * @since 10.7.0
+ *
+ * @param string $source The source of the fulfillment ("fulfillments_modal", "bulk_action", or "api").
+ * @param string $initial_status The initial status of the fulfillment ("draft" or "fulfilled").
+ * @param string $fulfillment_type Whether all remaining items were included ("full" or "partial").
+ * @param int $item_count Total quantity of items in the fulfillment (sum of item quantities).
+ * @param int $total_quantity Total quantity of all items in the order.
+ * @param bool $notification_sent Whether the customer notification was requested.
+ * @return void
+ */
+ public static function track_fulfillment_creation( string $source, string $initial_status, string $fulfillment_type, int $item_count, int $total_quantity, bool $notification_sent ): void {
+ WC_Tracks::record_event(
+ 'fulfillment_created',
+ array(
+ 'source' => $source,
+ 'initial_status' => $initial_status,
+ 'fulfillment_type' => $fulfillment_type,
+ 'item_count' => $item_count,
+ 'total_quantity' => $total_quantity,
+ 'notification_sent' => $notification_sent,
+ )
+ );
+ }
+
+ /**
+ * Track when an existing fulfillment is successfully updated.
+ *
+ * Tracked from: OrderFulfillmentsRestController::update_fulfillment().
+ * Measures: How often merchants modify fulfillments and which fields change most.
+ *
+ * @since 10.7.0
+ *
+ * @param string $source The source of the update ("fulfillments_modal" or "api").
+ * @param int $fulfillment_id The ID of the fulfillment being updated.
+ * @param string $original_status The status before the update ("draft" or "fulfilled").
+ * @param array $changed_fields The changes as returned by Fulfillment::get_changes(). Core data
+ * props (e.g. 'status') at top level, meta changes under 'meta_data'.
+ * Serialized as JSON by WC_Tracks.
+ * @param bool $notification_sent Whether a customer re-notification was requested.
+ *
+ * @return void
+ */
+ public static function track_fulfillment_update( string $source, int $fulfillment_id, string $original_status, array $changed_fields, bool $notification_sent ): void {
+ WC_Tracks::record_event(
+ 'fulfillment_updated',
+ array(
+ 'source' => $source,
+ 'fulfillment_id' => $fulfillment_id,
+ 'original_status' => $original_status,
+ 'changed_fields' => $changed_fields,
+ 'notification_sent' => $notification_sent,
+ )
+ );
+ }
+
+ /**
+ * Track when a fulfillment is successfully deleted.
+ *
+ * Tracked from: OrderFulfillmentsRestController::delete_fulfillment().
+ * Measures: How often merchants remove fulfillments and at what stage.
+ *
+ * @since 10.7.0
+ *
+ * @param string $source The source of the deletion ("fulfillments_modal" or "api").
+ * @param int $fulfillment_id The ID of the fulfillment being deleted.
+ * @param string $status_at_deletion The status at the time of deletion ("draft" or "fulfilled").
+ * @param bool $notification_sent Whether a deletion notification was requested.
+ *
+ * @return void
+ */
+ public static function track_fulfillment_deletion( string $source, int $fulfillment_id, string $status_at_deletion, bool $notification_sent ): void {
+ WC_Tracks::record_event(
+ 'fulfillment_deleted',
+ array(
+ 'source' => $source,
+ 'fulfillment_id' => $fulfillment_id,
+ 'status_at_deletion' => $status_at_deletion,
+ 'notification_sent' => $notification_sent,
+ )
+ );
+ }
+
+ // ──────────────────────────────────────────────
+ // 2. Tracking Information Workflow
+ // ──────────────────────────────────────────────
+
+ /**
+ * Track when tracking information is successfully added to a fulfillment.
+ *
+ * Tracked from: OrderFulfillmentsRestController (create and update flows).
+ * Measures: How merchants add tracking info (auto-lookup vs. manual vs. API) and which
+ * carriers are used. The provider_name property for custom providers is used to
+ * identify the most frequently added custom carriers, informing the roadmap for
+ * expanding native carrier support.
+ *
+ * @since 10.7.0
+ *
+ * @param int $fulfillment_id The ID of the fulfillment to which tracking was added.
+ * @param string $entry_method How the tracking was added ("ui_auto_lookup", "ui_manual_select", "ui_manual_custom", or "api").
+ * @param string $provider_name The name/key of the shipping provider (e.g., "usps", "fedex").
+ * @param bool $is_custom_provider Whether the provider is a custom (non-native) provider.
+ *
+ * @return void
+ */
+ public static function track_fulfillment_tracking_added( int $fulfillment_id, string $entry_method, string $provider_name, bool $is_custom_provider ): void {
+ WC_Tracks::record_event(
+ 'fulfillment_tracking_added',
+ array(
+ 'fulfillment_id' => $fulfillment_id,
+ 'entry_method' => $entry_method,
+ 'provider_name' => $provider_name,
+ 'is_custom_provider' => $is_custom_provider,
+ )
+ );
+ }
+
+ /**
+ * Track when a tracking number auto-lookup is attempted.
+ *
+ * Tracked from: FulfillmentsManager::try_parse_tracking_number().
+ * Measures: Effectiveness of auto-detection. A high failure rate indicates the need to improve
+ * carrier detection logic. The url_generated flag checks if a functional tracking URL
+ * was constructed (a success requires both provider identification AND URL generation).
+ *
+ * @since 10.7.0
+ *
+ * @param string $lookup_status The lookup result ("success" or "not_found").
+ * @param string $provider_identified The standardized carrier name identified (e.g., "usps"). Empty if not found.
+ * @param bool $url_generated Whether the system successfully constructed a tracking URL.
+ *
+ * @return void
+ */
+ public static function track_fulfillment_tracking_lookup_attempt( string $lookup_status, string $provider_identified, bool $url_generated = false ): void {
+ WC_Tracks::record_event(
+ 'fulfillment_tracking_lookup_attempted',
+ array(
+ 'lookup_status' => $lookup_status,
+ 'provider_identified' => $provider_identified,
+ 'url_generated' => $url_generated,
+ )
+ );
+ }
+
+ // ──────────────────────────────────────────────
+ // 3. Efficiency & Power-User Features
+ // ──────────────────────────────────────────────
+
+ /**
+ * Track when a merchant applies a fulfillment-related bulk action from the orders list.
+ *
+ * Tracked from: FulfillmentsRenderer::handle_fulfillment_bulk_actions().
+ * Measures: Whether merchants use the time-saving bulk-fulfill feature.
+ *
+ * @since 10.7.0
+ *
+ * @param string $action The action performed ("fulfill_orders" or "unfulfill_orders").
+ * @param int $order_count The number of orders selected for the bulk action.
+ *
+ * @return void
+ */
+ public static function track_fulfillment_bulk_action_used( string $action, int $order_count ): void {
+ WC_Tracks::record_event(
+ 'fulfillment_bulk_action_used',
+ array(
+ 'action' => $action,
+ 'order_count' => $order_count,
+ )
+ );
+ }
+
+ /**
+ * Track when the orders list is filtered using a fulfillment-related filter.
+ *
+ * Tracked from: FulfillmentsRenderer::filter_orders_list_table_query().
+ * Measures: Whether merchants use fulfillment filters and which values they filter by most.
+ *
+ * @since 10.7.0
+ *
+ * @param string $filter_by The filter field ("fulfillment_status" or "shipping_provider").
+ * @param string $filter_value The specific value selected (e.g., "partially_fulfilled", "usps").
+ *
+ * @return void
+ */
+ public static function track_fulfillment_filter_used( string $filter_by, string $filter_value ): void {
+ WC_Tracks::record_event(
+ 'fulfillment_filter_used',
+ array(
+ 'filter_by' => $filter_by,
+ 'filter_value' => $filter_value,
+ )
+ );
+ }
+
+ // ──────────────────────────────────────────────
+ // 4. Customer Communication
+ // ──────────────────────────────────────────────
+
+ /**
+ * Track when a fulfillment notification email is successfully queued to a customer.
+ *
+ * Tracked from: OrderFulfillmentsRestController (create, update, and delete flows).
+ * Measures: Whether the communication loop is being closed; how often merchants notify customers.
+ *
+ * @since 10.7.0
+ *
+ * @param string $trigger_action The action that triggered the notification ("fulfillment_created", "fulfillment_updated", or "fulfillment_deleted").
+ * @param int $fulfillment_id The ID of the fulfillment.
+ * @param int $order_id The ID of the associated order.
+ *
+ * @return void
+ */
+ public static function track_fulfillment_notification_sent( string $trigger_action, int $fulfillment_id, int $order_id ): void {
+ WC_Tracks::record_event(
+ 'fulfillment_notification_sent',
+ array(
+ 'trigger_action' => $trigger_action,
+ 'fulfillment_id' => $fulfillment_id,
+ 'order_id' => $order_id,
+ )
+ );
+ }
+
+ /**
+ * Track when a merchant saves changes to a fulfillment email template in settings.
+ *
+ * Tracked from: FulfillmentsManager (hooked to woocommerce_update_options_email_{id}).
+ * Measures: Whether merchants customize fulfillment email templates.
+ *
+ * @since 10.7.0
+ *
+ * @param string $template_name The email template ID that was customized (e.g., "customer_fulfillment_created").
+ *
+ * @return void
+ */
+ public static function track_fulfillment_email_template_customized( string $template_name ): void {
+ WC_Tracks::record_event(
+ 'fulfillment_email_template_customized',
+ array(
+ 'template_name' => $template_name,
+ )
+ );
+ }
+
+ // ──────────────────────────────────────────────
+ // 5. Friction & Error Tracking
+ // ──────────────────────────────────────────────
+
+ /**
+ * Track when a fulfillment action fails due to a validation error.
+ *
+ * Tracked from: OrderFulfillmentsRestController (create, update, and delete flows).
+ * Measures: Where users encounter errors; helps proactively identify bugs and UX problems.
+ *
+ * @since 10.7.0
+ *
+ * @param string $action_attempted The action that was attempted ("create", "update", "delete", or "fulfill").
+ * @param string $error_code The error code from the exception.
+ * @param string $source The source of the error ("fulfillments_modal", "bulk_action", or "api").
+ *
+ * @return void
+ */
+ public static function track_fulfillment_validation_error( string $action_attempted, string $error_code, string $source ): void {
+ WC_Tracks::record_event(
+ 'fulfillment_validation_error',
+ array(
+ 'action_attempted' => $action_attempted,
+ 'error_code' => $error_code,
+ 'source' => $source,
+ )
+ );
+ }
+
+ // ──────────────────────────────────────────────
+ // Helpers
+ // ──────────────────────────────────────────────
+
+ /**
+ * Determine the tracking entry method from the request source and fulfillment meta data.
+ *
+ * Maps the shipping option meta value to the standardized entry_method values expected
+ * by the fulfillment_tracking_added event. Whether the provider is custom or native is
+ * tracked separately via the is_custom_provider property on the event.
+ *
+ * - "ui_auto_lookup" : Tracking number was auto-detected via the lookup API.
+ * - "ui_manual" : Merchant manually selected or entered a provider.
+ * - "api" : Tracking was added via the REST API (not through the UI).
+ *
+ * @since 10.7.0
+ *
+ * @param string $source The request source ("fulfillments_modal" or "api").
+ * @param string $shipping_option The shipping option meta value ("tracking-number", "manual-entry", or "no-info").
+ *
+ * @return string The entry method identifier.
+ */
+ public static function determine_tracking_entry_method( string $source, string $shipping_option ): string {
+ if ( 'fulfillments_modal' !== $source ) {
+ return 'api';
+ }
+
+ if ( 'tracking-number' === $shipping_option ) {
+ return 'ui_auto_lookup';
+ }
+
+ if ( 'manual-entry' === $shipping_option ) {
+ return 'ui_manual';
+ }
+
+ return 'api';
+ }
+}
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
index 5773bf432bf..9f8e7b979d6 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
@@ -251,6 +251,19 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
public function create_fulfillment( WP_REST_Request $request ) {
$order_id = (int) $request->get_param( 'order_id' );
$notify_customer = (bool) $request->get_param( 'notify_customer' );
+
+ $order = wc_get_order( $order_id );
+
+ if ( ! $order ) {
+ // If the order does not exist, return an error.
+ FulfillmentsTracker::track_fulfillment_validation_error( 'create', 'woocommerce_rest_order_invalid_id', $this->check_request_source( $request ) );
+ return $this->prepare_error_response(
+ 'woocommerce_rest_order_invalid_id',
+ esc_html__( 'Invalid order ID.', 'woocommerce' ),
+ WP_Http::NOT_FOUND
+ );
+ }
+
// Create a new fulfillment.
try {
$fulfillment = new Fulfillment();
@@ -261,6 +274,18 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
$fulfillment->save();
+ FulfillmentsTracker::track_fulfillment_creation(
+ $this->check_request_source( $request ),
+ $fulfillment->get_is_fulfilled() ? 'fulfilled' : 'draft',
+ $fulfillment->get_item_count() === (int) $order->get_item_count() ? 'full' : 'partial',
+ $fulfillment->get_item_count(),
+ (int) $order->get_item_count(),
+ $notify_customer
+ );
+
+ // Track if tracking information was added with this new fulfillment.
+ $this->maybe_track_tracking_added( $fulfillment, $request );
+
if ( $fulfillment->get_is_fulfilled() && $notify_customer ) {
/**
* Trigger the fulfillment created notification on creating a fulfilled fulfillment.
@@ -268,15 +293,17 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @since 10.1.0
*/
do_action( 'woocommerce_fulfillment_created_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
+ FulfillmentsTracker::track_fulfillment_notification_sent( 'fulfillment_created', $fulfillment->get_id(), $order_id );
}
} catch ( ApiException $ex ) {
+ FulfillmentsTracker::track_fulfillment_validation_error( 'create', $ex->getErrorCode(), $this->check_request_source( $request ) );
return $this->prepare_error_response(
$ex->getErrorCode(),
$ex->getMessage(),
WP_Http::BAD_REQUEST
);
-
- } catch ( \Throwable $e ) {
+ } catch ( \Exception $e ) {
+ FulfillmentsTracker::track_fulfillment_validation_error( 'create', (string) $e->getCode(), $this->check_request_source( $request ) );
return $this->prepare_error_response(
$e->getCode(),
$e->getMessage(),
@@ -336,10 +363,22 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
$notify_customer = (bool) $request->get_param( 'notify_customer' );
+ $order = wc_get_order( $order_id );
+ if ( ! $order ) {
+ // If the order does not exist, return an error.
+ FulfillmentsTracker::track_fulfillment_validation_error( 'update', 'woocommerce_rest_order_invalid_id', $this->check_request_source( $request ) );
+ return $this->prepare_error_response(
+ 'woocommerce_rest_order_invalid_id',
+ esc_html__( 'Invalid order ID.', 'woocommerce' ),
+ WP_Http::NOT_FOUND
+ );
+ }
+
// Update the fulfillment for the order.
try {
- $fulfillment = new Fulfillment( $fulfillment_id );
- $previous_state = $fulfillment->get_is_fulfilled();
+ $fulfillment = new Fulfillment( $fulfillment_id );
+ $previous_state = $fulfillment->get_is_fulfilled();
+ $previous_status = $fulfillment->get_status() ?? 'unfulfilled';
$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );
$fulfillment->set_props( $request->get_json_params() );
@@ -359,8 +398,22 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
}
}
}
+
+ $changed_fields = $fulfillment->get_changes();
+
$fulfillment->save();
+ FulfillmentsTracker::track_fulfillment_update(
+ $this->check_request_source( $request ),
+ $fulfillment->get_id(),
+ $previous_status,
+ $changed_fields,
+ $notify_customer
+ );
+
+ // Track if tracking information was added or changed in this update.
+ $this->maybe_track_tracking_added( $fulfillment, $request, $changed_fields );
+
if ( $notify_customer ) {
if ( ! $previous_state && $next_state ) {
/**
@@ -369,6 +422,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @since 10.1.0
*/
do_action( 'woocommerce_fulfillment_created_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
+ FulfillmentsTracker::track_fulfillment_notification_sent( 'fulfillment_created', $fulfillment->get_id(), $order_id );
} elseif ( $next_state ) {
/**
* Trigger the fulfillment updated notification on updating a fulfillment.
@@ -376,15 +430,18 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @since 10.1.0
*/
do_action( 'woocommerce_fulfillment_updated_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
+ FulfillmentsTracker::track_fulfillment_notification_sent( 'fulfillment_updated', $fulfillment->get_id(), $order_id );
}
}
} catch ( ApiException $ex ) {
+ FulfillmentsTracker::track_fulfillment_validation_error( 'update', $ex->getErrorCode(), $this->check_request_source( $request ) );
return $this->prepare_error_response(
$ex->getErrorCode(),
$ex->getMessage(),
WP_Http::BAD_REQUEST
);
- } catch ( \Throwable $e ) {
+ } catch ( \Exception $e ) {
+ FulfillmentsTracker::track_fulfillment_validation_error( 'update', (string) $e->getCode(), $this->check_request_source( $request ) );
return $this->prepare_error_response(
$e->getCode(),
$e->getMessage(),
@@ -414,14 +471,23 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
try {
$fulfillment = new Fulfillment( $fulfillment_id );
$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );
+ $status = $fulfillment->get_status() ?? 'unfulfilled';
$fulfillment->delete();
+ FulfillmentsTracker::track_fulfillment_deletion(
+ $this->check_request_source( $request ),
+ $fulfillment_id,
+ $status,
+ $notify_customer
+ );
} catch ( ApiException $ex ) {
+ FulfillmentsTracker::track_fulfillment_validation_error( 'delete', $ex->getErrorCode(), $this->check_request_source( $request ) );
return $this->prepare_error_response(
$ex->getErrorCode(),
$ex->getMessage(),
WP_Http::BAD_REQUEST
);
- } catch ( \Throwable $e ) {
+ } catch ( \Exception $e ) {
+ FulfillmentsTracker::track_fulfillment_validation_error( 'delete', (string) $e->getCode(), $this->check_request_source( $request ) );
return $this->prepare_error_response(
$e->getCode(),
$e->getMessage(),
@@ -436,7 +502,9 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @since 10.1.0
*/
do_action( 'woocommerce_fulfillment_deleted_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
+ FulfillmentsTracker::track_fulfillment_notification_sent( 'fulfillment_deleted', $fulfillment_id, $order_id );
}
+
return new WP_REST_Response(
array(
'message' => __( 'Fulfillment deleted successfully.', 'woocommerce' ),
@@ -1152,4 +1220,69 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
throw new \Exception( esc_html__( 'Invalid fulfillment ID.', 'woocommerce' ) );
}
}
+
+ /**
+ * Check the request source by inspecting headers or parameters.
+ *
+ * @param WP_REST_Request $request The request object.
+ *
+ * @return string The request source identifier.
+ *
+ * @phpstan-ignore-next-line missingType.generics
+ */
+ protected function check_request_source( WP_REST_Request $request ): string {
+ // Check for a custom header.
+ if ( $request->get_header( 'X-WC-Fulfillments-UI' ) ) {
+ return 'fulfillments_modal';
+ }
+
+ return 'api'; // Default to API if no specific source is identified.
+ }
+
+ /**
+ * Track fulfillment_tracking_added if tracking information was added or changed.
+ *
+ * For new fulfillments ($changes is empty), fires whenever a tracking number is present.
+ * For updates, only fires when tracking-related meta (_tracking_number, _shipment_provider,
+ * or _tracking_url) actually changed.
+ *
+ * @param Fulfillment $fulfillment The fulfillment object (after save).
+ * @param WP_REST_Request $request The original request.
+ * @param array $changes The changes from Fulfillment::get_changes(), empty for creates.
+ *
+ * @phpstan-ignore-next-line missingType.generics
+ */
+ private function maybe_track_tracking_added( Fulfillment $fulfillment, WP_REST_Request $request, array $changes = array() ): void {
+ $tracking_number = $fulfillment->get_tracking_number();
+ if ( empty( $tracking_number ) ) {
+ return;
+ }
+
+ // For updates, only track when tracking-related meta actually changed.
+ if ( ! empty( $changes ) ) {
+ $meta_changes = $changes['meta_data'] ?? array();
+ $tracking_changed = array_key_exists( '_tracking_number', $meta_changes )
+ || array_key_exists( '_shipment_provider', $meta_changes )
+ || array_key_exists( '_tracking_url', $meta_changes );
+ if ( ! $tracking_changed ) {
+ return;
+ }
+ }
+
+ $source = $this->check_request_source( $request );
+ $shipping_option = $fulfillment->get_meta( '_shipping_option', true );
+ $shipping_option = ! empty( $shipping_option ) ? $shipping_option : '';
+ $shipment_provider = $fulfillment->get_shipment_provider() ?? '';
+ $is_custom = 'other' === $shipment_provider;
+
+ $entry_method = FulfillmentsTracker::determine_tracking_entry_method( $source, $shipping_option );
+ $resolved_provider = FulfillmentUtils::resolve_provider_name( $fulfillment );
+
+ FulfillmentsTracker::track_fulfillment_tracking_added(
+ $fulfillment->get_id(),
+ $entry_method,
+ $resolved_provider,
+ $is_custom
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-emails-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-emails-tests.php
index deb305d6e0c..755c314cfda 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-emails-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-emails-tests.php
@@ -1,4 +1,5 @@
<?php
+declare( strict_types = 1 );
use Automattic\WooCommerce\Admin\Features\Fulfillments\Fulfillment;
use Automattic\WooCommerce\Tests\Admin\Features\Fulfillments\Helpers\FulfillmentsHelper;
@@ -68,6 +69,8 @@ class WC_Emails_Tests extends \WC_Unit_Test_Case {
*/
public function test_fulfillment_meta() {
// Ensure the FulfillmentsController is registered, which is necessary for the translation of meta keys.
+ // Delete the DB tables flag to force recreation in case another test class left stale state.
+ delete_option( 'woocommerce_fulfillments_db_tables_created' );
update_option( 'woocommerce_feature_fulfillments_enabled', 'yes' );
$container = wc_get_container();
$controller = $container->get( \Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsController::class );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
index 926f446d805..c08235417ed 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
@@ -1,4 +1,5 @@
<?php
+declare( strict_types = 1 );
use Automattic\WooCommerce\Admin\Notes\Note;
@@ -127,7 +128,6 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
WC_Install::delete_obsolete_notes();
$this->assertEmpty( $data_store->get_notes_with_name( $note_name ) );
-
}
/**
@@ -354,6 +354,9 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
* @return void
*/
public function test_order_stats_schema_does_not_include_fulfillment_status_for_new_install_without_fulfillments_feature_enabled(): void {
+ // Ensure the fulfillments feature is disabled (a prior test class may have enabled it).
+ delete_option( 'woocommerce_feature_fulfillments_enabled' );
+
// Mock is_new_install to return true.
$version = null;
$shop_id = null;
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php
index f33718ee7f6..5ba494fade6 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php
@@ -83,7 +83,8 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
}
);
- $this->store->create( $this->get_test_fulfillment( $this->order->get_id() ) );
+ $fulfillment = $this->get_test_fulfillment( $this->order->get_id() );
+ $this->store->create( $fulfillment );
$this->assertTrue( $hook_called, 'The fulfillment before create hook was not called.' );
$fulfillments = $this->store->read_fulfillments( WC_Order::class, (string) $this->order->get_id() );
$this->assertCount( 1, $fulfillments, 'Fulfillment was not created.' );
@@ -105,7 +106,8 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
$this->expectException( \Exception::class );
$this->expectExceptionMessage( 'Fulfillment creation prevented by hook.' );
- $this->store->create( $this->get_test_fulfillment( $this->order->get_id() ) );
+ $fulfillment = $this->get_test_fulfillment( $this->order->get_id() );
+ $this->store->create( $fulfillment );
$this->assertTrue( $hook_called, 'The fulfillment before create hook was not called.' );
@@ -131,7 +133,8 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
2
);
- $this->store->create( $this->get_test_fulfillment( $this->order->get_id() ) );
+ $fulfillment = $this->get_test_fulfillment( $this->order->get_id() );
+ $this->store->create( $fulfillment );
$this->assertTrue( $hook_called, 'The fulfillment after create hook was not called.' );
// Compare the received fulfillment with the expected data.
@@ -217,17 +220,17 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
$fulfillment->save();
$this->assertNotNull( $fulfillment->get_id(), 'Fulfillment ID should not be null.' );
- $hook_called = false;
- $received_fulfillment = null;
- $received_changed = null;
- $received_old_state = null;
+ $hook_called = false;
+ $received_fulfillment = null;
+ $received_changes = null;
+ $received_previous_status = null;
add_action(
'woocommerce_fulfillment_after_update',
- function ( $fulfillment, $changed_props, $old_state ) use ( &$hook_called, &$received_fulfillment, &$received_changed, &$received_old_state ) {
- $received_fulfillment = $fulfillment;
- $received_changed = $changed_props;
- $received_old_state = $old_state;
- $hook_called = true;
+ function ( $fulfillment, $changes, $previous_status ) use ( &$hook_called, &$received_fulfillment, &$received_changes, &$received_previous_status ) {
+ $received_fulfillment = $fulfillment;
+ $received_changes = $changes;
+ $received_previous_status = $previous_status;
+ $hook_called = true;
},
10,
3
@@ -235,7 +238,7 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
$old_status = $fulfillment->get_status();
- // Modify a tracked property so changed_props is populated.
+ // Modify a tracked property so changes are populated.
$fulfillment->set_status( 'fulfilled' );
$fulfillment->add_meta_data( 'test_meta_update', 'test_meta_value' );
@@ -253,12 +256,11 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
$this->assertEquals( $received_fulfillment->get_meta( 'test_meta_update' ), $fulfillment->get_meta( 'test_meta_update' ) );
$this->assertEquals( $received_fulfillment->get_items(), $fulfillment->get_items() );
- // Verify changed_props and old_state are passed correctly.
- $this->assertIsArray( $received_changed );
- $this->assertContains( 'status', $received_changed );
- $this->assertIsArray( $received_old_state );
- $this->assertArrayHasKey( 'status', $received_old_state );
- $this->assertEquals( $old_status, $received_old_state['status'] );
+ // Verify changes and previous_status are passed correctly.
+ $this->assertIsArray( $received_changes );
+ $this->assertArrayHasKey( 'status', $received_changes );
+ $this->assertSame( 'fulfilled', $received_changes['status'] );
+ $this->assertEquals( $old_status, $received_previous_status );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
index 524e7d31373..0dbccb46ce3 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
@@ -219,7 +219,7 @@ class FulfillmentOrderNotesTest extends \WC_Unit_Test_Case {
),
),
'_tracking_number' => 'TRACK123456',
- '_shipping_provider' => 'fedex',
+ '_shipment_provider' => 'fedex',
)
);
@@ -305,7 +305,7 @@ class FulfillmentOrderNotesTest extends \WC_Unit_Test_Case {
// Update with tracking info (non-status change).
$fulfillment->update_meta_data( '_tracking_number', 'UPS999' );
- $fulfillment->update_meta_data( '_shipping_provider', 'ups' );
+ $fulfillment->update_meta_data( '_shipment_provider', 'ups' );
$fulfillment->update_meta_data( '_tracking_url', 'https://ups.com/track/UPS999' );
$fulfillment->save();
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentTest.php
index d2c6d1e8764..7cdfd260173 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentTest.php
@@ -239,6 +239,159 @@ class FulfillmentTest extends \WC_Unit_Test_Case {
$this->assertEquals( '', $fulfillment->get_meta( '_lock_message' ) );
}
+ /**
+ * @testdox get_changes returns empty array when nothing is modified on a persisted fulfillment.
+ */
+ public function test_get_changes_returns_empty_when_nothing_changed(): void {
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_id' => 123,
+ )
+ );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+
+ $this->assertEmpty( $reloaded->get_changes(), 'A freshly loaded fulfillment should have no changes' );
+ }
+
+ /**
+ * @testdox get_changes detects core data property changes via set_prop.
+ */
+ public function test_get_changes_detects_core_data_changes(): void {
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_id' => 123,
+ 'status' => 'unfulfilled',
+ )
+ );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+ $reloaded->set_status( 'fulfilled' );
+
+ $changes = $reloaded->get_changes();
+
+ $this->assertArrayHasKey( 'status', $changes );
+ $this->assertSame( 'fulfilled', $changes['status'] );
+ $this->assertArrayHasKey( 'is_fulfilled', $changes );
+ $this->assertTrue( $changes['is_fulfilled'] );
+ }
+
+ /**
+ * @testdox get_changes detects meta-based field changes under the meta_data key.
+ */
+ public function test_get_changes_detects_meta_changes(): void {
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_id' => 123,
+ )
+ );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+ $reloaded->set_tracking_number( '1Z999AA10123456784' );
+ $reloaded->set_shipment_provider( 'ups' );
+
+ $changes = $reloaded->get_changes();
+
+ $this->assertArrayHasKey( 'meta_data', $changes );
+ $this->assertArrayHasKey( '_tracking_number', $changes['meta_data'] );
+ $this->assertSame( '1Z999AA10123456784', $changes['meta_data']['_tracking_number'] );
+ $this->assertArrayHasKey( '_shipment_provider', $changes['meta_data'] );
+ $this->assertSame( 'ups', $changes['meta_data']['_shipment_provider'] );
+ }
+
+ /**
+ * @testdox get_changes detects both core data and meta changes together.
+ */
+ public function test_get_changes_detects_core_and_meta_changes_together(): void {
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_id' => 123,
+ 'status' => 'unfulfilled',
+ )
+ );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+ $reloaded->set_status( 'fulfilled' );
+ $reloaded->set_tracking_number( 'TRACK123' );
+
+ $changes = $reloaded->get_changes();
+
+ $this->assertArrayHasKey( 'status', $changes, 'Core data change should be at top level' );
+ $this->assertArrayHasKey( 'meta_data', $changes, 'Meta changes should be under meta_data key' );
+ $this->assertArrayHasKey( '_tracking_number', $changes['meta_data'] );
+ }
+
+ /**
+ * @testdox get_changes detects custom metadata changes added via update_meta_data.
+ */
+ public function test_get_changes_detects_custom_meta_changes(): void {
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_id' => 123,
+ )
+ );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+ $reloaded->update_meta_data( '_custom_field', 'custom_value' );
+
+ $changes = $reloaded->get_changes();
+
+ $this->assertArrayHasKey( 'meta_data', $changes );
+ $this->assertArrayHasKey( '_custom_field', $changes['meta_data'] );
+ $this->assertSame( 'custom_value', $changes['meta_data']['_custom_field'] );
+ }
+
+ /**
+ * @testdox get_changes detects deleted metadata.
+ */
+ public function test_get_changes_detects_deleted_meta(): void {
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_id' => 123,
+ ),
+ array(
+ '_custom_key' => 'some_value',
+ '_items' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ )
+ );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+ $reloaded->delete_meta_data( '_custom_key' );
+
+ $changes = $reloaded->get_changes();
+
+ $this->assertArrayHasKey( 'meta_data', $changes );
+ $this->assertArrayHasKey( '_custom_key', $changes['meta_data'] );
+ $this->assertNull( $changes['meta_data']['_custom_key'] );
+ }
+
+ /**
+ * @testdox apply_changes resets change tracking so get_changes returns empty.
+ */
+ public function test_apply_changes_resets_change_tracking(): void {
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_id' => 123,
+ 'status' => 'unfulfilled',
+ )
+ );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+ $reloaded->set_status( 'fulfilled' );
+ $reloaded->set_tracking_number( 'TRACK123' );
+
+ $this->assertNotEmpty( $reloaded->get_changes(), 'Should have changes before apply_changes' );
+
+ $reloaded->save();
+
+ $this->assertEmpty( $reloaded->get_changes(), 'Should have no changes after save' );
+ }
+
/**
* Test that the fulfillment status is validated correctly, and the fallback doesn't change is_fulfilled flag.
*/
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentUtilsTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentUtilsTest.php
index 8795e80ccb5..9e87632abaa 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentUtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentUtilsTest.php
@@ -2,7 +2,9 @@
namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments;
+use Automattic\WooCommerce\Admin\Features\Fulfillments\Fulfillment;
use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentUtils;
+use Automattic\WooCommerce\Tests\Admin\Features\Fulfillments\Helpers\FulfillmentsHelper;
/**
* FulfillmentUtilsTest class.
@@ -85,4 +87,85 @@ class FulfillmentUtilsTest extends \WC_Unit_Test_Case {
$this->assertArrayHasKey( 'custom_status', $fulfillment_statuses );
$this->assertEquals( 'Custom Status', $fulfillment_statuses['custom_status']['label'] );
}
+
+ /**
+ * @testdox resolve_provider_name returns slug for known providers.
+ */
+ public function test_resolve_provider_name_returns_slug_for_known_providers(): void {
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array( 'entity_id' => 123 ),
+ array(
+ '_items' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ '_shipment_provider' => 'usps',
+ '_provider_name' => 'USPS',
+ )
+ );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+
+ $this->assertSame( 'usps', FulfillmentUtils::resolve_provider_name( $reloaded ) );
+ }
+
+ /**
+ * @testdox resolve_provider_name returns display name for custom providers.
+ */
+ public function test_resolve_provider_name_returns_display_name_for_custom_providers(): void {
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array( 'entity_id' => 123 ),
+ array(
+ '_items' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ '_shipment_provider' => 'other',
+ '_provider_name' => 'My Custom Carrier',
+ )
+ );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+
+ $this->assertSame( 'My Custom Carrier', FulfillmentUtils::resolve_provider_name( $reloaded ) );
+ }
+
+ /**
+ * @testdox resolve_provider_name falls back to display name when slug is empty (auto-lookup case).
+ */
+ public function test_resolve_provider_name_falls_back_to_display_name_when_slug_empty(): void {
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array( 'entity_id' => 123 ),
+ array(
+ '_items' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ '_provider_name' => 'UPS',
+ )
+ );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+
+ $this->assertSame( 'UPS', FulfillmentUtils::resolve_provider_name( $reloaded ) );
+ }
+
+ /**
+ * @testdox resolve_provider_name returns empty string when no provider info is set.
+ */
+ public function test_resolve_provider_name_returns_empty_when_no_provider_info(): void {
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array( 'entity_id' => 123 )
+ );
+
+ $reloaded = new Fulfillment( $fulfillment->get_id() );
+
+ $this->assertSame( '', FulfillmentUtils::resolve_provider_name( $reloaded ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsManagerTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsManagerTest.php
index e3477aabbdd..7766666d6c2 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsManagerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsManagerTest.php
@@ -357,8 +357,8 @@ class FulfillmentsManagerTest extends \WC_Unit_Test_Case {
add_filter(
'woocommerce_fulfillment_shipping_providers',
function ( $providers ) {
- $providers = array();
- return $providers;
+ unset( $providers );
+ return array();
}
);
@@ -366,4 +366,22 @@ class FulfillmentsManagerTest extends \WC_Unit_Test_Case {
$parsed_number = $this->manager->try_parse_tracking_number( $tracking_number, 'US', 'CA' );
$this->assertEquals( array(), $parsed_number );
}
+
+ /**
+ * @testdox Email template tracking hooks are registered for all fulfillment email types.
+ */
+ public function test_email_template_tracking_hooks_are_registered(): void {
+ $email_ids = array(
+ 'customer_fulfillment_created',
+ 'customer_fulfillment_updated',
+ 'customer_fulfillment_deleted',
+ );
+
+ foreach ( $email_ids as $email_id ) {
+ $this->assertNotFalse(
+ has_action( 'woocommerce_update_options_email_' . $email_id ),
+ "Tracking hook should be registered for woocommerce_update_options_email_{$email_id}"
+ );
+ }
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php
index 9806fc3e8c6..572680d41b5 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php
@@ -88,9 +88,9 @@ class FulfillmentsRendererTest extends \WC_Unit_Test_Case {
$fulfillment = new Fulfillment();
$fulfillment->set_entity_type( WC_Order::class );
$fulfillment->set_entity_id( (string) $order->get_id() );
- $fulfillment->add_meta_data( '_tracking_number', '123456789' );
- $fulfillment->add_meta_data( '_tracking_url', 'https://example.com/track/123456789' );
- $fulfillment->add_meta_data( '_shipment_provider', 'UPS' );
+ $fulfillment->set_tracking_number( '123456789' );
+ $fulfillment->set_tracking_url( 'https://example.com/track/123456789' );
+ $fulfillment->set_shipment_provider( 'UPS' );
$fulfillment->set_items(
array(
array(
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsTrackerTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsTrackerTest.php
new file mode 100644
index 00000000000..76b33b380ed
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsTrackerTest.php
@@ -0,0 +1,188 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments;
+
+use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsTracker;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the FulfillmentsTracker class.
+ */
+class FulfillmentsTrackerTest extends WC_Unit_Test_Case {
+
+ /**
+ * @testdox determine_tracking_entry_method returns api when source is api.
+ */
+ public function test_determine_entry_method_returns_api_for_api_source(): void {
+ $result = FulfillmentsTracker::determine_tracking_entry_method( 'api', 'tracking-number' );
+
+ $this->assertSame( 'api', $result, 'API source should always return api entry method' );
+ }
+
+ /**
+ * @testdox determine_tracking_entry_method returns ui_auto_lookup for tracking-number option from UI.
+ */
+ public function test_determine_entry_method_returns_ui_auto_lookup(): void {
+ $result = FulfillmentsTracker::determine_tracking_entry_method( 'fulfillments_modal', 'tracking-number' );
+
+ $this->assertSame( 'ui_auto_lookup', $result, 'tracking-number option from modal should return ui_auto_lookup' );
+ }
+
+ /**
+ * @testdox determine_tracking_entry_method returns ui_manual for manual-entry from UI.
+ */
+ public function test_determine_entry_method_returns_ui_manual(): void {
+ $result = FulfillmentsTracker::determine_tracking_entry_method( 'fulfillments_modal', 'manual-entry' );
+
+ $this->assertSame( 'ui_manual', $result, 'manual-entry from modal should return ui_manual' );
+ }
+
+ /**
+ * @testdox determine_tracking_entry_method returns api for unknown shipping option from UI.
+ */
+ public function test_determine_entry_method_returns_api_for_unknown_option(): void {
+ $result = FulfillmentsTracker::determine_tracking_entry_method( 'fulfillments_modal', 'no-info' );
+
+ $this->assertSame( 'api', $result, 'Unknown shipping option from modal should fall back to api' );
+ }
+
+ /**
+ * @testdox determine_tracking_entry_method returns api for empty shipping option from UI.
+ */
+ public function test_determine_entry_method_returns_api_for_empty_option(): void {
+ $result = FulfillmentsTracker::determine_tracking_entry_method( 'fulfillments_modal', '' );
+
+ $this->assertSame( 'api', $result, 'Empty shipping option from modal should fall back to api' );
+ }
+
+ /**
+ * Data provider for entry method scenarios.
+ *
+ * @return array<string, array{string, string, string}>
+ */
+ public function entry_method_data_provider(): array {
+ return array(
+ 'API with tracking-number' => array( 'api', 'tracking-number', 'api' ),
+ 'API with manual-entry' => array( 'api', 'manual-entry', 'api' ),
+ 'UI auto lookup' => array( 'fulfillments_modal', 'tracking-number', 'ui_auto_lookup' ),
+ 'UI manual entry' => array( 'fulfillments_modal', 'manual-entry', 'ui_manual' ),
+ 'UI no-info falls back to api' => array( 'fulfillments_modal', 'no-info', 'api' ),
+ 'Unknown source falls back' => array( 'bulk_action', 'tracking-number', 'api' ),
+ );
+ }
+
+ /**
+ * @testdox determine_tracking_entry_method returns expected value for various input combinations.
+ *
+ * @dataProvider entry_method_data_provider
+ *
+ * @param string $source The request source.
+ * @param string $shipping_option The shipping option.
+ * @param string $expected The expected entry method.
+ */
+ public function test_determine_entry_method_data_provider( string $source, string $shipping_option, string $expected ): void {
+ $result = FulfillmentsTracker::determine_tracking_entry_method( $source, $shipping_option );
+
+ $this->assertSame( $expected, $result );
+ }
+
+ /**
+ * @testdox track_fulfillment_modal_opened is callable with expected parameters.
+ */
+ public function test_track_fulfillment_modal_opened_is_callable(): void {
+ $this->assertTrue(
+ method_exists( FulfillmentsTracker::class, 'track_fulfillment_modal_opened' ),
+ 'track_fulfillment_modal_opened method should exist'
+ );
+
+ $reflection = new \ReflectionMethod( FulfillmentsTracker::class, 'track_fulfillment_modal_opened' );
+ $params = $reflection->getParameters();
+
+ $this->assertCount( 2, $params, 'track_fulfillment_modal_opened should accept 2 parameters' );
+ $this->assertSame( 'source', $params[0]->getName() );
+ $this->assertSame( 'order_id', $params[1]->getName() );
+ }
+
+ /**
+ * @testdox track_fulfillment_creation is callable with expected parameters.
+ */
+ public function test_track_fulfillment_creation_is_callable(): void {
+ $reflection = new \ReflectionMethod( FulfillmentsTracker::class, 'track_fulfillment_creation' );
+ $params = $reflection->getParameters();
+
+ $this->assertCount( 6, $params, 'track_fulfillment_creation should accept 6 parameters' );
+ $this->assertSame( 'source', $params[0]->getName() );
+ $this->assertSame( 'initial_status', $params[1]->getName() );
+ $this->assertSame( 'fulfillment_type', $params[2]->getName() );
+ $this->assertSame( 'item_count', $params[3]->getName() );
+ $this->assertSame( 'total_quantity', $params[4]->getName() );
+ $this->assertSame( 'notification_sent', $params[5]->getName() );
+ }
+
+ /**
+ * @testdox track_fulfillment_tracking_added is callable with expected parameters.
+ */
+ public function test_track_fulfillment_tracking_added_is_callable(): void {
+ $reflection = new \ReflectionMethod( FulfillmentsTracker::class, 'track_fulfillment_tracking_added' );
+ $params = $reflection->getParameters();
+
+ $this->assertCount( 4, $params, 'track_fulfillment_tracking_added should accept 4 parameters' );
+ $this->assertSame( 'fulfillment_id', $params[0]->getName() );
+ $this->assertSame( 'entry_method', $params[1]->getName() );
+ $this->assertSame( 'provider_name', $params[2]->getName() );
+ $this->assertSame( 'is_custom_provider', $params[3]->getName() );
+ }
+
+ /**
+ * @testdox track_fulfillment_tracking_lookup_attempt accepts url_generated parameter.
+ */
+ public function test_track_fulfillment_tracking_lookup_attempt_has_url_generated_param(): void {
+ $reflection = new \ReflectionMethod( FulfillmentsTracker::class, 'track_fulfillment_tracking_lookup_attempt' );
+ $params = $reflection->getParameters();
+
+ $this->assertCount( 3, $params, 'track_fulfillment_tracking_lookup_attempt should accept 3 parameters' );
+ $this->assertSame( 'url_generated', $params[2]->getName() );
+ $this->assertTrue( $params[2]->isDefaultValueAvailable(), 'url_generated should have a default value' );
+ $this->assertFalse( $params[2]->getDefaultValue(), 'url_generated should default to false' );
+ }
+
+ /**
+ * @testdox track_fulfillment_email_template_customized is callable with expected parameters.
+ */
+ public function test_track_fulfillment_email_template_customized_is_callable(): void {
+ $reflection = new \ReflectionMethod( FulfillmentsTracker::class, 'track_fulfillment_email_template_customized' );
+ $params = $reflection->getParameters();
+
+ $this->assertCount( 1, $params, 'track_fulfillment_email_template_customized should accept 1 parameter' );
+ $this->assertSame( 'template_name', $params[0]->getName() );
+ }
+
+ /**
+ * @testdox track_fulfillment_validation_error is callable with expected parameters.
+ */
+ public function test_track_fulfillment_validation_error_is_callable(): void {
+ $reflection = new \ReflectionMethod( FulfillmentsTracker::class, 'track_fulfillment_validation_error' );
+ $params = $reflection->getParameters();
+
+ $this->assertCount( 3, $params, 'track_fulfillment_validation_error should accept 3 parameters' );
+ $this->assertSame( 'action_attempted', $params[0]->getName() );
+ $this->assertSame( 'error_code', $params[1]->getName() );
+ $this->assertSame( 'source', $params[2]->getName() );
+ }
+
+ /**
+ * @testdox All tracker methods are static.
+ */
+ public function test_all_tracker_methods_are_static(): void {
+ $reflection = new \ReflectionClass( FulfillmentsTracker::class );
+ $methods = $reflection->getMethods( \ReflectionMethod::IS_PUBLIC );
+
+ foreach ( $methods as $method ) {
+ $this->assertTrue(
+ $method->isStatic(),
+ "Method {$method->getName()} should be static"
+ );
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
index 06f9e6fdcc1..690cd35c2af 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
@@ -1891,4 +1891,164 @@ class OrderFulfillmentsRestControllerTest extends WC_REST_Unit_Test_Case {
wp_set_current_user( $current_user->ID );
}
+
+ /**
+ * @testdox maybe_track_tracking_added method exists on the controller.
+ */
+ public function test_maybe_track_tracking_added_method_exists(): void {
+ $reflection = new \ReflectionClass( OrderFulfillmentsRestController::class );
+ $this->assertTrue(
+ $reflection->hasMethod( 'maybe_track_tracking_added' ),
+ 'maybe_track_tracking_added method should exist on OrderFulfillmentsRestController'
+ );
+
+ $method = $reflection->getMethod( 'maybe_track_tracking_added' );
+ $this->assertTrue( $method->isPrivate(), 'maybe_track_tracking_added should be private' );
+ }
+
+ /**
+ * @testdox check_request_source returns fulfillments_modal when UI header is present.
+ */
+ public function test_check_request_source_returns_modal_for_ui_header(): void {
+ $reflection = new \ReflectionClass( OrderFulfillmentsRestController::class );
+ $method = $reflection->getMethod( 'check_request_source' );
+ $method->setAccessible( true );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/orders/1/fulfillments' );
+ $request->set_header( 'X-WC-Fulfillments-UI', 'true' );
+
+ $result = $method->invoke( $this->controller, $request );
+ $this->assertSame( 'fulfillments_modal', $result );
+ }
+
+ /**
+ * @testdox check_request_source returns api when no UI header is present.
+ */
+ public function test_check_request_source_returns_api_without_ui_header(): void {
+ $reflection = new \ReflectionClass( OrderFulfillmentsRestController::class );
+ $method = $reflection->getMethod( 'check_request_source' );
+ $method->setAccessible( true );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/orders/1/fulfillments' );
+
+ $result = $method->invoke( $this->controller, $request );
+ $this->assertSame( 'api', $result );
+ }
+
+ /**
+ * @testdox Creating a fulfillment with tracking info succeeds and includes tracking metadata.
+ */
+ public function test_create_fulfillment_with_tracking_info_succeeds(): void {
+ $order = WC_Helper_Order::create_order( get_current_user_id() );
+ $this->assertInstanceOf( WC_Order::class, $order );
+
+ wp_set_current_user( 1 );
+ $request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/fulfillments' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'entity_type' => WC_Order::class,
+ 'entity_id' => '' . $order->get_id(),
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ ),
+ array(
+ 'id' => 0,
+ 'key' => '_tracking_number',
+ 'value' => '1Z999AA10123456784',
+ ),
+ array(
+ 'id' => 0,
+ 'key' => '_shipment_provider',
+ 'value' => 'ups',
+ ),
+ array(
+ 'id' => 0,
+ 'key' => '_tracking_url',
+ 'value' => 'https://www.ups.com/track?tracknum=1Z999AA10123456784',
+ ),
+ array(
+ 'id' => 0,
+ 'key' => '_shipping_option',
+ 'value' => 'tracking-number',
+ ),
+ ),
+ )
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( WP_Http::CREATED, $response->get_status() );
+ $fulfillment = $response->get_data();
+ $this->assertIsArray( $fulfillment );
+ $this->assertArrayHasKey( 'id', $fulfillment );
+
+ $meta_keys = array_column( $fulfillment['meta_data'], 'key' );
+ $this->assertContains( '_tracking_number', $meta_keys, 'Fulfillment should have tracking number metadata' );
+ $this->assertContains( '_shipment_provider', $meta_keys, 'Fulfillment should have shipping provider metadata' );
+ $this->assertContains( '_tracking_url', $meta_keys, 'Fulfillment should have tracking URL metadata' );
+
+ wp_set_current_user( 0 );
+ }
+
+ /**
+ * @testdox Creating a fulfillment without tracking info succeeds without tracking metadata.
+ */
+ public function test_create_fulfillment_without_tracking_info_succeeds(): void {
+ $order = WC_Helper_Order::create_order( get_current_user_id() );
+ $this->assertInstanceOf( WC_Order::class, $order );
+
+ wp_set_current_user( 1 );
+ $request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/fulfillments' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'entity_type' => WC_Order::class,
+ 'entity_id' => '' . $order->get_id(),
+ 'status' => 'unfulfilled',
+ 'is_fulfilled' => false,
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ ),
+ array(
+ 'id' => 0,
+ 'key' => '_shipping_option',
+ 'value' => 'no-info',
+ ),
+ ),
+ )
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( WP_Http::CREATED, $response->get_status() );
+ $fulfillment = $response->get_data();
+ $this->assertIsArray( $fulfillment );
+ $this->assertArrayHasKey( 'id', $fulfillment );
+
+ $meta_keys = array_column( $fulfillment['meta_data'], 'key' );
+ $this->assertNotContains( '_tracking_number', $meta_keys, 'Fulfillment without tracking should not have tracking number metadata' );
+
+ wp_set_current_user( 0 );
+ }
}