Skip to content
This repository was archived by the owner on May 21, 2025. It is now read-only.

Commit 9a17acd

Browse files
authored
[WC 9.2] Fix $0 simple product order totals when purchasing subscriptions and simple products with a coupon (#660)
* Store a stack of recurring cart keys being calculated so we can correctly set which one is being calculated * Add basic unit tests for the cart class * Make sure we disable constructors on mock carts * Fix unit test and tear down the class * Add more tests for recurring cart calculations * Add test to confirm no infinite loop and no double calculating occurs when calculate totals is nested * Remove space added by error * add changelog entry * Reinstate the calculation type check to protect against infinite loops * Add more tests to confirm the calculation stack behaves as expected
1 parent 0e20545 commit 9a17acd

File tree

4 files changed

+356
-5
lines changed

4 files changed

+356
-5
lines changed

changelog.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
*** WooCommerce Subscriptions Core Changelog ***
22

3+
= 7.4.2 - xxxx-xx-xx =
4+
* Fix - Resolved an issue where simple product prices were incorrectly set to $0 when purchasing subscriptions and simple products with a coupon in WC 9.2.
5+
36
= 7.4.1 - 2024-08-21 =
47
* Fix - Add a year to the next renewal date billing interval is 12 months or more for a synced subscription.
58

includes/class-wc-subscriptions-cart.php

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ class WC_Subscriptions_Cart {
4747
*/
4848
private static $cached_recurring_cart = null;
4949

50+
/**
51+
* A stack of recurring cart keys being calculated.
52+
*
53+
* Before calculating a cart's totals, we set the recurring cart key and calculation type to match that cart's key and type. @see self::set_recurring_cart_key_before_calculate_totals()
54+
* After a cart's totals have been calculated, we restore the recurring cart key and calculation type. @see self::update_recurring_cart_key_after_calculate_totals()
55+
*
56+
* @var array
57+
*/
58+
private static $recurring_totals_calculation_stack = [];
59+
5060
/**
5161
* Bootstraps the class and hooks required actions & filters.
5262
*
@@ -113,6 +123,10 @@ public static function init() {
113123

114124
// Redirect the user immediately to the checkout page after clicking "Sign Up Now" buttons to encourage immediate checkout
115125
add_filter( 'woocommerce_add_to_cart_redirect', array( __CLASS__, 'add_to_cart_redirect' ) );
126+
127+
// Set the recurring cart being calculated.
128+
add_action( 'woocommerce_before_calculate_totals', [ __CLASS__, 'set_recurring_cart_key_before_calculate_totals' ], 1 );
129+
add_action( 'woocommerce_after_calculate_totals', [ __CLASS__, 'update_recurring_cart_key_after_calculate_totals' ], 1 );
116130
}
117131

118132
/**
@@ -226,6 +240,37 @@ public static function set_subscription_prices_for_calculation( $price, $product
226240
return $price;
227241
}
228242

243+
/**
244+
* Sets the recurring cart key and calculation type before calculating a carts totals.
245+
*
246+
* @param WC_Cart $cart The cart object being calculated.
247+
*/
248+
public static function set_recurring_cart_key_before_calculate_totals( $cart ) {
249+
$recurring_cart_key = ! empty( $cart->recurring_cart_key ) ? $cart->recurring_cart_key : 'none';
250+
251+
// Store the recurring cart key in the stack.
252+
array_unshift( self::$recurring_totals_calculation_stack, $recurring_cart_key );
253+
254+
// Set the current recurring cart key and calculation type.
255+
self::set_recurring_cart_key( $recurring_cart_key );
256+
self::set_calculation_type( 'none' === $recurring_cart_key ? 'none' : 'recurring_total' );
257+
}
258+
259+
/**
260+
* Updates the recurring cart key and calculation type after calculating a carts totals.
261+
*
262+
* @param WC_Cart $cart The cart object that finished calculating it's totals.
263+
*/
264+
public static function update_recurring_cart_key_after_calculate_totals( $cart ) {
265+
// Remove the recurring cart key from the stack. It has finished calculating.
266+
array_shift( self::$recurring_totals_calculation_stack );
267+
268+
$recurring_cart_key = empty( self::$recurring_totals_calculation_stack ) ? 'none' : reset( self::$recurring_totals_calculation_stack );
269+
270+
self::set_recurring_cart_key( $recurring_cart_key );
271+
self::set_calculation_type( 'none' === $recurring_cart_key ? 'none' : 'recurring_total' );
272+
}
273+
229274
/**
230275
* Calculate the initial and recurring totals for all subscription products in the cart.
231276
*
@@ -243,9 +288,21 @@ public static function set_subscription_prices_for_calculation( $price, $product
243288
* @version 1.0.0 - Migrated from WooCommerce Subscriptions v2.0
244289
*/
245290
public static function calculate_subscription_totals( $total, $cart ) {
246-
if ( ! self::cart_contains_subscription() && ! wcs_cart_contains_resubscribe() ) { // cart doesn't contain subscription
291+
// If the cart doesn't contain a subscription, skip calculating recurring totals.
292+
if ( ! self::cart_contains_subscription() && ! wcs_cart_contains_resubscribe() ) {
247293
return $total;
248-
} elseif ( 'none' != self::$calculation_type ) { // We're in the middle of a recalculation, let it run
294+
}
295+
296+
// We're in the middle of a recalculation, let it run.
297+
if ( 'none' !== self::$calculation_type ) {
298+
return $total;
299+
}
300+
301+
/**
302+
* If we're in the middle of calculating recurring totals, skip this calculation to avoid infinite loops.
303+
* We use whether there's a recurring cart key in the calculation stack (ie has started but hasn't finished) to determine if we're in the middle calculating recurring totals.
304+
*/
305+
if ( ! empty( array_diff( self::$recurring_totals_calculation_stack, [ 'none' ] ) ) ) {
249306
return $total;
250307
}
251308

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
<?php
2+
/**
3+
* Tests for the WC_Subscriptions_Cart class.
4+
*/
5+
class WC_Subscriptions_Cart_Test extends WP_UnitTestCase {
6+
7+
/**
8+
* @var WC_Cart
9+
*/
10+
private $cart;
11+
12+
/**
13+
* The number of times woocommerce_subscriptions_calculated_total was triggered.
14+
*
15+
* @var int
16+
*/
17+
private $calculated_subscription_totals_count = 0;
18+
19+
/**
20+
* Set up the test class.
21+
*/
22+
public function set_up() {
23+
parent::set_up();
24+
25+
$this->cart = WC()->cart;
26+
27+
// Count the number of times woocommerce_subscriptions_calculated_total is triggered.
28+
add_action(
29+
'woocommerce_subscriptions_calculated_total',
30+
function( $subscription_total ) {
31+
$this->calculated_subscription_totals_count++;
32+
return $subscription_total;
33+
}
34+
);
35+
}
36+
37+
/**
38+
* Tear down the test class.
39+
*/
40+
public function tear_down() {
41+
parent::tear_down();
42+
43+
$this->cart->empty_cart();
44+
$this->cart->recurring_carts = [];
45+
46+
$this->calculated_subscription_totals_count = 0;
47+
}
48+
49+
/**
50+
* Test that recurring carts are created when calculating totals.
51+
*/
52+
public function test_calculate_subscription_totals() {
53+
$product = WCS_Helper_Product::create_simple_subscription_product( [ 'price' => 10 ] );
54+
55+
// First, check that there are no recurring carts.
56+
$this->assertEmpty( $this->cart->recurring_carts );
57+
58+
$this->cart->add_to_cart( $product->get_id() );
59+
60+
// Calculate the totals. This should create a recurring cart.
61+
$this->cart->calculate_totals();
62+
63+
// Check that the recurring cart was created.
64+
$this->assertNotEmpty( $this->cart->recurring_carts );
65+
$this->assertCount( 1, $this->cart->recurring_carts );
66+
}
67+
68+
/**
69+
* Test that recurring carts are created when calculating totals.
70+
*/
71+
public function test_calculate_subscription_totals_multiple_recurring_carts() {
72+
$product_1 = WCS_Helper_Product::create_simple_subscription_product( [ 'price' => 10 ] );
73+
$product_2 = WCS_Helper_Product::create_simple_subscription_product(
74+
[
75+
'price' => 20,
76+
'subscription_period' => 'year',
77+
]
78+
);
79+
80+
// First, check that there are no recurring carts.
81+
$this->assertEmpty( $this->cart->recurring_carts );
82+
83+
$this->cart->add_to_cart( $product_1->get_id() );
84+
$this->cart->add_to_cart( $product_2->get_id() );
85+
86+
// Calculate the totals. This should create a recurring cart.
87+
$this->cart->calculate_totals();
88+
89+
// Check that the recurring cart was created.
90+
$this->assertNotEmpty( $this->cart->recurring_carts );
91+
$this->assertCount( 2, $this->cart->recurring_carts );
92+
}
93+
94+
/**
95+
* Test that recurring carts are created when calculating totals.
96+
*/
97+
public function test_calculate_subscription_totals_multiple_items_one_cart() {
98+
$product_1 = WCS_Helper_Product::create_simple_subscription_product(
99+
[
100+
'price' => 10,
101+
'subscription_period' => 'year',
102+
]
103+
);
104+
$product_2 = WCS_Helper_Product::create_simple_subscription_product(
105+
[
106+
'price' => 20,
107+
'subscription_period' => 'year',
108+
]
109+
);
110+
111+
// First, check that there are no recurring carts.
112+
$this->assertEmpty( $this->cart->recurring_carts );
113+
114+
$this->cart->add_to_cart( $product_1->get_id() );
115+
$this->cart->add_to_cart( $product_2->get_id() );
116+
117+
// Calculate the totals. This should create a recurring cart.
118+
$this->cart->calculate_totals();
119+
120+
// Check that the recurring cart was created.
121+
$this->assertNotEmpty( $this->cart->recurring_carts );
122+
$this->assertCount( 1, $this->cart->recurring_carts ); // Only one recurring cart should be created for both items.
123+
$this->assertCount( 2, reset( $this->cart->recurring_carts )->get_cart() );
124+
}
125+
126+
/**
127+
* Test that recurring carts are created when calculating totals with a mixed cart.
128+
*/
129+
public function test_calculate_subscription_totals_with_mixed_cart() {
130+
$subscription = WCS_Helper_Product::create_simple_subscription_product( [ 'price' => 10 ] );
131+
$simple = WC_Helper_Product::create_simple_product();
132+
133+
// First, check that there are no recurring carts.
134+
$this->assertEmpty( $this->cart->recurring_carts );
135+
136+
$this->cart->add_to_cart( $subscription->get_id() );
137+
$this->cart->add_to_cart( $simple->get_id() );
138+
139+
// Calculate the totals. This should create a recurring cart.
140+
$this->cart->calculate_totals();
141+
142+
// Check that the recurring cart was created.
143+
$this->assertNotEmpty( $this->cart->recurring_carts );
144+
$this->assertCount( 1, $this->cart->recurring_carts );
145+
$this->assertCount( 1, reset( $this->cart->recurring_carts )->get_cart() );
146+
}
147+
148+
/**
149+
* Test that recurring carts are created only once when calculating totals with nested calls.
150+
*/
151+
public function test_calculate_subscription_totals_with_nested_calls() {
152+
$subscription = WCS_Helper_Product::create_simple_subscription_product( [ 'price' => 10 ] );
153+
$simple = WC_Helper_Product::create_simple_product();
154+
155+
// First, check that there are no recurring carts.
156+
$this->assertEmpty( $this->cart->recurring_carts );
157+
158+
$this->cart->add_to_cart( $subscription->get_id() );
159+
$this->cart->add_to_cart( $simple->get_id() );
160+
161+
add_action( 'woocommerce_calculate_totals', [ $this, 'mock_nested_callback' ] );
162+
163+
$this->calculated_subscription_totals_count = 0;
164+
165+
// Calculate the totals. This should create a recurring cart.
166+
$this->cart->calculate_totals();
167+
168+
// Check that the recurring cart was created.
169+
$this->assertNotEmpty( $this->cart->recurring_carts );
170+
$this->assertCount( 1, $this->cart->recurring_carts );
171+
$this->assertCount( 1, reset( $this->cart->recurring_carts )->get_cart() );
172+
$this->assertEquals( 1, $this->calculated_subscription_totals_count );
173+
}
174+
175+
/**
176+
* A function to mock calling WC()->cart->calculate_totals() from within the calculate_totals action.
177+
*
178+
* @param WC_Cart $cart
179+
*/
180+
public function mock_nested_callback( $cart ) {
181+
if ( ! isset( $cart->recurring_cart_key ) ) {
182+
return;
183+
}
184+
185+
// Nest the calculate_totals call by calling it again.
186+
WC()->cart->calculate_totals();
187+
}
188+
189+
/**
190+
* Test that the calculation type is set when set_recurring_cart_key_before_calculate_totals() is called.
191+
*/
192+
public function test_set_recurring_cart_key_before_calculate_totals() {
193+
$cart = $this->getMockBuilder( 'WC_Cart' )
194+
->disableOriginalConstructor()
195+
->setMethods( null ) // This makes the mock retain normal PHP object behavior
196+
->getMock();
197+
198+
/**
199+
* Recurring cart.
200+
*/
201+
$cart->recurring_cart_key = 'test_key';
202+
WC_Subscriptions_Cart::set_recurring_cart_key_before_calculate_totals( $cart );
203+
204+
$this->assertEquals( 'recurring_total', WC_Subscriptions_Cart::get_calculation_type() );
205+
206+
/**
207+
* Non-recurring cart.
208+
*/
209+
$cart->recurring_cart_key = '';
210+
WC_Subscriptions_Cart::set_recurring_cart_key_before_calculate_totals( $cart );
211+
212+
$this->assertEquals( 'none', WC_Subscriptions_Cart::get_calculation_type() );
213+
214+
/**
215+
* Non-recurring cart.
216+
*/
217+
unset( $cart->recurring_cart_key );
218+
WC_Subscriptions_Cart::set_recurring_cart_key_before_calculate_totals( $cart );
219+
220+
$this->assertEquals( 'none', WC_Subscriptions_Cart::get_calculation_type() );
221+
222+
unset( $cart );
223+
}
224+
225+
/**
226+
* Test that the calculation type is set when set_recurring_cart_key_before_calculate_totals() and then is called.
227+
*/
228+
public function test_update_recurring_cart_key_after_calculate_totals_empty_stack() {
229+
$cart = $this->getMockBuilder( 'WC_Cart' )
230+
->disableOriginalConstructor()
231+
->setMethods( null ) // This makes the mock retain normal PHP object behavior
232+
->getMock();
233+
234+
/**
235+
* Empty stack.
236+
*/
237+
$cart->recurring_cart_key = 'test_key';
238+
WC_Subscriptions_Cart::set_recurring_cart_key_before_calculate_totals( $cart );
239+
WC_Subscriptions_Cart::update_recurring_cart_key_after_calculate_totals( $cart );
240+
241+
// Check that after a recurring cart has been been set (started) and then finished, the stack is empty and returns 'none'.
242+
$this->assertEquals( 'none', WC_Subscriptions_Cart::get_calculation_type() );
243+
244+
unset( $cart );
245+
}
246+
247+
/**
248+
* Test that the calculation type is set when update_recurring_cart_key_after_calculate_totals() is called and there' a recurring cart in the stack.
249+
*/
250+
public function test_update_recurring_cart_key_after_calculate_totals_with_recurring_cart() {
251+
$cart = $this->getMockBuilder( 'WC_Cart' )
252+
->disableOriginalConstructor()
253+
->setMethods( null ) // This makes the mock retain normal PHP object behavior
254+
->getMock();
255+
256+
/**
257+
* Recurring cart left in stack.
258+
*/
259+
$cart->recurring_cart_key = 'test_key';
260+
261+
// First, populate the stack with a cart with a recurring key.
262+
WC_Subscriptions_Cart::set_recurring_cart_key_before_calculate_totals( $cart );
263+
264+
// Now, populate the stack with a cart without a recurring key.
265+
$cart->recurring_cart_key = '';
266+
WC_Subscriptions_Cart::set_recurring_cart_key_before_calculate_totals( $cart );
267+
WC_Subscriptions_Cart::update_recurring_cart_key_after_calculate_totals( $cart );
268+
269+
$this->assertEquals( 'recurring_total', WC_Subscriptions_Cart::get_calculation_type() );
270+
}
271+
272+
/**
273+
* Test that the calculation type is set when update_recurring_cart_key_after_calculate_totals() is called and there' a recurring cart in the stack.
274+
*/
275+
public function test_update_recurring_cart_key_after_calculate_totals_with_initial_cart() {
276+
$cart = $this->getMockBuilder( 'WC_Cart' )
277+
->disableOriginalConstructor()
278+
->setMethods( null ) // This makes the mock retain normal PHP object behavior
279+
->getMock();
280+
281+
// First, populate the stack with a cart without a recurring key.
282+
WC_Subscriptions_Cart::set_recurring_cart_key_before_calculate_totals( $cart );
283+
284+
// Now, populate the stack with a cart with a recurring key.
285+
$cart->recurring_cart_key = 'test_recurring_cart_key';
286+
WC_Subscriptions_Cart::set_recurring_cart_key_before_calculate_totals( $cart );
287+
WC_Subscriptions_Cart::update_recurring_cart_key_after_calculate_totals( $cart );
288+
289+
$this->assertEquals( 'none', WC_Subscriptions_Cart::get_calculation_type() );
290+
}
291+
}

0 commit comments

Comments
 (0)