Commit 757f400b160 for woocommerce
commit 757f400b1604906c132e285a5c720236dd36112d
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Thu Mar 26 17:07:00 2026 +0300
[WOOPLUG-6360+6047] Add custom shipping providers and provider filter (#63766)
diff --git a/plugins/woocommerce/changelog/63766-add-custom-shipping-providers-and-provider-filter b/plugins/woocommerce/changelog/63766-add-custom-shipping-providers-and-provider-filter
new file mode 100644
index 00000000000..9f8cb463772
--- /dev/null
+++ b/plugins/woocommerce/changelog/63766-add-custom-shipping-providers-and-provider-filter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add custom shipping providers settings UI and shipping provider filter for the orders list.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-manual-entry-form.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-manual-entry-form.tsx
index cf1126d506b..bc4ae6f81a5 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-manual-entry-form.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-manual-entry-form.tsx
@@ -10,7 +10,7 @@ import { __ } from '@wordpress/i18n';
*/
import { useShipmentFormContext } from '../../context/shipment-form-context';
import ShipmentProviders from '../../data/shipment-providers';
-import { SearchIcon } from '../../utils/icons';
+import { SearchIcon, TruckIcon } from '../../utils/icons';
const ShippingProviderListItem = ( {
item,
@@ -25,11 +25,13 @@ const ShippingProviderListItem = ( {
item.value,
].join( ' ' ) }
>
- { item.icon && (
- <div className="woocommerce-fulfillment-shipping-provider-list-item-icon">
- <img src={ item.icon } alt="" aria-hidden="true" />
- </div>
- ) }
+ <div className="woocommerce-fulfillment-shipping-provider-list-item-icon">
+ { item.icon ? (
+ <img src={ item.icon } alt={ item.label } />
+ ) : (
+ <TruckIcon />
+ ) }
+ </div>
<div className="woocommerce-fulfillment-shipping-provider-list-item-label">
{ item.label }
</div>
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-tracking-number-form.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-tracking-number-form.tsx
index 170c9a327e0..5464b0b1a25 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-tracking-number-form.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-tracking-number-form.tsx
@@ -15,7 +15,7 @@ import { addQueryArgs } from '@wordpress/url';
*/
import { useShipmentFormContext } from '../../context/shipment-form-context';
import ErrorLabel from '../user-interface/error-label';
-import { EditIcon } from '../../utils/icons';
+import { EditIcon, TruckIcon } from '../../utils/icons';
import { findShipmentProviderName } from '../../utils/fulfillment-utils';
import ShipmentProviders from '../../data/shipment-providers';
import { useFulfillmentContext } from '../../context/fulfillment-context';
@@ -35,18 +35,21 @@ interface TrackingNumberParsingResponse {
const ShipmentProviderIcon = ( { providerKey }: { providerKey: string } ) => {
const provider = ShipmentProviders.find( ( p ) => p.value === providerKey );
- const icon = provider?.icon;
- if ( ! provider || ! icon ) {
+ if ( ! provider ) {
return null;
}
return (
<div className="woocommerce-fulfillment-shipment-provider-icon">
- <img
- src={ icon }
- alt={ `${ provider.label } shipping provider logo` }
- key={ providerKey }
- />
+ { provider.icon ? (
+ <img
+ src={ provider.icon }
+ alt={ provider.label }
+ key={ providerKey }
+ />
+ ) : (
+ <TruckIcon />
+ ) }
</div>
);
};
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-viewer.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-viewer.tsx
index 7f66a836dfa..cb3e935f508 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-viewer.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-viewer.tsx
@@ -52,9 +52,9 @@ export default function ShipmentViewer() {
header={
isShipmentInformationProvided ? (
<>
- { shipmentProviderObject ? (
+ { shipmentProviderObject?.icon ? (
<img
- src={ shipmentProviderObject.icon || '' }
+ src={ shipmentProviderObject.icon }
alt={ shipmentProviderObject.label || '' }
/>
) : (
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/test/shipment-manual-entry-form.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/test/shipment-manual-entry-form.test.js
index 80078935833..fbaed33d78e 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/test/shipment-manual-entry-form.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/test/shipment-manual-entry-form.test.js
@@ -16,6 +16,7 @@ jest.mock( '../../../context/shipment-form-context', () => ( {
jest.mock( '../../../utils/icons', () => ( {
SearchIcon: () => <span data-testid="search-icon" />,
+ TruckIcon: () => <span data-testid="truck-icon" />,
} ) );
jest.mock( '@wordpress/components', () => ( {
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/test/shipment-tracking-number-form.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/test/shipment-tracking-number-form.test.js
index 1ee7e96d436..96523a5767c 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/test/shipment-tracking-number-form.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/test/shipment-tracking-number-form.test.js
@@ -24,6 +24,7 @@ jest.mock( '../../../context/shipment-form-context', () => ( {
jest.mock( '../../../utils/icons', () => ( {
EditIcon: () => <span data-testid="edit-icon" />,
+ TruckIcon: () => <span data-testid="truck-icon" />,
} ) );
jest.mock( '@wordpress/api-fetch' );
diff --git a/plugins/woocommerce/client/legacy/js/admin/wc-shipping-providers.js b/plugins/woocommerce/client/legacy/js/admin/wc-shipping-providers.js
new file mode 100644
index 00000000000..e4068be7f39
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/admin/wc-shipping-providers.js
@@ -0,0 +1,223 @@
+/* global shippingProvidersLocalizeScript, ajaxurl */
+( function( $, data, wp, ajaxurl ) {
+ $( function() {
+ if (
+ ! document.getElementById( 'tmpl-wc-shipping-provider-row' ) ||
+ ! document.getElementById( 'tmpl-wc-shipping-provider-row-blank' )
+ ) {
+ return;
+ }
+
+ var $tbody = $( '.wc-shipping-provider-rows' ),
+ $row_template = wp.template( 'wc-shipping-provider-row' ),
+ $blank_template = wp.template( 'wc-shipping-provider-row-blank' ),
+
+ // Backbone model
+ ShippingProvider = Backbone.Model.extend({
+ save: function( changes ) {
+ var self = this;
+ $.ajax({
+ url: ajaxurl + ( ajaxurl.indexOf( '?' ) > 0 ? '&' : '?' ) + 'action=woocommerce_shipping_providers_save_changes',
+ type: 'POST',
+ data: {
+ wc_shipping_providers_nonce : data.wc_shipping_providers_nonce,
+ changes: changes,
+ },
+ dataType: 'json'
+ }).done( function( response ) {
+ if ( response.success ) {
+ if ( response.data.error ) {
+ window.alert( response.data.error );
+ }
+ shippingProvider.set( 'providers', response.data.shipping_providers );
+ shippingProvider.trigger( 'saved:providers' );
+ } else if ( response.data ) {
+ window.alert( response.data );
+ } else {
+ window.alert( data.strings.save_failed );
+ }
+ }).fail( function() {
+ window.alert( data.strings.save_failed );
+ }).always( function() {
+ shippingProviderView.unblock();
+ });
+ }
+ } ),
+
+ // Backbone view
+ ShippingProviderView = Backbone.View.extend({
+ rowTemplate: $row_template,
+ initialize: function() {
+ this.listenTo( this.model, 'saved:providers', this.render );
+ $( document.body ).on( 'click', '.wc-shipping-provider-add-new', { view: this }, this.configureNewShippingProvider );
+ $( document.body ).on( 'wc_backbone_modal_response', { view: this }, this.onConfigureShippingProviderSubmitted );
+ $( document.body ).on( 'wc_backbone_modal_loaded', { view: this }, this.onLoadBackboneModal );
+ $( document.body ).on( 'wc_backbone_modal_validation', this.validateFormArguments );
+ },
+ block: function() {
+ $( this.el ).block({
+ message: null,
+ overlayCSS: {
+ background: '#fff',
+ opacity: 0.6
+ }
+ });
+ },
+ unblock: function() {
+ $( this.el ).unblock();
+ },
+ render: function() {
+ var providers = _.indexBy( this.model.get( 'providers' ), 'term_id' ),
+ view = this;
+
+ this.$el.empty();
+ this.unblock();
+
+ if ( _.size( providers ) ) {
+ providers = _.sortBy( providers, function( provider ) {
+ return provider.name;
+ } );
+
+ $.each( providers, function( id, rowData ) {
+ view.renderRow( rowData );
+ } );
+ } else {
+ view.$el.append( $blank_template );
+ }
+ },
+ renderRow: function( rowData ) {
+ var view = this;
+ view.$el.append( view.rowTemplate( rowData ) );
+ view.initRow( rowData );
+ },
+ initRow: function( rowData ) {
+ var view = this;
+ var $tr = view.$el.find( 'tr[data-id="' + rowData.term_id + '"]');
+
+ $tr.find( 'select' ).each( function() {
+ var attribute = $( this ).data( 'attribute' );
+ $( this ).find( 'option[value="' + rowData[ attribute ] + '"]' ).prop( 'selected', true );
+ } );
+
+ $tr.find( '.view' ).show();
+ $tr.find( '.edit' ).hide();
+ $tr.find( '.wc-shipping-provider-edit' ).on( 'click', { view: this }, this.onEditRow );
+ $tr.find( '.wc-shipping-provider-delete' ).on( 'click', { view: this }, this.onDeleteRow );
+ },
+ configureNewShippingProvider: function( event ) {
+ event.preventDefault();
+ const term_id = 'new-1-' + Date.now();
+
+ $( this ).WCBackboneModal({
+ template : 'wc-shipping-provider-configure',
+ variable : {
+ term_id,
+ action: 'create',
+ },
+ data : {
+ term_id,
+ action: 'create',
+ }
+ });
+ },
+ onConfigureShippingProviderSubmitted: function( event, target, posted_data ) {
+ if ( target === 'wc-shipping-provider-configure' ) {
+ const view = event.data.view;
+ const model = view.model;
+ const isNewRow = posted_data.term_id.includes( 'new-1-' );
+ const rowData = Object.assign( {}, posted_data );
+
+ if ( isNewRow ) {
+ rowData.newRow = true;
+ }
+
+ view.block();
+
+ model.save( {
+ [ posted_data.term_id ]: rowData
+ } );
+ }
+ },
+ validateFormArguments: function( element, target, formData ) {
+ const requiredFields = [ 'name' ];
+ const formIsComplete = Object.keys( formData ).every( function( key ) {
+ if ( requiredFields.indexOf( key ) === -1 ) {
+ return true;
+ }
+ if ( Array.isArray( formData[ key ] ) ) {
+ return formData[ key ].length && !!formData[ key ][ 0 ];
+ }
+ return !!formData[ key ];
+ } );
+ const createButton = document.getElementById( 'btn-ok' );
+ createButton.disabled = ! formIsComplete;
+ createButton.classList.toggle( 'disabled', ! formIsComplete );
+ },
+ onEditRow: function( event ) {
+ const term_id = $( this ).closest('tr').data('id');
+ const model = event.data.view.model;
+ const providers = _.indexBy( model.get( 'providers' ), 'term_id' );
+ const rowData = providers[ term_id ];
+
+ event.preventDefault();
+ $( this ).WCBackboneModal({
+ template : 'wc-shipping-provider-configure',
+ variable: Object.assign( { action: 'edit' }, rowData ),
+ data : Object.assign( { action: 'edit' }, rowData )
+ });
+ },
+ onLoadBackboneModal: function( event, target ) {
+ if ( 'wc-shipping-provider-configure' === target ) {
+ const modalContent = $('.wc-backbone-modal-content');
+ const term_id = modalContent.data('id');
+ const model = event.data.view.model;
+ const providers = _.indexBy( model.get( 'providers' ), 'term_id' );
+ const rowData = providers[ term_id ];
+
+ // Make slug read-only when editing an existing provider.
+ if ( rowData ) {
+ $('.wc-backbone-modal-content').find( 'input[name="slug"]' ).prop( 'readonly', true );
+ }
+
+ if ( rowData ) {
+ $('.wc-backbone-modal-content').find( 'select' ).each( function() {
+ var attribute = $( this ).data( 'attribute' );
+ $( this ).find( 'option[value="' + rowData[ attribute ] + '"]' ).prop( 'selected', true );
+ } );
+ }
+ }
+ },
+ onDeleteRow: function( event ) {
+ var view = event.data.view,
+ model = view.model,
+ term_id = $( this ).closest('tr').data('id');
+
+ event.preventDefault();
+
+ var confirmMsg = data.strings.delete_confirmation ||
+ 'Are you sure you want to delete this shipping provider?';
+ if ( ! window.confirm( confirmMsg ) ) {
+ return;
+ }
+
+ view.block();
+
+ model.save( {
+ [ term_id ]: {
+ term_id,
+ deleted: 'deleted',
+ }
+ } );
+ },
+ } ),
+ shippingProvider = new ShippingProvider({
+ providers: data.providers
+ } ),
+ shippingProviderView = new ShippingProviderView({
+ model: shippingProvider,
+ el: $tbody
+ } );
+
+ shippingProviderView.render();
+ });
+})( jQuery, shippingProvidersLocalizeScript, wp, ajaxurl );
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
index f91a7e812de..858acfb0a06 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
@@ -264,6 +264,12 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
'dependencies' => array( 'jquery', 'wp-util', 'underscore', 'backbone', 'wc-backbone-modal' ),
'version' => $version,
),
+ array(
+ 'handle' => 'wc-shipping-providers',
+ 'path' => $plugin_url . '/assets/js/admin/wc-shipping-providers' . $suffix . '.js',
+ 'dependencies' => array( 'jquery', 'wp-util', 'underscore', 'backbone', 'wc-backbone-modal' ),
+ 'version' => $version,
+ ),
array(
'handle' => 'wc-clipboard',
'path' => $plugin_url . '/assets/js/admin/wc-clipboard' . $suffix . '.js',
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-shipping.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-shipping.php
index 32132b77fab..7d51580dabb 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-shipping.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-shipping.php
@@ -8,6 +8,7 @@
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
defined( 'ABSPATH' ) || exit;
@@ -59,6 +60,10 @@ class WC_Settings_Shipping extends WC_Settings_Page {
'classes' => __( 'Classes', 'woocommerce' ),
);
+ if ( FeaturesUtil::feature_is_enabled( 'fulfillments' ) ) {
+ $sections['fulfillment-providers'] = __( 'Shipping providers', 'woocommerce' );
+ }
+
if ( ! $this->wc_is_installing() ) {
// Load shipping methods so we can show any global options they may have.
$shipping_methods = $this->get_shipping_methods();
@@ -184,6 +189,9 @@ class WC_Settings_Shipping extends WC_Settings_Page {
} elseif ( 'classes' === $current_section ) {
$hide_save_button = true;
$this->output_shipping_class_screen();
+ } elseif ( 'fulfillment-providers' === $current_section && FeaturesUtil::feature_is_enabled( 'fulfillments' ) ) {
+ $hide_save_button = true;
+ $this->output_shipping_providers_screen();
} else {
$is_shipping_method = false;
foreach ( $shipping_methods as $method ) {
@@ -212,6 +220,11 @@ class WC_Settings_Shipping extends WC_Settings_Page {
case 'classes':
$this->do_update_options_action();
break;
+ case 'fulfillment-providers':
+ if ( FeaturesUtil::feature_is_enabled( 'fulfillments' ) ) {
+ $this->do_update_options_action();
+ }
+ break;
case '':
break;
default:
@@ -357,7 +370,7 @@ class WC_Settings_Shipping extends WC_Settings_Page {
);
wp_enqueue_script( 'wc-shipping-zone-methods' );
- include_once dirname( __FILE__ ) . '/views/html-admin-page-shipping-zone-methods.php';
+ include_once __DIR__ . '/views/html-admin-page-shipping-zone-methods.php';
}
/**
@@ -387,7 +400,7 @@ class WC_Settings_Shipping extends WC_Settings_Page {
);
wp_enqueue_script( 'wc-shipping-zones' );
- include_once dirname( __FILE__ ) . '/views/html-admin-page-shipping-zones.php';
+ include_once __DIR__ . '/views/html-admin-page-shipping-zones.php';
}
/**
@@ -420,7 +433,7 @@ class WC_Settings_Shipping extends WC_Settings_Page {
$shipping_method->display_errors();
}
- include_once dirname( __FILE__ ) . '/views/html-admin-page-shipping-zones-instance.php';
+ include_once __DIR__ . '/views/html-admin-page-shipping-zones-instance.php';
}
/**
@@ -458,7 +471,67 @@ class WC_Settings_Shipping extends WC_Settings_Page {
)
);
- include_once dirname( __FILE__ ) . '/views/html-admin-page-shipping-classes.php';
+ include_once __DIR__ . '/views/html-admin-page-shipping-classes.php';
+ }
+
+ /**
+ * Handles output of the shipping providers settings screen.
+ *
+ * @since 10.7.0
+ */
+ protected function output_shipping_providers_screen(): void {
+ $providers = get_terms(
+ array(
+ 'taxonomy' => 'wc_fulfillment_shipping_provider',
+ 'hide_empty' => false,
+ )
+ );
+
+ if ( is_wp_error( $providers ) ) {
+ $providers = array();
+ }
+
+ $shipping_providers = array();
+ foreach ( $providers as $provider ) {
+ $shipping_providers[] = array(
+ 'term_id' => $provider->term_id,
+ 'name' => $provider->name,
+ 'slug' => $provider->slug,
+ 'tracking_url_template' => get_term_meta( $provider->term_id, 'tracking_url_template', true ),
+ 'icon' => get_term_meta( $provider->term_id, 'icon', true ),
+ );
+ }
+
+ wp_localize_script(
+ 'wc-shipping-providers',
+ 'shippingProvidersLocalizeScript',
+ array(
+ 'providers' => $shipping_providers,
+ 'default_shipping_provider' => array(
+ 'term_id' => 0,
+ 'name' => '',
+ 'slug' => '',
+ 'tracking_url_template' => '',
+ 'icon' => '',
+ ),
+ 'wc_shipping_providers_nonce' => wp_create_nonce( 'wc_shipping_providers_nonce' ),
+ 'strings' => array(
+ 'unload_confirmation_msg' => __( 'Your changed data will be lost if you leave this page without saving.', 'woocommerce' ),
+ 'save_failed' => __( 'Your changes were not saved. Please retry.', 'woocommerce' ),
+ 'delete_confirmation' => __( 'Are you sure you want to delete this shipping provider?', 'woocommerce' ),
+ ),
+ )
+ );
+ wp_enqueue_script( 'wc-shipping-providers' );
+
+ $shipping_provider_columns = array(
+ 'wc-shipping-provider-name' => __( 'Name', 'woocommerce' ),
+ 'wc-shipping-provider-slug' => __( 'Slug', 'woocommerce' ),
+ 'wc-shipping-provider-tracking-url-template' => __( 'Tracking URL template', 'woocommerce' ),
+ 'wc-shipping-provider-icon' => __( 'Icon URL', 'woocommerce' ),
+ );
+
+ include_once __DIR__ . '/views/html-admin-page-shipping-providers.php';
}
}
diff --git a/plugins/woocommerce/includes/admin/settings/views/html-admin-page-shipping-providers.php b/plugins/woocommerce/includes/admin/settings/views/html-admin-page-shipping-providers.php
new file mode 100644
index 00000000000..326daa8acd9
--- /dev/null
+++ b/plugins/woocommerce/includes/admin/settings/views/html-admin-page-shipping-providers.php
@@ -0,0 +1,183 @@
+<?php
+/**
+ * Shipping providers admin
+ *
+ * @package WooCommerce\Admin\Shipping
+ */
+
+declare( strict_types = 1 );
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+<h2 class="wc-shipping-zones-heading">
+ <span><?php esc_html_e( 'Shipping providers', 'woocommerce' ); ?></span>
+ <a class="page-title-action wc-shipping-provider-add-new" href="#"><?php esc_html_e( 'Add shipping provider', 'woocommerce' ); ?></a>
+</h2>
+
+<p class="wc-shipping-zone-help-text">
+ <?php esc_html_e( 'Add custom shipping providers so they appear in the fulfillment form when creating shipments. Use the tracking URL template to auto-generate tracking links.', 'woocommerce' ); ?>
+</p>
+
+<table class="wc-shipping-classes widefat">
+ <thead>
+ <tr>
+ <?php foreach ( $shipping_provider_columns as $class => $heading ) : // @phpstan-ignore variable.undefined ?>
+ <th class="<?php echo esc_attr( $class ); ?>"><?php echo esc_html( $heading ); ?></th>
+ <?php endforeach; ?>
+ <th />
+ </tr>
+ </thead>
+ <tbody class="wc-shipping-provider-rows wc-shipping-tables-tbody"></tbody>
+</table>
+
+<script type="text/html" id="tmpl-wc-shipping-provider-row-blank">
+ <tr>
+ <td class="wc-shipping-classes-blank-state" colspan="<?php echo absint( count( $shipping_provider_columns ) + 1 ); ?>"><p><?php esc_html_e( 'No custom shipping providers have been created.', 'woocommerce' ); ?></p></td>
+ </tr>
+</script>
+
+<script type="text/html" id="tmpl-wc-shipping-provider-configure">
+<div class="wc-backbone-modal wc-shipping-class-modal">
+ <div class="wc-backbone-modal-content" data-id="{{ data.term_id }}">
+ <section class="wc-backbone-modal-main" role="main">
+ <header class="wc-backbone-modal-header">
+ <h1><?php esc_html_e( 'Add shipping provider', 'woocommerce' ); ?></h1>
+ <button class="modal-close modal-close-link dashicons dashicons-no-alt">
+ <span class="screen-reader-text"><?php esc_html_e( 'Close modal panel', 'woocommerce' ); ?></span>
+ </button>
+ </header>
+ <article>
+ <form action="" method="post">
+ <input type="hidden" name="term_id" value="{{{ data.term_id }}}" />
+ <?php
+ foreach ( $shipping_provider_columns as $class => $heading ) {
+ echo '<div class="wc-shipping-class-modal-input ' . esc_attr( $class ) . '">';
+ switch ( $class ) {
+ case 'wc-shipping-provider-name':
+ ?>
+ <div class="view">
+ <?php echo esc_html( $heading ); ?> *
+ </div>
+ <div class="edit">
+ <input type="text" name="name" data-attribute="name" value="{{ data.name }}" placeholder="<?php esc_attr_e( 'e.g. My Local Courier', 'woocommerce' ); ?>" />
+ </div>
+ <div class="wc-shipping-class-modal-help-text"><?php esc_html_e( 'The display name for this shipping provider.', 'woocommerce' ); ?></div>
+ <?php
+ break;
+ case 'wc-shipping-provider-slug':
+ ?>
+ <div class="view">
+ <?php echo esc_html( $heading ); ?>
+ </div>
+ <div class="edit">
+ <input type="text" name="slug" data-attribute="slug" value="{{ data.slug }}" placeholder="<?php esc_attr_e( 'e.g. my-local-courier', 'woocommerce' ); ?>" />
+ </div>
+ <div class="wc-shipping-class-modal-help-text"><?php esc_html_e( 'Unique identifier (auto-generated if left blank).', 'woocommerce' ); ?></div>
+ <?php
+ break;
+ case 'wc-shipping-provider-tracking-url-template':
+ ?>
+ <div class="view">
+ <?php echo esc_html( $heading ); ?>
+ </div>
+ <div class="edit">
+ <input type="text" name="tracking_url_template" data-attribute="tracking_url_template" value="{{ data.tracking_url_template }}" placeholder="<?php esc_attr_e( 'e.g. https://example.com/track?id=__PLACEHOLDER__', 'woocommerce' ); ?>" />
+ </div>
+ <div class="wc-shipping-class-modal-help-text"><?php esc_html_e( 'Use __PLACEHOLDER__ where the tracking number should appear in the URL.', 'woocommerce' ); ?></div>
+ <?php
+ break;
+ case 'wc-shipping-provider-icon':
+ ?>
+ <div class="view">
+ <?php echo esc_html( $heading ); ?>
+ </div>
+ <div class="edit">
+ <input type="text" name="icon" data-attribute="icon" value="{{ data.icon }}" placeholder="<?php esc_attr_e( 'e.g. https://example.com/icon.png', 'woocommerce' ); ?>" />
+ </div>
+ <div class="wc-shipping-class-modal-help-text"><?php esc_html_e( 'Optional URL for the provider icon.', 'woocommerce' ); ?></div>
+ <?php
+ break;
+ default:
+ ?>
+ <div class="view wc-shipping-class-hide-sibling-view">
+ <?php echo esc_html( $heading ); ?>
+ </div>
+ <?php
+ /**
+ * Fires for custom columns in the shipping providers configure modal.
+ *
+ * @since 10.7.0
+ */
+ do_action( 'woocommerce_shipping_providers_column_' . $class );
+ break;
+ }
+ echo '</div>';
+ }
+ ?>
+ </form>
+ </article>
+ <footer>
+ <div class="inner">
+ <button id="btn-ok" disabled class="button button-primary button-large disabled">
+ <div class="wc-backbone-modal-action-{{ data.action === 'create' ? 'active' : 'inactive' }}"><?php esc_html_e( 'Create', 'woocommerce' ); ?></div>
+ <div class="wc-backbone-modal-action-{{ data.action === 'edit' ? 'active' : 'inactive' }}"><?php esc_html_e( 'Save', 'woocommerce' ); ?></div>
+ </button>
+ </div>
+ </footer>
+ </section>
+ </div>
+ </div>
+ <div class="wc-backbone-modal-backdrop modal-close"></div>
+</script>
+
+<script type="text/html" id="tmpl-wc-shipping-provider-row">
+ <tr data-id="{{ data.term_id }}">
+ <?php
+ foreach ( $shipping_provider_columns as $class => $heading ) {
+ echo '<td class="' . esc_attr( $class ) . '">';
+ switch ( $class ) {
+ case 'wc-shipping-provider-name':
+ ?>
+ <div class="view">
+ {{ data.name }}
+ </div>
+ <?php
+ break;
+ case 'wc-shipping-provider-slug':
+ ?>
+ <div class="view">{{ data.slug }}</div>
+ <?php
+ break;
+ case 'wc-shipping-provider-tracking-url-template':
+ ?>
+ <div class="view">{{ data.tracking_url_template }}</div>
+ <?php
+ break;
+ case 'wc-shipping-provider-icon':
+ ?>
+ <div class="view">{{ data.icon }}</div>
+ <?php
+ break;
+ default:
+ /**
+ * Fires for custom columns in the shipping providers table row.
+ *
+ * @since 10.7.0
+ */
+ do_action( 'woocommerce_shipping_providers_column_' . $class );
+ break;
+ }
+ echo '</td>';
+ }
+ ?>
+ <td class="wc-shipping-zone-actions">
+ <div>
+ <a class="wc-shipping-provider-edit wc-shipping-zone-action-edit" href="#"><?php esc_html_e( 'Edit', 'woocommerce' ); ?></a> | <a href="#" class="wc-shipping-provider-delete wc-shipping-zone-actions"><?php esc_html_e( 'Delete', 'woocommerce' ); ?></a>
+ </div>
+ </td>
+ </tr>
+</script>
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index b68876d8782..7851b3ee695 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -211,6 +211,7 @@ class WC_AJAX {
'shipping_zone_methods_save_changes',
'shipping_zone_methods_save_settings',
'shipping_classes_save_changes',
+ 'shipping_providers_save_changes',
'toggle_gateway_enabled',
'load_status_widget',
'load_recent_reviews_widget',
@@ -3838,6 +3839,242 @@ class WC_AJAX {
);
}
+ /**
+ * Handle AJAX save for custom shipping providers (taxonomy-based).
+ *
+ * @since 10.7.0
+ */
+ public static function shipping_providers_save_changes(): void {
+ if ( ! \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'fulfillments' ) ) {
+ wp_send_json_error( 'feature_disabled' );
+ }
+
+ if ( ! isset( $_POST['wc_shipping_providers_nonce'], $_POST['changes'] ) ) {
+ wp_send_json_error( 'missing_fields' );
+ }
+
+ if ( ! wp_verify_nonce( wp_unslash( $_POST['wc_shipping_providers_nonce'] ), 'wc_shipping_providers_nonce' ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ wp_send_json_error( 'bad_nonce' );
+ }
+
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ wp_send_json_error( 'missing_capabilities' );
+ }
+
+ $taxonomy = 'wc_fulfillment_shipping_provider';
+ $changes = wp_unslash( $_POST['changes'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+
+ if ( ! is_array( $changes ) ) {
+ wp_send_json_error( 'invalid_changes' );
+ }
+
+ // Collect only built-in provider keys (class-based, not custom taxonomy providers).
+ $all_providers = \Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentUtils::get_shipping_providers();
+ $built_in_keys = array();
+ foreach ( $all_providers as $provider ) {
+ if ( is_string( $provider ) && class_exists( $provider ) && is_subclass_of( $provider, \Automattic\WooCommerce\Admin\Features\Fulfillments\Providers\AbstractShippingProvider::class ) ) {
+ try {
+ $instance = wc_get_container()->get( $provider );
+ $built_in_keys[] = $instance->get_key();
+ } catch ( \Throwable $e ) {
+ continue;
+ }
+ }
+ }
+ $reserved_slug_error = '';
+
+ foreach ( $changes as $term_id => $data ) {
+ if ( ! is_numeric( $term_id ) && ! isset( $data['newRow'] ) ) {
+ continue;
+ }
+ $term_id = absint( $term_id );
+
+ if ( isset( $data['deleted'] ) ) {
+ if ( isset( $data['newRow'] ) ) {
+ continue;
+ }
+ $term_to_delete = get_term( $term_id, $taxonomy );
+ if ( $term_to_delete instanceof \WP_Term && self::is_shipping_provider_in_use( $term_to_delete->slug ) ) {
+ $reserved_slug_error = sprintf(
+ /* translators: %s: provider name */
+ __( 'Cannot delete "%s" because it is used by existing fulfillments. Remove all fulfillments using this provider first.', 'woocommerce' ),
+ $term_to_delete->name
+ );
+ continue;
+ }
+ $delete_result = wp_delete_term( $term_id, $taxonomy );
+ if ( is_wp_error( $delete_result ) || false === $delete_result ) {
+ $reserved_slug_error = is_wp_error( $delete_result )
+ ? $delete_result->get_error_message()
+ : __( 'Failed to delete the shipping provider.', 'woocommerce' );
+ }
+ continue;
+ }
+
+ $update_args = array();
+
+ if ( isset( $data['name'] ) && is_string( $data['name'] ) ) {
+ $update_args['name'] = sanitize_text_field( $data['name'] );
+ }
+
+ // Validate and set slug only on new rows. Slug is immutable after creation.
+ if ( isset( $data['newRow'] ) && isset( $data['slug'] ) && is_string( $data['slug'] ) && '' !== $data['slug'] ) {
+ $candidate_slug = sanitize_title( $data['slug'] );
+ if ( in_array( $candidate_slug, $built_in_keys, true ) ) {
+ $reserved_slug_error = sprintf(
+ /* translators: %s: slug value */
+ __( 'The slug "%s" is already used by a built-in shipping provider. Please choose a different slug.', 'woocommerce' ),
+ $candidate_slug
+ );
+ continue;
+ }
+ $update_args['slug'] = $candidate_slug;
+ }
+
+ // Validate tracking URL template: must be a valid http/https URL.
+ // null means "not submitted" (preserve existing), empty string means "clear".
+ $tracking_url_template = null;
+ if ( isset( $data['tracking_url_template'] ) && is_string( $data['tracking_url_template'] ) ) {
+ if ( '' === $data['tracking_url_template'] ) {
+ $tracking_url_template = '';
+ } else {
+ $testable_url = str_replace( '__PLACEHOLDER__', 'test', $data['tracking_url_template'] );
+ if ( filter_var( $testable_url, FILTER_VALIDATE_URL ) && preg_match( '#^https?://#i', $testable_url ) ) {
+ $tracking_url_template = esc_url_raw( $data['tracking_url_template'], array( 'http', 'https' ) );
+ } else {
+ $reserved_slug_error = __( 'The tracking URL template must be a valid HTTP or HTTPS URL.', 'woocommerce' );
+ }
+ }
+ }
+
+ // Validate icon URL: must be a valid http/https URL.
+ $icon_url = null;
+ if ( isset( $data['icon'] ) && is_string( $data['icon'] ) ) {
+ if ( '' === $data['icon'] ) {
+ $icon_url = '';
+ } elseif ( filter_var( $data['icon'], FILTER_VALIDATE_URL ) && preg_match( '#^https?://#i', $data['icon'] ) ) {
+ $icon_url = esc_url_raw( $data['icon'], array( 'http', 'https' ) );
+ } else {
+ $reserved_slug_error = __( 'The icon URL must be a valid HTTP or HTTPS URL.', 'woocommerce' );
+ }
+ }
+
+ if ( isset( $data['newRow'] ) ) {
+ $provider_name = strval( $update_args['name'] ?? '' );
+ $update_args = array_filter( $update_args );
+ if ( empty( $provider_name ) ) {
+ continue;
+ }
+
+ $inserted_term = wp_insert_term( $provider_name, $taxonomy, $update_args );
+ if ( is_wp_error( $inserted_term ) ) {
+ $reserved_slug_error = $inserted_term->get_error_message();
+ continue;
+ }
+ $term_id = $inserted_term['term_id'];
+
+ // Verify auto-generated slug doesn't collide with built-in keys.
+ $new_term = get_term( $term_id, $taxonomy );
+ if ( ! $new_term instanceof \WP_Term ) {
+ continue;
+ }
+ if ( in_array( $new_term->slug, $built_in_keys, true ) ) {
+ wp_delete_term( $term_id, $taxonomy );
+ $reserved_slug_error = sprintf(
+ /* translators: %s: provider name */
+ __( 'Could not create provider "%s" because its auto-generated slug conflicts with a built-in shipping provider. Please specify a different slug.', 'woocommerce' ),
+ $provider_name
+ );
+ continue;
+ }
+ } else {
+ $update_result = wp_update_term( $term_id, $taxonomy, $update_args );
+ if ( is_wp_error( $update_result ) ) {
+ $reserved_slug_error = $update_result->get_error_message();
+ continue;
+ }
+ }
+
+ if ( $term_id ) {
+ if ( null !== $tracking_url_template ) {
+ update_term_meta( $term_id, 'tracking_url_template', $tracking_url_template );
+ }
+ if ( null !== $icon_url ) {
+ update_term_meta( $term_id, 'icon', $icon_url );
+ }
+ }
+ }
+
+ $terms = get_terms(
+ array(
+ 'taxonomy' => $taxonomy,
+ 'hide_empty' => false,
+ )
+ );
+ $shipping_providers = array();
+
+ if ( ! is_wp_error( $terms ) ) {
+ foreach ( $terms as $term ) {
+ $shipping_providers[] = array(
+ 'term_id' => $term->term_id,
+ 'name' => $term->name,
+ 'slug' => $term->slug,
+ 'tracking_url_template' => get_term_meta( $term->term_id, 'tracking_url_template', true ),
+ 'icon' => get_term_meta( $term->term_id, 'icon', true ),
+ );
+ }
+ }
+
+ $response = array(
+ 'shipping_providers' => $shipping_providers,
+ );
+
+ if ( ! empty( $reserved_slug_error ) ) {
+ $response['error'] = $reserved_slug_error;
+ }
+
+ wp_send_json_success(
+ $response
+ );
+ }
+
+ /**
+ * Check if a shipping provider slug is referenced by any fulfillment record.
+ *
+ * @since 10.7.0
+ *
+ * @param string $provider_slug The provider slug to check.
+ * @return bool True if the provider is in use.
+ */
+ private static function is_shipping_provider_in_use( string $provider_slug ): bool {
+ global $wpdb;
+
+ $fulfillments_table = $wpdb->prefix . 'wc_order_fulfillments';
+ $meta_table = $wpdb->prefix . 'wc_order_fulfillment_meta';
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $exists = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT 1 FROM {$fulfillments_table} f
+ INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
+ WHERE m.meta_key = '_shipping_provider'
+ AND m.meta_value = %s
+ AND f.date_deleted IS NULL
+ AND m.date_deleted IS NULL
+ LIMIT 1",
+ $provider_slug
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ // Fail safe: assume in use if the query itself failed.
+ if ( $wpdb->last_error ) {
+ return true;
+ }
+
+ return null !== $exists;
+ }
+
/**
* Toggle payment gateway on or off via AJAX.
*
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
index ab27e5d7bff..3e28b733323 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
@@ -75,6 +75,9 @@ class FulfillmentsController {
// Create the database tables if they do not exist.
$this->maybe_create_db_tables();
+ // Register the custom shipping providers taxonomy.
+ $this->register_custom_shipping_providers_taxonomy();
+
// Register the classes that this controller provides.
foreach ( $this->provides as $class ) {
$class = $container->get( $class );
@@ -84,6 +87,37 @@ class FulfillmentsController {
}
}
+ /**
+ * Register the custom shipping providers taxonomy.
+ */
+ private function register_custom_shipping_providers_taxonomy(): void {
+ if ( taxonomy_exists( 'wc_fulfillment_shipping_provider' ) ) {
+ return;
+ }
+
+ register_taxonomy(
+ 'wc_fulfillment_shipping_provider',
+ array(),
+ array(
+ 'labels' => array(
+ 'name' => __( 'Shipping providers', 'woocommerce' ),
+ 'singular_name' => __( 'Shipping provider', 'woocommerce' ),
+ 'add_new_item' => __( 'Add new shipping provider', 'woocommerce' ),
+ 'edit_item' => __( 'Edit shipping provider', 'woocommerce' ),
+ ),
+ 'public' => false,
+ 'show_ui' => false,
+ 'hierarchical' => false,
+ 'show_in_rest' => false,
+ 'show_admin_column' => false, // phpcs:ignore WordPress.Arrays.MultipleStatementAlignment.DoubleArrowNotAligned
+ 'show_in_nav_menus' => false, // phpcs:ignore WordPress.Arrays.MultipleStatementAlignment.DoubleArrowNotAligned
+ 'show_tagcloud' => false,
+ 'query_var' => false,
+ 'rewrite' => false,
+ )
+ );
+ }
+
/**
* Create the database tables if they do not exist.
*
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
index abf7e6c84b6..792b3226e1d 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\Admin\Features\Fulfillments\Providers\CustomShippingProvider;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;
use WC_Order_Refund;
@@ -33,6 +34,7 @@ class FulfillmentsManager {
*/
public function register() {
add_filter( 'woocommerce_fulfillment_shipping_providers', array( $this, 'get_initial_shipping_providers' ), 10, 1 );
+ add_filter( 'woocommerce_fulfillment_shipping_providers', array( $this, 'get_custom_shipping_providers' ), 20, 1 );
add_filter( 'woocommerce_fulfillment_translate_meta_key', array( $this, 'translate_fulfillment_meta_key' ), 10, 1 );
add_filter( 'woocommerce_fulfillment_parse_tracking_number', array( $this, 'try_parse_tracking_number' ), 10, 3 );
@@ -182,6 +184,49 @@ class FulfillmentsManager {
return $shipping_providers;
}
+ /**
+ * Load custom shipping providers from the wc_fulfillment_shipping_provider taxonomy.
+ *
+ * @since 10.7.0
+ *
+ * @param array $shipping_providers The current list of shipping providers.
+ * @return array The modified list of shipping providers with custom providers appended.
+ */
+ public function get_custom_shipping_providers( $shipping_providers ) {
+ if ( ! is_array( $shipping_providers ) ) {
+ $shipping_providers = array();
+ }
+
+ if ( ! taxonomy_exists( 'wc_fulfillment_shipping_provider' ) ) {
+ return $shipping_providers;
+ }
+
+ $terms = get_terms(
+ array(
+ 'taxonomy' => 'wc_fulfillment_shipping_provider',
+ 'hide_empty' => false,
+ )
+ );
+
+ if ( is_wp_error( $terms ) || empty( $terms ) ) {
+ return $shipping_providers;
+ }
+
+ foreach ( $terms as $term ) {
+ $icon = get_term_meta( $term->term_id, 'icon', true );
+ $tracking_url_template = get_term_meta( $term->term_id, 'tracking_url_template', true );
+
+ $shipping_providers[] = new CustomShippingProvider(
+ $term->slug,
+ $term->name,
+ is_string( $icon ) ? $icon : '',
+ is_string( $tracking_url_template ) ? $tracking_url_template : ''
+ );
+ }
+
+ return $shipping_providers;
+ }
+
/**
* Update order fulfillment status after a fulfillment is created, updated, or deleted.
*
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
index bfddf24fc85..50e71e4902e 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
@@ -64,14 +64,18 @@ class FulfillmentsRenderer {
add_filter( 'handle_bulk_actions-woocommerce_page_wc-orders', array( $this, 'handle_fulfillment_bulk_actions' ), 10, 3 );
// For custom orders table, we need to filter the query to include fulfillment status.
add_action( 'woocommerce_order_list_table_restrict_manage_orders', array( $this, 'render_fulfillment_filters' ) );
+ add_action( 'woocommerce_order_list_table_restrict_manage_orders', array( $this, 'render_shipping_provider_filter' ) );
add_filter( 'woocommerce_order_query_args', array( $this, 'filter_orders_list_table_query' ), 10, 1 );
+ add_filter( 'woocommerce_order_list_table_prepare_items_query_args', array( $this, 'filter_orders_by_shipping_provider' ), 10, 1 );
} else {
// For legacy orders table, we need to add the bulk actions to the legacy orders table.
add_filter( 'bulk_actions-edit-shop_order', array( $this, 'define_fulfillment_bulk_actions' ) );
add_filter( 'handle_bulk_actions-edit-shop_order', array( $this, 'handle_fulfillment_bulk_actions' ), 10, 3 );
// For legacy orders table, we need to filter the query to include fulfillment status.
add_action( 'restrict_manage_posts', array( $this, 'render_fulfillment_filters_legacy' ) );
+ add_action( 'restrict_manage_posts', array( $this, 'render_shipping_provider_filter_legacy' ) );
add_action( 'pre_get_posts', array( $this, 'filter_legacy_orders_list_query' ) );
+ add_action( 'pre_get_posts', array( $this, 'filter_legacy_orders_by_shipping_provider' ) );
}
}
@@ -528,6 +532,190 @@ class FulfillmentsRenderer {
}
}
+ /**
+ * Render the shipping provider filter dropdown in the orders table.
+ *
+ * @since 10.7.0
+ */
+ public function render_shipping_provider_filter(): void {
+ if ( ! self::should_render_fulfillment_drawer() ) {
+ return;
+ }
+
+ $providers = FulfillmentUtils::get_shipping_providers_object();
+
+ // This is a read-only filter on the admin orders table, so nonce verification is not required.
+ // phpcs:ignore WordPress.Security.NonceVerification
+ $selected_provider = isset( $_GET['shipping_provider'] ) ? sanitize_text_field( wp_unslash( $_GET['shipping_provider'] ) ) : '';
+ ?>
+ <select id="shipping-provider-filter" name="shipping_provider">
+ <option value="" <?php selected( $selected_provider, '' ); ?>><?php esc_html_e( 'Filter by shipping provider', 'woocommerce' ); ?></option>
+ <?php foreach ( $providers as $key => $provider ) : ?>
+ <option value="<?php echo esc_attr( $key ); ?>" <?php selected( $selected_provider, $key ); ?>>
+ <?php echo esc_html( $provider['label'] ?? '' ); ?>
+ </option>
+ <?php endforeach; ?>
+ <option value="__other__" <?php selected( $selected_provider, '__other__' ); ?>><?php esc_html_e( 'Other', 'woocommerce' ); ?></option>
+ </select>
+ <?php
+ }
+
+ /**
+ * Render the shipping provider filter in the legacy orders table.
+ *
+ * @since 10.7.0
+ */
+ public function render_shipping_provider_filter_legacy(): void {
+ global $typenow;
+
+ if ( 'shop_order' !== $typenow ) {
+ return;
+ }
+
+ $this->render_shipping_provider_filter();
+ }
+
+ /**
+ * Filter orders by shipping provider for the HPOS orders list.
+ *
+ * @since 10.7.0
+ *
+ * @param array $args The query arguments for the orders list.
+ * @return array The modified query arguments.
+ */
+ public function filter_orders_by_shipping_provider( $args ) {
+ // This is a read-only filter on the admin orders table, so nonce verification is not required.
+ // phpcs:ignore WordPress.Security.NonceVerification
+ if ( ! isset( $_GET['shipping_provider'] ) || empty( $_GET['shipping_provider'] ) ) {
+ return $args;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification
+ $shipping_provider = sanitize_text_field( wp_unslash( $_GET['shipping_provider'] ) );
+ $order_ids = $this->get_order_ids_by_shipping_provider( $shipping_provider );
+
+ if ( empty( $order_ids ) ) {
+ $args['post__in'] = array( 0 );
+ } elseif ( isset( $args['post__in'] ) && is_array( $args['post__in'] ) ) {
+ $args['post__in'] = array_intersect( $args['post__in'], $order_ids );
+ if ( empty( $args['post__in'] ) ) {
+ $args['post__in'] = array( 0 );
+ }
+ } else {
+ $args['post__in'] = $order_ids;
+ }
+
+ return $args;
+ }
+
+ /**
+ * Filter legacy orders by shipping provider.
+ *
+ * @since 10.7.0
+ *
+ * @param \WP_Query $query The WP_Query object.
+ */
+ public function filter_legacy_orders_by_shipping_provider( $query ): void {
+ if (
+ ! is_admin()
+ || ! $query->is_main_query()
+ || 'shop_order' !== $query->get( 'post_type' )
+ // This is a read-only filter on the admin orders table, so nonce verification is not required.
+ // phpcs:ignore WordPress.Security.NonceVerification
+ || ! isset( $_GET['shipping_provider'] )
+ // phpcs:ignore WordPress.Security.NonceVerification
+ || empty( $_GET['shipping_provider'] )
+ ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification
+ $shipping_provider = sanitize_text_field( wp_unslash( $_GET['shipping_provider'] ) );
+ $order_ids = $this->get_order_ids_by_shipping_provider( $shipping_provider );
+
+ if ( empty( $order_ids ) ) {
+ $query->set( 'post__in', array( 0 ) );
+ } else {
+ $existing = $query->get( 'post__in' );
+ if ( ! empty( $existing ) && is_array( $existing ) ) {
+ $order_ids = array_intersect( $existing, $order_ids );
+ if ( empty( $order_ids ) ) {
+ $order_ids = array( 0 );
+ }
+ }
+ $query->set( 'post__in', $order_ids );
+ }
+ }
+
+ /**
+ * Get order IDs that have fulfillments with a specific shipping provider.
+ *
+ * @since 10.7.0
+ *
+ * @param string $shipping_provider The shipping provider key to filter by.
+ * @return array Array of order IDs.
+ */
+ private function get_order_ids_by_shipping_provider( string $shipping_provider ): array {
+ global $wpdb;
+
+ $fulfillments_table = $wpdb->prefix . 'wc_order_fulfillments';
+ $meta_table = $wpdb->prefix . 'wc_order_fulfillment_meta';
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ if ( '__other__' === $shipping_provider ) {
+ $known_providers = FulfillmentUtils::get_shipping_providers_object();
+ $known_keys = array_keys( $known_providers );
+
+ if ( empty( $known_keys ) ) {
+ $results = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT DISTINCT f.entity_id
+ FROM {$fulfillments_table} f
+ INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
+ WHERE m.meta_key = %s
+ AND m.meta_value IS NOT NULL
+ AND m.meta_value != ''
+ AND f.date_deleted IS NULL
+ AND m.date_deleted IS NULL",
+ '_shipping_provider'
+ )
+ );
+ } else {
+ $placeholders = implode( ',', array_fill( 0, count( $known_keys ), '%s' ) );
+ $results = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT DISTINCT f.entity_id
+ FROM {$fulfillments_table} f
+ INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
+ WHERE m.meta_key = '_shipping_provider'
+ AND m.meta_value NOT IN ({$placeholders})
+ AND m.meta_value IS NOT NULL
+ AND m.meta_value != ''
+ AND f.date_deleted IS NULL
+ AND m.date_deleted IS NULL",
+ ...$known_keys
+ )
+ );
+ }
+ } else {
+ $results = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT DISTINCT f.entity_id
+ FROM {$fulfillments_table} f
+ INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
+ WHERE m.meta_key = '_shipping_provider'
+ AND m.meta_value = %s
+ AND f.date_deleted IS NULL
+ AND m.date_deleted IS NULL",
+ $shipping_provider
+ )
+ );
+ }
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+
+ return array_map( 'absint', $results );
+ }
+
/**
* Check if the fulfillment drawer should be rendered (admin only).
*
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/Providers/CustomShippingProvider.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/Providers/CustomShippingProvider.php
new file mode 100644
index 00000000000..f8027cf1714
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/Providers/CustomShippingProvider.php
@@ -0,0 +1,114 @@
+<?php declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Admin\Features\Fulfillments\Providers;
+
+/**
+ * Custom shipping provider loaded from the wc_fulfillment_shipping_provider taxonomy.
+ *
+ * Unlike built-in providers, custom providers do not support automatic tracking
+ * number parsing; they rely on user-supplied tracking URL templates.
+ *
+ * @since 10.7.0
+ */
+class CustomShippingProvider extends AbstractShippingProvider {
+
+ /**
+ * The provider key (taxonomy term slug).
+ *
+ * @var string
+ */
+ private string $key;
+
+ /**
+ * The provider display name.
+ *
+ * @var string
+ */
+ private string $name;
+
+ /**
+ * The provider icon URL.
+ *
+ * @var string
+ */
+ private string $icon;
+
+ /**
+ * The tracking URL template containing __PLACEHOLDER__ for the tracking number.
+ *
+ * @var string
+ */
+ private string $tracking_url_template;
+
+ /**
+ * Constructor.
+ *
+ * @param string $key The provider key (term slug).
+ * @param string $name The provider display name.
+ * @param string $icon The provider icon URL.
+ * @param string $tracking_url_template The tracking URL template.
+ */
+ public function __construct( string $key, string $name, string $icon, string $tracking_url_template ) {
+ $this->key = $key;
+ $this->name = $name;
+ $this->icon = $icon;
+ $this->tracking_url_template = $tracking_url_template;
+ }
+
+ /**
+ * Get the key of the shipping provider.
+ *
+ * @return string
+ */
+ public function get_key(): string {
+ return $this->key;
+ }
+
+ /**
+ * Get the name of the shipping provider.
+ *
+ * @return string
+ */
+ public function get_name(): string {
+ return $this->name;
+ }
+
+ /**
+ * Get the icon URL of the shipping provider.
+ *
+ * @return string
+ */
+ public function get_icon(): string {
+ return $this->icon;
+ }
+
+ /**
+ * Get the tracking URL for a given tracking number.
+ *
+ * Replaces __PLACEHOLDER__ in the template with the actual tracking number.
+ *
+ * @param string $tracking_number The tracking number.
+ * @return string The tracking URL with the placeholder replaced.
+ */
+ public function get_tracking_url( string $tracking_number ): string {
+ if ( empty( $this->tracking_url_template ) ) {
+ return '';
+ }
+
+ return str_replace( '__PLACEHOLDER__', rawurlencode( $tracking_number ), $this->tracking_url_template );
+ }
+
+ /**
+ * Custom providers do not support automatic tracking number parsing.
+ *
+ * @param string $tracking_number The tracking number.
+ * @param string $shipping_from The country code from which the shipment is sent.
+ * @param string $shipping_to The country code to which the shipment is sent.
+ * @return array|null Always returns null for custom providers.
+ *
+ * phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter
+ */
+ public function try_parse_tracking_number( string $tracking_number, string $shipping_from, string $shipping_to ): ?array {
+ return null;
+ }
+}
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 7766666d6c2..4ddfbaff2e5 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsManagerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsManagerTest.php
@@ -348,6 +348,58 @@ class FulfillmentsManagerTest extends \WC_Unit_Test_Case {
$this->assertSame( '0', $fulfillments_after, 'Fulfillments should be deleted after order deletion' );
}
+ /**
+ * @testdox Should register the custom shipping providers filter hook.
+ */
+ public function test_custom_shipping_providers_hook_registered(): void {
+ $this->assertNotFalse( has_filter( 'woocommerce_fulfillment_shipping_providers', array( $this->manager, 'get_custom_shipping_providers' ) ) );
+ }
+
+ /**
+ * @testdox Should load custom shipping providers from the taxonomy into the providers list.
+ */
+ public function test_get_custom_shipping_providers_loads_taxonomy_terms(): void {
+ if ( ! taxonomy_exists( 'wc_fulfillment_shipping_provider' ) ) {
+ register_taxonomy( 'wc_fulfillment_shipping_provider', array() );
+ }
+
+ $term = wp_insert_term( 'Test Custom Provider', 'wc_fulfillment_shipping_provider', array( 'slug' => 'test-custom-provider' ) );
+ $this->assertNotWPError( $term );
+
+ update_term_meta( $term['term_id'], 'tracking_url_template', 'https://example.com/track?id=__PLACEHOLDER__' );
+ update_term_meta( $term['term_id'], 'icon', 'https://example.com/icon.png' );
+
+ $providers = $this->manager->get_custom_shipping_providers( array() );
+
+ $this->assertNotEmpty( $providers );
+
+ $found = false;
+ foreach ( $providers as $provider ) {
+ if ( $provider instanceof \Automattic\WooCommerce\Admin\Features\Fulfillments\Providers\CustomShippingProvider && 'test-custom-provider' === $provider->get_key() ) {
+ $found = true;
+ $this->assertSame( 'Test Custom Provider', $provider->get_name() );
+ $this->assertSame( 'https://example.com/icon.png', $provider->get_icon() );
+ $this->assertSame( 'https://example.com/track?id=ABC123', $provider->get_tracking_url( 'ABC123' ) );
+ break;
+ }
+ }
+
+ $this->assertTrue( $found, 'Custom provider should be loaded from taxonomy' );
+
+ wp_delete_term( $term['term_id'], 'wc_fulfillment_shipping_provider' );
+ }
+
+ /**
+ * @testdox Should return existing providers when no custom providers exist.
+ */
+ public function test_get_custom_shipping_providers_returns_existing_when_no_terms(): void {
+ $existing = array( 'some_provider' );
+
+ $result = $this->manager->get_custom_shipping_providers( $existing );
+
+ $this->assertContains( 'some_provider', $result );
+ }
+
/**
* Test tracking number parsing without any shipping providers.
*/
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/Providers/CustomShippingProviderTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/Providers/CustomShippingProviderTest.php
new file mode 100644
index 00000000000..a6cf7d272b3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/Providers/CustomShippingProviderTest.php
@@ -0,0 +1,100 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments\Providers;
+
+use Automattic\WooCommerce\Admin\Features\Fulfillments\Providers\CustomShippingProvider;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the CustomShippingProvider class.
+ */
+class CustomShippingProviderTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var CustomShippingProvider
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new CustomShippingProvider(
+ 'my-courier',
+ 'My Local Courier',
+ 'https://example.com/icon.png',
+ 'https://example.com/track?id=__PLACEHOLDER__'
+ );
+ }
+
+ /**
+ * @testdox Should return the correct provider key.
+ */
+ public function test_get_key(): void {
+ $this->assertSame( 'my-courier', $this->sut->get_key() );
+ }
+
+ /**
+ * @testdox Should return the correct provider name.
+ */
+ public function test_get_name(): void {
+ $this->assertSame( 'My Local Courier', $this->sut->get_name() );
+ }
+
+ /**
+ * @testdox Should return the correct icon URL.
+ */
+ public function test_get_icon(): void {
+ $this->assertSame( 'https://example.com/icon.png', $this->sut->get_icon() );
+ }
+
+ /**
+ * @testdox Should replace __PLACEHOLDER__ in tracking URL template with the tracking number.
+ */
+ public function test_get_tracking_url_replaces_placeholder(): void {
+ $result = $this->sut->get_tracking_url( 'ABC123' );
+
+ $this->assertSame( 'https://example.com/track?id=ABC123', $result );
+ }
+
+ /**
+ * @testdox Should URL-encode the tracking number in the tracking URL.
+ */
+ public function test_get_tracking_url_encodes_tracking_number(): void {
+ $result = $this->sut->get_tracking_url( 'ABC 123&test' );
+
+ $this->assertSame( 'https://example.com/track?id=ABC%20123%26test', $result );
+ }
+
+ /**
+ * @testdox Should return empty string when tracking URL template is empty.
+ */
+ public function test_get_tracking_url_returns_empty_when_no_template(): void {
+ $provider = new CustomShippingProvider( 'test', 'Test', '', '' );
+
+ $result = $provider->get_tracking_url( 'ABC123' );
+
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * @testdox Should always return null for try_parse_tracking_number.
+ */
+ public function test_try_parse_tracking_number_returns_null(): void {
+ $result = $this->sut->try_parse_tracking_number( 'ABC123', 'US', 'CA' );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @testdox Should return empty arrays for shipping country methods.
+ */
+ public function test_shipping_countries_return_empty_arrays(): void {
+ $this->assertSame( array(), $this->sut->get_shipping_from_countries() );
+ $this->assertSame( array(), $this->sut->get_shipping_to_countries() );
+ }
+}