Commit eb4a3ae5a0b for woocommerce

commit eb4a3ae5a0bc7413c8099403591a82b267097996
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Thu Mar 19 16:52:44 2026 +0300

    [WOOPLUG-6353] add: orphaned fulfillment records when order is deleted (#63572)

    * [WOOPLUG-6353] add: orphaned fulfillment records when order is deleted

    * Delete orphaned fulfillment records when an order is permanently deleted

    Add delete_by_entity() to FulfillmentsDataStore for hard-deleting
    fulfillment records and metadata within a transaction. Hook into
    woocommerce_before_delete_order and before_delete_post via
    FulfillmentsManager to trigger cleanup on order deletion.

    * Move validation method docblock to appropriate location

    * fix: handle errors when deleting fulfillment metadata and records

    * fix: add missing throws tag

    * Address PR review feedback for delete_by_entity

    - Move is_order() check inside try/catch to prevent unhandled exceptions
      in WordPress hook chain
    - Change delete_by_entity $entity_id type from int to string for
      consistency with other datastore methods
    - Update prepared statement placeholders from %d to %s to match
    - Add START TRANSACTION failure check with RuntimeException

    * Use wc_transaction_query for transaction management in delete_by_entity

    Replace raw $wpdb->query calls with wc_transaction_query() which
    respects the WC_USE_TRANSACTIONS constant and follows WooCommerce
    conventions for transaction handling.

    * Use %s placeholder for entity_id in test SQL queries

    Match the string-typed entity_id used by the datastore methods by
    changing %d to %s and integer literals to string literals in test
    assertion queries.

    * Update FulfillmentsManagerTest.php

    Co-authored-by: Fernando Espinosa <Ferdev@users.noreply.github.com>

    ---------

    Co-authored-by: Fernando Espinosa <Ferdev@users.noreply.github.com>

diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
index d021cfb642e..924f6565e5c 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
@@ -621,6 +621,64 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
 		return $fulfillments;
 	}

+	/**
+	 * Hard-delete all fulfillment records (and their metadata) for a given entity.
+	 *
+	 * This is used when an order is permanently deleted to prevent orphaned rows.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string $entity_type The entity type (e.g. 'WC_Order').
+	 * @param string $entity_id   The entity ID.
+	 *
+	 * @return int The number of fulfillment records deleted.
+	 *
+	 * @throws \RuntimeException If a database query fails.
+	 * @throws \Throwable If the deletion fails.
+	 */
+	public function delete_by_entity( string $entity_type, string $entity_id ): int {
+		global $wpdb;
+
+		wc_transaction_query( 'start' );
+
+		try {
+			// Delete metadata for all fulfillments belonging to this entity.
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are safe.
+			$result = $wpdb->query(
+				$wpdb->prepare(
+					"DELETE m FROM {$wpdb->prefix}wc_order_fulfillment_meta m INNER JOIN {$wpdb->prefix}wc_order_fulfillments f ON m.fulfillment_id = f.fulfillment_id WHERE f.entity_type = %s AND f.entity_id = %s",
+					$entity_type,
+					$entity_id
+				)
+			);
+
+			if ( false === $result ) {
+				throw new \RuntimeException( 'Failed to delete fulfillment metadata: ' . $wpdb->last_error );
+			}
+
+			// Delete the fulfillment records themselves.
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is safe.
+			$rows_deleted = $wpdb->query(
+				$wpdb->prepare(
+					"DELETE FROM {$wpdb->prefix}wc_order_fulfillments WHERE entity_type = %s AND entity_id = %s",
+					$entity_type,
+					$entity_id
+				)
+			);
+
+			if ( false === $rows_deleted ) {
+				throw new \RuntimeException( 'Failed to delete fulfillment records: ' . $wpdb->last_error );
+			}
+
+			wc_transaction_query( 'commit' );
+		} catch ( \Throwable $e ) {
+			wc_transaction_query( 'rollback' );
+			throw $e;
+		}
+
+		return (int) $rows_deleted;
+	}
+
 	/**
 	 * Method to validate the items in a fulfillment.
 	 *
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
index 541f5dbca68..cd43d4f4d9c 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Admin\Features\Fulfillments;

 use Automattic\WooCommerce\Admin\Features\Fulfillments\Providers\AbstractShippingProvider;
+use Automattic\WooCommerce\Utilities\OrderUtil;
 use WC_Order;
 use WC_Order_Refund;

@@ -37,6 +38,7 @@ class FulfillmentsManager {

 		$this->init_fulfillment_status_hooks();
 		$this->init_refund_hooks();
+		$this->init_order_deletion_hooks();

 		if ( ! $this->fulfillment_order_notes ) {
 			$this->fulfillment_order_notes = wc_get_container()->get( FulfillmentOrderNotes::class );
@@ -67,6 +69,44 @@ class FulfillmentsManager {
 		add_action( 'woocommerce_delete_order_refund', array( $this, 'update_fulfillment_status_after_refund_deleted' ), 10, 1 );
 	}

+	/**
+	 * Initialize order deletion hooks.
+	 *
+	 * Registers hooks to clean up fulfillment records when an order is permanently deleted.
+	 */
+	private function init_order_deletion_hooks(): void {
+		add_action( 'woocommerce_before_delete_order', array( $this, 'delete_order_fulfillments' ), 10, 1 );
+		add_action( 'before_delete_post', array( $this, 'delete_order_fulfillments' ), 10, 1 );
+	}
+
+	/**
+	 * Delete all fulfillment records for an order that is being permanently deleted.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param int $order_id The ID of the order being deleted.
+	 */
+	public function delete_order_fulfillments( int $order_id ): void {
+		try {
+			if ( ! OrderUtil::is_order( $order_id, wc_get_order_types() ) ) {
+				return;
+			}
+
+			/**
+			 * Fulfillments data store.
+			 *
+			 * @var \Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore $fulfillments_data_store
+			 */
+			$fulfillments_data_store = \WC_Data_Store::load( 'order-fulfillment' );
+			$fulfillments_data_store->delete_by_entity( WC_Order::class, (string) $order_id );
+		} catch ( \Throwable $e ) {
+			wc_get_logger()->error(
+				sprintf( 'Failed to delete fulfillments for order %d: %s', $order_id, $e->getMessage() ),
+				array( 'source' => 'fulfillments' )
+			);
+		}
+	}
+
 	/**
 	 * Translate fulfillment meta keys.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreTest.php
index 615c7fae315..c198ceaa8d9 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreTest.php
@@ -904,4 +904,124 @@ class FulfillmentsDataStoreTest extends \WC_Unit_Test_Case {
 			$this->assertEquals( $meta_value, json_decode( reset( $record )->meta_value, true ) );
 		}
 	}
+
+	/**
+	 * @testdox Should hard-delete all fulfillment records and metadata for a given entity.
+	 */
+	public function test_delete_by_entity(): void {
+		global $wpdb;
+
+		$entity_id   = '999';
+		$entity_type = 'order-fulfillment';
+
+		$fulfillment1 = new Fulfillment();
+		$fulfillment1->set_entity_type( $entity_type );
+		$fulfillment1->set_entity_id( (string) $entity_id );
+		$fulfillment1->set_status( 'unfulfilled' );
+		$fulfillment1->set_items(
+			array(
+				array(
+					'item_id' => 1,
+					'qty'     => 2,
+				),
+			)
+		);
+		$fulfillment1->save();
+
+		$fulfillment2 = new Fulfillment();
+		$fulfillment2->set_entity_type( $entity_type );
+		$fulfillment2->set_entity_id( (string) $entity_id );
+		$fulfillment2->set_status( 'fulfilled' );
+		$fulfillment2->set_items(
+			array(
+				array(
+					'item_id' => 3,
+					'qty'     => 1,
+				),
+			)
+		);
+		$fulfillment2->save();
+
+		$this->assertGreaterThan( 0, $fulfillment1->get_id() );
+		$this->assertGreaterThan( 0, $fulfillment2->get_id() );
+
+		$rows_deleted = $this->data_store->delete_by_entity( $entity_type, $entity_id );
+
+		$this->assertSame( 2, $rows_deleted, 'Should have deleted 2 fulfillment records' );
+
+		$remaining = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_fulfillments WHERE entity_type = %s AND entity_id = %s",
+				$entity_type,
+				$entity_id
+			)
+		);
+		$this->assertSame( '0', $remaining, 'No fulfillment records should remain' );
+
+		$remaining_meta = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_fulfillment_meta WHERE fulfillment_id IN (%d, %d)",
+				$fulfillment1->get_id(),
+				$fulfillment2->get_id()
+			)
+		);
+		$this->assertSame( '0', $remaining_meta, 'No fulfillment metadata should remain' );
+	}
+
+	/**
+	 * @testdox Should not affect fulfillments belonging to other entities.
+	 */
+	public function test_delete_by_entity_does_not_affect_other_entities(): void {
+		global $wpdb;
+
+		$entity_type = 'order-fulfillment';
+
+		$fulfillment_to_delete = new Fulfillment();
+		$fulfillment_to_delete->set_entity_type( $entity_type );
+		$fulfillment_to_delete->set_entity_id( '100' );
+		$fulfillment_to_delete->set_status( 'unfulfilled' );
+		$fulfillment_to_delete->set_items(
+			array(
+				array(
+					'item_id' => 1,
+					'qty'     => 1,
+				),
+			)
+		);
+		$fulfillment_to_delete->save();
+
+		$fulfillment_to_keep = new Fulfillment();
+		$fulfillment_to_keep->set_entity_type( $entity_type );
+		$fulfillment_to_keep->set_entity_id( '200' );
+		$fulfillment_to_keep->set_status( 'unfulfilled' );
+		$fulfillment_to_keep->set_items(
+			array(
+				array(
+					'item_id' => 2,
+					'qty'     => 1,
+				),
+			)
+		);
+		$fulfillment_to_keep->save();
+
+		$this->data_store->delete_by_entity( $entity_type, '100' );
+
+		$remaining = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_fulfillments WHERE entity_type = %s AND entity_id = %s",
+				$entity_type,
+				'200'
+			)
+		);
+		$this->assertSame( '1', $remaining, 'Fulfillments for other entities should not be affected' );
+	}
+
+	/**
+	 * @testdox Should return zero when no fulfillments exist for the entity.
+	 */
+	public function test_delete_by_entity_returns_zero_when_no_records(): void {
+		$rows_deleted = $this->data_store->delete_by_entity( 'order-fulfillment', '12345' );
+
+		$this->assertSame( 0, $rows_deleted );
+	}
 }
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 569e8eec46e..e3477aabbdd 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsManagerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsManagerTest.php
@@ -292,6 +292,62 @@ class FulfillmentsManagerTest extends \WC_Unit_Test_Case {
 		$this->assertEquals( array(), $parsed_number );
 	}

+	/**
+	 * @testdox Should register order deletion hooks.
+	 */
+	public function test_order_deletion_hooks_registered(): void {
+		$this->assertNotFalse( has_action( 'woocommerce_before_delete_order', array( $this->manager, 'delete_order_fulfillments' ) ) );
+		$this->assertNotFalse( has_action( 'before_delete_post', array( $this->manager, 'delete_order_fulfillments' ) ) );
+	}
+
+	/**
+	 * @testdox Should delete fulfillments when an order is permanently deleted.
+	 */
+	public function test_delete_order_fulfillments_on_order_deletion(): void {
+		global $wpdb;
+
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type' => WC_Order::class,
+				'entity_id'   => $order->get_id(),
+				'status'      => 'unfulfilled',
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => 1,
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		$order_id = $order->get_id();
+
+		$fulfillments_before = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_fulfillments WHERE entity_type = %s AND entity_id = %s",
+				WC_Order::class,
+				$order_id
+			)
+		);
+		$this->assertSame( '1', $fulfillments_before, 'Fulfillment should exist before deletion' );
+
+		$order->delete( true );
+
+		$fulfillments_after = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_fulfillments WHERE entity_type = %s AND entity_id = %d",
+				WC_Order::class,
+				$order_id
+			)
+		);
+		$this->assertSame( '0', $fulfillments_after, 'Fulfillments should be deleted after order deletion' );
+	}
+
 	/**
 	 * Test tracking number parsing without any shipping providers.
 	 */