From dd7fe5f1589aa154aaa851ccf5b8b2ed432e5cdb Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 19 Jul 2017 12:26:01 +0100 Subject: [PATCH] More tests and precision fixes --- includes/class-wc-discounts.php | 205 ++++++++++------- tests/unit-tests/discounts/discounts.php | 268 +++++++++++++++++++---- 2 files changed, 359 insertions(+), 114 deletions(-) diff --git a/includes/class-wc-discounts.php b/includes/class-wc-discounts.php index 89d2693ef75..6744aace191 100644 --- a/includes/class-wc-discounts.php +++ b/includes/class-wc-discounts.php @@ -29,12 +29,36 @@ class WC_Discounts { */ protected $discounts = array(); + /** + * Precision so we can work in cents. + * + * @var int + */ + protected $precision = 1; + + /** + * Constructor. + */ + public function __construct() { + $this->precision = pow( 10, wc_get_price_decimals() ); + } + + /** + * Remove precision from a price. + * + * @param int $value + * @return float + */ + protected function remove_precision( $value ) { + return wc_format_decimal( $value / $this->precision, wc_get_price_decimals() ); + } + /** * Reset items and discounts to 0. */ protected function reset() { $this->items = array(); - $this->discounts = array( 'cart' => 0 ); + $this->discounts = array(); } /** @@ -47,6 +71,48 @@ class WC_Discounts { return $this->items; } + /** + * Get discount by key without precision. + * + * @since 3.2.0 + * @return array + */ + public function get_discount( $key ) { + return isset( $this->discounts[ $key ] ) ? $this->remove_precision( $this->discounts[ $key ] ) : 0; + } + + /** + * Get all discount totals without precision. + * + * @since 3.2.0 + * @return array + */ + public function get_discounts() { + return array_map( array( $this, 'remove_precision' ), $this->discounts ); + } + + /** + * Get discounted price of an item without precision. + * + * @since 3.2.0 + * @param object $item + * @return float + */ + public function get_discounted_price( $item ) { + return $this->remove_precision( $this->get_discounted_price_in_cents( $item ) ); + } + + /** + * Get discounted price of an item to precision (in cents). + * + * @since 3.2.0 + * @param object $item + * @return float + */ + public function get_discounted_price_in_cents( $item ) { + return $item->price - $this->discounts[ $item->key ]; + } + /** * Set cart/order items which will be discounted. * @@ -59,7 +125,7 @@ class WC_Discounts { if ( ! empty( $raw_items ) && is_array( $raw_items ) ) { foreach ( $raw_items as $raw_item ) { $item = (object) array( - 'price' => 0, // Line price without discounts. + 'price' => 0, // Line price without discounts, in cents. 'quantity' => 0, // Line qty. 'product' => false, ); @@ -72,12 +138,12 @@ class WC_Discounts { } elseif ( is_a( $raw_item, 'WC_Order_Item_Product' ) ) { $item->key = $raw_item->get_id(); $item->quantity = $raw_item->get_quantity(); + $item->price = $raw_item->get_subtotal() * $this->precision; $item->product = $raw_item->get_product(); } else { $item->key = $raw_item['key']; - // @todo remove when we implement WC_Cart_Item. This is the old cart item schema. $item->quantity = $raw_item['quantity']; - $item->price = $raw_item['data']->get_price() * $raw_item['quantity']; + $item->price = $raw_item['data']->get_price() * $this->precision * $raw_item['quantity']; $item->product = $raw_item['data']; } $this->items[ $item->key ] = $item; @@ -86,26 +152,6 @@ class WC_Discounts { } } - /** - * Get all discount totals. - * - * @since 3.2.0 - * @return array - */ - public function get_discounts() { - return $this->discounts; - } - - /** - * Get discount by key. - * - * @since 3.2.0 - * @return array - */ - public function get_discount( $key ) { - return isset( $this->discounts[ $key ] ) ? $this->discounts[ $key ] : 0; - } - /** * Apply a discount to all items using a coupon. * @@ -143,51 +189,29 @@ class WC_Discounts { $this->apply_percentage_discount( $coupon->get_amount() ); break; case 'fixed_product' : - $this->apply_fixed_product_discount( $coupon->get_amount() ); + $this->apply_fixed_product_discount( $coupon->get_amount() * $this->precision ); break; case 'fixed_cart' : - $this->apply_fixed_cart_discount( $coupon->get_amount() ); + $this->apply_fixed_cart_discount( $coupon->get_amount() * $this->precision ); break; } } - /** - * Get discounted price of an item. - * - * @since 3.2.0 - * @param object $item - * @return float - */ - public function get_discounted_price( $item ) { - return $item->price - $this->discounts[ $item->key ]; - } - /** * Apply a discount amount to an item and ensure it does not go negative. * * @since 3.2.0 * @param object $item - * @param float $discount - * @return float + * @param int $discount + * @return int Amount discounted. */ - protected function apply_discount_to_item( &$item, $discount ) { - $discounted_price = $this->get_discounted_price( $item ); + protected function add_item_discount( &$item, $discount ) { + $discounted_price = $this->get_discounted_price_in_cents( $item ); $discount = $discount > $discounted_price ? $discounted_price : $discount; $this->discounts[ $item->key ] = $this->discounts[ $item->key ] + $discount; return $discount; } - /** - * Apply a discount amount to the cart. - * - * @since 3.2.0 - * @param object $item - * @param float $discount - */ - protected function apply_discount_to_cart( $discount ) { - $this->discounts['cart'] += $discount; - } - /** * Apply percent discount to items. * @@ -196,7 +220,7 @@ class WC_Discounts { */ protected function apply_percentage_discount( $amount ) { foreach ( $this->items as $item ) { - $this->apply_discount_to_item( $item, (float) $amount * ( $this->get_discounted_price( $item ) / 100 ) ); + $this->add_item_discount( $item, $amount * ( $this->get_discounted_price_in_cents( $item ) / 100 ) ); } } @@ -204,39 +228,72 @@ class WC_Discounts { * Apply fixed product discount to items. * * @since 3.2.0 - * @param float $amount + * @param int $amount */ protected function apply_fixed_product_discount( $discount ) { foreach ( $this->items as $item ) { - $this->apply_discount_to_item( $item, $discount * $item->quantity ); + $this->add_item_discount( $item, $discount * $item->quantity ); } } - /*protected function filter_taxable_items( $item ) { - return $item->is_taxable; - }*/ - /** - * Apply fixed cart discount to items. Cart discounts will be stored and - * displayed separately to items, and taxes will be apportioned. + * Filter out all products which have been fully discounted to 0. + * Used as array_filter callback. * - * @since 3.2.0 - * @param float $cart_discount + * @param object $item + * @return bool + */ + protected function filter_products_with_price( $item ) { + return $this->get_discounted_price_in_cents( $item ) > 0; + } + + /** + * Apply fixed cart discount to items. + * + * @since 3.2.0 + * @param int $cart_discount */ protected function apply_fixed_cart_discount( $cart_discount ) { - // @todo Fixed cart discounts will be apportioned based on tax class. - /*$tax_class_counts = array_count_values( wp_list_pluck( array_filter( $this->items, array( $this, 'filter_taxable_items' ) ), 'tax_class' ) ); - $item_count = array_sum( wp_list_pluck( $this->items, 'quantity' ) ); - $cart_discount_taxes = array(); + $items_to_discount = array_filter( $this->items, array( $this, 'filter_products_with_price' ) ); - foreach ( $tax_class_counts as $tax_class => $tax_class_count ) { - $proportion = $tax_class_count / $item_count; - $cart_discount_proportion = $cart_discount * $proportion; - $tax_rates = WC_Tax::get_rates( $tax_class ); - $cart_discount_taxes[ $tax_class ] = WC_Tax::calc_tax( $cart_discount_proportion, $tax_rates ); + if ( ! $item_count = array_sum( wp_list_pluck( $items_to_discount, 'quantity' ) ) ) { + return; } - var_dump($cart_discount_taxes);*/ - $this->apply_discount_to_cart( $cart_discount ); + $per_item_discount = floor( $cart_discount / $item_count ); // round it down to the nearest cent number. + $amount_discounted = 0; + + if ( $per_item_discount > 0 ) { + foreach ( $items_to_discount as $item ) { + $amount_discounted += $this->add_item_discount( $item, $per_item_discount * $item->quantity ); + } + + /** + * If there is still discount remaining, repeat the process. + */ + if ( $amount_discounted > 0 && $amount_discounted < $cart_discount ) { + $this->apply_fixed_cart_discount( $cart_discount - $amount_discounted ); + } + return; + } + + /** + * Deal with remaining fractional discounts by splitting it over items + * until the amount is expired, discounting 1 cent at a time. + */ + if ( $cart_discount > 0 ) { + foreach ( $items_to_discount as $item ) { + for ( $i = 0; $i < $item->quantity; $i ++ ) { + $amount_discounted += $this->add_item_discount( $item, 1 ); + + if ( $amount_discounted >= $cart_discount ) { + break 2; + } + } + if ( $amount_discounted >= $cart_discount ) { + break; + } + } + } } } diff --git a/tests/unit-tests/discounts/discounts.php b/tests/unit-tests/discounts/discounts.php index e0ccc69e86a..8de3e11e887 100644 --- a/tests/unit-tests/discounts/discounts.php +++ b/tests/unit-tests/discounts/discounts.php @@ -49,12 +49,48 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { } /** - * Test applying a coupon to a set of items. + * Test applying a coupon (make sure it changes prices). */ public function test_apply_coupon() { $discounts = new WC_Discounts(); // Create dummy content. + $product = WC_Helper_Product::create_simple_product(); + $product->set_tax_status( 'taxable' ); + $product->save(); + WC()->cart->empty_cart(); + WC()->cart->add_to_cart( $product->get_id(), 1 ); + $coupon = new WC_Coupon; + $coupon->set_code( 'test' ); + $coupon->set_amount( 10 ); + + // Apply a percent discount. + $coupon->set_discount_type( 'percent' ); + $discounts->set_items( WC()->cart->get_cart() ); + $discounts->apply_coupon( $coupon ); + $this->assertEquals( 9, $discounts->get_discounted_price( current( $discounts->get_items() ) ) ); + + // Apply a fixed cart coupon. + $coupon->set_discount_type( 'fixed_cart' ); + $discounts->set_items( WC()->cart->get_cart() ); + $discounts->apply_coupon( $coupon ); + $this->assertEquals( 0, $discounts->get_discounted_price( current( $discounts->get_items() ) ) ); + + // Apply a fixed product coupon. + $coupon->set_discount_type( 'fixed_product' ); + $discounts->set_items( WC()->cart->get_cart() ); + $discounts->apply_coupon( $coupon ); + $this->assertEquals( 0, $discounts->get_discounted_price( current( $discounts->get_items() ) ) ); + + // Cleanup. + WC()->cart->empty_cart(); + $product->delete( true ); + } + + /** + * Test various discount calculations are working correctly and produding expected results. + */ + public function test_calculations() { $tax_rate = array( 'tax_rate_country' => '', 'tax_rate_state' => '', @@ -68,51 +104,203 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { ); $tax_rate_id = WC_Tax::_insert_tax_rate( $tax_rate ); update_option( 'woocommerce_calc_taxes', 'yes' ); - $product = WC_Helper_Product::create_simple_product(); - $product->set_tax_status( 'taxable' ); - $product->save(); - WC()->cart->empty_cart(); - WC()->cart->add_to_cart( $product->get_id(), 1 ); - $coupon = new WC_Coupon; - $coupon->set_code( 'test' ); - $coupon->set_discount_type( 'percent' ); - $coupon->set_amount( 20 ); - // Apply a percent discount. - $coupon->set_discount_type( 'percent' ); - $discounts->set_items( WC()->cart->get_cart() ); - $discounts->apply_coupon( $coupon ); - $this->assertEquals( 8, $discounts->get_discounted_price( current( $discounts->get_items() ) ) ); + $tests = array( + array( + 'prices_include_tax' => false, + 'cart' => array( + array( + 'price' => 10, + 'qty' => 1, + ) + ), + 'coupons' => array( + array( + 'code' => 'test', + 'discount_type' => 'percent', + 'amount' => '20', + ) + ), + 'expected_total_discount' => 2, + ), + array( + 'prices_include_tax' => false, + 'cart' => array( + array( + 'price' => 10, + 'qty' => 2, + ) + ), + 'coupons' => array( + array( + 'code' => 'test', + 'discount_type' => 'fixed_cart', + 'amount' => '10', + ) + ), + 'expected_total_discount' => 10, + ), + array( + 'prices_include_tax' => false, + 'cart' => array( + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ) + ), + 'coupons' => array( + array( + 'code' => 'test', + 'discount_type' => 'fixed_cart', + 'amount' => '10', + ) + ), + 'expected_total_discount' => 10, + ), + array( + 'prices_include_tax' => false, + 'cart' => array( + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ) + ), + 'coupons' => array( + array( + 'code' => 'test', + 'discount_type' => 'fixed_cart', + 'amount' => '10', + ) + ), + 'expected_total_discount' => 10, + ), + array( + 'prices_include_tax' => false, + 'cart' => array( + array( + 'price' => 10, + 'qty' => 2, + ), + array( + 'price' => 10, + 'qty' => 3, + ), + array( + 'price' => 10, + 'qty' => 2, + ) + ), + 'coupons' => array( + array( + 'code' => 'test', + 'discount_type' => 'fixed_cart', + 'amount' => '10', + ) + ), + 'expected_total_discount' => 10, + ), + array( + 'prices_include_tax' => false, + 'cart' => array( + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ), + array( + 'price' => 10, + 'qty' => 1, + ) + ), + 'coupons' => array( + array( + 'code' => 'test', + 'discount_type' => 'fixed_cart', + 'amount' => '10', + ) + ), + 'expected_total_discount' => 10, + ) + ); - // Apply a fixed cart coupon. - $coupon->set_discount_type( 'fixed_cart' ); - $discounts->set_items( WC()->cart->get_cart() ); - $discounts->apply_coupon( $coupon ); - $this->assertEquals( 20, $discounts->get_discount( 'cart' ) ); + foreach ( $tests as $test_index => $test ) { + $discounts = new WC_Discounts(); + $products = array(); - // Apply a fixed cart coupon. - $coupon->set_discount_type( 'fixed_cart' ); - WC()->cart->empty_cart(); - WC()->cart->add_to_cart( $product->get_id(), 4 ); - $discounts->set_items( WC()->cart->get_cart() ); - $discounts->apply_coupon( $coupon ); - $this->assertEquals( 20, $discounts->get_discount( 'cart' ) ); + foreach ( $test['cart'] as $item ) { + $product = WC_Helper_Product::create_simple_product(); + $product->set_regular_price( $item['price'] ); + $product->set_tax_status( 'taxable' ); + $product->save(); + WC()->cart->add_to_cart( $product->get_id(), $item['qty'] ); + $products[] = $product; + } - $discounts->apply_coupon( $coupon ); - $this->assertEquals( 40, $discounts->get_discount( 'cart' ) ); + $discounts->set_items( WC()->cart->get_cart() ); - // Apply a fixed product coupon. - $coupon->set_discount_type( 'fixed_product' ); - $coupon->set_amount( 1 ); - WC()->cart->empty_cart(); - WC()->cart->add_to_cart( $product->get_id(), 4 ); - $discounts->set_items( WC()->cart->get_cart() ); - $discounts->apply_coupon( $coupon ); - $this->assertEquals( 36, $discounts->get_discounted_price( current( $discounts->get_items() ) ) ); + foreach ( $test['coupons'] as $coupon_props ) { + $coupon = new WC_Coupon; + $coupon->set_props( $coupon_props ); + $discounts->apply_coupon( $coupon ); + } + + $this->assertEquals( $test['expected_total_discount'], array_sum( $discounts->get_discounts() ), 'Test case ' . $test_index . ' failed (' . print_r( $test, true ) . ' - ' . print_r( $discounts->get_discounts(), true ) . ')' ); + + // Clean. + WC()->cart->empty_cart(); + + foreach ( $products as $product ) { + $product->delete( true ); + } + } - // Cleanup. - WC()->cart->empty_cart(); - $product->delete( true ); WC_Tax::_delete_tax_rate( $tax_rate_id ); update_option( 'woocommerce_calc_taxes', 'no' ); }