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.
*/