Commit 45380b04ad7 for woocommerce
commit 45380b04ad74fd8b66ab7df80ed79d829b1feef0
Author: Ceili Cornelison <ceili@ceilicornelison.com>
Date: Mon Oct 12 07:53:21 2026 -0500
Fix/30922 apply coupon customerless orders (#69625)
* Failing tests to cover customerless order case throwing 500
* Gotta cover no billing email error as well, calling the same stuff
* Update the way we check for guest checkouts to look for customer id
* Ensure we have get_billing_email() available to call before we try and use it
* A refund should have the same taxable location as its parent order
* Add test requested by @opr (https://github.com/woocommerce/woocommerce/pull/67495#pullrequestreview-5066696966)
* Move around phpcs comments to satisfy linter
* Update changelog with fix
* Remove phpcs comments and add docblocs to satisfy docstring coverage warning on pr
* Failing test to handle per-email limited coupon codes
* Handle order types without a customer ID; tidy up long line a lil
* Responding to codex/coderabbit suggestions, a refund should never consume a coupon use -- failing tests
* Coupons cannot be applied to refunds -- never an issue before because the system would 500 instead
* Update tests to handle coupon usage on refund
* How would you apply a $5 off coupon to a refund? You cannot apply coupons to refunds.
* Only order data stores can record coupon usage.
* Unneeded now that refunds return early from `apply_coupon()`
* A refund without a parent order uses the store base address as its tax location.
* Add CustomerlessTestOrder to DRY up class-wc-abstract-order-test.php
* Address phpstan issues & regenerate
diff --git a/plugins/woocommerce/changelog/fix-30922-apply-coupon-on-refunds-and-custom-order-types b/plugins/woocommerce/changelog/fix-30922-apply-coupon-on-refunds-and-custom-order-types
new file mode 100644
index 00000000000..5ebafe543f5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-30922-apply-coupon-on-refunds-and-custom-order-types
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix a fatal error when applying a coupon to a refund or to a custom order type that has no customer ID or billing email.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index f24bac99c94..98dab092d03 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -1701,7 +1701,9 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$data_store = $coupon->get_data_store();
// Check specific for guest checkouts here as well since WC_Cart handles that separately in check_customer_coupons.
- if ( $data_store && 0 === $this->get_customer_id() ) {
+ // Order types without a customer ID are treated as guests.
+ $customer_id = is_callable( array( $this, 'get_customer_id' ) ) ? $this->get_customer_id() : 0;
+ if ( $data_store && 0 === $customer_id && is_callable( array( $this, 'get_billing_email' ) ) ) {
$usage_count = $data_store->get_usage_by_email( $coupon, $this->get_billing_email() );
if ( 0 < $coupon->get_usage_limit_per_user() && $usage_count >= $coupon->get_usage_limit_per_user() ) {
return new WP_Error(
@@ -1733,12 +1735,18 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
// Record usage so counts and validation is correct.
$used_by = $this->get_user_id();
- if ( ! $used_by ) {
+ if ( ! $used_by && is_callable( array( $this, 'get_billing_email' ) ) ) {
$used_by = $this->get_billing_email();
}
+ /**
+ * Data store wrapper.
+ *
+ * @var WC_Data_Store $order_data_store
+ */
$order_data_store = $this->get_data_store();
- if ( $order_data_store->get_recorded_coupon_usage_counts( $this ) ) {
+ // @phpstan-ignore-next-line method.notFound (Guarded by has_callable() and called via __call() on the underlying order data store instance.)
+ if ( $order_data_store->has_callable( 'get_recorded_coupon_usage_counts' ) && $order_data_store->get_recorded_coupon_usage_counts( $this ) ) {
$coupon->increase_usage_count( $used_by );
}
diff --git a/plugins/woocommerce/includes/class-wc-coupon.php b/plugins/woocommerce/includes/class-wc-coupon.php
index 5cdc77398b0..153c1260670 100644
--- a/plugins/woocommerce/includes/class-wc-coupon.php
+++ b/plugins/woocommerce/includes/class-wc-coupon.php
@@ -924,8 +924,8 @@ class WC_Coupon extends WC_Legacy_Coupon {
/**
* Increase usage count for current coupon.
*
- * @param string $used_by Either user ID or billing email.
- * @param WC_Order $order If provided, will clear the coupons held by this order.
+ * @param int|string $used_by Either user ID or billing email.
+ * @param WC_Order $order If provided, will clear the coupons held by this order.
* @return void
*/
public function increase_usage_count( $used_by = '', $order = null ) {
@@ -943,7 +943,7 @@ class WC_Coupon extends WC_Legacy_Coupon {
/**
* Decrease usage count for current coupon.
*
- * @param string $used_by Either user ID or billing email.
+ * @param int|string $used_by Either user ID or billing email.
* @return void
*/
public function decrease_usage_count( $used_by = '' ) {
diff --git a/plugins/woocommerce/includes/class-wc-order-refund.php b/plugins/woocommerce/includes/class-wc-order-refund.php
index 0db69d6fcfe..c00fe93b7b1 100644
--- a/plugins/woocommerce/includes/class-wc-order-refund.php
+++ b/plugins/woocommerce/includes/class-wc-order-refund.php
@@ -129,6 +129,43 @@ class WC_Order_Refund extends WC_Abstract_Order {
return $this->get_prop( 'refunded_payment', $context );
}
+ /**
+ * Get the tax location of the parent order, since refunds have no address of their own.
+ * In case of no parent order taxable location, return the store base location.
+ *
+ * @since 11.3.0
+ * @param array $args Override the location.
+ * @return array
+ */
+ protected function get_tax_location( $args = array() ) {
+ $parent_order = wc_get_order( $this->get_parent_id() );
+
+ if ( $parent_order ) {
+ return $parent_order->get_taxable_location( $args );
+ }
+
+ return wp_parse_args(
+ $args,
+ array(
+ 'country' => WC()->countries->get_base_country(),
+ 'state' => WC()->countries->get_base_state(),
+ 'postcode' => WC()->countries->get_base_postcode(),
+ 'city' => WC()->countries->get_base_city(),
+ )
+ );
+ }
+
+ /**
+ * Coupons cannot be applied to refunds, so a refund never records coupon usage.
+ *
+ * @since 11.3.0
+ * @param string|WC_Coupon $raw_coupon Coupon code or object.
+ * @return WP_Error
+ */
+ public function apply_coupon( $raw_coupon ) {
+ return new WP_Error( 'invalid_coupon', __( 'Coupons cannot be applied to refunds.', 'woocommerce' ) );
+ }
+
/**
* Get formatted refunded amount.
*
diff --git a/plugins/woocommerce/includes/wc-order-functions.php b/plugins/woocommerce/includes/wc-order-functions.php
index ad787ec3d50..1741d3473b6 100644
--- a/plugins/woocommerce/includes/wc-order-functions.php
+++ b/plugins/woocommerce/includes/wc-order-functions.php
@@ -1020,7 +1020,20 @@ add_action( 'woocommerce_before_delete_order', 'wc_update_total_sales_counts' );
function wc_update_coupon_usage_counts( $order_id ) {
$order = wc_get_order( $order_id );
- if ( ! $order ) {
+ // Refunds never consume coupon usage.
+ if ( ! $order || $order instanceof WC_Order_Refund ) {
+ return;
+ }
+
+ /**
+ * Data store wrapper.
+ *
+ * @var WC_Data_Store $data_store
+ */
+ $data_store = $order->get_data_store();
+
+ // Only order data stores can record coupon usage.
+ if ( ! $data_store->has_callable( 'get_recorded_coupon_usage_counts' ) ) {
return;
}
@@ -1061,7 +1074,7 @@ function wc_update_coupon_usage_counts( $order_id ) {
$coupon = new WC_Coupon( $code );
$used_by = $order->get_user_id();
- if ( ! $used_by ) {
+ if ( ! $used_by && is_callable( array( $order, 'get_billing_email' ) ) ) {
$used_by = $order->get_billing_email();
}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 49c0a8ab8ca..fbf318cdfe8 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -366,12 +366,6 @@ parameters:
count: 1
path: includes/abstracts/abstract-wc-order.php
- -
- message: '#^Call to an undefined method WC_Abstract_Order\:\:get_billing_email\(\)\.$#'
- identifier: method.notFound
- count: 2
- path: includes/abstracts/abstract-wc-order.php
-
-
message: '#^Call to an undefined method WC_Abstract_Order\:\:get_billing_postcode\(\)\.$#'
identifier: method.notFound
@@ -384,12 +378,6 @@ parameters:
count: 1
path: includes/abstracts/abstract-wc-order.php
- -
- message: '#^Call to an undefined method WC_Abstract_Order\:\:get_customer_id\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: includes/abstracts/abstract-wc-order.php
-
-
message: '#^Call to an undefined method WC_Abstract_Order\:\:get_shipping_city\(\)\.$#'
identifier: method.notFound
@@ -546,12 +534,6 @@ parameters:
count: 6
path: includes/abstracts/abstract-wc-order.php
- -
- message: '#^Call to an undefined method object\:\:get_recorded_coupon_usage_counts\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: includes/abstracts/abstract-wc-order.php
-
-
message: '#^Call to an undefined method object\:\:get_subtotal_tax\(\)\.$#'
identifier: method.notFound
@@ -756,12 +738,6 @@ parameters:
count: 1
path: includes/abstracts/abstract-wc-order.php
- -
- message: '#^Parameter \#1 \$used_by of method WC_Coupon\:\:decrease_usage_count\(\) expects string, int given\.$#'
- identifier: argument.type
- count: 1
- path: includes/abstracts/abstract-wc-order.php
-
-
message: '#^Parameter \#1 \$value of method WC_Abstract_Order\:\:set_cart_tax\(\) expects string, float\|int given\.$#'
identifier: argument.type
@@ -33927,12 +33903,6 @@ parameters:
count: 1
path: includes/wc-order-functions.php
- -
- message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:get_billing_email\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: includes/wc-order-functions.php
-
-
message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:get_remaining_refund_amount\(\)\.$#'
identifier: method.notFound
@@ -34131,12 +34101,6 @@ parameters:
count: 1
path: includes/wc-order-functions.php
- -
- message: '#^Parameter \#1 \$order of function wc_release_coupons_for_order expects int\|WC_Order, WC_Order\|WC_Order_Refund given\.$#'
- identifier: argument.type
- count: 2
- path: includes/wc-order-functions.php
-
-
message: '#^Parameter \#1 \$order of function wc_restock_refunded_items expects WC_Order, WC_Order\|WC_Order_Refund given\.$#'
identifier: argument.type
@@ -34149,18 +34113,6 @@ parameters:
count: 1
path: includes/wc-order-functions.php
- -
- message: '#^Parameter \#1 \$used_by of method WC_Coupon\:\:decrease_usage_count\(\) expects string, int\<min, \-1\>\|int\<1, max\>\|string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/wc-order-functions.php
-
- -
- message: '#^Parameter \#1 \$used_by of method WC_Coupon\:\:increase_usage_count\(\) expects string, int\<min, \-1\>\|int\<1, max\>\|string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/wc-order-functions.php
-
-
message: '#^Parameter \#1 \$value of method WC_Abstract_Order\:\:set_total\(\) expects string, \(float\|int\) given\.$#'
identifier: argument.type
@@ -34191,12 +34143,6 @@ parameters:
count: 1
path: includes/wc-order-functions.php
- -
- message: '#^Parameter \#2 \$order of method WC_Coupon\:\:increase_usage_count\(\) expects WC_Order\|null, WC_Order\|WC_Order_Refund given\.$#'
- identifier: argument.type
- count: 1
- path: includes/wc-order-functions.php
-
-
message: '#^Parameter \#2 \$value of method WC_Data\:\:update_meta_data\(\) expects array\|string, \(float\|int\) given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/tests/php/helpers/CustomerlessTestOrder.php b/plugins/woocommerce/tests/php/helpers/CustomerlessTestOrder.php
new file mode 100644
index 00000000000..7df5d9f7447
--- /dev/null
+++ b/plugins/woocommerce/tests/php/helpers/CustomerlessTestOrder.php
@@ -0,0 +1,45 @@
+<?php
+
+declare( strict_types = 1 );
+
+/**
+ * A custom order type built only on WC_Abstract_Order, so it has no get_customer_id() or get_billing_email().
+ *
+ * It uses the 'test-custom-order' data store, which tests must register through the 'woocommerce_data_stores' filter.
+ */
+class CustomerlessTestOrder extends WC_Abstract_Order {
+
+ /**
+ * Data store registered by the test.
+ *
+ * @var string
+ */
+ protected $data_store_name = 'test-custom-order';
+
+ /**
+ * Get the order type.
+ *
+ * @return string
+ */
+ public function get_type() {
+ return 'test_custom_order';
+ }
+
+ /**
+ * Return an empty tax location, as this order type has no billing or shipping getters.
+ *
+ * @param array $args Override the location.
+ * @return array
+ */
+ protected function get_tax_location( $args = array() ) {
+ return wp_parse_args(
+ $args,
+ array(
+ 'country' => '',
+ 'state' => '',
+ 'postcode' => '',
+ 'city' => '',
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
index 2421c84648e..962c488bc20 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
@@ -537,6 +537,175 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
$this->assertEquals( $coupon_code, $coupon_info[1] );
}
+ /**
+ * Data provider for refund coupon tests.
+ *
+ * @return array
+ */
+ public function provide_refund_parent_customer_ids(): array {
+ return array(
+ 'guest parent order' => array( 0 ),
+ 'registered parent order' => array( 1 ),
+ );
+ }
+
+ /**
+ * Create a refund for one line item of a new order.
+ *
+ * @param int $customer_id Customer ID of the parent order.
+ * @return WC_Order_Refund
+ */
+ private function create_line_item_refund( int $customer_id ): WC_Order_Refund {
+ $order = WC_Helper_Order::create_order( $customer_id );
+ $item = current( $order->get_items() );
+
+ return wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => $item->get_total(),
+ 'line_items' => array(
+ $item->get_id() => array(
+ 'qty' => $item->get_quantity(),
+ 'refund_total' => $item->get_total(),
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * @testdox Applying a coupon to a refund returns an error and records no coupon usage.
+ * @dataProvider provide_refund_parent_customer_ids
+ *
+ * @param int $customer_id Customer ID of the parent order.
+ */
+ public function test_apply_coupon_on_refund_returns_error( int $customer_id ) {
+ $coupon_code = 'coupon_test_refund_' . $customer_id;
+ WC_Helper_Coupon::create_coupon( $coupon_code );
+ $refund = $this->create_line_item_refund( $customer_id );
+
+ $result = $refund->apply_coupon( $coupon_code );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'invalid_coupon', $result->get_error_code() );
+ $this->assertCount( 0, $refund->get_items( 'coupon' ) );
+ $this->assertSame( 0, ( new WC_Coupon( $coupon_code ) )->get_usage_count() );
+ }
+
+ /**
+ * @testdox A refund uses the tax location of its parent order.
+ */
+ public function test_refund_get_taxable_location_returns_parent_tax_location() {
+ $refund = $this->create_line_item_refund( 1 );
+ $parent_order = wc_get_order( $refund->get_parent_id() );
+
+ $this->assertSame( 'US', $parent_order->get_taxable_location()['country'] );
+ $this->assertSame( $parent_order->get_taxable_location(), $refund->get_taxable_location() );
+ }
+
+ /**
+ * @testdox A refund without a parent order uses the store base address as its tax location.
+ */
+ public function test_refund_get_taxable_location_without_parent_returns_base_address() {
+ update_option( 'woocommerce_default_country', 'US:CA' );
+ update_option( 'woocommerce_store_city', 'San Francisco' );
+ update_option( 'woocommerce_store_postcode', '94110' );
+
+ $refund = new WC_Order_Refund();
+
+ $this->assertSame(
+ array(
+ 'country' => 'US',
+ 'state' => 'CA',
+ 'postcode' => '94110',
+ 'city' => 'San Francisco',
+ ),
+ $refund->get_taxable_location()
+ );
+ }
+
+ /**
+ * @testdox A coupon can be applied to a custom order type that has no customer ID or billing email.
+ */
+ public function test_apply_coupon_on_custom_order_type_without_customer() {
+ $coupon_code = 'coupon_test_custom_order_type';
+ WC_Helper_Coupon::create_coupon( $coupon_code );
+ $order = $this->create_custom_order();
+
+ $this->assertFalse( is_callable( array( $order, 'get_customer_id' ) ) );
+ $this->assertFalse( is_callable( array( $order, 'get_billing_email' ) ) );
+
+ $result = $order->apply_coupon( $coupon_code );
+
+ $this->assertTrue( $result );
+ $this->assertCount( 1, $order->get_items( 'coupon' ) );
+ }
+
+ /**
+ * Create and save a CustomerlessTestOrder with one line item.
+ *
+ * @param string|null $billing_email When set, the order type also has get_billing_email() returning this.
+ * @return CustomerlessTestOrder
+ */
+ private function create_custom_order( ?string $billing_email = null ): CustomerlessTestOrder {
+ // A custom order type registers its own data store, as WC_Order's store expects WC_Order methods.
+ $data_store = new class() extends Abstract_WC_Order_Data_Store_CPT {
+ };
+ add_filter(
+ 'woocommerce_data_stores',
+ function ( $stores ) use ( $data_store ) {
+ $stores['test-custom-order'] = $data_store;
+ return $stores;
+ }
+ );
+
+ if ( null === $billing_email ) {
+ $order = new CustomerlessTestOrder();
+ } else {
+ $order = new class() extends CustomerlessTestOrder {
+ /**
+ * Billing email returned by get_billing_email().
+ *
+ * @var string
+ */
+ public $test_billing_email = '';
+
+ /**
+ * Get the billing email.
+ *
+ * @return string
+ */
+ public function get_billing_email() {
+ return $this->test_billing_email;
+ }
+ };
+
+ $order->test_billing_email = $billing_email;
+ }
+ $order->add_product( WC_Helper_Product::create_simple_product(), 1 );
+ $order->save();
+
+ return $order;
+ }
+
+ /**
+ * @testdox The per-email coupon usage limit applies to a custom order type that has a billing email but no customer ID.
+ */
+ public function test_apply_coupon_enforces_email_usage_limit_on_custom_order_type_without_customer_id() {
+ $coupon_code = 'coupon_test_custom_order_type_email_limit';
+ $coupon = WC_Helper_Coupon::create_coupon( $coupon_code, array( 'usage_limit_per_user' => '1' ) );
+ $coupon->increase_usage_count( 'guest@example.com' );
+ $order = $this->create_custom_order( 'guest@example.com' );
+
+ $this->assertFalse( is_callable( array( $order, 'get_customer_id' ) ) );
+
+ $result = $order->apply_coupon( $coupon_code );
+
+ $this->assertWPError( $result );
+ $this->assertSame( $coupon->get_coupon_error( 106 ), $result->get_error_message() );
+ $this->assertCount( 0, $order->get_items( 'coupon' ) );
+ }
+
/**
* Test remove_coupon fires woocommerce_order_removed_coupon hook with WC_Coupon object.
*/
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 3a79a6cdc6b..5f0ee75078d 100644
--- a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
@@ -5,6 +5,7 @@
* @package WooCommerce\Tests\Order.
*/
+use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Enums\OrderInternalStatus;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\Utilities\Users;
@@ -178,6 +179,75 @@ class WC_Order_Functions_Test extends \WC_Unit_Test_Case {
$this->assertEquals( 1, ( new WC_Coupon( $coupon ) )->get_usage_count() );
}
+ /**
+ * @testdox wc_update_coupon_usage_counts() does not count coupon usage for a refund.
+ */
+ public function test_wc_update_coupon_usage_counts_ignores_refunds() {
+ $coupon = WC_Helper_Coupon::create_coupon( 'test_usage_on_refund' );
+ $refund = wc_create_refund(
+ array(
+ 'order_id' => WC_Helper_Order::create_order( 0 )->get_id(),
+ 'amount' => 1,
+ )
+ );
+ $item = new WC_Order_Item_Coupon();
+ $item->set_code( $coupon->get_code() );
+ $refund->add_item( $item );
+ $refund->save();
+
+ wc_update_coupon_usage_counts( $refund->get_id() );
+
+ $coupon = new WC_Coupon( $coupon->get_code() );
+ $this->assertSame( 0, $coupon->get_usage_count() );
+ $this->assertSame( array(), $coupon->get_used_by() );
+ }
+
+ /**
+ * @testdox wc_update_coupon_usage_counts() does not count coupon usage for an order whose data store cannot record it.
+ */
+ public function test_wc_update_coupon_usage_counts_skips_orders_whose_data_store_cannot_record_usage() {
+ $coupon = WC_Helper_Coupon::create_coupon( 'test_usage_without_recording_support' );
+ $order = WC_Helper_Order::create_order();
+ $item = new WC_Order_Item_Coupon();
+ $item->set_code( $coupon->get_code() );
+ $order->add_item( $item );
+ $order->save();
+
+ // Load the order with a data store that lacks the coupon usage methods of WC_Order_Data_Store_Interface.
+ add_filter(
+ 'woocommerce_data_stores',
+ function ( $stores ) {
+ $stores['test-basic-order'] = new class() extends Abstract_WC_Order_Data_Store_CPT {
+ };
+ return $stores;
+ }
+ );
+ $order_class = get_class(
+ new class() extends WC_Order {
+ /**
+ * Get a data store without the coupon usage methods.
+ *
+ * @return WC_Data_Store
+ */
+ public function get_data_store() {
+ return WC_Data_Store::load( 'test-basic-order' );
+ }
+ }
+ );
+ add_filter(
+ 'woocommerce_order_class',
+ function () use ( $order_class ) {
+ return $order_class;
+ }
+ );
+ wc_get_container()->get( OrderCache::class )->remove( $order->get_id() );
+
+ wc_update_coupon_usage_counts( $order->get_id() );
+ wc_update_coupon_usage_counts( $order->get_id() );
+
+ $this->assertSame( 0, ( new WC_Coupon( $coupon->get_code() ) )->get_usage_count() );
+ }
+
/**
* Test getting total refunded for an item with and without refunds.
*/