Commit 0a46e4a6b15 for woocommerce
commit 0a46e4a6b154099565662b36ead91b205dc156ce
Author: Samuel Urbanowicz <samiuelson@gmail.com>
Date: Wed Jun 10 18:35:02 2026 +0200
[1/2] Add DataUtils helpers for v4 refund preview endpoint (#65334)
* Add DataUtils helpers for refund preview endpoint
Adds three new public methods to Refunds\DataUtils to support an
upcoming refund preview endpoint (POST /wc/v4/refunds/preview):
- compute_line_item_refund_total(): tax-inclusive refund total for
a given line item at a requested quantity.
- build_refund_preview(): returns the structured refund breakdown
(products/shipping/fees with per-section subtotal/tax/total plus
top-level subtotal/tax/total/max_refundable).
- validate_preview_line_items(): validates a preview request against
the order — checks status (REFUNDABLE_STATUSES), remaining refundable
amount, line item existence, and remaining refundable quantity/total.
Reuses the same tax extraction path (WC_Tax::calc_inclusive_tax) as
the create endpoint to guarantee preview/create equivalence.
Relaxes visibility (private -> protected) on three existing helpers
(build_tax_rates_array, convert_line_item_taxes_to_internal_format,
convert_proportional_taxes_to_schema_format) so the new methods and
tests can reuse them.
Part of WOOMOB-2684. The endpoint that consumes these helpers will
follow in a separate PR.
* Add changefile(s) from automation for the following project(s): woocommerce
* Tighten validate_preview_line_items input validation
- Reject missing/non-int/non-positive quantity with new code
invalid_quantity. Previously `quantity = $line_item['quantity'] ?? 0`
silently passed for missing/string/float input, then downstream
consumers saw 0 or a coerced value.
- Require quantity === 1 for shipping/fee items (they aren't
quantity-divisible — the remaining-total branch was already not
scaled by quantity).
- Switch shipping/fee remaining-total math to abs()-based so
legitimately negative-total fees (discount-as-fee pattern) aren't
rejected as "fully refunded".
- Replace the catch-all invalid_line_item code with 4 distinct codes
so clients can distinguish failure modes:
invalid_line_item (empty array) -> missing_line_items
invalid_line_item (missing id) -> missing_line_item_id
invalid_line_item (not found) -> line_item_not_found
invalid_line_item (unsupported) -> unsupported_item_type
Addresses review issues #1, #3, and the lower-priority error-code
split from the PR #65334 review.
* Throw on missing item in build_refund_preview
Replace silent `continue` with InvalidArgumentException so callers
can't get a successful-looking empty preview when a line_item_id is
invalid (e.g. typo, race with delete, validation bypassed).
Document precondition in the docblock: callers must invoke
validate_preview_line_items() first.
Addresses review issue #2.
* Accumulate raw floats in build_refund_preview section sums
Previously the per-section subtotal/tax/total were accumulated by
casting already-formatted decimal strings back to floats via
`(float) $item['subtotal']`, which loses precision and can produce
a 1-cent drift between `breakdown.products.total` and the sum of
`breakdown.products.items[].total` on multi-line refunds.
Refactor: keep running raw-float totals per section during the
per-item loop, format once at section level. Item-level strings are
unchanged.
Addresses review issue #5.
* Log malformed tax data and zero-quantity branches
- compute_line_item_refund_total: emit a warning before returning 0.0
when a product item has zero original quantity. Indicates corrupted
order data that would otherwise silently produce a $0 preview.
- build_refund_preview: emit a warning when an item's taxes array is
non-empty but all entries are filtered out by the
is_numeric && > 0 check. Surfaces malformed tax metadata for ops
without changing user-visible behavior.
Both warnings use wc_get_logger() with source 'wc-v4-refunds'.
Addresses review issues #6 and #8.
* Add preconditions to compute_line_item_refund_total
Guard $quantity >= 1 with an InvalidArgumentException at method entry.
Document the precondition in the docblock plus a note that shipping
and fee items ignore quantity, and that the return value can be
negative for negative-total items (discount fees).
The validator catches bad input at the request boundary; this guard
protects direct callers since the method is public on an Internal\*
class that may be reused by the create endpoint.
Addresses review issue #7.
* Expand DataUtils unit tests, drop reflection tests
Delete the two reflection-based test_build_tax_rates_array_* tests.
build_tax_rates_array is exercised indirectly by
test_convert_line_items_extracts_tax_automatically and
test_build_refund_preview_with_tax; the project convention is to test
through public interfaces (see tests/php/src/CLAUDE.md).
Add 19 unit tests for the helpers introduced in this PR:
- compute_line_item_refund_total:
* zero-original-quantity branch returns 0.0
* shipping item (full total + tax, quantity ignored)
* fee item with positive total
* fee item with negative total (sign preserved)
* InvalidArgumentException for quantity < 1 (data provider)
- build_refund_preview:
* shipping-only order (products + fees sections empty)
* fee-only order (products + shipping sections empty)
* mixed sections (products + shipping + fees aggregate correctly)
* multi-item fractional-price aggregation (no drift between
section total and sum of item totals)
* InvalidArgumentException for missing line_item_id
- validate_preview_line_items:
* empty array -> missing_line_items
* order with no remaining refund amount -> order_not_refundable
* missing line_item_id key -> missing_line_item_id
* cross-order line_item_id -> line_item_not_found
* unsupported item type (tax line) -> unsupported_item_type
* invalid quantity values (data provider) -> invalid_quantity
* shipping with quantity \!= 1 -> invalid_quantity
* shipping fully refunded -> order_not_refundable
* negative-total fee passes validation
* Fix PHPCS issues in DataUtils.php (escape exception output, align assignments)
* Apply PHPCBF auto-fixes to DataUtilsTest
* Remove unused @var docblock to satisfy lint
* Restore @var docblock with short description for PHPStan type narrowing
* Populate HTTP status data on validate_preview_line_items WP_Errors
Each WP_Error now carries a per-code 'status' key in its error
data, so the REST controller can map to the right HTTP status
instead of flattening everything to 400:
missing_line_items -> 400 Bad Request
missing_line_item_id -> 400 Bad Request
invalid_quantity -> 400 Bad Request
line_item_not_found -> 404 Not Found
order_not_refundable -> 422 Unprocessable Entity
unsupported_item_type -> 422 Unprocessable Entity
quantity_exceeds_refundable -> 422 Unprocessable Entity
The controller-side switch to get_route_error_response_from_object
landed in the endpoint PR (#65335); this commit activates it by
populating the data the helper reads.
* Address review comments
CodeRabbit + codex review findings closed:
(A) Per-line cap for partially-refunded shipping/fees in
validate_preview_line_items. Previously only fully-refunded lines
were rejected, so a $10 shipping line partially refunded by $5
would pass validation and let build_refund_preview return $10 even
though only $5 was refundable. The new check computes the requested
refund via compute_line_item_refund_total() and rejects with
'quantity_exceeds_refundable' (422) when it exceeds remaining.
(B) Preserve the signed tax split for negative-tax discount fees.
The tax-extraction filter in build_refund_preview previously dropped
tax IDs where `amount > 0`, so a -$10 fee with -$1 stored tax
previewed as subtotal -$11 / tax $0 — losing the breakdown.
Switch the filter to `amount != 0` so non-zero (positive or
negative) tax amounts are kept and WC_Tax::calc_inclusive_tax
propagates the sign correctly.
(C) @since 10.8.0 → @since 10.9.0 on all new public methods, and
add @since 10.9.0 to the newly-protected helpers
(convert_line_item_taxes_to_internal_format,
convert_proportional_taxes_to_schema_format, build_tax_rates_array).
Per .ai/skills/woocommerce-backend-dev/code-entities.md, @since
is moved to the last line of each docblock.
(D) Remove the duplicate changelog file
65334-woomob-2684-refund-preview-helpers — the unprefixed
woomob-2684-refund-preview-helpers entry is the canonical one.
(E) Fix the @testdox on test_validate_preview_line_items_shipping_fully_refunded
to match the assertion (order_not_refundable). The order-level
remaining-amount guard fires first when shipping is fully refunded.
Tests added:
- test_validate_preview_line_items_shipping_partial_remaining —
pins the new per-line cap. Order has a $10 shipping line + $50
product; $5 of shipping is pre-refunded; previewing shipping at
qty=1 must return quantity_exceeds_refundable rather than
passing through to an oversized total.
- test_build_refund_preview_negative_fee_with_negative_tax —
pins the negative-tax breakdown fix. -$10 fee + -$1 stored tax
must preview as subtotal -$10 / tax -$1 / total -$11.
* Add changefile(s) from automation for the following project(s): woocommerce
* Address review comments on validate_preview_line_items
- Compare shipping/fee refunds on a tax-inclusive basis. The previous
code pitted the tax-inclusive $requested_total against a tax-exclusive
$remaining_total — for a $10 shipping line with $1.50 tax, the
comparison was 11.50 > 10.00 and rejected a legitimate full refund.
compute_refunded_quantities_and_totals() now records fee/shipping
totals tax-inclusive so the validator can compare like-for-like.
- Replace the hardcoded `'status' => 422` literals in
validate_preview_line_items with WP_Http::UNPROCESSABLE_ENTITY,
matching the convention used elsewhere in V4 routes.
- Add a regression test (shipping line with tax, no prior refund) to
lock in the tax-inclusive comparison.
- Delete the duplicate changelog entry; keep the PR-prefixed one auto-
generated by CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Align tax filter in convert_line_items_to_internal_format with preview side
The creation-side filter dropped tax IDs whose stored amount was <= 0,
so a negative-fee discount line (e.g. a -$10 fee with -$1 stored tax) had
its tax breakdown stripped on save — refund_total stayed at -$11 and
refund_tax was emitted as []. The preview side (build_refund_preview)
keeps any non-zero stored tax and renders the signed split correctly, so
a refund moving from preview to create lost the tax breakdown.
Match the preview rule (non-zero, not strictly positive). Add a
regression test that converts a negative fee with negative stored tax
and verifies the signed split survives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/65334-woomob-2684-refund-preview-helpers b/plugins/woocommerce/changelog/65334-woomob-2684-refund-preview-helpers
new file mode 100644
index 00000000000..a50de52f046
--- /dev/null
+++ b/plugins/woocommerce/changelog/65334-woomob-2684-refund-preview-helpers
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add internal helper methods on Refunds\DataUtils to support the upcoming refund preview endpoint.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
index 280a3d93b65..fb0376f79b8 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -12,9 +12,13 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Enums\OrderItemType;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Utilities\NumberUtil;
-use WP_Error;
use WC_Order;
+use WC_Order_Item_Fee;
+use WC_Order_Item_Product;
+use WC_Order_Item_Shipping;
use WC_Tax;
+use WP_Error;
+use WP_Http;
/**
* Helper methods for the REST API.
@@ -63,11 +67,15 @@ class DataUtils {
$original_item = $order->get_item( $line_item['line_item_id'] );
if ( $original_item ) {
$original_taxes = $original_item->get_taxes();
- // Filter to only include tax IDs that have non-zero amounts.
+ // Keep any non-zero stored tax (positive or negative). Negative-tax
+ // discount fees (e.g. a -$10 fee with -$1 stored tax) must retain
+ // their tax breakdown so the create side matches the preview side
+ // in build_refund_preview() — filtering on `> 0` previously dropped
+ // them and emitted refund_total=$line_total / refund_tax=[].
$tax_totals = array_filter(
$original_taxes['total'] ?? array(),
function ( $amount ) {
- return is_numeric( $amount ) && $amount > 0;
+ return is_numeric( $amount ) && 0.0 !== (float) $amount;
}
);
$tax_ids = array_keys( $tax_totals );
@@ -113,12 +121,14 @@ class DataUtils {
}
/**
- * Convert line item taxes (schema format) to internal format. This keys arrays by tax ID and has some different naming
+ * Convert line item taxes (schema format) to internal format. This keys arrays by tax ID and has some different naming.
*
* @param array $line_item_taxes The taxes to convert.
* @return array The converted taxes.
+ *
+ * @since 10.9.0
*/
- private function convert_line_item_taxes_to_internal_format( $line_item_taxes ) {
+ protected function convert_line_item_taxes_to_internal_format( $line_item_taxes ) {
$prepared_taxes = array();
foreach ( $line_item_taxes as $line_item_tax ) {
@@ -253,8 +263,10 @@ class DataUtils {
*
* @param array $calculated_taxes Taxes keyed by tax ID with amounts.
* @return array Schema format with id and refund_total keys.
+ *
+ * @since 10.9.0
*/
- private function convert_proportional_taxes_to_schema_format( array $calculated_taxes ): array {
+ protected function convert_proportional_taxes_to_schema_format( array $calculated_taxes ): array {
$result = array();
foreach ( $calculated_taxes as $tax_id => $amount ) {
$result[] = array(
@@ -271,8 +283,10 @@ class DataUtils {
* @param WC_Order $order The order.
* @param array $tax_ids Array of tax rate IDs that apply to an item.
* @return array Tax rates array formatted for WC_Tax::calc_*_tax() methods.
+ *
+ * @since 10.9.0
*/
- private function build_tax_rates_array( WC_Order $order, array $tax_ids ): array {
+ protected function build_tax_rates_array( WC_Order $order, array $tax_ids ): array {
$tax_rates = array();
$tax_items = $order->get_items( OrderItemType::TAX );
@@ -292,11 +306,328 @@ class DataUtils {
return $tax_rates;
}
+ /**
+ * Compute the tax-inclusive refund total for a line item at a given quantity.
+ *
+ * Precondition: $item must be one of WC_Order_Item_Product, WC_Order_Item_Shipping,
+ * WC_Order_Item_Fee, and $quantity must be a positive integer (>= 1). For
+ * shipping and fee items the quantity is informational only — the full item
+ * total is returned regardless. Callers using untrusted input should validate
+ * via {@see validate_preview_line_items()} first.
+ *
+ * @param WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item The order item.
+ * @param int $quantity The quantity to refund (>= 1).
+ * @return float The tax-inclusive refund total. May be negative for items with negative totals (e.g. discount fees).
+ * @throws \InvalidArgumentException When $quantity is less than 1.
+ *
+ * @since 10.9.0
+ */
+ public function compute_line_item_refund_total( $item, int $quantity ): float {
+ if ( $quantity < 1 ) {
+ // Exception message is developer-facing only; the value is a typed int and the format is a literal string.
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ throw new \InvalidArgumentException( sprintf( 'Quantity must be >= 1, got %d.', (int) $quantity ) );
+ }
+
+ $price_decimals = wc_get_price_decimals();
+
+ if ( $item instanceof WC_Order_Item_Product ) {
+ $original_qty = $item->get_quantity();
+ if ( 0 === $original_qty ) {
+ wc_get_logger()->warning(
+ sprintf( 'Refund preview: product item %d has zero original quantity on order %d.', $item->get_id(), $item->get_order_id() ),
+ array( 'source' => 'wc-v4-refunds' )
+ );
+ return 0.0;
+ }
+ $unit_price_with_tax = ( (float) $item->get_total() + (float) $item->get_total_tax() ) / $original_qty;
+ return NumberUtil::round( $unit_price_with_tax * $quantity, $price_decimals );
+ }
+
+ return NumberUtil::round( (float) $item->get_total() + (float) $item->get_total_tax(), $price_decimals );
+ }
+
+ /**
+ * Build a refund preview showing authoritative totals and breakdowns.
+ *
+ * Callers must invoke {@see validate_preview_line_items()} first — this
+ * method assumes inputs have been validated and throws on missing items.
+ *
+ * @param WC_Order $order The order being previewed for refund.
+ * @param array $line_items Array of line items with 'line_item_id' and 'quantity' keys.
+ * @return array The structured preview response.
+ * @throws \InvalidArgumentException When a line_item_id does not resolve to an item on the order.
+ *
+ * @since 10.9.0
+ */
+ public function build_refund_preview( WC_Order $order, array $line_items ): array {
+ $price_decimals = wc_get_price_decimals();
+ $sections = array(
+ 'products' => array(
+ 'items' => array(),
+ 'subtotal' => 0.0,
+ 'tax' => 0.0,
+ 'total' => 0.0,
+ ),
+ 'shipping' => array(
+ 'items' => array(),
+ 'subtotal' => 0.0,
+ 'tax' => 0.0,
+ 'total' => 0.0,
+ ),
+ 'fees' => array(
+ 'items' => array(),
+ 'subtotal' => 0.0,
+ 'tax' => 0.0,
+ 'total' => 0.0,
+ ),
+ );
+
+ foreach ( $line_items as $line_item ) {
+ $item = $order->get_item( $line_item['line_item_id'] );
+ if ( ! $item ) {
+ // Exception message is developer-facing only; both values are typed ints and the format is a literal string.
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ throw new \InvalidArgumentException( sprintf( 'Line item %d not found on order %d.', (int) $line_item['line_item_id'], (int) $order->get_id() ) );
+ }
+
+ /**
+ * Validated by validate_preview_line_items() upstream.
+ *
+ * @var WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item
+ */
+ $refund_total_with_tax = $this->compute_line_item_refund_total( $item, $line_item['quantity'] );
+ $subtotal = $refund_total_with_tax;
+ $tax = 0.0;
+
+ $original_taxes = $item->get_taxes();
+ // Keep any non-zero stored tax (positive or negative). Negative-tax
+ // discount fees (e.g. a -$10 fee with -$1 stored tax) must retain
+ // their tax breakdown — filtering on `> 0` previously dropped them
+ // and emitted subtotal=$line_total / tax=0 instead of the correct
+ // signed split.
+ $tax_totals = array_filter(
+ $original_taxes['total'] ?? array(),
+ function ( $amount ) {
+ return is_numeric( $amount ) && 0.0 !== (float) $amount;
+ }
+ );
+
+ if ( ! empty( $original_taxes['total'] ?? array() ) && empty( $tax_totals ) ) {
+ wc_get_logger()->warning(
+ sprintf(
+ 'Refund preview: tax totals filtered to empty for item %d on order %d (non-numeric or zero values).',
+ (int) $line_item['line_item_id'],
+ $order->get_id()
+ ),
+ array( 'source' => 'wc-v4-refunds' )
+ );
+ }
+
+ if ( ! empty( $tax_totals ) ) {
+ $tax_rates = $this->build_tax_rates_array( $order, array_keys( $tax_totals ) );
+ $calculated_taxes = WC_Tax::calc_inclusive_tax( $refund_total_with_tax, $tax_rates );
+ $calculated_taxes = array_map(
+ function ( $t ) use ( $price_decimals ) {
+ return NumberUtil::round( $t, $price_decimals );
+ },
+ $calculated_taxes
+ );
+ $tax = NumberUtil::round( array_sum( $calculated_taxes ), $price_decimals );
+ $subtotal = NumberUtil::round( $refund_total_with_tax - $tax, $price_decimals );
+ }
+
+ $item_data = array(
+ 'id' => $line_item['line_item_id'],
+ 'quantity' => $line_item['quantity'],
+ 'subtotal' => wc_format_decimal( $subtotal, $price_decimals ),
+ 'tax' => wc_format_decimal( $tax, $price_decimals ),
+ 'total' => wc_format_decimal( $refund_total_with_tax, $price_decimals ),
+ );
+
+ $item_data['name'] = $item->get_name();
+
+ if ( $item instanceof WC_Order_Item_Product ) {
+ $item_data['product_id'] = $item->get_product_id();
+ $item_data['variation_id'] = $item->get_variation_id();
+ $section_key = 'products';
+ } elseif ( $item instanceof WC_Order_Item_Shipping ) {
+ $section_key = 'shipping';
+ } else {
+ $section_key = 'fees';
+ }
+
+ $sections[ $section_key ]['items'][] = $item_data;
+ $sections[ $section_key ]['subtotal'] += $subtotal;
+ $sections[ $section_key ]['tax'] += $tax;
+ $sections[ $section_key ]['total'] += $refund_total_with_tax;
+ }
+
+ $format_section = function ( array $section ) use ( $price_decimals ): array {
+ return array(
+ 'items' => $section['items'],
+ 'subtotal' => wc_format_decimal( $section['subtotal'], $price_decimals ),
+ 'tax' => wc_format_decimal( $section['tax'], $price_decimals ),
+ 'total' => wc_format_decimal( $section['total'], $price_decimals ),
+ );
+ };
+
+ $grand_subtotal = $sections['products']['subtotal'] + $sections['shipping']['subtotal'] + $sections['fees']['subtotal'];
+ $grand_tax = $sections['products']['tax'] + $sections['shipping']['tax'] + $sections['fees']['tax'];
+ $grand_total = $sections['products']['total'] + $sections['shipping']['total'] + $sections['fees']['total'];
+
+ return array(
+ 'breakdown' => array(
+ 'products' => $format_section( $sections['products'] ),
+ 'shipping' => $format_section( $sections['shipping'] ),
+ 'fees' => $format_section( $sections['fees'] ),
+ ),
+ 'subtotal' => wc_format_decimal( $grand_subtotal, $price_decimals ),
+ 'tax' => wc_format_decimal( $grand_tax, $price_decimals ),
+ 'total' => wc_format_decimal( $grand_total, $price_decimals ),
+ 'max_refundable' => wc_format_decimal( $order->get_remaining_refund_amount(), $price_decimals ),
+ );
+ }
+
+ /**
+ * Validate line items for a preview request.
+ *
+ * @param array $line_items The line items to validate.
+ * @param WC_Order $order The order object.
+ * @return true|WP_Error True on success, WP_Error on failure.
+ *
+ * @since 10.9.0
+ */
+ public function validate_preview_line_items( array $line_items, WC_Order $order ) {
+ if ( empty( $line_items ) ) {
+ return new WP_Error(
+ 'missing_line_items',
+ __( 'At least one line item is required.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+
+ if ( ! in_array( $order->get_status(), self::REFUNDABLE_STATUSES, true ) ) {
+ return new WP_Error(
+ 'order_not_refundable',
+ __( 'This order cannot be refunded.', 'woocommerce' ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+
+ if ( (float) $order->get_remaining_refund_amount() <= 0 ) {
+ return new WP_Error(
+ 'order_not_refundable',
+ __( 'This order has already been fully refunded.', 'woocommerce' ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+
+ $refund_data = $this->compute_refunded_quantities_and_totals( $order );
+
+ foreach ( $line_items as $line_item ) {
+ $line_item_id = $line_item['line_item_id'] ?? null;
+ if ( ! $line_item_id ) {
+ return new WP_Error(
+ 'missing_line_item_id',
+ __( 'Line item ID is required.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+
+ $item = $order->get_item( $line_item_id );
+ if ( ! $item || $item->get_order_id() !== $order->get_id() ) {
+ return new WP_Error(
+ 'line_item_not_found',
+ __( 'Line item not found.', 'woocommerce' ),
+ array( 'status' => WP_Http::NOT_FOUND )
+ );
+ }
+
+ if ( ! $item instanceof WC_Order_Item_Product && ! $item instanceof WC_Order_Item_Fee && ! $item instanceof WC_Order_Item_Shipping ) {
+ return new WP_Error(
+ 'unsupported_item_type',
+ __( 'Line item is not a product, fee, or shipping line.', 'woocommerce' ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+
+ if ( ! isset( $line_item['quantity'] ) || ! is_int( $line_item['quantity'] ) || $line_item['quantity'] < 1 ) {
+ return new WP_Error(
+ 'invalid_quantity',
+ __( 'Quantity must be a positive integer.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+ $quantity = $line_item['quantity'];
+
+ if ( $item instanceof WC_Order_Item_Product ) {
+ $remaining_qty = $item->get_quantity() + ( $refund_data['qtys'][ $line_item_id ] ?? 0 );
+ if ( $quantity > $remaining_qty ) {
+ return new WP_Error(
+ 'quantity_exceeds_refundable',
+ sprintf(
+ /* translators: %d: remaining refundable quantity */
+ __( 'Requested quantity exceeds remaining refundable quantity (%d).', 'woocommerce' ),
+ $remaining_qty
+ ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+ }
+
+ if ( $item instanceof WC_Order_Item_Shipping || $item instanceof WC_Order_Item_Fee ) {
+ if ( 1 !== $quantity ) {
+ return new WP_Error(
+ 'invalid_quantity',
+ __( 'Shipping and fee line items must be refunded with quantity of 1.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+
+ // Compare on a tax-inclusive basis: compute_line_item_refund_total() (and
+ // therefore $requested_total below) already includes tax, and
+ // compute_refunded_quantities_and_totals() also returns tax-inclusive
+ // fee/shipping totals.
+ $refunded_total = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
+ $remaining_total = abs( (float) $item->get_total() + (float) $item->get_total_tax() ) - $refunded_total;
+ if ( $remaining_total <= 0 ) {
+ return new WP_Error(
+ 'quantity_exceeds_refundable',
+ __( 'This line item has already been fully refunded.', 'woocommerce' ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+
+ // Cap against the line's remaining refundable amount. The preview
+ // shape only takes quantity, so a fee/shipping line that's been
+ // partially refunded cannot be previewed again at the full original
+ // total — the request would over-refund and the eventual create
+ // call would fail. Reject up-front with a clear error.
+ $requested_total = abs( $this->compute_line_item_refund_total( $item, $quantity ) );
+ if ( $requested_total > NumberUtil::round( $remaining_total, wc_get_price_decimals() ) ) {
+ return new WP_Error(
+ 'quantity_exceeds_refundable',
+ sprintf(
+ /* translators: %s: remaining refundable amount */
+ __( 'Requested refund exceeds the remaining refundable amount for this line item (%s).', 'woocommerce' ),
+ wc_format_decimal( $remaining_total, wc_get_price_decimals() )
+ ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+ }
+ }
+
+ return true;
+ }
+
/**
* Pre-compute refund data for all line items in an order.
*
* Loads refunds once and builds lookup maps for refunded quantities and totals per item ID,
- * avoiding repeated get_refunds() calls during serialization.
+ * avoiding repeated get_refunds() calls during serialization. Fee and shipping totals are
+ * tax-inclusive so they can be compared directly against {@see compute_line_item_refund_total()}.
*
* @param WC_Order $order Order instance.
* @return array{qtys: array<int, int>, totals: array<int, float>}
@@ -324,7 +655,7 @@ class DataUtils {
$refunded_fees = $refund->get_items( 'fee' );
foreach ( $refunded_fees as $refunded_item ) {
$original_id = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
- $totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + (float) $refunded_item->get_total() * -1;
+ $totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
}
/**
* Refunded shipping items.
@@ -334,7 +665,7 @@ class DataUtils {
$refunded_shipping = $refund->get_items( 'shipping' );
foreach ( $refunded_shipping as $refunded_item ) {
$original_id = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
- $totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + (float) $refunded_item->get_total() * -1;
+ $totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
index 4759e733eab..d37f19147f1 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
@@ -3,14 +3,16 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Tests\Internal\RestApi\Routes\V4\Refunds;
+use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
use WC_Cache_Helper;
use WC_Helper_Product;
use WC_Order;
+use WC_Order_Item_Fee;
use WC_Order_Item_Product;
+use WC_Order_Item_Shipping;
use WC_Tax;
use WC_Unit_Test_Case;
-use ReflectionClass;
/**
* DataUtilsTest class.
@@ -45,101 +47,6 @@ class DataUtilsTest extends WC_Unit_Test_Case {
parent::tearDown();
}
- /**
- * Test building tax rates array with a single rate.
- */
- public function test_build_tax_rates_array_with_single_rate() {
- // Create a tax rate.
- $tax_rate_id = WC_Tax::_insert_tax_rate(
- array(
- 'tax_rate_country' => 'US',
- 'tax_rate_state' => '',
- 'tax_rate' => '10.0000',
- 'tax_rate_name' => 'VAT',
- 'tax_rate_priority' => '1',
- 'tax_rate_compound' => '0',
- 'tax_rate_shipping' => '1',
- 'tax_rate_order' => '1',
- 'tax_rate_class' => '',
- )
- );
-
- // Create an order with the tax rate.
- $order = $this->create_order_with_taxes( array( $tax_rate_id ) );
-
- // Use reflection to access private method.
- $reflection = new ReflectionClass( $this->data_utils );
- $method = $reflection->getMethod( 'build_tax_rates_array' );
- $method->setAccessible( true );
-
- // Call the method.
- $result = $method->invoke( $this->data_utils, $order, array( $tax_rate_id ) );
-
- // Assertions.
- $this->assertIsArray( $result );
- $this->assertArrayHasKey( $tax_rate_id, $result );
- $this->assertEquals( 10.0, $result[ $tax_rate_id ]['rate'] );
- $this->assertEquals( 'VAT', $result[ $tax_rate_id ]['label'] );
- $this->assertEquals( 'no', $result[ $tax_rate_id ]['compound'] );
- }
-
- /**
- * Test building tax rates array with multiple rates.
- */
- public function test_build_tax_rates_array_with_multiple_rates() {
- // Create two tax rates.
- $tax_rate_id_1 = WC_Tax::_insert_tax_rate(
- array(
- 'tax_rate_country' => 'US',
- 'tax_rate_state' => '',
- 'tax_rate' => '10.0000',
- 'tax_rate_name' => 'VAT',
- 'tax_rate_priority' => '1',
- 'tax_rate_compound' => '0',
- 'tax_rate_shipping' => '1',
- 'tax_rate_order' => '1',
- 'tax_rate_class' => '',
- )
- );
-
- $tax_rate_id_2 = WC_Tax::_insert_tax_rate(
- array(
- 'tax_rate_country' => 'US',
- 'tax_rate_state' => '',
- 'tax_rate' => '5.0000',
- 'tax_rate_name' => 'Regional',
- 'tax_rate_priority' => '2',
- 'tax_rate_compound' => '0',
- 'tax_rate_shipping' => '1',
- 'tax_rate_order' => '2',
- 'tax_rate_class' => '',
- )
- );
-
- // Create an order with both tax rates.
- $order = $this->create_order_with_taxes( array( $tax_rate_id_1, $tax_rate_id_2 ) );
-
- // Use reflection to access private method.
- $reflection = new ReflectionClass( $this->data_utils );
- $method = $reflection->getMethod( 'build_tax_rates_array' );
- $method->setAccessible( true );
-
- // Call the method.
- $result = $method->invoke( $this->data_utils, $order, array( $tax_rate_id_1, $tax_rate_id_2 ) );
-
- // Assertions.
- $this->assertIsArray( $result );
- $this->assertCount( 2, $result );
-
- $this->assertArrayHasKey( $tax_rate_id_1, $result );
- $this->assertEquals( 10.0, $result[ $tax_rate_id_1 ]['rate'] );
- $this->assertEquals( 'VAT', $result[ $tax_rate_id_1 ]['label'] );
-
- $this->assertArrayHasKey( $tax_rate_id_2, $result );
- $this->assertEquals( 5.0, $result[ $tax_rate_id_2 ]['rate'] );
- $this->assertEquals( 'Regional', $result[ $tax_rate_id_2 ]['label'] );
- }
-
/**
* Test that tax is automatically extracted when not provided.
*/
@@ -165,11 +72,12 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$item = reset( $items );
// Line items WITHOUT explicit refund_tax.
+ // refund_total 110.00 includes 10% tax.
$line_items = array(
array(
'line_item_id' => $item->get_id(),
'quantity' => 1,
- 'refund_total' => 110.00, // Includes 10% tax.
+ 'refund_total' => 110.00,
),
);
@@ -214,6 +122,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$item = reset( $items );
// Line items WITH explicit refund_tax (legacy format).
+ // Explicit refund_tax value (7.50) should be preserved by the converter.
$line_items = array(
array(
'line_item_id' => $item->get_id(),
@@ -222,7 +131,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
'refund_tax' => array(
array(
'id' => $tax_rate_id,
- 'refund_total' => 7.50, // Explicit value.
+ 'refund_total' => 7.50,
),
),
),
@@ -251,7 +160,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
* attempt to extract taxes from refund_total in this case.
*/
public function test_convert_line_items_skips_tax_extraction_for_zero_tax_items() {
- // Create a tax rate (applied to products but not shipping in this test).
+ // Create a tax rate that applies to products but NOT shipping (tax_rate_shipping => '0').
$tax_rate_id = WC_Tax::_insert_tax_rate(
array(
'tax_rate_country' => 'US',
@@ -260,7 +169,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
'tax_rate_name' => 'VAT',
'tax_rate_priority' => '1',
'tax_rate_compound' => '0',
- 'tax_rate_shipping' => '0', // Tax does NOT apply to shipping.
+ 'tax_rate_shipping' => '0',
'tax_rate_order' => '1',
'tax_rate_class' => '',
)
@@ -279,11 +188,12 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$this->assertEquals( 0, (float) $shipping_taxes['total'][ $tax_rate_id ] );
// Line items WITHOUT explicit refund_tax for shipping.
+ // refund_total 10.00 is the shipping cost (no tax included).
$line_items = array(
array(
'line_item_id' => $shipping_item->get_id(),
'quantity' => 1,
- 'refund_total' => 10.00, // Shipping cost to refund (no tax included).
+ 'refund_total' => 10.00,
),
);
@@ -301,6 +211,72 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$this->assertEmpty( $result[ $shipping_item->get_id() ]['refund_tax'] );
}
+ /**
+ * @testdox Should extract a negative tax split when converting a negative-fee line with stored negative tax.
+ *
+ * Regression guard for the creation/preview tax-filter divergence: an earlier
+ * filter rule of `$amount > 0` dropped the negative tax ID for a discount fee,
+ * so the internal format ended up with refund_total = -$11 and refund_tax = [].
+ * The preview path (build_refund_preview) already keeps non-zero taxes; the
+ * create path must agree, otherwise a refund moved from preview to create loses
+ * the signed split.
+ */
+ public function test_convert_line_items_extracts_negative_tax_for_negative_fee() {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '0',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $order = wc_create_order();
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Loyalty discount',
+ 'total' => -10.00,
+ )
+ );
+ $fee->set_taxes( array( 'total' => array( $tax_rate_id => -1.00 ) ) );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $tax_item = new \WC_Order_Item_Tax();
+ $tax_item->set_rate( $tax_rate_id );
+ $tax_item->set_tax_total( -1.00 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->save();
+
+ // refund_total -11.00 is the tax-inclusive amount; the converter should
+ // split it into a -10.00 base and -1.00 tax for the matching rate ID.
+ $line_items = array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'quantity' => 1,
+ 'refund_total' => -11.00,
+ ),
+ );
+
+ $result = $this->data_utils->convert_line_items_to_internal_format( $line_items, $order );
+
+ $this->assertArrayHasKey( $fee->get_id(), $result );
+ $this->assertArrayHasKey( 'refund_tax', $result[ $fee->get_id() ] );
+ $this->assertArrayHasKey( $tax_rate_id, $result[ $fee->get_id() ]['refund_tax'] );
+ $this->assertEqualsWithDelta( -1.00, $result[ $fee->get_id() ]['refund_tax'][ $tax_rate_id ], 0.01 );
+ $this->assertEqualsWithDelta( -10.00, $result[ $fee->get_id() ]['refund_total'], 0.01 );
+
+ $order->delete( true );
+ }
+
/**
* Test that calculate_refund_amount handles floating point precision correctly.
*
@@ -359,6 +335,994 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$this->assertNull( $this->data_utils->calculate_refund_amount( array() ) );
}
+ /**
+ * @testdox Should compute line item refund total for a product based on unit price and quantity.
+ */
+ public function test_compute_line_item_refund_total_product(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 25.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 4,
+ 'subtotal' => 100.00,
+ 'total' => 100.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->save();
+
+ $this->assertSame( 50.00, $this->data_utils->compute_line_item_refund_total( $item, 2 ) );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Should return error when preview line item quantity exceeds refundable.
+ */
+ public function test_validate_preview_line_items_quantity_exceeds_refundable(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 25.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 2,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_total( 50.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 25.00,
+ 'line_items' => array(
+ $item->get_id() => array(
+ 'qty' => 1,
+ 'refund_total' => 25.00,
+ 'refund_tax' => array(),
+ ),
+ ),
+ )
+ );
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 2,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'quantity_exceeds_refundable', $result->get_error_code() );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Should return error when order is not refundable.
+ */
+ public function test_validate_preview_line_items_order_not_refundable(): void {
+ $order = $this->create_order_with_taxes( array(), 50.00 );
+ $order->set_status( OrderStatus::CANCELLED );
+ $order->save();
+
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'order_not_refundable', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should return 0.0 for product line item with zero original quantity.
+ */
+ public function test_compute_line_item_refund_total_zero_original_quantity(): void {
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'quantity' => 0,
+ 'subtotal' => 0,
+ 'total' => 0,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->save();
+
+ $this->assertSame( 0.0, $this->data_utils->compute_line_item_refund_total( $item, 1 ) );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Should return full item total + tax for shipping items, ignoring quantity.
+ */
+ public function test_compute_line_item_refund_total_shipping(): void {
+ $shipping = new WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => 10.00,
+ )
+ );
+ $shipping->set_taxes( array( 'total' => array( 1 => 1.50 ) ) );
+ $shipping->save();
+
+ $this->assertSame( 11.50, $this->data_utils->compute_line_item_refund_total( $shipping, 1 ) );
+ }
+
+ /**
+ * @testdox Should return full item total + tax for fee items.
+ */
+ public function test_compute_line_item_refund_total_fee_positive(): void {
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Handling',
+ 'total' => 20.00,
+ )
+ );
+ $fee->set_taxes( array( 'total' => array( 1 => 3.00 ) ) );
+ $fee->save();
+
+ $this->assertSame( 23.00, $this->data_utils->compute_line_item_refund_total( $fee, 1 ) );
+ }
+
+ /**
+ * @testdox Should preserve negative sign for negative-total fee items (discount fees).
+ */
+ public function test_compute_line_item_refund_total_fee_negative(): void {
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Loyalty discount',
+ 'total' => -10.00,
+ )
+ );
+ $fee->set_taxes( array( 'total' => array() ) );
+ $fee->save();
+
+ $this->assertSame( -10.00, $this->data_utils->compute_line_item_refund_total( $fee, 1 ) );
+ }
+
+ /**
+ * @testdox Should throw InvalidArgumentException when quantity is less than 1.
+ *
+ * @dataProvider provider_invalid_quantities_for_compute
+ *
+ * @param int $quantity Quantity to test.
+ */
+ public function test_compute_line_item_refund_total_invalid_quantity( int $quantity ): void {
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Fee',
+ 'total' => 5.00,
+ )
+ );
+ $fee->save();
+
+ $this->expectException( \InvalidArgumentException::class );
+ $this->data_utils->compute_line_item_refund_total( $fee, $quantity );
+ }
+
+ /**
+ * @return array<string, array<int>>
+ */
+ public function provider_invalid_quantities_for_compute(): array {
+ return array(
+ 'zero' => array( 0 ),
+ 'negative' => array( -1 ),
+ );
+ }
+
+ /**
+ * @testdox Should populate breakdown.shipping for orders with only shipping line items.
+ */
+ public function test_build_refund_preview_shipping_only(): void {
+ $order = wc_create_order();
+ $shipping = new WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => 10.00,
+ )
+ );
+ $shipping->save();
+ $order->add_item( $shipping );
+ $order->save();
+
+ $result = $this->data_utils->build_refund_preview(
+ $order,
+ array(
+ array(
+ 'line_item_id' => $shipping->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertCount( 1, $result['breakdown']['shipping']['items'] );
+ $this->assertSame( array(), $result['breakdown']['products']['items'] );
+ $this->assertSame( array(), $result['breakdown']['fees']['items'] );
+ $this->assertEquals( '10.00', $result['breakdown']['shipping']['total'] );
+ $this->assertEquals( '10.00', $result['total'] );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Should populate breakdown.fees for orders with only fee line items.
+ */
+ public function test_build_refund_preview_fee_only(): void {
+ $order = wc_create_order();
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Service fee',
+ 'total' => 20.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+ $order->save();
+
+ $result = $this->data_utils->build_refund_preview(
+ $order,
+ array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertCount( 1, $result['breakdown']['fees']['items'] );
+ $this->assertSame( array(), $result['breakdown']['products']['items'] );
+ $this->assertSame( array(), $result['breakdown']['shipping']['items'] );
+ $this->assertEquals( '20.00', $result['breakdown']['fees']['total'] );
+ $this->assertEquals( '20.00', $result['total'] );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Should aggregate products, shipping, and fees across all three sections in mixed orders.
+ */
+ public function test_build_refund_preview_mixed_sections(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 50.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $shipping = new WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => 10.00,
+ )
+ );
+ $shipping->save();
+ $order->add_item( $shipping );
+
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Service fee',
+ 'total' => 5.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $order->save();
+
+ $result = $this->data_utils->build_refund_preview(
+ $order,
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ array(
+ 'line_item_id' => $shipping->get_id(),
+ 'quantity' => 1,
+ ),
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( '50.00', $result['breakdown']['products']['total'] );
+ $this->assertEquals( '10.00', $result['breakdown']['shipping']['total'] );
+ $this->assertEquals( '5.00', $result['breakdown']['fees']['total'] );
+ $this->assertEquals( '65.00', $result['total'] );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Section totals should equal the sum of item totals at byte-exact precision across many fractional-price items.
+ */
+ public function test_build_refund_preview_multi_item_fractional_aggregation(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->save();
+
+ $order = wc_create_order();
+ $prices = array( 19.99, 7.33, 12.50, 4.99, 0.01 );
+ $ids = array();
+ foreach ( $prices as $price ) {
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => $price,
+ 'total' => $price,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $ids[] = $item->get_id();
+ }
+ $order->save();
+
+ $line_items = array_map(
+ fn( $id ) => array(
+ 'line_item_id' => $id,
+ 'quantity' => 1,
+ ),
+ $ids
+ );
+ $result = $this->data_utils->build_refund_preview( $order, $line_items );
+
+ $item_total_sum = 0.0;
+ foreach ( $result['breakdown']['products']['items'] as $i ) {
+ $item_total_sum += (float) $i['total'];
+ }
+ $this->assertEqualsWithDelta(
+ (float) $result['breakdown']['products']['total'],
+ $item_total_sum,
+ 0.0001,
+ 'Section total should equal sum of item totals without drift.'
+ );
+ $this->assertEquals( '44.82', $result['breakdown']['products']['total'] );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Should throw InvalidArgumentException when line_item_id does not resolve to an order item.
+ */
+ public function test_build_refund_preview_missing_line_item_id(): void {
+ $order = wc_create_order();
+ $order->save();
+
+ $this->expectException( \InvalidArgumentException::class );
+ $this->data_utils->build_refund_preview(
+ $order,
+ array(
+ array(
+ 'line_item_id' => 999999,
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Should return missing_line_items error for empty line_items array.
+ */
+ public function test_validate_preview_line_items_empty(): void {
+ $order = $this->create_order_with_taxes( array(), 50.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $result = $this->data_utils->validate_preview_line_items( array(), $order );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'missing_line_items', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should return order_not_refundable when remaining refund amount is zero.
+ */
+ public function test_validate_preview_line_items_no_remaining_amount(): void {
+ $order = $this->create_order_with_taxes( array(), 50.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 50.00,
+ 'line_items' => array(
+ $item->get_id() => array(
+ 'qty' => 1,
+ 'refund_total' => 50.00,
+ 'refund_tax' => array(),
+ ),
+ ),
+ )
+ );
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'order_not_refundable', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should return missing_line_item_id when line_item_id key is absent.
+ */
+ public function test_validate_preview_line_items_missing_id(): void {
+ $order = $this->create_order_with_taxes( array(), 50.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array( array( 'quantity' => 1 ) ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'missing_line_item_id', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should return line_item_not_found when line_item_id belongs to a different order.
+ */
+ public function test_validate_preview_line_items_cross_order_id(): void {
+ $order_a = $this->create_order_with_taxes( array(), 50.00 );
+ $order_a->set_status( OrderStatus::COMPLETED );
+ $order_a->save();
+ $order_b = $this->create_order_with_taxes( array(), 50.00 );
+ $order_b->set_status( OrderStatus::COMPLETED );
+ $order_b->save();
+ $order_b_items = $order_b->get_items( 'line_item' );
+ $order_b_item = reset( $order_b_items );
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $order_b_item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order_a
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'line_item_not_found', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should return unsupported_item_type when line_item_id refers to a tax line.
+ */
+ public function test_validate_preview_line_items_unsupported_type(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+ $order = $this->create_order_with_taxes( array( $tax_rate_id ), 50.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $tax_items = $order->get_items( 'tax' );
+ $tax_item = reset( $tax_items );
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $tax_item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'unsupported_item_type', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should return invalid_quantity for missing, zero, negative, string, or float quantity values.
+ *
+ * @dataProvider provider_invalid_quantities_for_validate
+ *
+ * @param array<string, mixed> $line_item_overrides Keys to merge into the test line item.
+ */
+ public function test_validate_preview_line_items_invalid_quantity( array $line_item_overrides ): void {
+ $order = $this->create_order_with_taxes( array(), 50.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+
+ $line_item = array_merge( array( 'line_item_id' => $item->get_id() ), $line_item_overrides );
+
+ $result = $this->data_utils->validate_preview_line_items( array( $line_item ), $order );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'invalid_quantity', $result->get_error_code() );
+ }
+
+ /**
+ * @return array<string, array<array<string, mixed>>>
+ */
+ public function provider_invalid_quantities_for_validate(): array {
+ return array(
+ 'missing key' => array( array() ),
+ 'zero' => array( array( 'quantity' => 0 ) ),
+ 'negative' => array( array( 'quantity' => -1 ) ),
+ 'string' => array( array( 'quantity' => 'abc' ) ),
+ 'float' => array( array( 'quantity' => 1.5 ) ),
+ 'null' => array( array( 'quantity' => null ) ),
+ );
+ }
+
+ /**
+ * @testdox Should reject shipping/fee items with quantity other than 1.
+ */
+ public function test_validate_preview_line_items_shipping_quantity_must_be_one(): void {
+ $order = wc_create_order();
+ $shipping = new WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => 10.00,
+ )
+ );
+ $shipping->save();
+ $order->add_item( $shipping );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->set_total( 10.00 );
+ $order->save();
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $shipping->get_id(),
+ 'quantity' => 2,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'invalid_quantity', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should return order_not_refundable when shipping line is fully refunded.
+ *
+ * Once the shipping line is fully refunded the order's remaining refundable
+ * amount drops to zero, so the order-level guard fires before the per-line
+ * `quantity_exceeds_refundable` check is reached.
+ */
+ public function test_validate_preview_line_items_shipping_fully_refunded(): void {
+ $order = wc_create_order();
+ $shipping = new WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => 10.00,
+ )
+ );
+ $shipping->save();
+ $order->add_item( $shipping );
+ $order->set_total( 10.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 10.00,
+ 'line_items' => array(
+ $shipping->get_id() => array(
+ 'qty' => 0,
+ 'refund_total' => 10.00,
+ 'refund_tax' => array(),
+ ),
+ ),
+ )
+ );
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $shipping->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ // 'order_not_refundable' is returned first because the order's total refundable amount is now zero.
+ $this->assertEquals( 'order_not_refundable', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should return quantity_exceeds_refundable when a partially-refunded shipping line cannot fit a full preview at its original total.
+ *
+ * Order has a $10 shipping line + a $50 product line so the order is still
+ * refundable after a $5 partial shipping refund. Previewing the shipping
+ * line at qty=1 would refund the full $10 — exceeds the $5 remaining on
+ * that line — so validation must reject with `quantity_exceeds_refundable`.
+ * Without the per-line cap, validate would pass and `build_refund_preview`
+ * would return an oversized total.
+ */
+ public function test_validate_preview_line_items_shipping_partial_remaining(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 50.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $shipping = new WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => 10.00,
+ )
+ );
+ $shipping->save();
+ $order->add_item( $shipping );
+
+ $order->set_total( 60.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ // Pre-refund $5 of the shipping line, leaving $5 remaining on it but
+ // keeping the order overall refundable ($55 of $60 remains).
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 5.00,
+ 'line_items' => array(
+ $shipping->get_id() => array(
+ 'qty' => 0,
+ 'refund_total' => 5.00,
+ 'refund_tax' => array(),
+ ),
+ ),
+ )
+ );
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $shipping->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'quantity_exceeds_refundable', $result->get_error_code() );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Should allow previewing a full shipping refund when the line carries tax and has no prior refund.
+ *
+ * Regression guard: an earlier implementation compared the tax-inclusive
+ * $requested_total (from compute_line_item_refund_total) against a
+ * tax-exclusive $remaining_total (only get_total()). For a $10 shipping line
+ * with $1.50 of tax that produced 11.50 > 10.00 → wrongly rejected.
+ */
+ public function test_validate_preview_line_items_shipping_with_tax_allows_full_refund(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '15.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 50.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $shipping = new WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => 10.00,
+ )
+ );
+ $shipping->set_taxes( array( 'total' => array( $tax_rate_id => 1.50 ) ) );
+ $shipping->save();
+ $order->add_item( $shipping );
+
+ $tax_item = new \WC_Order_Item_Tax();
+ $tax_item->set_rate( $tax_rate_id );
+ $tax_item->set_shipping_tax_total( 1.50 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->set_total( 61.50 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $shipping->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertTrue( $result, 'Full shipping refund covering line total + tax with no prior refund should pass validation.' );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox build_refund_preview preserves the negative tax split on a fee with a negative stored tax.
+ *
+ * Regression guard: a previous implementation filtered tax IDs by `amount > 0`,
+ * which dropped negative tax entries entirely and emitted `tax: 0.00` on
+ * negative-fee discount lines. The fix keeps any non-zero stored tax so the
+ * preview returns the signed split.
+ */
+ public function test_build_refund_preview_negative_fee_with_negative_tax(): void {
+ // A 10% rate is needed so WC_Tax::calc_inclusive_tax can split a tax-inclusive total.
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '0',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $order = wc_create_order();
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Loyalty discount',
+ 'total' => -10.00,
+ )
+ );
+ $fee->set_taxes( array( 'total' => array( $tax_rate_id => -1.00 ) ) );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $tax_item = new \WC_Order_Item_Tax();
+ $tax_item->set_rate( $tax_rate_id );
+ $tax_item->set_tax_total( -1.00 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->save();
+
+ $result = $this->data_utils->build_refund_preview(
+ $order,
+ array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ // Total stays at the tax-inclusive -$11. The split between subtotal
+ // (-$10) and tax (-$1) must be preserved on the fee item entry.
+ $this->assertSame( '-11.00', $result['breakdown']['fees']['total'] );
+ $this->assertCount( 1, $result['breakdown']['fees']['items'] );
+ $this->assertEquals( '-10.00', $result['breakdown']['fees']['items'][0]['subtotal'] );
+ $this->assertEquals( '-1.00', $result['breakdown']['fees']['items'][0]['tax'] );
+ $this->assertEquals( '-11.00', $result['breakdown']['fees']['items'][0]['total'] );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Should allow validating a negative-total fee (discount fee) that has no prior refund.
+ */
+ public function test_validate_preview_line_items_negative_fee_passes(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 50.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Discount',
+ 'total' => -10.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $order->set_total( 40.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertTrue( $result, 'Negative-total fee with no prior refund should pass validation.' );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Should build refund preview with correct tax extraction.
+ */
+ public function test_build_refund_preview_with_tax(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $order = $this->create_order_with_taxes( array( $tax_rate_id ), 100.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+ $result = $this->data_utils->build_refund_preview(
+ $order,
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( '100.00', $result['subtotal'] );
+ $this->assertEquals( '10.00', $result['tax'] );
+ $this->assertEquals( '110.00', $result['total'] );
+ $this->assertArrayHasKey( 'breakdown', $result );
+ $this->assertArrayHasKey( 'max_refundable', $result );
+ $this->assertCount( 1, $result['breakdown']['products']['items'] );
+ $this->assertArrayHasKey( 'name', $result['breakdown']['products']['items'][0] );
+ $this->assertArrayHasKey( 'product_id', $result['breakdown']['products']['items'][0] );
+ }
+
/**
* Helper: Create an order with shipping that has tax rate IDs but zero tax amounts.
*
@@ -393,7 +1357,8 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$tax_item = new \WC_Order_Item_Tax();
$tax_item->set_rate( $tax_rate_id );
$tax_item->set_order_id( $order->get_id() );
- $tax_item->set_tax_total( 0 ); // Product tax would be here, but we're focusing on shipping.
+ $tax_item->set_tax_total( 0 );
+ // Product tax would be here, but we're focusing on shipping.
$tax_item->set_shipping_tax_total( 0 );
$tax_item->save();
$order->add_item( $tax_item );