Commit 779e7bb1a20 for woocommerce

commit 779e7bb1a20a7463e11508ad4d509bb1e0ef09e3
Author: Bogdan Ungureanu <bogdanungureanu21@gmail.com>
Date:   Thu Sep 24 17:57:34 2026 +0300

    Add weight placeholder in shipping cost (#68835)

    * Add a [weight] placeholder to flat rate shipping costs

    A package whose items have no weight resolves to 0, so formulas dividing by [weight] are
    rejected at save time rather than silently offering free shipping. [weight min="1"] sets a floor for that case.

    * Add validation for max/min

    * Fix negative item weights in flat rate shipping

    * Fix review issues

    * Fix malformed weight placeholder matching

    * Remove description and bump version

    ---------

    Co-authored-by: Sam Najian <dev@najian.info>

diff --git a/plugins/woocommerce/changelog/add-flat-rate-weight-placeholder b/plugins/woocommerce/changelog/add-flat-rate-weight-placeholder
new file mode 100644
index 00000000000..6b9812e031b
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-flat-rate-weight-placeholder
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a `[weight]` placeholder to flat rate shipping cost formulas, resolving to the total weight of the shippable items in the package or shipping class, in the store's weight unit. Accepts optional `min` and `max` attributes, e.g. `[weight min="1" max="20"]`. Invalid weight limits produce a validation error when saving; decimal limits use a dot and no thousands separators.
diff --git a/plugins/woocommerce/client/legacy/js/admin/utils/number-validation.js b/plugins/woocommerce/client/legacy/js/admin/utils/number-validation.js
index 61fa7bdc130..358721b72a2 100644
--- a/plugins/woocommerce/client/legacy/js/admin/utils/number-validation.js
+++ b/plugins/woocommerce/client/legacy/js/admin/utils/number-validation.js
@@ -29,6 +29,10 @@ function isValidFormattedNumber( value, config ) {
         return false;
     }

+	// Whitespace or "]" must follow "weight", excluding names such as [weight-foo].
+	// Attributes stop at the first closing bracket; the server validates their dot-decimal limits on save.
+	value = value.replace( /\[weight(?=\s|\])[^\]]*\]/g, '[weight]' );
+
 	var decimalSeparator = config.decimalSeparator || '.';
 	var thousandSeparator = config.thousandSeparator || ',';

diff --git a/plugins/woocommerce/client/legacy/js/admin/utils/test/number-validation.test.js b/plugins/woocommerce/client/legacy/js/admin/utils/test/number-validation.test.js
index 973872d7252..3bf7396f989 100644
--- a/plugins/woocommerce/client/legacy/js/admin/utils/test/number-validation.test.js
+++ b/plugins/woocommerce/client/legacy/js/admin/utils/test/number-validation.test.js
@@ -53,6 +53,39 @@ describe( 'Number Validation Utils - isValidFormattedNumber', () => {
 		} );
 	} );

+	describe( 'Weight placeholder limits', () => {
+		const config = {
+			decimalSeparator: ',',
+			thousandSeparator: '.',
+		};
+
+		test.each( [
+			'[weight min="0.5"]',
+			'10 * [weight max="1000.5"]',
+			'[weight min="0.5" max="1.5"] + 2,5',
+			'[weight min="0.5"] + [weight max="1.5"]',
+		] )( 'allows dot-decimal weight limits in %s for server validation', ( value ) => {
+			expect( isValidFormattedNumber( value, config ) ).toBe( true );
+		} );
+
+		test( 'still validates the decimal separator outside weight limits', () => {
+			expect( isValidFormattedNumber( '[weight min="0.5"] * 2.5', config ) ).toBe( false );
+		} );
+
+		test.each( [
+			'[weightless min="0.5"]',
+			'[weight-foo]',
+			'[weight.foo]',
+			'[weight-foo min="0.5"]',
+			'[weight.foo max="1.5"]',
+			'[weight min="0.5"',
+			'[weight min="0.5"] + [weightless min="1.5"]',
+			'[weight min="0.5"] + [weight max="1.5"',
+		] )( 'does not bypass number validation in %s', ( value ) => {
+			expect( isValidFormattedNumber( value, config ) ).toBe( false );
+		} );
+	} );
+
 	describe( 'Formula validation - US format', () => {
 		const config = {
 			decimalSeparator: '.',
diff --git a/plugins/woocommerce/includes/shipping/flat-rate/class-wc-shipping-flat-rate.php b/plugins/woocommerce/includes/shipping/flat-rate/class-wc-shipping-flat-rate.php
index f3ad1192c03..c17a7dd7470 100644
--- a/plugins/woocommerce/includes/shipping/flat-rate/class-wc-shipping-flat-rate.php
+++ b/plugins/woocommerce/includes/shipping/flat-rate/class-wc-shipping-flat-rate.php
@@ -8,6 +8,8 @@

 defined( 'ABSPATH' ) || exit;

+use Automattic\WooCommerce\Internal\Shipping\FlatRate\WeightPlaceholder;
+
 /**
  * WC_Shipping_Flat_Rate class.
  */
@@ -69,7 +71,7 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
 	 * Evaluate a cost from a sum/string.
 	 *
 	 * @param  string $sum Sum of shipping.
-	 * @param  array  $args Args, must contain `cost` and `qty` keys. Having `array()` as default is for back compat reasons.
+	 * @param  array  $args Args, must contain `cost` and `qty` keys, and may contain a numeric `weight` key (since 11.3.0), which defaults to 0. Having `array()` as default is for back compat reasons.
 	 * @return string
 	 */
 	protected function evaluate_cost( $sum, $args = array() ) {
@@ -86,6 +88,9 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
 		$decimals       = array( wc_get_price_decimal_separator(), $locale['decimal_point'], $locale['mon_decimal_point'], ',' );
 		$this->fee_cost = $args['cost'];

+		// Expanded before the shortcodes below, since the weight is substituted rather than registered as one.
+		$sum = $this->get_weight_placeholder()->expand( (string) $sum, $args['weight'] ?? null );
+
 		// Expand shortcodes.
 		add_shortcode( 'fee', array( $this, 'fee' ) );

@@ -103,7 +108,7 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
 			)
 		);

-		remove_shortcode( 'fee', array( $this, 'fee' ) );
+		remove_shortcode( 'fee' );

 		// Remove whitespace from string.
 		$sum = preg_replace( '/\s+/', '', $sum );
@@ -155,6 +160,17 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
 		return (string) $calculated_fee;
 	}

+	/**
+	 * Get the [weight] placeholder handler.
+	 *
+	 * Private so that the placeholder adds no new subclassing surface.
+	 *
+	 * @return WeightPlaceholder
+	 */
+	private function get_weight_placeholder() {
+		return wc_get_container()->get( WeightPlaceholder::class );
+	}
+
 	/**
 	 * Calculate the shipping costs.
 	 *
@@ -177,8 +193,9 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
 			$rate['cost'] = $this->evaluate_cost(
 				$cost,
 				array(
-					'qty'  => $this->get_package_item_qty( $package ),
-					'cost' => $package['contents_cost'],
+					'qty'    => $this->get_package_item_qty( $package ),
+					'cost'   => $package['contents_cost'],
+					'weight' => $this->get_weight_placeholder()->get_for_package( $package ),
 				)
 			);
 		}
@@ -203,8 +220,9 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
 				$class_cost = $this->evaluate_cost(
 					$class_cost_string,
 					array(
-						'qty'  => array_sum( wp_list_pluck( $products, 'quantity' ) ),
-						'cost' => array_sum( wp_list_pluck( $products, 'line_total' ) ),
+						'qty'    => array_sum( wp_list_pluck( $products, 'quantity' ) ),
+						'cost'   => array_sum( wp_list_pluck( $products, 'line_total' ) ),
+						'weight' => $this->get_weight_placeholder()->get_for_items( $products ),
 					)
 				);

@@ -310,6 +328,7 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
 	public function sanitize_cost( $value ) {
 		$value = is_null( $value ) ? '' : $value;
 		$value = wp_kses_post( trim( wp_unslash( $value ) ) );
+		$this->get_weight_placeholder()->validate( $value );
 		$value = str_replace( array( get_woocommerce_currency_symbol(), html_entity_decode( get_woocommerce_currency_symbol() ) ), '', $value );

 		$contains_shortcodes = false !== strpos( $value, '[' ) || false !== strpos( $value, ']' );
@@ -322,13 +341,29 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
 		$dummy_cost = $this->evaluate_cost(
 			$value,
 			array(
-				'cost' => 1,
-				'qty'  => 1,
+				'cost'   => 1,
+				'qty'    => 1,
+				'weight' => 1,
 			)
 		);
 		if ( false === $dummy_cost ) {
 			throw new Exception( WC_Eval_Math::$last_error );
 		}
+
+		// A package weighs nothing when none of its items have a weight set, which is a common configuration.
+		// Reject costs that can't be evaluated in that case, rather than silently offering the rate for free.
+		$zero_weight_cost = $this->evaluate_cost(
+			$value,
+			array(
+				'cost'   => 1,
+				'qty'    => 1,
+				'weight' => 0,
+			)
+		);
+		if ( false === $zero_weight_cost ) {
+			throw new Exception( esc_html__( 'This cost can\'t be calculated when no item in the cart has a weight. Use [weight min="1"] to set a minimum billable weight.', 'woocommerce' ) );
+		}
+
 		return $value;
 	}
 }
diff --git a/plugins/woocommerce/includes/shipping/flat-rate/includes/settings-flat-rate.php b/plugins/woocommerce/includes/shipping/flat-rate/includes/settings-flat-rate.php
index 98f3da92f79..6f79de707b6 100644
--- a/plugins/woocommerce/includes/shipping/flat-rate/includes/settings-flat-rate.php
+++ b/plugins/woocommerce/includes/shipping/flat-rate/includes/settings-flat-rate.php
@@ -8,8 +8,15 @@
 defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Enums\ProductTaxStatus;
+use Automattic\WooCommerce\Utilities\I18nUtil;

-$cost_desc = __( 'Enter a cost (excl. tax) or sum, e.g. <code>10.00 * [qty]</code>.', 'woocommerce' ) . '<br/><br/>' . __( 'Use <code>[qty]</code> for the number of items, <br/><code>[cost]</code> for the total cost of items, and <code>[fee percent="10" min_fee="20" max_fee=""]</code> for percentage based fees.', 'woocommerce' );
+// This description is shown as a tooltip, and wc_sanitize_tooltip() strips <code> tags, so the placeholders are
+// listed as `placeholder = meaning` pairs rather than relying on markup to delimit them.
+$cost_desc = __( 'Enter a cost (excl. tax) or sum, e.g. 10.00 * [qty].', 'woocommerce' ) . '<br/><br/>' . sprintf(
+	/* translators: %s: store weight unit label, e.g. kg */
+	__( 'Supports the following placeholders: [qty] = number of items, [cost] = total cost of items, [weight] = total weight of items in %s, [fee percent="10" min_fee="20" max_fee=""] = percentage based fee.', 'woocommerce' ),
+	I18nUtil::get_weight_unit_label( get_option( 'woocommerce_weight_unit', 'kg' ) )
+) . '<br/><br/>' . __( 'The weight accepts an optional minimum and maximum, e.g. [weight min="1" max="20"]. Use a dot for decimal values and no thousands separators in weight limits.', 'woocommerce' );
 $cost_link = sprintf( '<span id="wc-shipping-advanced-costs-help-text">%s <a target="_blank" href="https://woocommerce.com/document/flat-rate-shipping/#advanced-costs">%s</a>.</span>', __( 'Charge a flat rate per item, or enter a cost formula to charge a percentage based cost or a minimum fee. Learn more about', 'woocommerce' ), __( 'advanced costs', 'woocommerce' ) );

 $settings = array(
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 7ffab833507..2d95727e16e 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -30580,12 +30580,6 @@ parameters:
 			count: 1
 			path: includes/shipping/flat-rate/class-wc-shipping-flat-rate.php

-		-
-			message: '#^Function remove_shortcode invoked with 2 parameters, 1 required\.$#'
-			identifier: arguments.count
-			count: 1
-			path: includes/shipping/flat-rate/class-wc-shipping-flat-rate.php
-
 		-
 			message: '#^Method WC_Shipping_Flat_Rate\:\:calculate_shipping\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/Shipping/FlatRate/WeightPlaceholder.php b/plugins/woocommerce/src/Internal/Shipping/FlatRate/WeightPlaceholder.php
new file mode 100644
index 00000000000..0491ffbc961
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Shipping/FlatRate/WeightPlaceholder.php
@@ -0,0 +1,176 @@
+<?php
+/**
+ * WeightPlaceholder class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Shipping\FlatRate;
+
+use WC_Product;
+
+/**
+ * The [weight] placeholder used in flat rate shipping cost formulas.
+ *
+ * Works out the weight a package resolves to, and substitutes it into a cost formula.
+ */
+class WeightPlaceholder {
+
+	/**
+	 * Matches [weight] along with its optional attributes, e.g. [weight min="1" max="20"].
+	 *
+	 * Whitespace or "]" must follow "weight". Attributes stop at the first closing bracket.
+	 */
+	private const PLACEHOLDER_PATTERN = '/\[weight(?=\s|\])(?<attributes>[^]]*)]/';
+
+	/**
+	 * Get the total weight of the shippable items in a package, in the store's weight unit.
+	 *
+	 * @param array $package Package of items from the cart.
+	 * @return float
+	 *
+	 * @since 11.3.0
+	 */
+	public function get_for_package( array $package ): float {
+		$items = $package['contents'] ?? array();
+
+		return is_array( $items ) ? $this->get_for_items( $items ) : 0.0;
+	}
+
+	/**
+	 * Sum the weight of the given items, multiplied by their quantity.
+	 *
+	 * Items that don't need shipping, and items without a weight, contribute nothing. To adjust the weight a
+	 * cost formula sees, hook `woocommerce_evaluate_shipping_cost_args` and change its `weight` argument.
+	 *
+	 * @param array $items Cart items, in the shape of a package's `contents`.
+	 * @return float
+	 *
+	 * @since 11.3.0
+	 */
+	public function get_for_items( array $items ): float {
+		$total_weight = 0.0;
+
+		foreach ( $items as $values ) {
+			if ( ! is_array( $values ) ) {
+				continue;
+			}
+
+			$product  = $values['data'] ?? null;
+			$quantity = $values['quantity'] ?? 0;
+
+			if ( $product instanceof WC_Product && is_numeric( $quantity ) && $quantity > 0 && $product->needs_shipping() && $product->has_weight() ) {
+				$total_weight += $this->normalize( $product->get_weight() ) * $quantity;
+			}
+		}
+
+		return $total_weight;
+	}
+
+	/**
+	 * Validate weight limits before saving a cost formula.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string $sum Cost formula.
+	 * @return void
+	 * @throws \InvalidArgumentException If a weight limit or range is invalid.
+	 */
+	public function validate( string $sum ): void {
+		preg_match_all( self::PLACEHOLDER_PATTERN, $sum, $matches, PREG_SET_ORDER );
+
+		foreach ( $matches as $match ) {
+			$atts = (array) shortcode_parse_atts( $match['attributes'] );
+
+			foreach ( array( 'min', 'max' ) as $attribute ) {
+				$limit = $atts[ $attribute ] ?? '';
+
+				if ( '' !== $limit && ( ! is_numeric( $limit ) || (float) $limit < 0 ) ) {
+					throw new \InvalidArgumentException(
+						sprintf(
+							/* translators: %s: weight placeholder attribute, either min or max. */
+							esc_html__( 'The [weight] %s value must be a non-negative number with a dot decimal separator and no thousands separators, e.g. 1000.5.', 'woocommerce' ),
+							esc_html( $attribute )
+						)
+					);
+				}
+			}
+
+			$min = $atts['min'] ?? '';
+			$max = $atts['max'] ?? '';
+
+			if ( '' !== $min && '' !== $max && (float) $min > (float) $max ) {
+				throw new \InvalidArgumentException( esc_html__( 'The [weight] min value cannot be greater than the max value.', 'woocommerce' ) );
+			}
+		}
+	}
+
+	/**
+	 * Replace every [weight] placeholder in a cost formula with the given weight.
+	 *
+	 * Parsed directly rather than registered as a shortcode, so that no global shortcode is added for the
+	 * duration of the calculation and nothing else in the formula is expanded.
+	 *
+	 * @param string $sum    Cost formula.
+	 * @param mixed  $weight Package weight. Anything non-numeric counts as zero.
+	 * @return string
+	 *
+	 * @since 11.3.0
+	 */
+	public function expand( string $sum, $weight ): string {
+		$weight = $this->normalize( $weight );
+
+		return preg_replace_callback(
+			self::PLACEHOLDER_PATTERN,
+			function ( $matches ) use ( $weight ) {
+				return $this->clamp( $weight, shortcode_parse_atts( $matches['attributes'] ) );
+			},
+			$sum
+		) ?? $sum;
+	}
+
+	/**
+	 * Coerce an untrusted weight into a non-negative float.
+	 *
+	 * Weights reach this class through filters, so they can be anything. Negative weights are clamped the same
+	 * way wc_get_weight() clamps them.
+	 *
+	 * @param mixed $weight Weight to coerce.
+	 * @return float
+	 */
+	private function normalize( $weight ): float {
+		return is_numeric( $weight ) ? max( 0.0, (float) $weight ) : 0.0;
+	}
+
+	/**
+	 * Clamp a weight to the optional `min` and `max` attributes of a placeholder, and format it for the
+	 * expression evaluator.
+	 *
+	 * @param float        $weight Weight, in the store's weight unit.
+	 * @param array|string $atts   Placeholder attributes, as returned by shortcode_parse_atts().
+	 * @return string
+	 */
+	private function clamp( float $weight, $atts ): string {
+		// shortcode_parse_atts() returns the raw string when there are no attributes to parse.
+		$atts = shortcode_atts(
+			array(
+				'min' => '',
+				'max' => '',
+			),
+			is_array( $atts ) ? $atts : array()
+		);
+
+		// is_numeric() rather than a truthiness test, so that min="0" and max="0" are honoured.
+		if ( is_numeric( $atts['min'] ) && $weight < (float) $atts['min'] ) {
+			$weight = (float) $atts['min'];
+		}
+
+		if ( is_numeric( $atts['max'] ) && $weight > (float) $atts['max'] ) {
+			$weight = (float) $atts['max'];
+		}
+
+		// WC_Eval_Math reads `e` as a constant and rejects scientific notation, so the weight has to be a plain
+		// dot-decimal string. A zero weight yields "0" rather than "", so `10 * [weight]` isn't trimmed to `10`.
+		return wc_format_decimal( $weight, false, true );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-test.php b/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-test.php
index 7d8fcffe831..a71eb981e69 100644
--- a/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-test.php
+++ b/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-test.php
@@ -206,6 +206,17 @@ class WC_Shipping_Flat_Rate_Test extends WC_Unit_Test_Case {
 			'empty string'                  => array( '', '.', ',' ),
 			'shortcode qty'                 => array( '[qty]', '.', ',' ),
 			'shortcode expression'          => array( '10.00 * [qty]', '.', ',' ),
+			'shortcode weight'              => array( '[weight]', '.', ',' ),
+			'shortcode weight expression'   => array( '2.50 * [weight] + 1', '.', ',' ),
+			'shortcode weight with min'     => array( '[weight min="1"]', '.', ',' ),
+			'shortcode weight min and max'  => array( '2 * [weight min="1" max="20"]', '.', ',' ),
+			'weight min zero'               => array( '2 * [weight min="0"]', '.', ',' ),
+			'weight max zero'               => array( '2 * [weight max="0"]', '.', ',' ),
+			'weight equal limits'           => array( '2 * [weight min="1" max="1"]', '.', ',' ),
+			'weight empty limits'           => array( '2 * [weight min="" max=""]', '.', ',' ),
+
+			// Safe because the minimum keeps the divisor away from zero.
+			'weight division with min'      => array( '10 / [weight min="1"]', '.', ',' ),

 			// period decimal, comma thousand.
 			'simple division'               => array( '3.50 / 1.21', '.', ',' ),
@@ -246,6 +257,182 @@ class WC_Shipping_Flat_Rate_Test extends WC_Unit_Test_Case {
 			// Invalid characters.
 			'alphabetic string'             => array( 'abc', '.', ',' ),
 			'alphanumeric'                  => array( '10abc', '.', ',' ),
+			'hyphenated weight name'        => array( '[weight-foo]', '.', ',' ),
+			'dotted weight name'            => array( '[weight.foo]', '.', ',' ),
+			'hyphenated weight with limits' => array( '2 * [weight-foo min="1"]', ',', '.' ),
+			'dotted weight with limits'     => array( '2 * [weight.foo max="2"]', ',', '.' ),
+
+			// Divides by zero on any package whose items have no weight set.
+			'weight division'               => array( '10 / [weight]', '.', ',' ),
+			'weight division with min zero' => array( '10 / [weight min="0"]', '.', ',' ),
+		);
+	}
+
+	/**
+	 * @testdox The [weight] placeholder is replaced with the package weight.
+	 *
+	 * @dataProvider provider_weight_substitution
+	 *
+	 * @param string           $sum      Cost expression to evaluate.
+	 * @param int|float|string $weight   Weight passed in the args.
+	 * @param float            $expected Expected result.
+	 */
+	public function test_evaluate_cost_substitutes_weight( string $sum, $weight, float $expected ): void {
+		$val = $this->call_evaluate_cost->call(
+			$this->sut,
+			$sum,
+			array(
+				'qty'    => 2,
+				'cost'   => 100,
+				'weight' => $weight,
+			)
+		);
+
+		$this->assertFloatEquals( $expected, (float) $val, null, "Expected '{$sum}' to evaluate to {$expected}." );
+	}
+
+	/**
+	 * Weight substitution cases.
+	 *
+	 * Only enough to prove the placeholder is wired into evaluate_cost() and survives the expression
+	 * evaluator. Substitution and clamping are covered exhaustively in WeightPlaceholderTest.
+	 *
+	 * Format: [ expression, weight, expected result ]. Quantity is always 2 and cost is always 100.
+	 *
+	 * @return array
+	 */
+	public function provider_weight_substitution(): array {
+		return array(
+			'weight on its own'            => array( '[weight]', 3, 3.0 ),
+			'weight multiplied'            => array( '2 * [weight]', 1.5, 3.0 ),
+			'weight combined with qty'     => array( '[weight] + [qty]', 2.25, 4.25 ),
+			'zero weight keeps expression' => array( '10 * [weight]', 0, 0.0 ),
+			'min raises a low weight'      => array( '[weight min="1"]', 0, 1.0 ),
+			'min keeps divisor non-zero'   => array( '10 / [weight min="2"]', 0, 5.0 ),
+		);
+	}
+
+	/**
+	 * @testdox The [weight] placeholder falls back to zero when callers omit the weight argument.
+	 */
+	public function test_evaluate_cost_weight_defaults_to_zero_when_arg_missing(): void {
+		$val = $this->call_evaluate_cost->call(
+			$this->sut,
+			'10 + [weight]',
+			array(
+				'qty'  => 1,
+				'cost' => 1,
+			)
+		);
+
+		$this->assertFloatEquals( 10.0, (float) $val, null, 'Subclasses that pass only cost and qty should still evaluate [weight] as zero.' );
+	}
+
+	/**
+	 * @testdox A non-numeric weight coming from the args filter is treated as zero.
+	 */
+	public function test_evaluate_cost_ignores_non_numeric_weight_from_filter(): void {
+		$callback = function ( $args ) {
+			$args['weight'] = 'not-a-number';
+			return $args;
+		};
+		add_filter( 'woocommerce_evaluate_shipping_cost_args', $callback );
+
+		try {
+			$val = $this->call_evaluate_cost->call(
+				$this->sut,
+				'5 + [weight]',
+				array(
+					'qty'    => 1,
+					'cost'   => 1,
+					'weight' => 2,
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_evaluate_shipping_cost_args', $callback );
+		}
+
+		$this->assertFloatEquals( 5.0, (float) $val, null, 'A non-numeric weight from a filter should fall back to zero.' );
+	}
+
+	/**
+	 * @testdox The [weight] placeholder works alongside a comma decimal separator.
+	 */
+	public function test_evaluate_cost_weight_with_comma_decimal_separator(): void {
+		$val = $this->call_evaluate_cost->call(
+			$this->sut,
+			'1,5 * [weight]',
+			array(
+				'qty'    => 1,
+				'cost'   => 1,
+				'weight' => 2.5,
+			)
+		);
+
+		$this->assertFloatEquals( 3.75, (float) $val, null, 'The substituted weight should survive decimal separator normalisation.' );
+	}
+
+	/**
+	 * @testdox Weight limits accept dot decimals even when the store uses a comma decimal separator.
+	 *
+	 * @testWith ["10 * [weight min=\"0.5\"]", 0.0, 5.0]
+	 *           ["10 * [weight max=\"1.5\"]", 3.0, 15.0]
+	 *
+	 * @param string $sum      Cost formula with a decimal weight limit.
+	 * @param float  $weight   Package weight in the store's unit.
+	 * @param float  $expected Expected shipping cost.
+	 */
+	public function test_evaluate_cost_weight_with_decimal_bounds( string $sum, float $weight, float $expected ): void {
+		$val = $this->call_evaluate_cost->call(
+			$this->sut,
+			$this->sut->sanitize_cost( $sum ),
+			array(
+				'qty'    => 1,
+				'cost'   => 1,
+				'weight' => $weight,
+			)
+		);
+
+		$this->assertFloatEquals( $expected, (float) $val, null, "Expected '{$sum}' to honor its decimal weight limit." );
+	}
+
+	/**
+	 * @testdox Invalid weight limits produce a validation error when saving the cost.
+	 *
+	 * @testWith ["10 * [weight min=\"0,5\"]"]
+	 *           ["10 * [weight max=\"1,5\"]"]
+	 *           ["10 * [weight min=\"100,000.50\"]"]
+	 *           ["10 * [weight max=\"100,00.5\"]"]
+	 *           ["10 * [weight min=\"1.000,50\"]"]
+	 *           ["10 * [weight max=\"1 000.50\"]"]
+	 *           ["10 * [weight min=\"abc\"]"]
+	 *           ["10 * [weight max=\"abc\"]"]
+	 *           ["10 * [weight min=\"-1\"]"]
+	 *           ["10 * [weight max=\"-1\"]"]
+	 *           ["10 * [weight min=\"20\" max=\"10\"]"]
+	 *           ["10 * [weight min=\"0.5\" max=\"0\"]"]
+	 *
+	 * @param string $sum Cost formula with an invalid weight limit.
+	 */
+	public function test_sanitize_cost_rejects_invalid_weight_limits( string $sum ): void {
+		$this->expectException( InvalidArgumentException::class );
+		$this->sut->sanitize_cost( $sum );
+	}
+
+	/**
+	 * @testdox The [fee] shortcode stays based on cost when a weight is supplied.
+	 */
+	public function test_evaluate_cost_weight_does_not_change_fee_base(): void {
+		$val = $this->call_evaluate_cost->call(
+			$this->sut,
+			'[fee percent="10"] + [weight]',
+			array(
+				'qty'    => 1,
+				'cost'   => 100,
+				'weight' => 2,
+			)
 		);
+
+		$this->assertFloatEquals( 12.0, (float) $val, null, 'The fee should be a percentage of cost, with the weight added on top.' );
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Shipping/FlatRate/WeightPlaceholderTest.php b/plugins/woocommerce/tests/php/src/Internal/Shipping/FlatRate/WeightPlaceholderTest.php
new file mode 100644
index 00000000000..37891745719
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Shipping/FlatRate/WeightPlaceholderTest.php
@@ -0,0 +1,256 @@
+<?php
+/**
+ * WeightPlaceholderTest class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Shipping\FlatRate;
+
+use Automattic\WooCommerce\Internal\Shipping\FlatRate\WeightPlaceholder;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for WeightPlaceholder.
+ */
+class WeightPlaceholderTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var WeightPlaceholder
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = $this->get_instance_of( WeightPlaceholder::class );
+	}
+
+	/**
+	 * @testdox Package weight sums shippable items with a weight, multiplied by quantity.
+	 */
+	public function test_get_for_package_sums_shippable_weighted_items(): void {
+		$package = array(
+			'contents' => array(
+				'weighted'    => array(
+					'data'     => WC_Helper_Product::create_simple_product( false, array( 'weight' => '1.5' ) ),
+					'quantity' => 2,
+				),
+				'no_weight'   => array(
+					'data'     => WC_Helper_Product::create_simple_product( false, array( 'weight' => '' ) ),
+					'quantity' => 1,
+				),
+				'virtual'     => array(
+					'data'     => WC_Helper_Product::create_simple_product(
+						false,
+						array(
+							'weight'  => '5',
+							'virtual' => true,
+						)
+					),
+					'quantity' => 3,
+				),
+				'no_quantity' => array(
+					'data'     => WC_Helper_Product::create_simple_product( false, array( 'weight' => '9' ) ),
+					'quantity' => 0,
+				),
+			),
+		);
+
+		$this->assertFloatEquals( 3.0, $this->sut->get_for_package( $package ), null, 'Only shippable items that have a weight should contribute to the package weight.' );
+	}
+
+	/**
+	 * @testdox Package weight uses the parent weight for variations that have none of their own.
+	 */
+	public function test_get_for_package_uses_parent_weight_for_variations(): void {
+		$variable_product = WC_Helper_Product::create_variation_product();
+		$variable_product->set_weight( '2' );
+		$variable_product->save();
+
+		$children  = $variable_product->get_children();
+		$variation = wc_get_product( $children[0] );
+
+		$package = array(
+			'contents' => array(
+				'variation' => array(
+					'data'     => $variation,
+					'quantity' => 2,
+				),
+			),
+		);
+
+		$this->assertFloatEquals( 4.0, $this->sut->get_for_package( $package ), null, 'A variation without its own weight should inherit the parent product weight.' );
+	}
+
+	/**
+	 * @testdox Package weight is zero when the package has no contents.
+	 */
+	public function test_get_for_package_returns_zero_for_empty_contents(): void {
+		$this->assertSame( 0.0, $this->sut->get_for_package( array( 'contents' => array() ) ), 'An empty package should weigh zero.' );
+	}
+
+	/**
+	 * @testdox Package weight is zero when the package has no contents key at all.
+	 */
+	public function test_get_for_package_returns_zero_when_contents_missing(): void {
+		$this->assertSame( 0.0, $this->sut->get_for_package( array() ), 'A package without contents should weigh zero.' );
+	}
+
+	/**
+	 * @testdox Non-array package contents do not cause a type error.
+	 *
+	 * @testWith [null]
+	 *           ["invalid"]
+	 *           [false]
+	 *           [12]
+	 *
+	 * @param mixed $contents Invalid package contents.
+	 */
+	public function test_get_for_package_ignores_invalid_contents( $contents ): void {
+		$this->assertSame( 0.0, $this->sut->get_for_package( array( 'contents' => $contents ) ), 'Invalid contents must not contribute weight.' );
+	}
+
+	/**
+	 * @testdox Malformed items do not prevent valid items from contributing weight.
+	 */
+	public function test_get_for_items_ignores_malformed_items(): void {
+		$items   = $this->single_item( '2.5', 2 );
+		$product = $items['item']['data'];
+
+		$items['missing_quantity'] = array( 'data' => $product );
+		$items['invalid_quantity'] = array(
+			'data'     => $product,
+			'quantity' => 'invalid',
+		);
+		$items['missing_product']  = array( 'quantity' => 2 );
+		$items['invalid_product']  = array(
+			'data'     => false,
+			'quantity' => 2,
+		);
+		$items['invalid_item']     = 'invalid';
+		$items['object_item']      = new \stdClass();
+
+		$this->assertSame( 5.0, $this->sut->get_for_items( $items ), 'Only valid items should contribute to the weight.' );
+	}
+
+	/**
+	 * @testdox A negative product weight is clamped to zero.
+	 */
+	public function test_get_for_items_clamps_negative_product_weight(): void {
+		$this->assertSame(
+			0.0,
+			$this->sut->get_for_items( $this->single_item( '-5', 2 ) ),
+			'A negative weight should be clamped, the same way wc_get_weight() clamps it.'
+		);
+	}
+
+	/**
+	 * @testdox Negative product weights do not reduce the weight of other items.
+	 *
+	 * @testWith ["-1"]
+	 *           ["-5"]
+	 *
+	 * @param string $negative_weight Weight of the product that must contribute nothing.
+	 */
+	public function test_get_for_items_does_not_subtract_negative_product_weights( string $negative_weight ): void {
+		$items = array(
+			array(
+				'data'     => WC_Helper_Product::create_simple_product( false, array( 'weight' => '2.5' ) ),
+				'quantity' => 2,
+			),
+			array(
+				'data'     => WC_Helper_Product::create_simple_product( false, array( 'weight' => $negative_weight ) ),
+				'quantity' => 2,
+			),
+		);
+
+		$this->assertSame( 5.0, $this->sut->get_for_items( $items ), 'Negative product weights must not cancel out positive weights.' );
+	}
+
+	/**
+	 * @testdox The [weight] placeholder is replaced with the weight.
+	 *
+	 * @dataProvider provider_placeholder_replacement
+	 *
+	 * @param string $sum      Cost formula.
+	 * @param mixed  $weight   Weight to substitute.
+	 * @param string $expected Expected formula after replacement.
+	 */
+	public function test_expand( string $sum, $weight, string $expected ): void {
+		$this->assertSame( $expected, $this->sut->expand( $sum, $weight ), "Expected '{$sum}' to expand to '{$expected}'." );
+	}
+
+	/**
+	 * Placeholder replacement cases.
+	 *
+	 * Format: [ formula, weight, expected formula ].
+	 *
+	 * @return array
+	 */
+	public function provider_placeholder_replacement(): array {
+		return array(
+			'bare placeholder'                => array( '[weight]', 3, '3' ),
+			'placeholder in an expression'    => array( '2 * [weight]', 1.5, '2 * 1.5' ),
+			'repeated placeholder'            => array( '[weight] + [weight]', 2, '2 + 2' ),
+			'independent placeholder limits'  => array( '[weight min="5"] + [weight max="2"]', 3, '5 + 2' ),
+			'longer placeholder name'         => array( '[weightless min="1"]', 3, '[weightless min="1"]' ),
+			'hyphenated placeholder name'     => array( '[weight-foo]', 3, '[weight-foo]' ),
+			'dotted placeholder name'         => array( '[weight.foo]', 3, '[weight.foo]' ),
+			'hyphenated name with limits'     => array( '[weight-foo min="1"]', 3, '[weight-foo min="1"]' ),
+			'dotted name with limits'         => array( '[weight.foo max="2"]', 3, '[weight.foo max="2"]' ),
+			'unterminated placeholder'        => array( '[weight min="1"', 3, '[weight min="1"' ),
+			'no placeholder is untouched'     => array( '10 * [qty]', 5, '10 * [qty]' ),
+			'numeric string weight'           => array( '[weight]', '2.5', '2.5' ),
+
+			// A zero weight must not collapse to an empty string, or `10 * [weight]` would become `10 *`.
+			'zero weight keeps the operand'   => array( '10 * [weight]', 0, '10 * 0' ),
+
+			// Anything non-numeric or negative counts as zero.
+			'non-numeric weight'              => array( '[weight]', 'not-a-number', '0' ),
+			'null weight'                     => array( '[weight]', null, '0' ),
+			'array weight'                    => array( '[weight]', array( 1 ), '0' ),
+			'negative weight clamped'         => array( '[weight]', -5, '0' ),
+
+			// WC_Eval_Math reads `e` as a constant and has no thousands separator.
+			'small weight is not an exponent' => array( '[weight]', 0.00001, '0.00001' ),
+			'large weight has no separator'   => array( '[weight]', 1000000.0, '1000000' ),
+			'float imprecision normalised'    => array( '[weight]', 0.1 + 0.2, '0.3' ),
+
+			// wc_get_rounding_precision() is 6 by default, so anything finer truncates away.
+			'weight below rounding precision' => array( '[weight]', 0.0000001, '0' ),
+
+			// Minimum and maximum attributes.
+			'min raises a low weight'         => array( '[weight min="1"]', 0, '1' ),
+			'min ignored above the floor'     => array( '[weight min="1"]', 4, '4' ),
+			'max caps a high weight'          => array( '[weight max="20"]', 50, '20' ),
+			'max ignored below the ceiling'   => array( '[weight max="20"]', 5, '5' ),
+			'min and max together'            => array( '[weight min="1" max="20"]', 0, '1' ),
+			'explicit min zero is honoured'   => array( '[weight min="0"]', 3, '3' ),
+			'min keeps a divisor non-zero'    => array( '10 / [weight min="2"]', 0, '10 / 2' ),
+			'unknown attributes are ignored'  => array( '[weight foo="bar"]', 3, '3' ),
+			'non-numeric min is ignored'      => array( '[weight min="abc"]', 3, '3' ),
+		);
+	}
+
+	/**
+	 * Build a package contents array holding a single simple product.
+	 *
+	 * @param string $weight   Product weight.
+	 * @param int    $quantity Item quantity.
+	 * @return array
+	 */
+	private function single_item( string $weight, int $quantity ): array {
+		return array(
+			'item' => array(
+				'data'     => WC_Helper_Product::create_simple_product( false, array( 'weight' => $weight ) ),
+				'quantity' => $quantity,
+			),
+		);
+	}
+}