Commit e584c70f906 for woocommerce

commit e584c70f9066ec8dca4f715aca724e0d55d0f417
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Mon Nov 2 21:25:35 2026 +0700

    Don't grant downloads for order lines refunded before the order is completed (#70535)

    * Add regression test for downloads of lines refunded before completion

    Fails on trunk: completing an order, or regenerating its permissions,
    grants downloads for lines that a refund already covered.

    Amp-Thread-ID: https://ampcode.com/threads/T-01a19b55-1079-7498-bf68-59f7bd4fbe9a
    Co-authored-by: Amp <amp@ampcode.com>

    * Don't grant download permissions for lines refunded before completion

    wc_downloadable_product_permissions() granted every downloadable line, so a
    line refunded while the order was on hold, or one revoked by a refund and then
    regenerated, got a working download. Skip every line a refund names, which is
    the same set wc_create_refund() revokes.

    Amp-Thread-ID: https://ampcode.com/threads/T-01a19b55-1079-7498-bf68-59f7bd4fbe9a
    Co-authored-by: Amp <amp@ampcode.com>

    * Reword the refunded downloads test in plain English

    Amp-Thread-ID: https://ampcode.com/threads/T-01a19b55-1079-7498-bf68-59f7bd4fbe9a
    Co-authored-by: Amp <amp@ampcode.com>

    * Remove fixed PHPStan baseline entry for wc_downloadable_file_permission

    Amp-Thread-ID: https://ampcode.com/threads/T-01a19b55-1079-7498-bf68-59f7bd4fbe9a
    Co-authored-by: Amp <amp@ampcode.com>

    ---------

    Co-authored-by: Amp <amp@ampcode.com>

diff --git a/plugins/woocommerce/changelog/fix-refunded-download-permissions b/plugins/woocommerce/changelog/fix-refunded-download-permissions
new file mode 100644
index 00000000000..8a2e7a12d49
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-refunded-download-permissions
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Don't grant downloads for order lines that were refunded before the order was completed.
diff --git a/plugins/woocommerce/includes/wc-order-functions.php b/plugins/woocommerce/includes/wc-order-functions.php
index 628c8191198..50fc95bef22 100644
--- a/plugins/woocommerce/includes/wc-order-functions.php
+++ b/plugins/woocommerce/includes/wc-order-functions.php
@@ -469,7 +469,7 @@ function wc_downloadable_file_permission( $download_id, $product, $order, $qty =
 function wc_downloadable_product_permissions( $order_id, $force = false ) {
 	$order = wc_get_order( $order_id );

-	if ( ! $order || ( $order->get_data_store()->get_download_permissions_granted( $order ) && ! $force ) ) {
+	if ( ! $order instanceof WC_Order || ( $order->get_data_store()->get_download_permissions_granted( $order ) && ! $force ) ) {
 		return;
 	}

@@ -477,9 +477,20 @@ function wc_downloadable_product_permissions( $order_id, $force = false ) {
 		return;
 	}

+	$refunded_item_ids = array();
+	foreach ( $order->get_refunds() as $refund ) {
+		foreach ( $refund->get_items() as $refund_item ) {
+			$refunded_item_ids[] = absint( $refund_item->get_meta( '_refunded_item_id' ) );
+		}
+	}
+
 	$line_items = $order->get_items();
 	if ( count( $line_items ) > 0 ) {
 		foreach ( $line_items as $item ) {
+			if ( in_array( $item->get_id(), $refunded_item_ids, true ) ) {
+				continue;
+			}
+
 			$product = $item->get_product();

 			if ( $product && $product->exists() && $product->is_downloadable() ) {
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 5d2afeb5600..0aa5d25acf6 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -34005,11 +34005,6 @@ parameters:
 			count: 2
 			path: includes/wc-order-functions.php

-		-
-			message: '#^Parameter \#3 \$order of function wc_downloadable_file_permission expects WC_Order, WC_Order\|WC_Order_Refund given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/wc-order-functions.php

 		-
 			message: '#^Call to an undefined method WC_Data_Store\:\:add_metadata\(\)\.$#'
diff --git a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
index 1ed39c627ab..d8d0961b026 100644
--- a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
@@ -1138,4 +1138,66 @@ class WC_Order_Functions_Test extends \WC_Unit_Test_Case {
 		$this->assertSame( array(), array_values( $coupon->get_used_by() ) );
 		$this->assertEquals( 0, $coupon->get_usage_count() );
 	}
+
+	/**
+	 * @testdox Completing an order, or regenerating its downloads, skips lines that were refunded.
+	 */
+	public function test_wc_downloadable_product_permissions_skips_refunded_lines() {
+		wc_get_container()->get( Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register::class )->add_approved_directory( 'https://always.trusted' );
+		$products = array();
+		foreach ( array( 'qty-refunded', 'amount-refunded', 'kept' ) as $name ) {
+			$products[ $name ] = WC_Helper_Product::create_downloadable_product(
+				array(
+					array(
+						'name' => $name,
+						'file' => "https://always.trusted/$name.pdf",
+					),
+				)
+			);
+		}
+
+		$order = new WC_Order();
+		foreach ( $products as $product ) {
+			$order->add_product( $product );
+		}
+		$order->calculate_totals();
+		$order->set_status( OrderStatus::ON_HOLD );
+		$order->save();
+
+		$items = array_values( $order->get_items() );
+		wc_create_refund(
+			array(
+				'amount'     => $items[0]->get_total() + 1,
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					$items[0]->get_id() => array(
+						'qty'          => 1,
+						'refund_total' => $items[0]->get_total(),
+					),
+					$items[1]->get_id() => array(
+						'qty'          => 0,
+						'refund_total' => 1,
+					),
+				),
+			)
+		);
+
+		wc_get_order( $order->get_id() )->update_status( OrderStatus::COMPLETED );
+		$this->assertSame( array( $products['kept']->get_id() ), $this->get_download_permission_product_ids( $order ), 'Completing the order should give a download only for the line that was not refunded.' );
+
+		WC_Data_Store::load( 'customer-download' )->delete_by_order_id( $order->get_id() );
+		wc_downloadable_product_permissions( $order->get_id(), true );
+		$this->assertSame( array( $products['kept']->get_id() ), $this->get_download_permission_product_ids( $order ), 'Regenerating the downloads should give a download only for the line that was not refunded.' );
+	}
+
+	/**
+	 * Get the product IDs of an order's download permissions.
+	 *
+	 * @param WC_Order $order The order.
+	 * @return int[]
+	 */
+	private function get_download_permission_product_ids( WC_Order $order ): array {
+		$downloads = WC_Data_Store::load( 'customer-download' )->get_downloads( array( 'order_id' => $order->get_id() ) );
+		return array_map( fn( $download ) => (int) $download->get_product_id(), $downloads );
+	}
 }