diff --git a/includes/abstracts/abstract-wc-order.php b/includes/abstracts/abstract-wc-order.php index 71ec1b30703..8e241775b8e 100644 --- a/includes/abstracts/abstract-wc-order.php +++ b/includes/abstracts/abstract-wc-order.php @@ -1001,22 +1001,20 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order { } /** - * Calculate taxes for all line items and shipping, and store the totals and tax rows. + * Get tax location for this order. * - * If by default the taxes are based on the shipping address and the current order doesn't - * have any, it would use the billing address rather than using the Shopping base location. - * - * Will use the base country unless customer addresses are set. - * @param $args array Added in 3.0.0 to pass things like location. + * @since 3.2.0 + * @param $args array Override the location. + * @return array */ - public function calculate_taxes( $args = array() ) { + protected function get_tax_location( $args = array() ) { $tax_based_on = get_option( 'woocommerce_tax_based_on' ); if ( 'shipping' === $tax_based_on && ! $this->get_shipping_country() ) { $tax_based_on = 'billing'; } - $args = wp_parse_args( $args, array( + $args = wp_parse_args( $args, array( 'country' => 'billing' === $tax_based_on ? $this->get_billing_country() : $this->get_shipping_country(), 'state' => 'billing' === $tax_based_on ? $this->get_billing_state() : $this->get_shipping_state(), 'postcode' => 'billing' === $tax_based_on ? $this->get_billing_postcode() : $this->get_shipping_postcode(), @@ -1032,75 +1030,38 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order { $args['city'] = ''; } - // Calc taxes for line items + return $args; + } + + /** + * Calculate taxes for all line items and shipping, and store the totals and tax rows. + * + * If by default the taxes are based on the shipping address and the current order doesn't + * have any, it would use the billing address rather than using the Shopping base location. + * + * Will use the base country unless customer addresses are set. + * + * @param array $args Added in 3.0.0 to pass things like location. + */ + public function calculate_taxes( $args = array() ) { + $calculate_tax_for = $this->get_tax_location( $args ); + $shipping_tax_class = get_option( 'woocommerce_shipping_tax_class' ); + + if ( 'inherit' === $shipping_tax_class ) { + $shipping_tax_class = current( array_intersect( array_merge( array( '' ), WC_Tax::get_tax_class_slugs() ), $this->get_items_tax_classes() ) ); + } + + // Trigger tax recalculation for all items. foreach ( $this->get_items( array( 'line_item', 'fee' ) ) as $item_id => $item ) { - $tax_class = $item->get_tax_class(); - $tax_status = $item->get_tax_status(); - - if ( '0' !== $tax_class && 'taxable' === $tax_status && wc_tax_enabled() ) { - $tax_rates = WC_Tax::find_rates( array( - 'country' => $args['country'], - 'state' => $args['state'], - 'postcode' => $args['postcode'], - 'city' => $args['city'], - 'tax_class' => $tax_class, - ) ); - - $total = $item->get_total(); - $taxes = WC_Tax::calc_tax( $total, $tax_rates, false ); - - if ( $item->is_type( 'line_item' ) ) { - $subtotal = $item->get_subtotal(); - $subtotal_taxes = WC_Tax::calc_tax( $subtotal, $tax_rates, false ); - $item->set_taxes( array( 'total' => $taxes, 'subtotal' => $subtotal_taxes ) ); - } else { - $item->set_taxes( array( 'total' => $taxes ) ); - } - } else { - $item->set_taxes( false ); - } + $item->calculate_taxes( $calculate_tax_for ); $item->save(); } - // Calc taxes for shipping foreach ( $this->get_shipping_methods() as $item_id => $item ) { - if ( wc_tax_enabled() ) { - $shipping_tax_class = get_option( 'woocommerce_shipping_tax_class' ); - - // Inherit tax class from items - if ( 'inherit' === $shipping_tax_class ) { - $tax_rates = array(); - $tax_classes = array_merge( array( '' ), WC_Tax::get_tax_class_slugs() ); - $found_tax_classes = $this->get_items_tax_classes(); - - foreach ( $tax_classes as $tax_class ) { - if ( in_array( $tax_class, $found_tax_classes ) ) { - $tax_rates = WC_Tax::find_shipping_rates( array( - 'country' => $args['country'], - 'state' => $args['state'], - 'postcode' => $args['postcode'], - 'city' => $args['city'], - 'tax_class' => $tax_class, - ) ); - break; - } - } - } else { - $tax_rates = WC_Tax::find_shipping_rates( array( - 'country' => $args['country'], - 'state' => $args['state'], - 'postcode' => $args['postcode'], - 'city' => $args['city'], - 'tax_class' => $shipping_tax_class, - ) ); - } - - $item->set_taxes( array( 'total' => WC_Tax::calc_tax( $item->get_total(), $tax_rates, false ) ) ); - } else { - $item->set_taxes( false ); - } + $item->calculate_taxes( array_merge( $calculate_tax_for, array( 'tax_class' => $shipping_tax_class ) ) ); $item->save(); } + $this->update_taxes(); } @@ -1150,7 +1111,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order { $this->add_item( $item ); } - // Save tax totals + // Save tax totals. $this->set_shipping_tax( WC_Tax::round( array_sum( $shipping_taxes ) ) ); $this->set_cart_tax( WC_Tax::round( array_sum( $cart_taxes ) ) ); $this->save(); @@ -1174,7 +1135,6 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order { $this->calculate_taxes(); } - // line items foreach ( $this->get_items() as $item ) { $cart_subtotal += $item->get_subtotal(); $cart_total += $item->get_total(); diff --git a/includes/class-wc-cart-totals.php b/includes/class-wc-cart-totals.php new file mode 100644 index 00000000000..2362e1555b5 --- /dev/null +++ b/includes/class-wc-cart-totals.php @@ -0,0 +1,616 @@ + 0, + 'fees_total_tax' => 0, + 'items_subtotal' => 0, + 'items_subtotal_tax' => 0, + 'items_total' => 0, + 'items_total_tax' => 0, + 'total' => 0, + 'taxes' => array(), + 'tax_total' => 0, + 'shipping_total' => 0, + 'shipping_tax_total' => 0, + 'discounts_total' => 0, + 'discounts_tax_total' => 0, + ); + + /** + * Sets up the items provided, and calculate totals. + * + * @since 3.2.0 + * @param object $cart Cart object to calculate totals for. + */ + public function __construct( &$cart = null ) { + if ( is_a( $cart, 'WC_Cart' ) ) { + $this->object = $cart; + $this->calculate_tax = wc_tax_enabled() && ! $cart->get_customer()->get_is_vat_exempt(); + $this->calculate(); + } + } + + /** + * Run all calculations methods on the given items in sequence. + * + * @since 3.2.0 + */ + protected function calculate() { + $this->calculate_item_totals(); + $this->calculate_fee_totals(); + $this->calculate_shipping_totals(); + $this->calculate_totals(); + } + + /** + * Get default blank set of props used per item. + * + * @since 3.2.0 + * @return array + */ + protected function get_default_item_props() { + return (object) array( + 'object' => null, + 'quantity' => 0, + 'product' => false, + 'price_includes_tax' => false, + 'subtotal' => 0, + 'subtotal_tax' => 0, + 'total' => 0, + 'total_tax' => 0, + 'taxes' => array(), + ); + } + + /** + * Get default blank set of props used per fee. + * + * @since 3.2.0 + * @return array + */ + protected function get_default_fee_props() { + return (object) array( + 'total_tax' => 0, + 'taxes' => array(), + ); + } + + /** + * Get default blank set of props used per shipping row. + * + * @since 3.2.0 + * @return array + */ + protected function get_default_shipping_props() { + return (object) array( + 'total' => 0, + 'total_tax' => 0, + 'taxes' => array(), + ); + } + + /** + * Should we round at subtotal level only? + * + * @return bool + */ + protected function round_at_subtotal() { + return 'yes' === get_option( 'woocommerce_tax_round_at_subtotal' ); + } + + /** + * Handles a cart or order object passed in for calculation. Normalises data + * into the same format for use by this class. + * + * Each item is made up of the following props, in addition to those returned by get_default_item_props() for totals. + * - key: An identifier for the item (cart item key or line item ID). + * - cart_item: For carts, the cart item from the cart which may include custom data. + * - quantity: The qty for this line. + * - price: The line price in cents. + * - product: The product object this cart item is for. + * + * @since 3.2.0 + */ + protected function set_items() { + $this->items = array(); + + foreach ( $this->object->get_cart() as $cart_item_key => $cart_item ) { + $item = $this->get_default_item_props(); + $item->object = $cart_item; + $item->price_includes_tax = wc_prices_include_tax(); + $item->quantity = $cart_item['quantity']; + $item->subtotal = wc_add_number_precision_deep( $cart_item['data']->get_price() ) * $cart_item['quantity']; + $item->product = $cart_item['data']; + $item->tax_rates = $this->get_item_tax_rates( $item ); + $this->items[ $cart_item_key ] = $item; + } + } + + /** + * Get fee objects from the cart. Normalises data + * into the same format for use by this class. + * + * @since 3.2.0 + */ + protected function set_fees() { + $this->fees = array(); + $this->object->calculate_fees(); + + foreach ( $this->object->get_fees() as $fee_key => $fee_object ) { + $fee = $this->get_default_fee_props(); + $fee->object = $fee_object; + $fee->total = wc_add_number_precision_deep( $fee->object->amount ); + + if ( $this->calculate_tax && $fee->object->taxable ) { + $fee->taxes = WC_Tax::calc_tax( $fee->total, WC_Tax::get_rates( $fee->object->tax_class, $this->object->get_customer() ), false ); + $fee->total_tax = array_sum( $fee->taxes ); + + if ( ! $this->round_at_subtotal() ) { + $fee->total_tax = wc_round_tax_total( $fee->total_tax, wc_get_rounding_precision() ); + } + } + + $this->fees[ $fee_key ] = $fee; + } + } + + /** + * Get shipping methods from the cart and normalise. + * + * @since 3.2.0 + */ + protected function set_shipping() { + $this->shipping = array(); + + foreach ( $this->object->calculate_shipping() as $key => $shipping_object ) { + $shipping_line = $this->get_default_shipping_props(); + $shipping_line->object = $shipping_object; + $shipping_line->total = wc_add_number_precision_deep( $shipping_object->cost ); + $shipping_line->taxes = wc_add_number_precision_deep( $shipping_object->taxes ); + $shipping_line->total_tax = wc_add_number_precision_deep( array_sum( $shipping_object->taxes ) ); + + if ( ! $this->round_at_subtotal() ) { + $shipping_line->total_tax = wc_round_tax_total( $shipping_line->total_tax, wc_get_rounding_precision() ); + } + + $this->shipping[ $key ] = $shipping_line; + } + } + + /** + * Return array of coupon objects from the cart. Normalises data + * into the same format for use by this class. + * + * @since 3.2.0 + */ + protected function set_coupons() { + $this->coupons = $this->object->get_coupons(); + } + + /** + * Only ran if woocommerce_adjust_non_base_location_prices is true. + * + * If the customer is outside of the base location, this removes the base + * taxes. This is off by default unless the filter is used. + * + * Uses edit context so unfiltered tax class is returned. + * + * @since 3.2.0 + * @param object $item Item to adjust the prices of. + * @return object + */ + protected function adjust_non_base_location_price( $item ) { + $base_tax_rates = WC_Tax::get_base_tax_rates( $item->product->get_tax_class( 'edit' ) ); + + if ( $item->tax_rates !== $base_tax_rates ) { + // Work out a new base price without the shop's base tax. + $taxes = WC_Tax::calc_tax( $item->subtotal, $base_tax_rates, true, true ); + + // Now we have a new item price (excluding TAX). + $item->subtotal = $item->subtotal - array_sum( $taxes ); + $item->price_includes_tax = false; + } + return $item; + } + + /** + * Get discounted price of an item with precision (in cents). + * + * @since 3.2.0 + * @param object $item_key Item to get the price of. + * @return int + */ + protected function get_discounted_price_in_cents( $item_key ) { + $item = $this->items[ $item_key ]; + $price = $item->subtotal - $this->discount_totals[ $item_key ]; + + if ( $item->price_includes_tax ) { + $price += $item->subtotal_tax; + } + return $price; + } + + /** + * Get tax rates for an item. Caches rates in class to avoid multiple look ups. + * + * @param object $item Item to get tax rates for. + * @return array of taxes + */ + protected function get_item_tax_rates( $item ) { + $tax_class = $item->product->get_tax_class(); + return isset( $this->item_tax_rates[ $tax_class ] ) ? $this->item_tax_rates[ $tax_class ] : $this->item_tax_rates[ $tax_class ] = WC_Tax::get_rates( $item->product->get_tax_class(), $this->object->get_customer() ); + } + + /** + * Get a single total with or without precision (in cents). + * + * @since 3.2.0 + * @param string $key Total to get. + * @param bool $in_cents Should the totals be returned in cents, or without precision. + * @return int|float + */ + public function get_total( $key = 'total', $in_cents = false ) { + $totals = $this->get_totals( $in_cents ); + return isset( $totals[ $key ] ) ? $totals[ $key ] : 0; + } + + /** + * Set a single total. + * + * @since 3.2.0 + * @param string $key Total name you want to set. + * @param int $total Total to set. + */ + protected function set_total( $key = 'total', $total ) { + $this->totals[ $key ] = $total; + } + + /** + * Get all totals with or without precision (in cents). + * + * @since 3.2.0 + * @param bool $in_cents Should the totals be returned in cents, or without precision. + * @return array. + */ + public function get_totals( $in_cents = false ) { + return $in_cents ? $this->totals : wc_remove_number_precision_deep( $this->totals ); + } + + /** + * Get all tax rows from items (including shipping and product line items). + * + * @since 3.2.0 + * @return array + */ + protected function get_merged_taxes() { + $taxes = array(); + + foreach ( array_merge( $this->items, $this->fees, $this->shipping ) as $item ) { + foreach ( $item->taxes as $rate_id => $rate ) { + $taxes[ $rate_id ] = array( 'tax_total' => 0, 'shipping_tax_total' => 0 ); + } + } + + foreach ( $this->items + $this->fees as $item ) { + foreach ( $item->taxes as $rate_id => $rate ) { + $taxes[ $rate_id ]['tax_total'] = $taxes[ $rate_id ]['tax_total'] + $rate; + } + } + + foreach ( $this->shipping as $item ) { + foreach ( $item->taxes as $rate_id => $rate ) { + $taxes[ $rate_id ]['shipping_tax_total'] = $taxes[ $rate_id ]['shipping_tax_total'] + $rate; + } + } + return $taxes; + } + + /* + |-------------------------------------------------------------------------- + | Calculation methods. + |-------------------------------------------------------------------------- + */ + + /** + * Calculate item totals. + * + * @since 3.2.0 + */ + protected function calculate_item_totals() { + $this->set_items(); + $this->calculate_item_subtotals(); + $this->calculate_discounts(); + + foreach ( $this->items as $item_key => $item ) { + $item->total = $this->get_discounted_price_in_cents( $item_key ); + $item->total_tax = 0; + + if ( has_filter( 'woocommerce_get_discounted_price' ) ) { + /** + * Allow plugins to filter this price like in the legacy cart class. + * + * This is legacy and should probably be deprecated in the future. + * $item->object is the cart item object. + * $this->object is the cart object. + */ + $item->total = wc_add_number_precision( + apply_filters( 'woocommerce_get_discounted_price', wc_remove_number_precision( $item->total ), $item->object, $this->object ) + ); + } + + if ( $this->calculate_tax && $item->product->is_taxable() ) { + $item->taxes = WC_Tax::calc_tax( $item->total, $item->tax_rates, $item->price_includes_tax ); + $item->total_tax = array_sum( $item->taxes ); + + if ( ! $this->round_at_subtotal() ) { + $item->total_tax = wc_round_tax_total( $item->total_tax, wc_get_rounding_precision() ); + } + + if ( $item->price_includes_tax ) { + $item->total = $item->total - $item->total_tax; + } else { + $item->total = $item->total; + } + } + + $this->object->cart_contents[ $item_key ]['line_total'] = wc_remove_number_precision( $item->total ); + $this->object->cart_contents[ $item_key ]['line_tax'] = wc_remove_number_precision( $item->total_tax ); + } + + $this->set_total( 'items_total', array_sum( array_values( wp_list_pluck( $this->items, 'total' ) ) ) ); + $this->set_total( 'items_total_tax', array_sum( array_values( wp_list_pluck( $this->items, 'total_tax' ) ) ) ); + } + + /** + * Subtotals are costs before discounts. + * + * To prevent rounding issues we need to work with the inclusive price where possible. + * otherwise we'll see errors such as when working with a 9.99 inc price, 20% VAT which would. + * be 8.325 leading to totals being 1p off. + * + * Pre tax coupons come off the price the customer thinks they are paying - tax is calculated. + * afterwards. + * + * e.g. $100 bike with $10 coupon = customer pays $90 and tax worked backwards from that. + * + * @since 3.2.0 + */ + protected function calculate_item_subtotals() { + foreach ( $this->items as $item_key => $item ) { + if ( $item->price_includes_tax && apply_filters( 'woocommerce_adjust_non_base_location_prices', true ) ) { + $item = $this->adjust_non_base_location_price( $item ); + } + + if ( $this->calculate_tax && $item->product->is_taxable() ) { + $subtotal_taxes = WC_Tax::calc_tax( $item->subtotal, $item->tax_rates, $item->price_includes_tax ); + $item->subtotal_tax = array_sum( $subtotal_taxes ); + + if ( ! $this->round_at_subtotal() ) { + $item->subtotal_tax = wc_round_tax_total( $item->subtotal_tax, wc_get_rounding_precision() ); + } + + if ( $item->price_includes_tax ) { + $item->subtotal = $item->subtotal - $item->subtotal_tax; + } + } + + $this->object->cart_contents[ $item_key ]['line_subtotal'] = wc_remove_number_precision( $item->subtotal ); + $this->object->cart_contents[ $item_key ]['line_subtotal_tax'] = wc_remove_number_precision( $item->subtotal_tax ); + } + $this->set_total( 'items_subtotal', array_sum( array_values( wp_list_pluck( $this->items, 'subtotal' ) ) ) ); + $this->set_total( 'items_subtotal_tax', array_sum( array_values( wp_list_pluck( $this->items, 'subtotal_tax' ) ) ) ); + + $this->object->subtotal = $this->get_total( 'items_subtotal' ) + $this->get_total( 'items_subtotal_tax' ); + $this->object->subtotal_ex_tax = $this->get_total( 'items_subtotal' ); + } + + /** + * Calculate all discount and coupon amounts. + * + * @since 3.2.0 + * @uses WC_Discounts class. + */ + protected function calculate_discounts() { + $this->set_coupons(); + + $discounts = new WC_Discounts( $this->object ); + + foreach ( $this->coupons as $coupon ) { + $discounts->apply_discount( $coupon ); + } + + $coupon_discount_amounts = $discounts->get_discounts_by_coupon( true ); + $coupon_discount_tax_amounts = array(); + + // See how much tax was 'discounted' per item and per coupon. + if ( $this->calculate_tax ) { + foreach ( $discounts->get_discounts( true ) as $coupon_code => $coupon_discounts ) { + $coupon_discount_tax_amounts[ $coupon_code ] = 0; + + foreach ( $coupon_discounts as $item_key => $item_discount ) { + $item = $this->items[ $item_key ]; + + if ( $item->product->is_taxable() ) { + $item_tax = array_sum( WC_Tax::calc_tax( $item_discount, $item->tax_rates, $item->price_includes_tax ) ); + $coupon_discount_tax_amounts[ $coupon_code ] += $item_tax; + } + } + + $coupon_discount_amounts[ $coupon_code ] -= $coupon_discount_tax_amounts[ $coupon_code ]; + } + } + + $this->discount_totals = $discounts->get_discounts_by_item( true ); + $this->object->coupon_discount_amounts = wc_remove_number_precision_deep( $coupon_discount_amounts ); + $this->object->coupon_discount_tax_amounts = wc_remove_number_precision_deep( $coupon_discount_tax_amounts ); + + $this->set_total( 'discounts_total', ! empty( $this->discount_totals ) ? array_sum( $this->discount_totals ) : 0 ); + $this->set_total( 'discounts_tax_total', array_sum( $coupon_discount_tax_amounts ) ); + } + + /** + * Return discounted tax amount for an item. + * + * @param object $item + * @param int $discount_amount + * @return int + */ + protected function get_item_discount_tax( $item, $discount_amount ) { + if ( $item->product->is_taxable() ) { + $taxes = WC_Tax::calc_tax( $discount_amount, $item->tax_rates, false ); + return array_sum( $taxes ); + } + return 0; + } + + /** + * Triggers the cart fees API, grabs the list of fees, and calculates taxes. + * + * Note: This class sets the totals for the 'object' as they are calculated. This is so that APIs like the fees API can see these totals if needed. + * + * @since 3.2.0 + */ + protected function calculate_fee_totals() { + $this->set_fees(); + $this->set_total( 'fees_total', array_sum( wp_list_pluck( $this->fees, 'total' ) ) ); + $this->set_total( 'fees_total_tax', array_sum( wp_list_pluck( $this->fees, 'total_tax' ) ) ); + + foreach ( $this->fees as $fee_key => $fee ) { + $this->object->fees[ $fee_key ]->tax = wc_remove_number_precision_deep( $fee->total_tax ); + $this->object->fees[ $fee_key ]->tax_data = wc_remove_number_precision_deep( $fee->taxes ); + } + $this->object->fee_total = wc_remove_number_precision_deep( array_sum( wp_list_pluck( $this->fees, 'total' ) ) ); + } + + /** + * Calculate any shipping taxes. + * + * @since 3.2.0 + */ + protected function calculate_shipping_totals() { + $this->set_shipping(); + $this->set_total( 'shipping_total', array_sum( wp_list_pluck( $this->shipping, 'total' ) ) ); + $this->set_total( 'shipping_tax_total', array_sum( wp_list_pluck( $this->shipping, 'total_tax' ) ) ); + + $this->object->shipping_total = $this->get_total( 'shipping_total' ); + $this->object->shipping_tax_total = $this->get_total( 'shipping_tax_total' ); + } + + /** + * Main cart totals. + * + * @since 3.2.0 + */ + protected function calculate_totals() { + $this->set_total( 'taxes', $this->get_merged_taxes() ); + $this->set_total( 'tax_total', array_sum( wp_list_pluck( $this->get_total( 'taxes', true ), 'tax_total' ) ) ); + $this->set_total( 'total', round( $this->get_total( 'items_total', true ) + $this->get_total( 'fees_total', true ) + $this->get_total( 'shipping_total', true ) + $this->get_total( 'tax_total', true ) + $this->get_total( 'shipping_tax_total', true ) ) ); + + // Add totals to cart object. + $this->object->taxes = wp_list_pluck( $this->get_total( 'taxes' ), 'shipping_tax_total' ); + $this->object->shipping_taxes = wp_list_pluck( $this->get_total( 'taxes' ), 'tax_total' ); + $this->object->cart_contents_total = $this->get_total( 'items_total' ); + $this->object->tax_total = $this->get_total( 'tax_total' ); + $this->object->total = $this->get_total( 'total' ); + $this->object->discount_cart = $this->get_total( 'discounts_total' ) - $this->get_total( 'discounts_tax_total' ); + $this->object->discount_cart_tax = $this->get_total( 'discounts_tax_total' ); + + // Allow plugins to hook and alter totals before final total is calculated. + if ( has_action( 'woocommerce_calculate_totals' ) ) { + do_action( 'woocommerce_calculate_totals', $this->object ); + } + + // Allow plugins to filter the grand total, and sum the cart totals in case of modifications. + $totals_to_sum = wc_add_number_precision_deep( array( $this->object->cart_contents_total, $this->object->tax_total, $this->object->shipping_tax_total, $this->object->shipping_total, $this->object->fee_total ) ); + $this->object->total = max( 0, apply_filters( 'woocommerce_calculated_total', wc_remove_number_precision( round( array_sum( $totals_to_sum ) ) ), $this->object ) ); + } +} diff --git a/includes/class-wc-cart.php b/includes/class-wc-cart.php index dfcfcb22f27..43dcbcac291 100644 --- a/includes/class-wc-cart.php +++ b/includes/class-wc-cart.php @@ -1,81 +1,158 @@ 0, 'total' => 0, @@ -106,8 +183,8 @@ class WC_Cart { */ public function __construct() { add_action( 'wp_loaded', array( $this, 'init' ) ); // Get cart after WP and plugins are loaded. - add_action( 'wp', array( $this, 'maybe_set_cart_cookies' ), 99 ); // Set cookies - add_action( 'shutdown', array( $this, 'maybe_set_cart_cookies' ), 0 ); // Set cookies before shutdown and ob flushing + add_action( 'wp', array( $this, 'maybe_set_cart_cookies' ), 99 ); // Set cookies. + add_action( 'shutdown', array( $this, 'maybe_set_cart_cookies' ), 0 ); // Set cookies before shutdown and ob flushing. add_action( 'woocommerce_add_to_cart', array( $this, 'calculate_totals' ), 20, 0 ); add_action( 'woocommerce_applied_coupon', array( $this, 'calculate_totals' ), 20, 0 ); } @@ -115,7 +192,7 @@ class WC_Cart { /** * Auto-load in-accessible properties on demand. * - * @param mixed $key + * @param mixed $key Key to get. * @return mixed */ public function __get( $key ) { @@ -145,10 +222,12 @@ class WC_Cart { case 'tax' : wc_deprecated_argument( 'WC_Cart->tax', '2.3', 'Use WC_Tax:: directly' ); $this->tax = new WC_Tax(); - return $this->tax; + return $this->tax; case 'discount_total': wc_deprecated_argument( 'WC_Cart->discount_total', '2.3', 'After tax coupons are no longer supported. For more information see: https://woocommerce.wordpress.com/2014/12/upcoming-coupon-changes-in-woocommerce-2-3/' ); - return 0; + return 0; + case 'coupons' : + return $this->get_coupons(); } } @@ -180,12 +259,12 @@ class WC_Cart { * Set cart hash cookie and items in cart. * * @access private - * @param bool $set (default: true) + * @param bool $set Should cookies be set (true) or unset. */ private function set_cart_cookies( $set = true ) { if ( $set ) { wc_setcookie( 'woocommerce_items_in_cart', 1 ); - wc_setcookie( 'woocommerce_cart_hash', md5( json_encode( $this->get_cart_for_session() ) ) ); + wc_setcookie( 'woocommerce_cart_hash', md5( wp_json_encode( $this->get_cart_for_session() ) ) ); } elseif ( isset( $_COOKIE['woocommerce_items_in_cart'] ) ) { wc_setcookie( 'woocommerce_items_in_cart', 0, time() - HOUR_IN_SECONDS ); wc_setcookie( 'woocommerce_cart_hash', '', time() - HOUR_IN_SECONDS ); @@ -193,15 +272,10 @@ class WC_Cart { do_action( 'woocommerce_set_cart_cookies', $set ); } - /* - /* Cart Session Handling - */ - /** * Get the cart data from the PHP session and store it in class variables. */ public function get_cart_from_session() { - // Load cart session data from session foreach ( $this->cart_session_data as $key => $default ) { $this->$key = WC()->session->get( $key, $default ); } @@ -223,7 +297,7 @@ class WC_Cart { } if ( is_array( $cart ) ) { - // Prime meta cache to reduce future queries + // Prime meta cache to reduce future queries. update_meta_cache( 'post', wp_list_pluck( $cart, 'product_id' ) ); update_object_term_cache( wp_list_pluck( $cart, 'product_id' ), 'product' ); @@ -233,32 +307,28 @@ class WC_Cart { if ( ! empty( $product ) && $product->exists() && $values['quantity'] > 0 ) { if ( ! $product->is_purchasable() ) { - - // Flag to indicate the stored cart should be update - $update_cart_session = true; + $update_cart_session = true; // Flag to indicate the stored cart should be updated. /* translators: %s: product name */ wc_add_notice( sprintf( __( '%s has been removed from your cart because it can no longer be purchased. Please contact us if you need assistance.', 'woocommerce' ), $product->get_name() ), 'error' ); do_action( 'woocommerce_remove_cart_item_from_session', $key, $values ); } else { - // Put session data into array. Run through filter so other plugins can load their own session data + // Put session data into array. Run through filter so other plugins can load their own session data. $session_data = array_merge( $values, array( 'data' => $product ) ); $this->cart_contents[ $key ] = apply_filters( 'woocommerce_get_cart_item_from_session', $session_data, $values, $key ); - } } } } - // Trigger action do_action( 'woocommerce_cart_loaded_from_session', $this ); if ( $update_cart_session ) { WC()->session->cart = $this->get_cart_for_session(); } - // Queue re-calc if subtotal is not set + // Queue re-calc if subtotal is not set. if ( ( ! $this->subtotal && ! $this->is_empty() ) || $update_cart_session ) { $this->calculate_totals(); } @@ -268,7 +338,6 @@ class WC_Cart { * Sets the php session data for the cart and coupons. */ public function set_session() { - // Set cart and coupon session data $cart_session = $this->get_cart_for_session(); WC()->session->set( 'cart', $cart_session ); @@ -291,10 +360,11 @@ class WC_Cart { /** * Empties the cart and optionally the persistent cart too. * - * @param bool $clear_persistent_cart (default: true) + * @param bool $clear_persistent_cart Should the persistant cart be cleared too. Defaults to true. */ public function empty_cart( $clear_persistent_cart = true ) { $this->cart_contents = array(); + $this->shipping_methods = null; $this->reset( true ); unset( WC()->session->order_awaiting_payment, WC()->session->applied_coupons, WC()->session->coupon_discount_amounts, WC()->session->coupon_discount_tax_amounts, WC()->session->cart ); @@ -306,10 +376,6 @@ class WC_Cart { do_action( 'woocommerce_cart_emptied' ); } - /* - * Persistent cart handling - */ - /** * Save the persistent cart when the cart is updated. */ @@ -326,23 +392,9 @@ class WC_Cart { delete_user_meta( get_current_user_id(), '_woocommerce_persistent_cart_' . get_current_blog_id() ); } - /* - * Cart Data Functions. - */ - - /** - * Coupons enabled function. Filterable. - * - * @deprecated 2.5.0 in favor to wc_coupons_enabled() - * - * @return bool - */ - public function coupons_enabled() { - return wc_coupons_enabled(); - } - /** * Get number of items in the cart. + * * @return int */ public function get_cart_contents_count() { @@ -351,6 +403,7 @@ class WC_Cart { /** * Get weight of items in the cart. + * * @since 2.5.0 * @return int */ @@ -370,18 +423,14 @@ class WC_Cart { * @return bool */ public function is_empty() { - return 0 === sizeof( $this->get_cart() ); + return 0 === count( $this->get_cart() ); } /** * Check all cart items for errors. */ public function check_cart_items() { - - // Result $return = true; - - // Check cart item validity $result = $this->check_cart_item_validity(); if ( is_wp_error( $result ) ) { @@ -389,7 +438,6 @@ class WC_Cart { $return = false; } - // Check item stock $result = $this->check_cart_item_stock(); if ( is_wp_error( $result ) ) { @@ -409,13 +457,10 @@ class WC_Cart { $coupon = new WC_Coupon( $code ); if ( ! $coupon->is_valid() ) { - // Error message $coupon->add_coupon_message( WC_Coupon::E_WC_COUPON_INVALID_REMOVED ); - - // Remove the coupon $this->remove_coupon( $code ); - // Flag totals for refresh + // Flag totals for refresh. WC()->session->set( 'refresh_totals', true ); } } @@ -468,7 +513,6 @@ class WC_Cart { $error = new WP_Error(); $product_qty_in_cart = $this->get_cart_item_quantities(); - // First stock check loop foreach ( $this->get_cart() as $cart_item_key => $values ) { $product = $values['data']; @@ -531,8 +575,8 @@ class WC_Cart { /** * Gets and formats a list of cart item data + variations for display on the frontend. * - * @param array $cart_item - * @param bool $flat (default: false) + * @param array $cart_item Cart item object. + * @param bool $flat Should the data be returned flat or in a list. * @return string */ public function get_item_data( $cart_item, $flat = false ) { @@ -544,16 +588,15 @@ class WC_Cart { foreach ( $cart_item['variation'] as $name => $value ) { $taxonomy = wc_attribute_taxonomy_name( str_replace( 'attribute_pa_', '', urldecode( $name ) ) ); - // If this is a term slug, get the term's nice name if ( taxonomy_exists( $taxonomy ) ) { + // If this is a term slug, get the term's nice name. $term = get_term_by( 'slug', $value, $taxonomy ); if ( ! is_wp_error( $term ) && $term && $term->name ) { $value = $term->name; } $label = wc_attribute_label( $taxonomy ); - - // If this is a custom option slug, get the options name. } else { + // If this is a custom option slug, get the options name. $value = apply_filters( 'woocommerce_variation_option_name', $value ); $label = wc_attribute_label( str_replace( 'attribute_', '', $name ), $cart_item['data'] ); } @@ -570,10 +613,10 @@ class WC_Cart { } } - // Filter item data to allow 3rd parties to add more to the array + // Filter item data to allow 3rd parties to add more to the array. $item_data = apply_filters( 'woocommerce_get_item_data', $item_data, $cart_item ); - // Format item data ready to display + // Format item data ready to display. foreach ( $item_data as $key => $data ) { // Set hidden to true to not display meta on cart. if ( ! empty( $data['hidden'] ) ) { @@ -584,8 +627,8 @@ class WC_Cart { $item_data[ $key ]['display'] = ! empty( $data['display'] ) ? $data['display'] : $data['value']; } - // Output flat or in list format - if ( sizeof( $item_data ) > 0 ) { + // Output flat or in list format. + if ( count( $item_data ) > 0 ) { ob_start(); if ( $flat ) { @@ -622,32 +665,10 @@ class WC_Cart { return apply_filters( 'woocommerce_cart_crosssell_ids', wp_parse_id_list( $cross_sells ), $this ); } - /** - * Gets the url to the cart page. - * - * @deprecated 2.5.0 in favor to wc_get_cart_url() - * - * @return string url to page - */ - public function get_cart_url() { - return wc_get_cart_url(); - } - - /** - * Gets the url to the checkout page. - * - * @deprecated 2.5.0 in favor to wc_get_checkout_url() - * - * @return string url to page - */ - public function get_checkout_url() { - return wc_get_checkout_url(); - } - /** * Gets the url to remove an item from the cart. * - * @param string $cart_item_key contains the id of the cart item + * @param string $cart_item_key contains the id of the cart item. * @return string url to page */ public function get_remove_url( $cart_item_key ) { @@ -658,7 +679,7 @@ class WC_Cart { /** * Gets the url to re-add an item into the cart. * - * @param string $cart_item_key + * @param string $cart_item_key Cart item key to undo. * @return string url to page */ public function get_undo_url( $cart_item_key ) { @@ -697,7 +718,7 @@ class WC_Cart { if ( $this->get_cart() ) { foreach ( $this->get_cart() as $key => $values ) { $cart_session[ $key ] = $values; - unset( $cart_session[ $key ]['data'] ); // Unset product object + unset( $cart_session[ $key ]['data'] ); // Unset product object. } } @@ -726,7 +747,6 @@ class WC_Cart { public function get_taxes() { $taxes = array(); - // Merge foreach ( array_keys( $this->taxes + $this->shipping_taxes ) as $key ) { $taxes[ $key ] = ( isset( $this->shipping_taxes[ $key ] ) ? $this->shipping_taxes[ $key ] : 0 ) + ( isset( $this->taxes[ $key ] ) ? $this->taxes[ $key ] : 0 ); } @@ -769,6 +789,7 @@ class WC_Cart { /** * Get all tax classes for items in the cart. + * * @return array */ public function get_cart_item_tax_classes() { @@ -805,16 +826,12 @@ class WC_Cart { } } - /* - * Add to cart handling. - */ - /** * Check if product is in the cart and return cart item key. * * Cart item key will be unique based on the item and its properties, such as variations. * - * @param mixed id of product to find in the cart + * @param mixed $cart_id id of product to find in the cart. * @return string cart item key */ public function find_product_in_cart( $cart_id = false ) { @@ -829,16 +846,16 @@ class WC_Cart { /** * Generate a unique ID for the cart item being added. * - * @param int $product_id - id of the product the key is being generated for - * @param int $variation_id of the product the key is being generated for - * @param array $variation data for the cart item - * @param array $cart_item_data other cart item data passed which affects this items uniqueness in the cart + * @param int $product_id - id of the product the key is being generated for. + * @param int $variation_id of the product the key is being generated for. + * @param array $variation data for the cart item. + * @param array $cart_item_data other cart item data passed which affects this items uniqueness in the cart. * @return string cart item key */ public function generate_cart_id( $product_id, $variation_id = 0, $variation = array(), $cart_item_data = array() ) { $id_parts = array( $product_id ); - if ( $variation_id && 0 != $variation_id ) { + if ( $variation_id && 0 !== $variation_id ) { $id_parts[] = $variation_id; } @@ -868,46 +885,43 @@ class WC_Cart { /** * Add a product to the cart. * - * @param int $product_id contains the id of the product to add to the cart - * @param int $quantity contains the quantity of the item to add - * @param int $variation_id - * @param array $variation attribute values - * @param array $cart_item_data extra cart item data we want to pass into the item + * @throws Exception To prevent adding to cart. + * @param int $product_id contains the id of the product to add to the cart. + * @param int $quantity contains the quantity of the item to add. + * @param int $variation_id ID of the variation being added to the cart. + * @param array $variation attribute values. + * @param array $cart_item_data extra cart item data we want to pass into the item. * @return string|bool $cart_item_key */ public function add_to_cart( $product_id = 0, $quantity = 1, $variation_id = 0, $variation = array(), $cart_item_data = array() ) { - // Wrap in try catch so plugins can throw an exception to prevent adding to cart + // Wrap in try catch so plugins can throw an exception to prevent adding to cart. try { $product_id = absint( $product_id ); $variation_id = absint( $variation_id ); - // Ensure we don't add a variation to the cart directly by variation ID + // Ensure we don't add a variation to the cart directly by variation ID. if ( 'product_variation' === get_post_type( $product_id ) ) { $variation_id = $product_id; $product_id = wp_get_post_parent_id( $variation_id ); } - // Get the product $product_data = wc_get_product( $variation_id ? $variation_id : $product_id ); - - // Filter quantity being added to the cart before stock checks $quantity = apply_filters( 'woocommerce_add_to_cart_quantity', $quantity, $product_id ); - // Sanity check if ( $quantity <= 0 || ! $product_data || 'trash' === $product_data->get_status() ) { return false; } - // Load cart item data - may be added by other plugins + // Load cart item data - may be added by other plugins. $cart_item_data = (array) apply_filters( 'woocommerce_add_cart_item_data', $cart_item_data, $product_id, $variation_id ); - // Generate a ID based on product ID, variation ID, variation data, and other cart item data + // Generate a ID based on product ID, variation ID, variation data, and other cart item data. $cart_id = $this->generate_cart_id( $product_id, $variation_id, $variation, $cart_item_data ); - // Find the cart item key in the existing cart + // Find the cart item key in the existing cart. $cart_item_key = $this->find_product_in_cart( $cart_id ); - // Force quantity to 1 if sold individually and check for existing item in cart + // Force quantity to 1 if sold individually and check for existing item in cart. if ( $product_data->is_sold_individually() ) { $quantity = apply_filters( 'woocommerce_add_to_cart_sold_individually_quantity', 1, $quantity, $product_id, $variation_id, $cart_item_data ); $found_in_cart = apply_filters( 'woocommerce_add_to_cart_sold_individually_found_in_cart', $cart_item_key && $this->cart_contents[ $cart_item_key ]['quantity'] > 0, $product_id, $variation_id, $cart_item_data, $cart_id ); @@ -918,12 +932,11 @@ class WC_Cart { } } - // Check product is_purchasable if ( ! $product_data->is_purchasable() ) { throw new Exception( __( 'Sorry, this product cannot be purchased.', 'woocommerce' ) ); } - // Stock check - only check if we're managing stock and backorders are not allowed + // Stock check - only check if we're managing stock and backorders are not allowed. if ( ! $product_data->is_in_stock() ) { throw new Exception( sprintf( __( 'You cannot add "%s" to the cart because the product is out of stock.', 'woocommerce' ), $product_data->get_name() ) ); } @@ -933,7 +946,7 @@ class WC_Cart { throw new Exception( sprintf( __( 'You cannot add that amount of "%1$s" to the cart because there is not enough stock (%2$s remaining).', 'woocommerce' ), $product_data->get_name(), wc_format_stock_quantity_for_display( $product_data->get_stock_quantity(), $product_data ) ) ); } - // Stock check - this time accounting for whats already in-cart + // Stock check - this time accounting for whats already in-cart. if ( $product_data->managing_stock() ) { $products_qty_in_cart = $this->get_cart_item_quantities(); @@ -947,20 +960,21 @@ class WC_Cart { } } - // If cart_item_key is set, the item is already in the cart + // If cart_item_key is set, the item is already in the cart. if ( $cart_item_key ) { $new_quantity = $quantity + $this->cart_contents[ $cart_item_key ]['quantity']; $this->set_quantity( $cart_item_key, $new_quantity, false ); } else { $cart_item_key = $cart_id; - // Add item after merging with $cart_item_data - hook to allow plugins to modify cart item + // Add item after merging with $cart_item_data - hook to allow plugins to modify cart item. $this->cart_contents[ $cart_item_key ] = apply_filters( 'woocommerce_add_cart_item', array_merge( $cart_item_data, array( - 'product_id' => $product_id, - 'variation_id' => $variation_id, - 'variation' => $variation, - 'quantity' => $quantity, - 'data' => $product_data, + 'key' => $cart_item_key, + 'product_id' => $product_id, + 'variation_id' => $variation_id, + 'variation' => $variation, + 'quantity' => $quantity, + 'data' => $product_data, ) ), $cart_item_key ); } @@ -984,7 +998,7 @@ class WC_Cart { * Remove a cart item. * * @since 2.3.0 - * @param string $cart_item_key + * @param string $cart_item_key Cart item key to remove from the cart. * @return bool */ public function remove_cart_item( $cart_item_key ) { @@ -1009,7 +1023,7 @@ class WC_Cart { /** * Restore a cart item. * - * @param string $cart_item_key + * @param string $cart_item_key Cart item key to restore to the cart. * @return bool */ public function restore_cart_item( $cart_item_key ) { @@ -1034,14 +1048,13 @@ class WC_Cart { /** * Set the quantity for an item in the cart. * - * @param string $cart_item_key contains the id of the cart item - * @param int $quantity contains the quantity of the item - * @param bool $refresh_totals whether or not to calculate totals after setting the new qty - * + * @param string $cart_item_key contains the id of the cart item. + * @param int $quantity contains the quantity of the item. + * @param bool $refresh_totals whether or not to calculate totals after setting the new qty. * @return bool */ public function set_quantity( $cart_item_key, $quantity = 1, $refresh_totals = true ) { - if ( 0 == $quantity || $quantity < 0 ) { + if ( 0 === $quantity || $quantity < 0 ) { do_action( 'woocommerce_before_cart_item_quantity_zero', $cart_item_key ); unset( $this->cart_contents[ $cart_item_key ] ); } else { @@ -1057,14 +1070,10 @@ class WC_Cart { return true; } - /* - * Cart Calculation Functions. - */ - /** * Reset cart totals to the defaults. Useful before running calculations. * - * @param bool $unset_session If true, the session data will be forced unset. + * @param bool $unset_session If true, the session data will be forced unset. * @access private */ private function reset( $unset_session = false ) { @@ -1078,26 +1087,22 @@ class WC_Cart { } /** - * Sort by subtotal. - * @param array $a - * @param array $b - * @return int + * Get cart's owner. + * + * @since 3.2.0 + * @return WC_Customer */ - private function sort_by_subtotal( $a, $b ) { - $first_item_subtotal = isset( $a['line_subtotal'] ) ? $a['line_subtotal'] : 0; - $second_item_subtotal = isset( $b['line_subtotal'] ) ? $b['line_subtotal'] : 0; - if ( $first_item_subtotal === $second_item_subtotal ) { - return 0; - } - return ( $first_item_subtotal < $second_item_subtotal ) ? 1 : -1; + public function get_customer() { + return WC()->customer; } /** * Calculate totals for the items in the cart. + * + * @uses WC_Cart_Totals */ public function calculate_totals() { $this->reset(); - $this->coupons = $this->get_coupons(); do_action( 'woocommerce_before_calculate_totals', $this ); @@ -1106,310 +1111,7 @@ class WC_Cart { return; } - $tax_rates = array(); - $shop_tax_rates = array(); - $cart = $this->get_cart(); - - /** - * Calculate subtotals for items. This is done first so that discount logic can use the values. - */ - foreach ( $cart as $cart_item_key => $values ) { - $product = $values['data']; - $line_price = $product->get_price() * $values['quantity']; - $line_subtotal = 0; - $line_subtotal_tax = 0; - - /** - * No tax to calculate. - */ - if ( ! $product->is_taxable() ) { - - // Subtotal is the undiscounted price - $this->subtotal += $line_price; - $this->subtotal_ex_tax += $line_price; - - /** - * Prices include tax. - * - * To prevent rounding issues we need to work with the inclusive price where possible. - * otherwise we'll see errors such as when working with a 9.99 inc price, 20% VAT which would. - * be 8.325 leading to totals being 1p off. - * - * Pre tax coupons come off the price the customer thinks they are paying - tax is calculated. - * afterwards. - * - * e.g. $100 bike with $10 coupon = customer pays $90 and tax worked backwards from that. - */ - } elseif ( $this->prices_include_tax ) { - - // Get base tax rates - if ( empty( $shop_tax_rates[ $product->get_tax_class( 'unfiltered' ) ] ) ) { - $shop_tax_rates[ $product->get_tax_class( 'unfiltered' ) ] = WC_Tax::get_base_tax_rates( $product->get_tax_class( 'unfiltered' ) ); - } - - // Get item tax rates - if ( empty( $tax_rates[ $product->get_tax_class() ] ) ) { - $tax_rates[ $product->get_tax_class() ] = WC_Tax::get_rates( $product->get_tax_class() ); - } - - $base_tax_rates = $shop_tax_rates[ $product->get_tax_class( 'unfiltered' ) ]; - $item_tax_rates = $tax_rates[ $product->get_tax_class() ]; - - /** - * ADJUST TAX - Calculations when base tax is not equal to the item tax. - * - * The woocommerce_adjust_non_base_location_prices filter can stop base taxes being taken off when dealing with out of base locations. - * e.g. If a product costs 10 including tax, all users will pay 10 regardless of location and taxes. - * This feature is experimental @since 2.4.7 and may change in the future. Use at your risk. - */ - if ( $item_tax_rates !== $base_tax_rates && apply_filters( 'woocommerce_adjust_non_base_location_prices', true ) ) { - - // Work out a new base price without the shop's base tax - $taxes = WC_Tax::calc_tax( $line_price, $base_tax_rates, true, true ); - - // Now we have a new item price (excluding TAX) - $line_subtotal = $line_price - array_sum( $taxes ); - - // Now add modified taxes - $tax_result = WC_Tax::calc_tax( $line_subtotal, $item_tax_rates ); - $line_subtotal_tax = array_sum( $tax_result ); - - /** - * Regular tax calculation (customer inside base and the tax class is unmodified. - */ - } else { - - // Calc tax normally - $taxes = WC_Tax::calc_tax( $line_price, $item_tax_rates, true ); - $line_subtotal_tax = array_sum( $taxes ); - $line_subtotal = $line_price - array_sum( $taxes ); - } - - /** - * Prices exclude tax. - * - * This calculation is simpler - work with the base, untaxed price. - */ - } else { - - // Get item tax rates - if ( empty( $tax_rates[ $product->get_tax_class() ] ) ) { - $tax_rates[ $product->get_tax_class() ] = WC_Tax::get_rates( $product->get_tax_class() ); - } - - $item_tax_rates = $tax_rates[ $product->get_tax_class() ]; - - // Base tax for line before discount - we will store this in the order data - $taxes = WC_Tax::calc_tax( $line_price, $item_tax_rates ); - $line_subtotal_tax = array_sum( $taxes ); - - $line_subtotal = $line_price; - } - - // Add to main subtotal - $this->subtotal += $line_subtotal + $line_subtotal_tax; - $this->subtotal_ex_tax += $line_subtotal; - } - - // Order cart items by price so coupon logic is 'fair' for customers and not based on order added to cart. - uasort( $cart, apply_filters( 'woocommerce_sort_by_subtotal_callback', array( $this, 'sort_by_subtotal' ) ) ); - - /** - * Calculate totals for items. - */ - foreach ( $cart as $cart_item_key => $values ) { - - $product = $values['data']; - - // Prices - $base_price = $product->get_price(); - $line_price = $product->get_price() * $values['quantity']; - - // Tax data - $taxes = array(); - $discounted_taxes = array(); - - /** - * No tax to calculate. - */ - if ( ! $product->is_taxable() ) { - - // Discounted Price (price with any pre-tax discounts applied) - $discounted_price = $this->get_discounted_price( $values, $base_price, true ); - $line_subtotal_tax = 0; - $line_subtotal = $line_price; - $line_tax = 0; - $line_total = round( $discounted_price * $values['quantity'], wc_get_rounding_precision() ); - - /** - * Prices include tax. - */ - } elseif ( $this->prices_include_tax ) { - - $base_tax_rates = $shop_tax_rates[ $product->get_tax_class( 'unfiltered' ) ]; - $item_tax_rates = $tax_rates[ $product->get_tax_class() ]; - - /** - * ADJUST TAX - Calculations when base tax is not equal to the item tax. - * - * The woocommerce_adjust_non_base_location_prices filter can stop base taxes being taken off when dealing with out of base locations. - * e.g. If a product costs 10 including tax, all users will pay 10 regardless of location and taxes. - * This feature is experimental @since 2.4.7 and may change in the future. Use at your risk. - */ - if ( $item_tax_rates !== $base_tax_rates && apply_filters( 'woocommerce_adjust_non_base_location_prices', true ) ) { - - // Work out a new base price without the shop's base tax - $taxes = WC_Tax::calc_tax( $line_price, $base_tax_rates, true, true ); - - // Now we have a new item price (excluding TAX) - $line_subtotal = round( $line_price - array_sum( $taxes ), wc_get_rounding_precision() ); - $taxes = WC_Tax::calc_tax( $line_subtotal, $item_tax_rates ); - $line_subtotal_tax = array_sum( $taxes ); - - // Adjusted price (this is the price including the new tax rate) - $adjusted_price = ( $line_subtotal + $line_subtotal_tax ) / $values['quantity']; - - // Apply discounts and get the discounted price FOR A SINGLE ITEM - $discounted_price = $this->get_discounted_price( $values, $adjusted_price, true ); - - // Convert back to line price - $discounted_line_price = $discounted_price * $values['quantity']; - - // Now use rounded line price to get taxes. - $discounted_taxes = WC_Tax::calc_tax( $discounted_line_price, $item_tax_rates, true ); - $line_tax = array_sum( $discounted_taxes ); - $line_total = $discounted_line_price - $line_tax; - - /** - * Regular tax calculation (customer inside base and the tax class is unmodified. - */ - } else { - - // Work out a new base price without the item tax - $taxes = WC_Tax::calc_tax( $line_price, $item_tax_rates, true ); - - // Now we have a new item price (excluding TAX) - $line_subtotal = $line_price - array_sum( $taxes ); - $line_subtotal_tax = array_sum( $taxes ); - - // Calc prices and tax (discounted) - $discounted_price = $this->get_discounted_price( $values, $base_price, true ); - - // Convert back to line price - $discounted_line_price = $discounted_price * $values['quantity']; - - // Now use rounded line price to get taxes. - $discounted_taxes = WC_Tax::calc_tax( $discounted_line_price, $item_tax_rates, true ); - $line_tax = array_sum( $discounted_taxes ); - $line_total = $discounted_line_price - $line_tax; - } - - // Tax rows - merge the totals we just got - foreach ( array_keys( $this->taxes + $discounted_taxes ) as $key ) { - $this->taxes[ $key ] = ( isset( $discounted_taxes[ $key ] ) ? $discounted_taxes[ $key ] : 0 ) + ( isset( $this->taxes[ $key ] ) ? $this->taxes[ $key ] : 0 ); - } - - /** - * Prices exclude tax. - */ - } else { - - $item_tax_rates = $tax_rates[ $product->get_tax_class() ]; - - // Work out a new base price without the shop's base tax - $taxes = WC_Tax::calc_tax( $line_price, $item_tax_rates ); - - // Now we have the item price (excluding TAX) - $line_subtotal = $line_price; - $line_subtotal_tax = array_sum( $taxes ); - - // Now calc product rates - $discounted_price = $this->get_discounted_price( $values, $base_price, true ); - $discounted_taxes = WC_Tax::calc_tax( $discounted_price * $values['quantity'], $item_tax_rates ); - $discounted_tax_amount = array_sum( $discounted_taxes ); - $line_tax = $discounted_tax_amount; - $line_total = $discounted_price * $values['quantity']; - - // Tax rows - merge the totals we just got - foreach ( array_keys( $this->taxes + $discounted_taxes ) as $key ) { - $this->taxes[ $key ] = ( isset( $discounted_taxes[ $key ] ) ? $discounted_taxes[ $key ] : 0 ) + ( isset( $this->taxes[ $key ] ) ? $this->taxes[ $key ] : 0 ); - } - } - - // Cart contents total is based on discounted prices and is used for the final total calculation - $this->cart_contents_total += $line_total; - - /** - * Store costs + taxes for lines. For tax inclusive prices, we do some extra rounding logic so the stored - * values "add up" when viewing the order in admin. This does have the disadvantage of not being able to - * recalculate the tax total/subtotal accurately in the future, but it does ensure the data looks correct. - * - * Tax exclusive prices are not affected. - */ - if ( ! $product->is_taxable() || $this->prices_include_tax ) { - $this->cart_contents[ $cart_item_key ]['line_total'] = round( $line_total + $line_tax - wc_round_tax_total( $line_tax ), $this->dp ); - $this->cart_contents[ $cart_item_key ]['line_subtotal'] = round( $line_subtotal + $line_subtotal_tax - wc_round_tax_total( $line_subtotal_tax ), $this->dp ); - $this->cart_contents[ $cart_item_key ]['line_tax'] = wc_round_tax_total( $line_tax ); - $this->cart_contents[ $cart_item_key ]['line_subtotal_tax'] = wc_round_tax_total( $line_subtotal_tax ); - $this->cart_contents[ $cart_item_key ]['line_tax_data'] = array( 'total' => array_map( 'wc_round_tax_total', $discounted_taxes ), 'subtotal' => array_map( 'wc_round_tax_total', $taxes ) ); - } else { - $this->cart_contents[ $cart_item_key ]['line_total'] = $line_total; - $this->cart_contents[ $cart_item_key ]['line_subtotal'] = $line_subtotal; - $this->cart_contents[ $cart_item_key ]['line_tax'] = $line_tax; - $this->cart_contents[ $cart_item_key ]['line_subtotal_tax'] = $line_subtotal_tax; - $this->cart_contents[ $cart_item_key ]['line_tax_data'] = array( 'total' => $discounted_taxes, 'subtotal' => $taxes ); - } - } - - // Only calculate the grand total + shipping if on the cart/checkout - if ( is_checkout() || is_cart() || defined( 'WOOCOMMERCE_CHECKOUT' ) || defined( 'WOOCOMMERCE_CART' ) ) { - - // Calculate the Shipping. - $local_pickup_methods = apply_filters( 'woocommerce_local_pickup_methods', array( 'legacy_local_pickup', 'local_pickup' ) ); - $had_local_pickup = 0 < count( array_intersect( wc_get_chosen_shipping_method_ids(), $local_pickup_methods ) ); - $this->calculate_shipping(); - $has_local_pickup = 0 < count( array_intersect( wc_get_chosen_shipping_method_ids(), $local_pickup_methods ) ); - - // If methods changed and local pickup is selected, we need to do a recalculation of taxes. - if ( true === apply_filters( 'woocommerce_apply_base_tax_for_local_pickup', true ) && $had_local_pickup !== $has_local_pickup ) { - return $this->calculate_totals(); - } - - // Trigger the fees API where developers can add fees to the cart - $this->calculate_fees(); - - // Total up/round taxes and shipping taxes - if ( $this->round_at_subtotal ) { - $this->tax_total = WC_Tax::get_tax_total( $this->taxes ); - $this->shipping_tax_total = WC_Tax::get_tax_total( $this->shipping_taxes ); - $this->taxes = array_map( array( 'WC_Tax', 'round' ), $this->taxes ); - $this->shipping_taxes = array_map( array( 'WC_Tax', 'round' ), $this->shipping_taxes ); - } else { - $this->tax_total = array_sum( $this->taxes ); - $this->shipping_tax_total = array_sum( $this->shipping_taxes ); - } - - // VAT exemption done at this point - so all totals are correct before exemption - if ( WC()->customer->get_is_vat_exempt() ) { - $this->remove_taxes(); - } - - // Allow plugins to hook and alter totals before final total is calculated - do_action( 'woocommerce_calculate_totals', $this ); - - // Grand Total - Discounted product prices, discounted tax, shipping cost + tax - $this->total = max( 0, apply_filters( 'woocommerce_calculated_total', round( $this->cart_contents_total + $this->tax_total + $this->shipping_tax_total + $this->shipping_total + $this->fee_total, $this->dp ), $this ) ); - - } else { - - // Set tax total to sum of all tax rows - $this->tax_total = WC_Tax::get_tax_total( $this->taxes ); - - // VAT exemption done at this point - so all totals are correct before exemption - if ( WC()->customer->get_is_vat_exempt() ) { - $this->remove_taxes(); - } - } + new WC_Cart_Totals( $this ); do_action( 'woocommerce_after_calculate_totals', $this ); @@ -1453,22 +1155,39 @@ class WC_Cart { * Uses the shipping class to calculate shipping then gets the totals when its finished. */ public function calculate_shipping() { - if ( $this->needs_shipping() && $this->show_shipping() ) { - WC()->shipping->calculate_shipping( $this->get_shipping_packages() ); - } else { - WC()->shipping->reset_shipping(); - } + $this->shipping_methods = $this->needs_shipping() ? $this->get_chosen_shipping_methods( WC()->shipping->calculate_shipping( $this->get_shipping_packages() ) ) : array(); - // Get totals for the chosen shipping method - $this->shipping_total = WC()->shipping->shipping_total; // Shipping Total - $this->shipping_taxes = WC()->shipping->shipping_taxes; // Shipping Taxes + // Set legacy totals for backwards compatibility with versions prior to 3.2. + $this->shipping_total = WC()->shipping->shipping_total = array_sum( wp_list_pluck( $this->shipping_methods, 'cost' ) ); + $this->shipping_taxes = WC()->shipping->shipping_taxes = wp_list_pluck( $this->shipping_methods, 'taxes' ); + + return $this->shipping_methods; + } + + /** + * Given a set of packages with rates, get the chosen ones only. + * + * @since 3.2.0 + * @param array $calculated_shipping_packages Array of packages. + * @return array + */ + protected function get_chosen_shipping_methods( $calculated_shipping_packages = array() ) { + $chosen_methods = array(); + // Get chosen methods for each package to get our totals. + foreach ( $calculated_shipping_packages as $key => $package ) { + $chosen_method = wc_get_chosen_shipping_method_for_package( $key, $package ); + if ( $chosen_method ) { + $chosen_methods[ $key ] = $package['rates'][ $chosen_method ]; + } + } + return $chosen_methods; } /** * Filter items needing shipping callback. * * @since 3.0.0 - * @param array $item + * @param array $item Item to check for shipping. * @return bool */ protected function filter_items_needing_shipping( $item ) { @@ -1510,12 +1229,12 @@ class WC_Cart { 'ID' => get_current_user_id(), ), 'destination' => array( - 'country' => WC()->customer->get_shipping_country(), - 'state' => WC()->customer->get_shipping_state(), - 'postcode' => WC()->customer->get_shipping_postcode(), - 'city' => WC()->customer->get_shipping_city(), - 'address' => WC()->customer->get_shipping_address(), - 'address_2' => WC()->customer->get_shipping_address_2(), + 'country' => $this->get_customer()->get_shipping_country(), + 'state' => $this->get_customer()->get_shipping_state(), + 'postcode' => $this->get_customer()->get_shipping_postcode(), + 'city' => $this->get_customer()->get_shipping_city(), + 'address' => $this->get_customer()->get_shipping_address(), + 'address_2' => $this->get_customer()->get_shipping_address_2(), ), 'cart_subtotal' => $this->get_displayed_subtotal(), ), @@ -1552,14 +1271,7 @@ class WC_Cart { * @return bool */ public function needs_shipping_address() { - - $needs_shipping_address = false; - - if ( $this->needs_shipping() === true && ! wc_ship_to_billing_address_only() ) { - $needs_shipping_address = true; - } - - return apply_filters( 'woocommerce_cart_needs_shipping_address', $needs_shipping_address ); + return apply_filters( 'woocommerce_cart_needs_shipping_address', $this->needs_shipping() === true && ! wc_ship_to_billing_address_only() ); } /** @@ -1573,8 +1285,8 @@ class WC_Cart { } if ( 'yes' === get_option( 'woocommerce_shipping_cost_requires_address' ) ) { - if ( ! WC()->customer->has_calculated_shipping() ) { - if ( ! WC()->customer->get_shipping_country() || ( ! WC()->customer->get_shipping_state() && ! WC()->customer->get_shipping_postcode() ) ) { + if ( ! $this->get_customer()->has_calculated_shipping() ) { + if ( ! $this->get_customer()->get_shipping_country() || ( ! $this->get_customer()->get_shipping_state() && ! $this->get_customer()->get_shipping_postcode() ) ) { return false; } } @@ -1583,17 +1295,6 @@ class WC_Cart { return apply_filters( 'woocommerce_cart_ready_to_calc_shipping', true ); } - /** - * Sees if we need a shipping address. - * - * @deprecated 2.5.0 in favor to wc_ship_to_billing_address_only() - * - * @return bool - */ - public function ship_to_billing_address_only() { - return wc_ship_to_billing_address_only(); - } - /** * Gets the shipping total (after calculation). * @@ -1603,9 +1304,7 @@ class WC_Cart { if ( isset( $this->shipping_total ) ) { if ( $this->shipping_total > 0 ) { - // Display varies depending on settings if ( 'excl' === $this->tax_display_cart ) { - $return = wc_price( $this->shipping_total ); if ( $this->shipping_tax_total > 0 && $this->prices_include_tax ) { @@ -1613,9 +1312,7 @@ class WC_Cart { } return $return; - } else { - $return = wc_price( $this->shipping_total + $this->shipping_tax_total ); if ( $this->shipping_tax_total > 0 && ! $this->prices_include_tax ) { @@ -1623,20 +1320,14 @@ class WC_Cart { } return $return; - } } else { return __( 'Free!', 'woocommerce' ); } } - return ''; } - /* - * Coupons/Discount related functions. - */ - /** * Check for user coupons (now that we have billing email). If a coupon is invalid, add an error. * @@ -1644,7 +1335,7 @@ class WC_Cart { * 1. Where a list of customer emails are set (limits coupon usage to those defined). * 2. Where a usage_limit_per_user is set (limits coupon usage to a number based on user ID and email). * - * @param array $posted + * @param array $posted Post data. */ public function check_customer_coupons( $posted ) { if ( ! empty( $this->applied_coupons ) ) { @@ -1653,8 +1344,8 @@ class WC_Cart { if ( $coupon->is_valid() ) { - // Limit to defined email addresses - if ( is_array( $coupon->get_email_restrictions() ) && sizeof( $coupon->get_email_restrictions() ) > 0 ) { + // Limit to defined email addresses. + if ( is_array( $coupon->get_email_restrictions() ) && count( $coupon->get_email_restrictions() ) > 0 ) { $check_emails = array(); if ( is_user_logged_in() ) { $current_user = wp_get_current_user(); @@ -1663,18 +1354,16 @@ class WC_Cart { $check_emails[] = $posted['billing_email']; $check_emails = array_map( 'sanitize_email', array_map( 'strtolower', $check_emails ) ); - if ( 0 == sizeof( array_intersect( $check_emails, $coupon->get_email_restrictions() ) ) ) { + if ( 0 === count( array_intersect( $check_emails, $coupon->get_email_restrictions() ) ) ) { $coupon->add_coupon_message( WC_Coupon::E_WC_COUPON_NOT_YOURS_REMOVED ); - - // Remove the coupon $this->remove_coupon( $code ); - // Flag totals for refresh + // Flag totals for refresh. WC()->session->set( 'refresh_totals', true ); } } - // Usage limits per user - check against billing and user email and user ID + // Usage limits per user - check against billing and user email and user ID. if ( $coupon->get_usage_limit_per_user() > 0 ) { $check_emails = array(); $used_by = $coupon->get_used_by(); @@ -1682,28 +1371,26 @@ class WC_Cart { if ( is_user_logged_in() ) { $current_user = wp_get_current_user(); $check_emails[] = sanitize_email( $current_user->user_email ); - $usage_count = sizeof( array_keys( $used_by, get_current_user_id() ) ); + $usage_count = count( array_keys( $used_by, get_current_user_id(), true ) ); } else { $check_emails[] = sanitize_email( $posted['billing_email'] ); $user = get_user_by( 'email', $posted['billing_email'] ); if ( $user ) { - $usage_count = sizeof( array_keys( $used_by, $user->ID ) ); + $usage_count = count( array_keys( $used_by, $user->ID, true ) ); } else { $usage_count = 0; } } foreach ( $check_emails as $check_email ) { - $usage_count = $usage_count + sizeof( array_keys( $used_by, $check_email ) ); + $usage_count = $usage_count + count( array_keys( $used_by, $check_email, true ) ); } if ( $usage_count >= $coupon->get_usage_limit_per_user() ) { $coupon->add_coupon_message( WC_Coupon::E_WC_COUPON_USAGE_LIMIT_REACHED ); - - // Remove the coupon $this->remove_coupon( $code ); - // Flag totals for refresh + // Flag totals for refresh. WC()->session->set( 'refresh_totals', true ); } } @@ -1714,18 +1401,19 @@ class WC_Cart { /** * Returns whether or not a discount has been applied. - * @param string $coupon_code + * + * @param string $coupon_code Coupon code to check. * @return bool */ public function has_discount( $coupon_code = '' ) { - return $coupon_code ? in_array( wc_format_coupon_code( $coupon_code ), $this->applied_coupons ) : sizeof( $this->applied_coupons ) > 0; + return $coupon_code ? in_array( wc_format_coupon_code( $coupon_code ), $this->applied_coupons, true ) : count( $this->applied_coupons ) > 0; } /** * Applies a coupon code passed to the method. * - * @param string $coupon_code - The code to apply - * @return bool True if the coupon is applied, false if it does not exist or cannot be applied + * @param string $coupon_code - The code to apply. + * @return bool True if the coupon is applied, false if it does not exist or cannot be applied. */ public function add_discount( $coupon_code ) { // Coupons are globally disabled. @@ -1763,7 +1451,7 @@ class WC_Cart { $coupons_to_keep = apply_filters( 'woocommerce_apply_individual_use_coupon', array(), $the_coupon, $this->applied_coupons ); foreach ( $this->applied_coupons as $applied_coupon ) { - $keep_key = array_search( $applied_coupon, $coupons_to_keep ); + $keep_key = array_search( $applied_coupon, $coupons_to_keep, true ); if ( false === $keep_key ) { $this->remove_coupon( $applied_coupon ); } else { @@ -1815,8 +1503,7 @@ class WC_Cart { /** * Get array of applied coupon objects and codes. * - * @param null $deprecated - * + * @param null $deprecated No longer used. * @return array of applied coupons */ public function get_coupons( $deprecated = null ) { @@ -1845,8 +1532,9 @@ class WC_Cart { /** * Get the discount amount for a used coupon. - * @param string $code coupon code - * @param bool $ex_tax inc or ex tax + * + * @param string $code coupon code. + * @param bool $ex_tax inc or ex tax. * @return float discount amount */ public function get_coupon_discount_amount( $code, $ex_tax = true ) { @@ -1861,8 +1549,8 @@ class WC_Cart { /** * Get the discount tax amount for a used coupon (for tax inclusive prices). - * @param string $code coupon code - * @param bool inc or ex tax + * + * @param string $code coupon code. * @return float discount amount */ public function get_coupon_discount_tax_amount( $code ) { @@ -1872,7 +1560,7 @@ class WC_Cart { /** * Remove coupons from the cart of a defined type. Type 1 is before tax, type 2 is after tax. * - * @param null $deprecated + * @param null $deprecated No longer used. */ public function remove_coupons( $deprecated = null ) { $this->applied_coupons = $this->coupon_discount_amounts = $this->coupon_discount_tax_amounts = $this->coupon_applied_count = array(); @@ -1883,18 +1571,18 @@ class WC_Cart { /** * Remove a single coupon by code. - * @param string $coupon_code Code of the coupon to remove + * + * @param string $coupon_code Code of the coupon to remove. * @return bool */ public function remove_coupon( $coupon_code ) { - // Coupons are globally disabled + // Coupons are globally disabled. if ( ! wc_coupons_enabled() ) { return false; } - // Get the coupon $coupon_code = wc_format_coupon_code( $coupon_code ); - $position = array_search( $coupon_code, $this->applied_coupons ); + $position = array_search( $coupon_code, $this->applied_coupons, true ); if ( false !== $position ) { unset( $this->applied_coupons[ $position ] ); @@ -1907,90 +1595,6 @@ class WC_Cart { return true; } - /** - * Function to apply discounts to a product and get the discounted price (before tax is applied). - * - * @param mixed $values - * @param mixed $price - * @param bool $add_totals (default: false) - * @return float price - */ - public function get_discounted_price( $values, $price, $add_totals = false ) { - if ( ! $price ) { - return $price; - } - - $undiscounted_price = $price; - - if ( ! empty( $this->coupons ) ) { - $product = $values['data']; - - foreach ( $this->coupons as $code => $coupon ) { - if ( $coupon->is_valid() && ( $coupon->is_valid_for_product( $product, $values ) || $coupon->is_valid_for_cart() ) ) { - $discount_amount = $coupon->get_discount_amount( 'yes' === get_option( 'woocommerce_calc_discounts_sequentially', 'no' ) ? $price : $undiscounted_price, $values, true ); - $discount_amount = min( $price, $discount_amount ); - $price = max( $price - $discount_amount, 0 ); - - // Store the totals for DISPLAY in the cart. - if ( $add_totals ) { - $total_discount = $discount_amount * $values['quantity']; - $total_discount_tax = 0; - - if ( wc_tax_enabled() && $product->is_taxable() ) { - $tax_rates = WC_Tax::get_rates( $product->get_tax_class() ); - $taxes = WC_Tax::calc_tax( $discount_amount, $tax_rates, $this->prices_include_tax ); - $total_discount_tax = WC_Tax::get_tax_total( $taxes ) * $values['quantity']; - $total_discount = $this->prices_include_tax ? $total_discount - $total_discount_tax : $total_discount; - $this->discount_cart_tax += $total_discount_tax; - } - - $this->discount_cart += $total_discount; - $this->increase_coupon_discount_amount( $code, $total_discount, $total_discount_tax ); - $this->increase_coupon_applied_count( $code, $values['quantity'] ); - } - } - - // If the price is 0, we can stop going through coupons because there is nothing more to discount for this product. - if ( 0 >= $price ) { - break; - } - } - } - - return apply_filters( 'woocommerce_get_discounted_price', $price, $values, $this ); - } - - /** - * Store how much discount each coupon grants. - * - * @access private - * @param string $code - * @param double $amount - * @param double $tax - */ - private function increase_coupon_discount_amount( $code, $amount, $tax ) { - $this->coupon_discount_amounts[ $code ] = isset( $this->coupon_discount_amounts[ $code ] ) ? $this->coupon_discount_amounts[ $code ] + $amount : $amount; - $this->coupon_discount_tax_amounts[ $code ] = isset( $this->coupon_discount_tax_amounts[ $code ] ) ? $this->coupon_discount_tax_amounts[ $code ] + $tax : $tax; - } - - /** - * Store how many times each coupon is applied to cart/items. - * - * @access private - * @param string $code - * @param int $count - */ - private function increase_coupon_applied_count( $code, $count = 1 ) { - if ( empty( $this->coupon_applied_count[ $code ] ) ) { - $this->coupon_applied_count[ $code ] = 0; - } - $this->coupon_applied_count[ $code ] += $count; - } - - /* - * Fees API to add additional costs to orders. - */ - /** * Add additional fee to the cart. * @@ -2007,12 +1611,11 @@ class WC_Cart { * @param string $tax_class The tax class for the fee if taxable. A blank string is standard tax class. (default: ''). */ public function add_fee( $name, $amount, $taxable = false, $tax_class = '' ) { - $new_fee_id = sanitize_title( $name ); // Only add each fee once. foreach ( $this->fees as $fee ) { - if ( $fee->id == $new_fee_id ) { + if ( $fee->id === $new_fee_id ) { return; } } @@ -2041,31 +1644,27 @@ class WC_Cart { * Calculate fees. */ public function calculate_fees() { - // Reset fees before calculation + // Reset fees before calculation. $this->fee_total = 0; $this->fees = array(); - // Fire an action where developers can add their fees + // Fire an action where developers can add their fees. do_action( 'woocommerce_cart_calculate_fees', $this ); - // If fees were added, total them and calculate tax + // If fees were added, total them and calculate tax. if ( ! empty( $this->fees ) ) { foreach ( $this->fees as $fee_key => $fee ) { $this->fee_total += $fee->amount; if ( $fee->taxable ) { - // Get tax rates $tax_rates = WC_Tax::get_rates( $fee->tax_class ); $fee_taxes = WC_Tax::calc_tax( $fee->amount, $tax_rates, false ); if ( ! empty( $fee_taxes ) ) { - // Set the tax total for this fee $this->fees[ $fee_key ]->tax = array_sum( $fee_taxes ); - - // Set tax data - Since 2.2 $this->fees[ $fee_key ]->tax_data = $fee_taxes; - // Tax rows - merge the totals we just got + // Tax rows - merge the totals we just got. foreach ( array_keys( $this->taxes + $fee_taxes ) as $key ) { $this->taxes[ $key ] = ( isset( $fee_taxes[ $key ] ) ? $fee_taxes[ $key ] : 0 ) + ( isset( $this->taxes[ $key ] ) ? $this->taxes[ $key ] : 0 ); } @@ -2075,10 +1674,6 @@ class WC_Cart { } } - /* - * Get Formatted Totals. - */ - /** * Gets the order total (after calculation). * @@ -2104,7 +1699,6 @@ class WC_Cart { /** * Gets the cart contents total (after calculation). * - * @todo deprecate? It's unused. * @return string formatted price */ public function get_cart_total() { @@ -2120,35 +1714,28 @@ class WC_Cart { /** * Gets the sub total (after calculation). * - * @param bool $compound whether to include compound taxes + * @param bool $compound whether to include compound taxes. * @return string formatted price */ public function get_cart_subtotal( $compound = false ) { - // If the cart has compound tax, we want to show the subtotal as - // cart + shipping + non-compound taxes (after discount) + /** + * If the cart has compound tax, we want to show the subtotal as cart + shipping + non-compound taxes (after discount). + */ if ( $compound ) { - $cart_subtotal = wc_price( $this->cart_contents_total + $this->shipping_total + $this->get_taxes_total( false, false ) ); - // Otherwise we show cart items totals only (before discount) + } elseif ( 'excl' === $this->tax_display_cart ) { + $cart_subtotal = wc_price( $this->subtotal_ex_tax ); + + if ( $this->tax_total > 0 && $this->prices_include_tax ) { + $cart_subtotal .= ' ' . WC()->countries->ex_tax_or_vat() . ''; + } } else { + $cart_subtotal = wc_price( $this->subtotal ); - // Display varies depending on settings - if ( 'excl' === $this->tax_display_cart ) { - - $cart_subtotal = wc_price( $this->subtotal_ex_tax ); - - if ( $this->tax_total > 0 && $this->prices_include_tax ) { - $cart_subtotal .= ' ' . WC()->countries->ex_tax_or_vat() . ''; - } - } else { - - $cart_subtotal = wc_price( $this->subtotal ); - - if ( $this->tax_total > 0 && ! $this->prices_include_tax ) { - $cart_subtotal .= ' ' . WC()->countries->inc_tax_or_vat() . ''; - } + if ( $this->tax_total > 0 && ! $this->prices_include_tax ) { + $cart_subtotal .= ' ' . WC()->countries->inc_tax_or_vat() . ''; } } @@ -2158,7 +1745,7 @@ class WC_Cart { /** * Get the product row price per item. * - * @param WC_Product $product + * @param WC_Product $product Product object. * @return string formatted price */ public function get_product_price( $product ) { @@ -2177,16 +1764,14 @@ class WC_Cart { * * When on the checkout (review order), this will get the subtotal based on the customer's tax rate rather than the base rate. * - * @param WC_Product $product - * @param int $quantity + * @param WC_Product $product Product object. + * @param int $quantity Quantity being purchased. * @return string formatted price */ public function get_product_subtotal( $product, $quantity ) { - $price = $product->get_price(); - $taxable = $product->is_taxable(); + $price = $product->get_price(); - // Taxable - if ( $taxable ) { + if ( $product->is_taxable() ) { if ( 'excl' === $this->tax_display_cart ) { @@ -2205,13 +1790,9 @@ class WC_Cart { $product_subtotal .= ' ' . WC()->countries->inc_tax_or_vat() . ''; } } - - // Non-taxable } else { - $row_price = $price * $quantity; $product_subtotal = wc_price( $row_price ); - } return apply_filters( 'woocommerce_cart_product_subtotal', $product_subtotal, $product, $quantity, $this ); @@ -2230,7 +1811,8 @@ class WC_Cart { /** * Get a tax amount. - * @param string $tax_rate_id + * + * @param string $tax_rate_id ID of the tax rate to get taxes for. * @return float amount */ public function get_tax_amount( $tax_rate_id ) { @@ -2239,7 +1821,8 @@ class WC_Cart { /** * Get a tax amount. - * @param string $tax_rate_id + * + * @param string $tax_rate_id ID of the tax rate to get taxes for. * @return float amount */ public function get_shipping_tax_amount( $tax_rate_id ) { @@ -2249,8 +1832,8 @@ class WC_Cart { /** * Get tax row amounts with or without compound taxes includes. * - * @param bool $compound True if getting compound taxes - * @param bool $display True if getting total to display + * @param bool $compound True if getting compound taxes. + * @param bool $display True if getting total to display. * @return float price */ public function get_taxes_total( $compound = true, $display = true ) { @@ -2297,68 +1880,6 @@ class WC_Cart { * @return mixed formatted price or false if there are none */ public function get_total_discount() { - if ( $this->get_cart_discount_total() ) { - $total_discount = wc_price( $this->get_cart_discount_total() ); - } else { - $total_discount = false; - } - return apply_filters( 'woocommerce_cart_total_discount', $total_discount, $this ); - } - - /** - * Gets the total (product) discount amount - these are applied before tax. - * - * @deprecated Order discounts (after tax) removed in 2.3 so multiple methods for discounts are no longer required. - * @return mixed formatted price or false if there are none - */ - public function get_discounts_before_tax() { - wc_deprecated_function( 'get_discounts_before_tax', '2.3', 'get_total_discount' ); - if ( $this->get_cart_discount_total() ) { - $discounts_before_tax = wc_price( $this->get_cart_discount_total() ); - } else { - $discounts_before_tax = false; - } - return apply_filters( 'woocommerce_cart_discounts_before_tax', $discounts_before_tax, $this ); - } - - /** - * Get the total of all order discounts (after tax discounts). - * - * @deprecated Order discounts (after tax) removed in 2.3 - * @return int - */ - public function get_order_discount_total() { - wc_deprecated_function( 'get_order_discount_total', '2.3' ); - return 0; - } - - /** - * Function to apply cart discounts after tax. - * @deprecated Coupons can not be applied after tax - * - * @param $values - * @param $price - */ - public function apply_cart_discounts_after_tax( $values, $price ) { - wc_deprecated_function( 'apply_cart_discounts_after_tax', '2.3' ); - } - - /** - * Function to apply product discounts after tax. - * @deprecated Coupons can not be applied after tax - * - * @param $values - * @param $price - */ - public function apply_product_discounts_after_tax( $values, $price ) { - wc_deprecated_function( 'apply_product_discounts_after_tax', '2.3' ); - } - - /** - * Gets the order discount amount - these are applied after tax. - * @deprecated Coupons can not be applied after tax - */ - public function get_discounts_after_tax() { - wc_deprecated_function( 'get_discounts_after_tax', '2.3' ); + return apply_filters( 'woocommerce_cart_total_discount', $this->get_cart_discount_total() ? wc_price( $this->get_cart_discount_total() ) : false, $this ); } } diff --git a/includes/class-wc-coupon.php b/includes/class-wc-coupon.php index 16cbb28ec0a..0fda8c44e2e 100644 --- a/includes/class-wc-coupon.php +++ b/includes/class-wc-coupon.php @@ -390,29 +390,7 @@ class WC_Coupon extends WC_Legacy_Coupon { $discount = $single ? $discount : $discount * $cart_item_qty; } - $discount = (float) min( $discount, $discounting_amount ); - - // Handle the limit_usage_to_x_items option - if ( ! $this->is_type( array( 'fixed_cart' ) ) ) { - if ( $discounting_amount ) { - if ( null === $this->get_limit_usage_to_x_items() ) { - $limit_usage_qty = $cart_item_qty; - } else { - $limit_usage_qty = min( $this->get_limit_usage_to_x_items(), $cart_item_qty ); - - $this->set_limit_usage_to_x_items( max( 0, ( $this->get_limit_usage_to_x_items() - $limit_usage_qty ) ) ); - } - if ( $single ) { - $discount = ( $discount * $limit_usage_qty ) / $cart_item_qty; - } else { - $discount = ( $discount / $cart_item_qty ) * $limit_usage_qty; - } - } - } - - $discount = round( $discount, wc_get_rounding_precision() ); - - return apply_filters( 'woocommerce_coupon_get_discount_amount', $discount, $discounting_amount, $cart_item, $single, $this ); + return apply_filters( 'woocommerce_coupon_get_discount_amount', round( min( $discount, $discounting_amount ), wc_get_rounding_precision() ), $discounting_amount, $cart_item, $single, $this ); } /* @@ -761,260 +739,22 @@ class WC_Coupon extends WC_Legacy_Coupon { } /** - * Ensure coupon exists or throw exception. + * Check if a coupon is valid for the cart. * + * @deprecated 3.2.0 In favor of WC_Discounts->is_coupon_valid. * @throws Exception - */ - private function validate_exists() { - if ( ! $this->get_id() ) { - throw new Exception( self::E_WC_COUPON_NOT_EXIST ); - } - } - - /** - * Ensure coupon usage limit is valid or throw exception. - * - * @throws Exception - */ - private function validate_usage_limit() { - if ( $this->get_usage_limit() > 0 && $this->get_usage_count() >= $this->get_usage_limit() ) { - throw new Exception( self::E_WC_COUPON_USAGE_LIMIT_REACHED ); - } - } - - /** - * Ensure coupon user usage limit is valid or throw exception. - * - * Per user usage limit - check here if user is logged in (against user IDs). - * Checked again for emails later on in WC_Cart::check_customer_coupons(). - * - * @param int $user_id - * @throws Exception - */ - private function validate_user_usage_limit( $user_id = 0 ) { - if ( empty( $user_id ) ) { - $user_id = get_current_user_id(); - } - if ( $this->get_usage_limit_per_user() > 0 && is_user_logged_in() && $this->get_id() && $this->data_store ) { - $usage_count = $this->data_store->get_usage_by_user_id( $this, $user_id ); - if ( $usage_count >= $this->get_usage_limit_per_user() ) { - throw new Exception( self::E_WC_COUPON_USAGE_LIMIT_REACHED ); - } - } - } - - /** - * Ensure coupon date is valid or throw exception. - * - * @throws Exception - */ - private function validate_expiry_date() { - if ( $this->get_date_expires() && current_time( 'timestamp', true ) > $this->get_date_expires()->getTimestamp() ) { - throw new Exception( $error_code = self::E_WC_COUPON_EXPIRED ); - } - } - - /** - * Ensure coupon amount is valid or throw exception. - * - * @throws Exception - */ - private function validate_minimum_amount() { - if ( $this->get_minimum_amount() > 0 && apply_filters( 'woocommerce_coupon_validate_minimum_amount', $this->get_minimum_amount() > WC()->cart->get_displayed_subtotal(), $this ) ) { - throw new Exception( self::E_WC_COUPON_MIN_SPEND_LIMIT_NOT_MET ); - } - } - - /** - * Ensure coupon amount is valid or throw exception. - * - * @throws Exception - */ - private function validate_maximum_amount() { - if ( $this->get_maximum_amount() > 0 && apply_filters( 'woocommerce_coupon_validate_maximum_amount', $this->get_maximum_amount() < WC()->cart->get_displayed_subtotal(), $this ) ) { - throw new Exception( self::E_WC_COUPON_MAX_SPEND_LIMIT_MET ); - } - } - - /** - * Ensure coupon is valid for products in the cart is valid or throw exception. - * - * @throws Exception - */ - private function validate_product_ids() { - if ( sizeof( $this->get_product_ids() ) > 0 ) { - $valid_for_cart = false; - if ( ! WC()->cart->is_empty() ) { - foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { - if ( in_array( $cart_item['product_id'], $this->get_product_ids() ) || in_array( $cart_item['variation_id'], $this->get_product_ids() ) || in_array( $cart_item['data']->get_parent_id(), $this->get_product_ids() ) ) { - $valid_for_cart = true; - } - } - } - if ( ! $valid_for_cart ) { - throw new Exception( self::E_WC_COUPON_NOT_APPLICABLE ); - } - } - } - - /** - * Ensure coupon is valid for product categories in the cart is valid or throw exception. - * - * @throws Exception - */ - private function validate_product_categories() { - if ( sizeof( $this->get_product_categories() ) > 0 ) { - $valid_for_cart = false; - if ( ! WC()->cart->is_empty() ) { - foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { - if ( $this->get_exclude_sale_items() && $cart_item['data'] && $cart_item['data']->is_on_sale() ) { - continue; - } - $product_cats = wc_get_product_cat_ids( $cart_item['product_id'] ); - - // If we find an item with a cat in our allowed cat list, the coupon is valid - if ( sizeof( array_intersect( $product_cats, $this->get_product_categories() ) ) > 0 ) { - $valid_for_cart = true; - } - } - } - if ( ! $valid_for_cart ) { - throw new Exception( self::E_WC_COUPON_NOT_APPLICABLE ); - } - } - } - - /** - * Ensure coupon is valid for sale items in the cart is valid or throw exception. - * - * @throws Exception - */ - private function validate_sale_items() { - if ( $this->get_exclude_sale_items() ) { - $valid_for_cart = false; - - if ( ! WC()->cart->is_empty() ) { - foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { - $product = $cart_item['data']; - - if ( ! $product->is_on_sale() ) { - $valid_for_cart = true; - } - } - } - if ( ! $valid_for_cart ) { - throw new Exception( self::E_WC_COUPON_NOT_VALID_SALE_ITEMS ); - } - } - } - - /** - * All exclusion rules must pass at the same time for a product coupon to be valid. - */ - private function validate_excluded_items() { - if ( ! WC()->cart->is_empty() && $this->is_type( wc_get_product_coupon_types() ) ) { - $valid = false; - - foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { - if ( $this->is_valid_for_product( $cart_item['data'], $cart_item ) ) { - $valid = true; - break; - } - } - - if ( ! $valid ) { - throw new Exception( self::E_WC_COUPON_NOT_APPLICABLE ); - } - } - } - - /** - * Cart discounts cannot be added if non-eligible product is found in cart. - */ - private function validate_cart_excluded_items() { - if ( ! $this->is_type( wc_get_product_coupon_types() ) ) { - $this->validate_cart_excluded_product_ids(); - $this->validate_cart_excluded_product_categories(); - } - } - - /** - * Exclude products from cart. - * - * @throws Exception - */ - private function validate_cart_excluded_product_ids() { - // Exclude Products - if ( sizeof( $this->get_excluded_product_ids() ) > 0 ) { - $valid_for_cart = true; - if ( ! WC()->cart->is_empty() ) { - foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { - if ( in_array( $cart_item['product_id'], $this->get_excluded_product_ids() ) || in_array( $cart_item['variation_id'], $this->get_excluded_product_ids() ) || in_array( $cart_item['data']->get_parent_id(), $this->get_excluded_product_ids() ) ) { - $valid_for_cart = false; - } - } - } - if ( ! $valid_for_cart ) { - throw new Exception( self::E_WC_COUPON_EXCLUDED_PRODUCTS ); - } - } - } - - /** - * Exclude categories from cart. - * - * @throws Exception - */ - private function validate_cart_excluded_product_categories() { - if ( sizeof( $this->get_excluded_product_categories() ) > 0 ) { - $valid_for_cart = true; - if ( ! WC()->cart->is_empty() ) { - foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { - if ( $this->get_exclude_sale_items() && $cart_item['data'] && $cart_item['data']->is_on_sale() ) { - continue; - } - $product_cats = wc_get_product_cat_ids( $cart_item['product_id'] ); - - if ( sizeof( array_intersect( $product_cats, $this->get_excluded_product_categories() ) ) > 0 ) { - $valid_for_cart = false; - } - } - } - if ( ! $valid_for_cart ) { - throw new Exception( self::E_WC_COUPON_EXCLUDED_CATEGORIES ); - } - } - } - - /** - * Check if a coupon is valid. - * - * @return boolean validity - * @throws Exception + * @return bool Validity. */ public function is_valid() { - try { - $this->validate_exists(); - $this->validate_usage_limit(); - $this->validate_user_usage_limit(); - $this->validate_expiry_date(); - $this->validate_minimum_amount(); - $this->validate_maximum_amount(); - $this->validate_product_ids(); - $this->validate_product_categories(); - $this->validate_sale_items(); - $this->validate_excluded_items(); - $this->validate_cart_excluded_items(); + $discounts = new WC_Discounts( WC()->cart ); + $valid = $discounts->is_coupon_valid( $this ); - if ( ! apply_filters( 'woocommerce_coupon_is_valid', true, $this ) ) { - throw new Exception( self::E_WC_COUPON_INVALID_FILTERED ); - } - } catch ( Exception $e ) { - $this->error_message = $this->get_coupon_error( $e->getMessage() ); + if ( is_wp_error( $valid ) ) { + $this->error_message = $valid->get_error_message(); return false; } - return true; + return $valid; } /** diff --git a/includes/class-wc-discount.php b/includes/class-wc-discount.php new file mode 100644 index 00000000000..6e9d12591c7 --- /dev/null +++ b/includes/class-wc-discount.php @@ -0,0 +1,82 @@ + 0, // Discount amount. + 'discount_type' => 'fixed', // Fixed, percent, or coupon. + 'discount_total' => 0, + ); + + /** + * Get discount amount. + * + * @return int + */ + public function get_amount() { + return $this->data['amount']; + } + + /** + * Discount amount - either fixed or percentage. + * + * @param string $raw_amount Amount discount gives. + */ + public function set_amount( $raw_amount ) { + $this->data['amount'] = wc_format_decimal( $raw_amount ); + } + + /** + * Get discount type. + * + * @return string + */ + public function get_discount_type() { + return $this->data['discount_type']; + } + + /** + * Set discount type. + * + * @param string $discount_type Type of discount. + */ + public function set_discount_type( $discount_type ) { + $this->data['discount_type'] = $discount_type; + } + + /** + * Get discount total. + * + * @return int + */ + public function get_discount_total() { + return $this->data['discount_total']; + } + + /** + * Discount total. + * + * @param string $total Total discount applied. + */ + public function set_discount_total( $total ) { + $this->data['discount_total'] = wc_format_decimal( $total ); + } +} diff --git a/includes/class-wc-discounts.php b/includes/class-wc-discounts.php new file mode 100644 index 00000000000..5b77db9f751 --- /dev/null +++ b/includes/class-wc-discounts.php @@ -0,0 +1,902 @@ + Item Key => Value + */ + protected $discounts = array(); + + /** + * An array of applied WC_Discount objects. + * + * @var array + */ + protected $manual_discounts = array(); + + /** + * Constructor. @todo accept order objects. + * + * @param array $object Cart or order object. + */ + public function __construct( $object = array() ) { + if ( is_a( $object, 'WC_Cart' ) ) { + $this->set_items_from_cart( $object ); + } + } + + /** + * Normalise cart/order items which will be discounted. + * + * @since 3.2.0 + * @param array $cart Cart object. + */ + public function set_items_from_cart( $cart ) { + $this->items = $this->discounts = $this->manual_discounts = array(); + + if ( ! is_a( $cart, 'WC_Cart' ) ) { + return; + } + + foreach ( $cart->get_cart() as $key => $cart_item ) { + $item = new stdClass(); + $item->key = $key; + $item->object = $cart_item; + $item->product = $cart_item['data']; + $item->quantity = $cart_item['quantity']; + $item->price = wc_add_number_precision_deep( $item->product->get_price() ) * $item->quantity; + $this->items[ $key ] = $item; + } + + uasort( $this->items, array( $this, 'sort_by_price' ) ); + } + + /** + * Get items. + * + * @since 3.2.0 + * @return object[] + */ + public function get_items() { + return $this->items; + } + + /** + * Get discount by key with or without precision. + * + * @since 3.2.0 + * @param string $key name of discount row to return. + * @param bool $in_cents Should the totals be returned in cents, or without precision. + * @return array + */ + public function get_discount( $key, $in_cents = false ) { + $item_discount_totals = $this->get_discounts_by_item( $in_cents ); + return isset( $item_discount_totals[ $key ] ) ? ( $in_cents ? $item_discount_totals[ $key ] : wc_remove_number_precision( $item_discount_totals[ $key ] ) ) : 0; + } + + /** + * Get all discount totals. + * + * @since 3.2.0 + * @param bool $in_cents Should the totals be returned in cents, or without precision. + * @return array + */ + public function get_discounts( $in_cents = false ) { + $discounts = $this->discounts; + + foreach ( $this->get_manual_discounts() as $manual_discount_key => $manual_discount ) { + $discounts[ $manual_discount_key ] = $manual_discount->get_discount_total(); + } + + return $in_cents ? $discounts : wc_remove_number_precision_deep( $discounts ); + } + + /** + * Get all discount totals per item. + * + * @since 3.2.0 + * @param bool $in_cents Should the totals be returned in cents, or without precision. + * @return array + */ + public function get_discounts_by_item( $in_cents = false ) { + $discounts = $this->discounts; + $item_discount_totals = array_shift( $discounts ); + + foreach ( $discounts as $item_discounts ) { + foreach ( $item_discounts as $item_key => $item_discount ) { + $item_discount_totals[ $item_key ] += $item_discount; + } + } + + return $in_cents ? $item_discount_totals : wc_remove_number_precision_deep( $item_discount_totals ); + } + + /** + * Get all discount totals per coupon. + * + * @since 3.2.0 + * @param bool $in_cents Should the totals be returned in cents, or without precision. + * @return array + */ + public function get_discounts_by_coupon( $in_cents = false ) { + $coupon_discount_totals = array_map( 'array_sum', $this->discounts ); + + return $in_cents ? $coupon_discount_totals : wc_remove_number_precision_deep( $coupon_discount_totals ); + } + + /** + * Get an array of manual discounts which have been applied. + * + * @since 3.2.0 + * @return WC_Discount[] + */ + public function get_manual_discounts() { + return $this->manual_discounts; + } + + /** + * Get discounted price of an item without precision. + * + * @since 3.2.0 + * @param object $item Get data for this item. + * @return float + */ + public function get_discounted_price( $item ) { + return wc_remove_number_precision_deep( $this->get_discounted_price_in_cents( $item ) ); + } + + /** + * Get discounted price of an item to precision (in cents). + * + * @since 3.2.0 + * @param object $item Get data for this item. + * @return int + */ + public function get_discounted_price_in_cents( $item ) { + return absint( $item->price - $this->get_discount( $item->key, true ) ); + } + + /** + * Get total remaining after discounts. + * + * @since 3.2.0 + * @return int + */ + protected function get_total_after_discounts() { + $total_to_discount = 0; + + foreach ( $this->items as $item ) { + $total_to_discount += $this->get_discounted_price_in_cents( $item ); + } + + foreach ( $this->manual_discounts as $key => $value ) { + $total_to_discount = $total_to_discount - $value->get_discount_total(); + } + + return $total_to_discount; + } + + /** + * Generate a unique ID for a discount. + * + * @param WC_Discount $discount Discount object. + * @return string + */ + protected function generate_discount_id( $discount ) { + $discount_id = ''; + $index = 1; + while ( ! $discount_id ) { + $discount_id = 'discount-' . $discount->get_amount() . ( 'percent' === $discount->get_discount_type() ? '%' : '' ); + + if ( 1 < $index ) { + $discount_id .= '-' . $index; + } + + if ( isset( $this->manual_discounts[ $discount_id ] ) ) { + $index ++; + $discount_id = ''; + } + } + return $discount_id; + } + + /** + * Apply a discount to all items. + * + * @param string|object $raw_discount Accepts a string (fixed or percent discounts), or WC_Coupon object. + * @return bool|WP_Error True if applied or WP_Error instance in failure. + */ + public function apply_discount( $raw_discount ) { + if ( is_a( $raw_discount, 'WC_Coupon' ) ) { + return $this->apply_coupon( $raw_discount ); + } + + $discount = new WC_Discount; + + if ( strstr( $raw_discount, '%' ) ) { + $discount->set_discount_type( 'percent' ); + $discount->set_amount( trim( $raw_discount, '%' ) ); + } elseif ( 0 < absint( $raw_discount ) ) { + $discount->set_discount_type( 'fixed' ); + $discount->set_amount( wc_add_number_precision( absint( $raw_discount ) ) ); + } + + if ( ! $discount->get_amount() ) { + return new WP_Error( 'invalid_coupon', __( 'Invalid discount', 'woocommerce' ) ); + } + + $total_to_discount = $this->get_total_after_discounts(); + + if ( 'percent' === $discount->get_discount_type() ) { + $discount->set_discount_total( $discount->get_amount() * ( $total_to_discount / 100 ) ); + } else { + $discount->set_discount_total( min( $discount->get_amount(), $total_to_discount ) ); + } + + $this->manual_discounts[ $this->generate_discount_id( $discount ) ] = $discount; + + return true; + } + + /** + * Apply a discount to all items using a coupon. + * + * @since 3.2.0 + * @param WC_Coupon $coupon Coupon object being applied to the items. + * @return bool|WP_Error True if applied or WP_Error instance in failure. + */ + protected function apply_coupon( $coupon ) { + $is_coupon_valid = $this->is_coupon_valid( $coupon ); + + if ( is_wp_error( $is_coupon_valid ) ) { + return $is_coupon_valid; + } + + if ( ! isset( $this->discounts[ $coupon->get_code() ] ) ) { + $this->discounts[ $coupon->get_code() ] = array_fill_keys( array_keys( $this->items ), 0 ); + } + + $items_to_apply = $this->get_items_to_apply_coupon( $coupon ); + $coupon_type = $coupon->get_discount_type(); + + // Core discounts are handled here as of 3.2. + switch ( $coupon->get_discount_type() ) { + case 'percent' : + $this->apply_coupon_percent( $coupon, $items_to_apply ); + break; + case 'fixed_product' : + $this->apply_coupon_fixed_product( $coupon, $items_to_apply ); + break; + case 'fixed_cart' : + $this->apply_coupon_fixed_cart( $coupon, $items_to_apply ); + break; + default : + foreach ( $items_to_apply as $item ) { + $discounted_price = $this->get_discounted_price_in_cents( $item ); + $price_to_discount = wc_remove_number_precision( ( 'yes' === get_option( 'woocommerce_calc_discounts_sequentially', 'no' ) ) ? $item->price : $discounted_price ); + $discount = min( $discounted_price, wc_add_number_precision( $coupon->get_discount_amount( $price_to_discount ), $item->object ) ); + + // Store code and discount amount per item. + $this->discounts[ $coupon->get_code() ][ $item->key ] += $discount; + } + break; + } + + return true; + } + + /** + * Sort by price. + * + * @since 3.2.0 + * @param array $a First element. + * @param array $b Second element. + * @return int + */ + protected function sort_by_price( $a, $b ) { + $price_1 = $a->price * $a->quantity; + $price_2 = $b->price * $b->quantity; + if ( $price_1 === $price_2 ) { + return 0; + } + return ( $price_1 < $price_2 ) ? 1 : -1; + } + + /** + * Filter out all products which have been fully discounted to 0. + * Used as array_filter callback. + * + * @since 3.2.0 + * @param object $item Get data for this item. + * @return bool + */ + protected function filter_products_with_price( $item ) { + return $this->get_discounted_price_in_cents( $item ) > 0; + } + + /** + * Get items which the coupon should be applied to. + * + * @since 3.2.0 + * @param object $coupon Coupon object. + * @return array + */ + protected function get_items_to_apply_coupon( $coupon ) { + $items_to_apply = array(); + $limit_usage_qty = 0; + $applied_count = 0; + + if ( null !== $coupon->get_limit_usage_to_x_items() ) { + $limit_usage_qty = $coupon->get_limit_usage_to_x_items(); + } + + foreach ( $this->items as $item ) { + if ( 0 === $this->get_discounted_price_in_cents( $item ) ) { + continue; + } + if ( ! $coupon->is_valid_for_product( $item->product, $item->object ) && ! $coupon->is_valid_for_cart() ) { + continue; + } + if ( $limit_usage_qty && $applied_count > $limit_usage_qty ) { + break; + } + if ( $limit_usage_qty && $item->quantity > ( $limit_usage_qty - $applied_count ) ) { + $limit_to_qty = absint( $limit_usage_qty - $applied_count ); + $item->price = ( $item->price / $item->quantity ) * $limit_to_qty; + $item->quantity = $limit_to_qty; // Lower the qty so the discount is applied less. + } + if ( 0 >= $item->quantity ) { + continue; + } + $items_to_apply[] = $item; + $applied_count += $item->quantity; + } + return $items_to_apply; + } + + /** + * Apply percent discount to items and return an array of discounts granted. + * + * @since 3.2.0 + * @param WC_Coupon $coupon Coupon object. Passed through filters. + * @param array $items_to_apply Array of items to apply the coupon to. + * @return int Total discounted. + */ + protected function apply_coupon_percent( $coupon, $items_to_apply ) { + $total_discount = 0; + $cart_total = 0; + + foreach ( $items_to_apply as $item ) { + // Find out how much price is available to discount for the item. + $discounted_price = $this->get_discounted_price_in_cents( $item ); + + // Get the price we actually want to discount, based on settings. + $price_to_discount = ( 'yes' === get_option( 'woocommerce_calc_discounts_sequentially', 'no' ) ) ? $item->price: $discounted_price; + + // Total up. + $cart_total += $price_to_discount; + + // Run coupon calculations. + $discount = floor( $price_to_discount * ( $coupon->get_amount() / 100 ) ); + $discount = min( $discounted_price, apply_filters( 'woocommerce_coupon_get_discount_amount', $discount, $price_to_discount, $item->object, false, $coupon ) ); + $total_discount += $discount; + + // Store code and discount amount per item. + $this->discounts[ $coupon->get_code() ][ $item->key ] += $discount; + } + + // Work out how much discount would have been given to the cart has a whole and compare to what was discounted on all line items. + $cart_total_discount = wc_cart_round_discount( $cart_total * ( $coupon->get_amount() / 100 ), 0 ); + + if ( $total_discount < $cart_total_discount ) { + $total_discount += $this->apply_coupon_remainder( $coupon, $items_to_apply, $cart_total_discount - $total_discount ); + } + + return $total_discount; + } + + /** + * Apply fixed product discount to items. + * + * @since 3.2.0 + * @param WC_Coupon $coupon Coupon object. Passed through filters. + * @param array $items_to_apply Array of items to apply the coupon to. + * @param int $amount Fixed discount amount to apply in cents. Leave blank to pull from coupon. + * @return int Total discounted. + */ + protected function apply_coupon_fixed_product( $coupon, $items_to_apply, $amount = null ) { + $total_discount = 0; + $amount = $amount ? $amount: wc_add_number_precision( $coupon->get_amount() ); + + foreach ( $items_to_apply as $item ) { + // Find out how much price is available to discount for the item. + $discounted_price = $this->get_discounted_price_in_cents( $item ); + + // Get the price we actually want to discount, based on settings. + $price_to_discount = ( 'yes' === get_option( 'woocommerce_calc_discounts_sequentially', 'no' ) ) ? $item->price: $discounted_price; + + // Run coupon calculations. + $discount = $amount * $item->quantity; + $discount = min( $discounted_price, apply_filters( 'woocommerce_coupon_get_discount_amount', $discount, $price_to_discount, $item->object, false, $coupon ) ); + $total_discount += $discount; + + // Store code and discount amount per item. + $this->discounts[ $coupon->get_code() ][ $item->key ] += $discount; + } + return $total_discount; + } + + /** + * Apply fixed cart discount to items. + * + * @since 3.2.0 + * @param WC_Coupon $coupon Coupon object. Passed through filters. + * @param array $items_to_apply Array of items to apply the coupon to. + * @param int $amount Fixed discount amount to apply in cents. Leave blank to pull from coupon. + * @return int Total discounted. + */ + protected function apply_coupon_fixed_cart( $coupon, $items_to_apply, $amount = null ) { + $total_discount = 0; + $amount = $amount ? $amount : wc_add_number_precision( $coupon->get_amount() ); + $items_to_apply = array_filter( $items_to_apply, array( $this, 'filter_products_with_price' ) ); + + if ( ! $item_count = array_sum( wp_list_pluck( $items_to_apply, 'quantity' ) ) ) { + return $total_discount; + } + + $per_item_discount = absint( $amount / $item_count ); // round it down to the nearest cent. + + if ( $per_item_discount > 0 ) { + $total_discount = $this->apply_coupon_fixed_product( $coupon, $items_to_apply, $per_item_discount ); + + /** + * If there is still discount remaining, repeat the process. + */ + if ( $total_discount > 0 && $total_discount < $amount ) { + $total_discount += $this->apply_coupon_fixed_cart( $coupon, $items_to_apply, $amount - $total_discount ); + } + } elseif ( $amount > 0 ) { + $total_discount += $this->apply_coupon_remainder( $coupon, $items_to_apply, $amount ); + } + return $total_discount; + } + + /** + * Deal with remaining fractional discounts by splitting it over items + * until the amount is expired, discounting 1 cent at a time. + * + * @since 3.2.0 + * @param WC_Coupon $coupon Coupon object if appliable. Passed through filters. + * @param array $items_to_apply Array of items to apply the coupon to. + * @param int $amount Fixed discount amount to apply. + * @return int Total discounted. + */ + protected function apply_coupon_remainder( $coupon, $items_to_apply, $amount ) { + $total_discount = 0; + + foreach ( $items_to_apply as $item ) { + for ( $i = 0; $i < $item->quantity; $i ++ ) { + // Find out how much price is available to discount for the item. + $discounted_price = $this->get_discounted_price_in_cents( $item ); + + // Get the price we actually want to discount, based on settings. + $price_to_discount = ( 'yes' === get_option( 'woocommerce_calc_discounts_sequentially', 'no' ) ) ? $item->price: $discounted_price; + + // Run coupon calculations. + $discount = min( $discounted_price, 1 ); + + // Store totals. + $total_discount += $discount; + + // Store code and discount amount per item. + $this->discounts[ $coupon->get_code() ][ $item->key ] += $discount; + + if ( $total_discount >= $amount ) { + break 2; + } + } + if ( $total_discount >= $amount ) { + break; + } + } + return $total_discount; + } + + /* + |-------------------------------------------------------------------------- + | Validation & Error Handling + |-------------------------------------------------------------------------- + */ + + /** + * Ensure coupon exists or throw exception. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @return bool + */ + protected function validate_coupon_exists( $coupon ) { + if ( ! $coupon->get_id() ) { + /* translators: %s: coupon code */ + throw new Exception( sprintf( __( 'Coupon "%s" does not exist!', 'woocommerce' ), $coupon->get_code() ), 105 ); + } + + return true; + } + + /** + * Ensure coupon usage limit is valid or throw exception. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @return bool + */ + protected function validate_coupon_usage_limit( $coupon ) { + if ( $coupon->get_usage_limit() > 0 && $coupon->get_usage_count() >= $coupon->get_usage_limit() ) { + throw new Exception( __( 'Coupon usage limit has been reached.', 'woocommerce' ), 106 ); + } + + return true; + } + + /** + * Ensure coupon user usage limit is valid or throw exception. + * + * Per user usage limit - check here if user is logged in (against user IDs). + * Checked again for emails later on in WC_Cart::check_customer_coupons(). + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @param int $user_id User ID. + * @return bool + */ + protected function validate_coupon_user_usage_limit( $coupon, $user_id = 0 ) { + if ( empty( $user_id ) ) { + $user_id = get_current_user_id(); + } + + if ( $coupon->get_usage_limit_per_user() > 0 && is_user_logged_in() && $coupon->get_id() && $coupon->get_data_store() ) { + $date_store = $coupon->get_data_store(); + $usage_count = $date_store->get_usage_by_user_id( $coupon, $user_id ); + if ( $usage_count >= $coupon->get_usage_limit_per_user() ) { + throw new Exception( __( 'Coupon usage limit has been reached.', 'woocommerce' ), 106 ); + } + } + + return true; + } + + /** + * Ensure coupon date is valid or throw exception. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @return bool + */ + protected function validate_coupon_expiry_date( $coupon ) { + if ( $coupon->get_date_expires() && current_time( 'timestamp', true ) > $coupon->get_date_expires()->getTimestamp() ) { + throw new Exception( __( 'This coupon has expired.', 'woocommerce' ), 107 ); + } + + return true; + } + + /** + * Ensure coupon amount is valid or throw exception. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @param float $subtotal Items subtotal. + * @return bool + */ + protected function validate_coupon_minimum_amount( $coupon, $subtotal = 0 ) { + if ( $coupon->get_minimum_amount() > 0 && apply_filters( 'woocommerce_coupon_validate_minimum_amount', $coupon->get_minimum_amount() > $subtotal, $coupon, $subtotal ) ) { + /* translators: %s: coupon minimum amount */ + throw new Exception( sprintf( __( 'The minimum spend for this coupon is %s.', 'woocommerce' ), wc_price( $coupon->get_minimum_amount() ) ), 108 ); + } + + return true; + } + + /** + * Ensure coupon amount is valid or throw exception. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @param float $subtotal Items subtotal. + * @return bool + */ + protected function validate_coupon_maximum_amount( $coupon, $subtotal = 0 ) { + if ( $coupon->get_maximum_amount() > 0 && apply_filters( 'woocommerce_coupon_validate_maximum_amount', $coupon->get_maximum_amount() < $subtotal, $coupon ) ) { + /* translators: %s: coupon maximum amount */ + throw new Exception( sprintf( __( 'The maximum spend for this coupon is %s.', 'woocommerce' ), wc_price( $coupon->get_maximum_amount() ) ), 112 ); + } + + return true; + } + + /** + * Ensure coupon is valid for products in the list is valid or throw exception. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @return bool + */ + protected function validate_coupon_product_ids( $coupon ) { + if ( count( $coupon->get_product_ids() ) > 0 ) { + $valid = false; + + foreach ( $this->items as $item ) { + if ( $item->product && in_array( $item->product->get_id(), $coupon->get_product_ids(), true ) || in_array( $item->product->get_parent_id(), $coupon->get_product_ids(), true ) ) { + $valid = true; + break; + } + } + + if ( ! $valid ) { + throw new Exception( __( 'Sorry, this coupon is not applicable to selected products.', 'woocommerce' ), 109 ); + } + } + + return true; + } + + /** + * Ensure coupon is valid for product categories in the list is valid or throw exception. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @return bool + */ + protected function validate_coupon_product_categories( $coupon ) { + if ( count( $coupon->get_product_categories() ) > 0 ) { + $valid = false; + + foreach ( $this->items as $item ) { + if ( $coupon->get_exclude_sale_items() && $item->product && $item->product->is_on_sale() ) { + continue; + } + + $product_cats = wc_get_product_cat_ids( $item->product->get_id() ); + + // If we find an item with a cat in our allowed cat list, the coupon is valid. + if ( count( array_intersect( $product_cats, $coupon->get_product_categories() ) ) > 0 ) { + $valid = true; + break; + } + } + + if ( ! $valid ) { + throw new Exception( __( 'Sorry, this coupon is not applicable to selected products.', 'woocommerce' ), 109 ); + } + } + + return true; + } + + /** + * Ensure coupon is valid for sale items in the list is valid or throw exception. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @return bool + */ + protected function validate_coupon_sale_items( $coupon ) { + if ( $coupon->get_exclude_sale_items() ) { + $valid = false; + + foreach ( $this->items as $item ) { + if ( $item->product && ! $item->product->is_on_sale() ) { + $valid = true; + break; + } + } + + if ( ! $valid ) { + throw new Exception( __( 'Sorry, this coupon is not valid for sale items.', 'woocommerce' ), 110 ); + } + } + + return true; + } + + /** + * All exclusion rules must pass at the same time for a product coupon to be valid. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @return bool + */ + protected function validate_coupon_excluded_items( $coupon ) { + if ( ! $this->items && $coupon->is_type( wc_get_product_coupon_types() ) ) { + $valid = false; + + foreach ( $this->items as $item ) { + if ( $item->product && $coupon->is_valid_for_product( $item->product, $item->object ) ) { + $valid = true; + break; + } + } + + if ( ! $valid ) { + throw new Exception( __( 'Sorry, this coupon is not applicable to selected products.', 'woocommerce' ), 109 ); + } + } + + return true; + } + + /** + * Cart discounts cannot be added if non-eligible product is found. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @return bool + */ + protected function validate_coupon_eligible_items( $coupon ) { + if ( ! $coupon->is_type( wc_get_product_coupon_types() ) ) { + $this->validate_coupon_excluded_product_ids( $coupon ); + $this->validate_coupon_excluded_product_categories( $coupon ); + } + + return true; + } + + /** + * Exclude products. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @return bool + */ + protected function validate_coupon_excluded_product_ids( $coupon ) { + // Exclude Products. + if ( count( $coupon->get_excluded_product_ids() ) > 0 ) { + $products = array(); + + foreach ( $this->items as $item ) { + if ( $item->product && in_array( $item->product->get_id(), $coupon->get_excluded_product_ids(), true ) || in_array( $item->product->get_parent_id(), $coupon->get_excluded_product_ids(), true ) ) { + $products[] = $item->product->get_name(); + } + } + + if ( ! empty( $products ) ) { + /* translators: %s: products list */ + throw new Exception( sprintf( __( 'Sorry, this coupon is not applicable to the products: %s.', 'woocommerce' ), implode( ', ', $products ) ), 113 ); + } + } + + return true; + } + + /** + * Exclude categories from product list. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @return bool + */ + protected function validate_coupon_excluded_product_categories( $coupon ) { + if ( count( $coupon->get_excluded_product_categories() ) > 0 ) { + $categories = array(); + + foreach ( $this->items as $item ) { + if ( $coupon->get_exclude_sale_items() && $item->product && $item->product->is_on_sale() ) { + continue; + } + + $product_cats = wc_get_product_cat_ids( $item->product->get_id() ); + $cat_id_list = array_intersect( $product_cats, $coupon->get_excluded_product_categories() ); + if ( count( $cat_id_list ) > 0 ) { + foreach ( $cat_id_list as $cat_id ) { + $cat = get_term( $cat_id, 'product_cat' ); + $categories[] = $cat->name; + } + } + } + + if ( ! empty( $categories ) ) { + /* translators: %s: categories list */ + throw new Exception( sprintf( __( 'Sorry, this coupon is not applicable to the categories: %s.', 'woocommerce' ), implode( ', ', array_unique( $categories ) ) ), 114 ); + } + } + + return true; + } + + /** + * Check if a coupon is valid. + * + * Error Codes: + * - 100: Invalid filtered. + * - 101: Invalid removed. + * - 102: Not yours removed. + * - 103: Already applied. + * - 104: Individual use only. + * - 105: Not exists. + * - 106: Usage limit reached. + * - 107: Expired. + * - 108: Minimum spend limit not met. + * - 109: Not applicable. + * - 110: Not valid for sale items. + * - 111: Missing coupon code. + * - 112: Maximum spend limit met. + * - 113: Excluded products. + * - 114: Excluded categories. + * + * @since 3.2.0 + * @throws Exception Error message. + * @param WC_Coupon $coupon Coupon data. + * @return bool|WP_Error + */ + public function is_coupon_valid( $coupon ) { + try { + $this->validate_coupon_exists( $coupon ); + $this->validate_coupon_usage_limit( $coupon ); + $this->validate_coupon_user_usage_limit( $coupon ); + $this->validate_coupon_expiry_date( $coupon ); + $this->validate_coupon_minimum_amount( $coupon ); + $this->validate_coupon_maximum_amount( $coupon ); + $this->validate_coupon_product_ids( $coupon ); + $this->validate_coupon_product_categories( $coupon ); + $this->validate_coupon_sale_items( $coupon ); + $this->validate_coupon_excluded_items( $coupon ); + $this->validate_coupon_eligible_items( $coupon ); + + if ( ! apply_filters( 'woocommerce_coupon_is_valid', true, $coupon, $this ) ) { + throw new Exception( __( 'Coupon is not valid.', 'woocommerce' ), 100 ); + } + } catch ( Exception $e ) { + /** + * Filter the coupon error message. + * + * @param string $error_message Error message. + * @param int $error_code Error code. + * @param WC_Coupon $coupon Coupon data. + */ + $message = apply_filters( 'woocommerce_coupon_error', $e->getMessage(), $e->getCode(), $coupon ); + + return new WP_Error( 'invalid_coupon', $message, array( + 'status' => 400, + ) ); + } + return true; + } +} diff --git a/includes/class-wc-order-item-shipping.php b/includes/class-wc-order-item-shipping.php index 9cd19a4b0e9..b77ef122b50 100644 --- a/includes/class-wc-order-item-shipping.php +++ b/includes/class-wc-order-item-shipping.php @@ -28,6 +28,27 @@ class WC_Order_Item_Shipping extends WC_Order_Item { ), ); + /** + * Calculate item taxes. + * + * @since 3.2.0 + * @param array $calculate_tax_for Location data to get taxes for. Required. + * @return bool True if taxes were calculated. + */ + public function calculate_taxes( $calculate_tax_for = array() ) { + if ( ! isset( $calculate_tax_for['country'], $calculate_tax_for['state'], $calculate_tax_for['postcode'], $calculate_tax_for['city'], $calculate_tax_for['tax_class'] ) ) { + return false; + } + if ( wc_tax_enabled() ) { + $tax_rates = WC_Tax::find_shipping_rates( $calculate_tax_for ); + $taxes = WC_Tax::calc_tax( $this->get_total(), $tax_rates, false ); + $this->set_taxes( array( 'total' => $taxes ) ); + } else { + $this->set_taxes( false ); + } + return true; + } + /* |-------------------------------------------------------------------------- | Setters diff --git a/includes/class-wc-order-item.php b/includes/class-wc-order-item.php index 36fe47b77cc..3ab1235dff9 100644 --- a/includes/class-wc-order-item.php +++ b/includes/class-wc-order-item.php @@ -152,7 +152,8 @@ class WC_Order_Item extends WC_Data implements ArrayAccess { */ /** - * Type checking + * Type checking. + * * @param string|array $type * @return boolean */ @@ -160,6 +161,34 @@ class WC_Order_Item extends WC_Data implements ArrayAccess { return is_array( $type ) ? in_array( $this->get_type(), $type ) : $type === $this->get_type(); } + /** + * Calculate item taxes. + * + * @since 3.2.0 + * @param array $calculate_tax_for Location data to get taxes for. Required. + * @return bool True if taxes were calculated. + */ + public function calculate_taxes( $calculate_tax_for = array() ) { + if ( ! isset( $calculate_tax_for['country'], $calculate_tax_for['state'], $calculate_tax_for['postcode'], $calculate_tax_for['city'] ) ) { + return false; + } + if ( '0' !== $this->get_tax_class() && 'taxable' === $this->get_tax_status() && wc_tax_enabled() ) { + $calculate_tax_for['tax_class'] = $this->get_tax_class(); + $tax_rates = WC_Tax::find_rates( $calculate_tax_for ); + $taxes = WC_Tax::calc_tax( $this->get_total(), $tax_rates, false ); + + if ( method_exists( $this, 'get_subtotal' ) ) { + $subtotal_taxes = WC_Tax::calc_tax( $this->get_subtotal(), $tax_rates, false ); + $this->set_taxes( array( 'total' => $taxes, 'subtotal' => $subtotal_taxes ) ); + } else { + $this->set_taxes( array( 'total' => $taxes ) ); + } + } else { + $this->set_taxes( false ); + } + return true; + } + /* |-------------------------------------------------------------------------- | Meta Data Handling diff --git a/includes/class-wc-shipping.php b/includes/class-wc-shipping.php index 0a1e931f8a2..ece466afd5e 100644 --- a/includes/class-wc-shipping.php +++ b/includes/class-wc-shipping.php @@ -26,12 +26,6 @@ class WC_Shipping { /** @var array|null Stores methods loaded into woocommerce. */ public $shipping_methods = null; - /** @var float Stores the cost of shipping */ - public $shipping_total = 0; - - /** @var array Stores an array of shipping taxes. */ - public $shipping_taxes = array(); - /** @var array Stores the shipping classes. */ public $shipping_classes = array(); @@ -237,18 +231,17 @@ class WC_Shipping { /** * Calculate shipping for (multiple) packages of cart items. * - * @param array $packages multi-dimensional array of cart items to calc shipping for + * @param array $packages multi-dimensional array of cart items to calc shipping for. + * @return array Array of calculated packages. */ public function calculate_shipping( $packages = array() ) { - $this->shipping_total = 0; - $this->shipping_taxes = array(); - $this->packages = array(); + $this->packages = array(); if ( ! $this->enabled || empty( $packages ) ) { - return; + return array(); } - // Calculate costs for passed packages + // Calculate costs for passed packages. foreach ( $packages as $package_key => $package ) { $this->packages[ $package_key ] = $this->calculate_shipping_for_package( $package, $package_key ); } @@ -264,58 +257,9 @@ class WC_Shipping { * * @param array $packages The array of packages after shipping costs are calculated. */ - $this->packages = apply_filters( 'woocommerce_shipping_packages', $this->packages ); + $this->packages = array_filter( (array) apply_filters( 'woocommerce_shipping_packages', $this->packages ) ); - if ( ! is_array( $this->packages ) || empty( $this->packages ) ) { - return; - } - - // Get all chosen methods - $chosen_methods = WC()->session->get( 'chosen_shipping_methods' ); - $method_counts = WC()->session->get( 'shipping_method_counts' ); - - // Get chosen methods for each package - foreach ( $this->packages as $i => $package ) { - $chosen_method = false; - $method_count = false; - - if ( ! empty( $chosen_methods[ $i ] ) ) { - $chosen_method = $chosen_methods[ $i ]; - } - - if ( ! empty( $method_counts[ $i ] ) ) { - $method_count = absint( $method_counts[ $i ] ); - } - - if ( sizeof( $package['rates'] ) > 0 ) { - - // If not set, not available, or available methods have changed, set to the DEFAULT option - if ( empty( $chosen_method ) || ! isset( $package['rates'][ $chosen_method ] ) || sizeof( $package['rates'] ) !== $method_count ) { - $chosen_method = apply_filters( 'woocommerce_shipping_chosen_method', $this->get_default_method( $package['rates'], false ), $package['rates'], $chosen_method ); - $chosen_methods[ $i ] = $chosen_method; - $method_counts[ $i ] = sizeof( $package['rates'] ); - do_action( 'woocommerce_shipping_method_chosen', $chosen_method ); - } - - // Store total costs - if ( $chosen_method && isset( $package['rates'][ $chosen_method ] ) ) { - $rate = $package['rates'][ $chosen_method ]; - - // Merge cost and taxes - label and ID will be the same - $this->shipping_total += $rate->cost; - - if ( ! empty( $rate->taxes ) && is_array( $rate->taxes ) ) { - foreach ( array_keys( $this->shipping_taxes + $rate->taxes ) as $key ) { - $this->shipping_taxes[ $key ] = ( isset( $rate->taxes[ $key ] ) ? $rate->taxes[ $key ] : 0 ) + ( isset( $this->shipping_taxes[ $key ] ) ? $this->shipping_taxes[ $key ] : 0 ); - } - } - } - } - } - - // Save all chosen methods (array) - WC()->session->set( 'chosen_shipping_methods', $chosen_methods ); - WC()->session->set( 'shipping_method_counts', $method_counts ); + return $this->packages; } /** @@ -396,8 +340,6 @@ class WC_Shipping { */ public function reset_shipping() { unset( WC()->session->chosen_shipping_methods ); - $this->shipping_total = 0; - $this->shipping_taxes = array(); $this->packages = array(); } diff --git a/includes/class-wc-tax.php b/includes/class-wc-tax.php index a772f093615..53556c76d61 100644 --- a/includes/class-wc-tax.php +++ b/includes/class-wc-tax.php @@ -452,13 +452,18 @@ class WC_Tax { * Used by get_rates(), get_shipping_rates(). * * @param $tax_class string Optional, passed to the filter for advanced tax setups. + * @param object $customer Override the customer object to get their location. * @return array */ - public static function get_tax_location( $tax_class = '' ) { + public static function get_tax_location( $tax_class = '', $customer = null ) { $location = array(); - if ( ! empty( WC()->customer ) ) { - $location = WC()->customer->get_taxable_address(); + if ( is_null( $customer ) && ! empty( WC()->customer ) ) { + $customer = WC()->customer; + } + + if ( ! empty( $customer ) ) { + $location = $customer->get_taxable_address(); } elseif ( wc_prices_include_tax() || 'base' === get_option( 'woocommerce_default_customer_address' ) || 'base' === get_option( 'woocommerce_tax_based_on' ) ) { $location = array( WC()->countries->get_base_country(), @@ -468,17 +473,19 @@ class WC_Tax { ); } - return apply_filters( 'woocommerce_get_tax_location', $location, $tax_class ); + return apply_filters( 'woocommerce_get_tax_location', $location, $tax_class, $customer ); } /** * Get's an array of matching rates for a tax class. - * @param string $tax_class + * + * @param string $tax_class Tax class to get rates for. + * @param object $customer Override the customer object to get their location. * @return array */ - public static function get_rates( $tax_class = '' ) { + public static function get_rates( $tax_class = '', $customer = null ) { $tax_class = sanitize_title( $tax_class ); - $location = self::get_tax_location( $tax_class ); + $location = self::get_tax_location( $tax_class, $customer ); $matched_tax_rates = array(); if ( sizeof( $location ) === 4 ) { @@ -526,10 +533,11 @@ class WC_Tax { /** * Gets an array of matching shipping tax rates for a given class. * - * @param string Tax Class - * @return mixed + * @param string $tax_class Tax class to get rates for. + * @param object $customer Override the customer object to get their location. + * @return mixed */ - public static function get_shipping_tax_rates( $tax_class = null ) { + public static function get_shipping_tax_rates( $tax_class = null, $customer = null ) { // See if we have an explicitly set shipping tax class $shipping_tax_class = get_option( 'woocommerce_shipping_tax_class' ); @@ -537,7 +545,7 @@ class WC_Tax { $tax_class = $shipping_tax_class; } - $location = self::get_tax_location( $tax_class ); + $location = self::get_tax_location( $tax_class, $customer ); $matched_tax_rates = array(); if ( sizeof( $location ) === 4 ) { diff --git a/includes/class-woocommerce.php b/includes/class-woocommerce.php index 92a0b4dd8e9..2e14dda15f8 100644 --- a/includes/class-woocommerce.php +++ b/includes/class-woocommerce.php @@ -335,6 +335,8 @@ final class WooCommerce { include_once( WC_ABSPATH . 'includes/class-wc-deprecated-action-hooks.php' ); include_once( WC_ABSPATH . 'includes/class-wc-deprecated-filter-hooks.php' ); include_once( WC_ABSPATH . 'includes/class-wc-background-emailer.php' ); + include_once( WC_ABSPATH . 'includes/class-wc-discounts.php' ); + include_once( WC_ABSPATH . 'includes/class-wc-cart-totals.php' ); /** * Data stores - used to store and retrieve CRUD object data from the database. diff --git a/includes/legacy/class-wc-legacy-cart.php b/includes/legacy/class-wc-legacy-cart.php new file mode 100644 index 00000000000..5a806b6b019 --- /dev/null +++ b/includes/legacy/class-wc-legacy-cart.php @@ -0,0 +1,150 @@ +cart_contents[ $cart_item_key ]; + + return $cart_item->get_line_total(); + } + + /** + * Gets the url to the cart page. + * + * @deprecated 2.5.0 in favor to wc_get_cart_url() + * @return string url to page + */ + public function get_cart_url() { + wc_deprecated_function( 'WC_Cart::get_cart_url', '2.5', 'wc_get_cart_url' ); + return wc_get_cart_url(); + } + + /** + * Gets the url to the checkout page. + * + * @deprecated 2.5.0 in favor to wc_get_checkout_url() + * @return string url to page + */ + public function get_checkout_url() { + wc_deprecated_function( 'WC_Cart::get_checkout_url', '2.5', 'wc_get_checkout_url' ); + return wc_get_checkout_url(); + } + + /** + * Sees if we need a shipping address. + * + * @deprecated 2.5.0 in favor to wc_ship_to_billing_address_only() + * @return bool + */ + public function ship_to_billing_address_only() { + wc_deprecated_function( 'WC_Cart::ship_to_billing_address_only', '2.5', 'wc_ship_to_billing_address_only' ); + return wc_ship_to_billing_address_only(); + } + + /** + * Coupons enabled function. Filterable. + * + * @deprecated 2.5.0 in favor to wc_coupons_enabled() + * @return bool + */ + public function coupons_enabled() { + return wc_coupons_enabled(); + } + + /** + * Gets the total (product) discount amount - these are applied before tax. + * + * @deprecated Order discounts (after tax) removed in 2.3 so multiple methods for discounts are no longer required. + * @return mixed formatted price or false if there are none. + */ + public function get_discounts_before_tax() { + wc_deprecated_function( 'get_discounts_before_tax', '2.3', 'get_total_discount' ); + if ( $this->get_cart_discount_total() ) { + $discounts_before_tax = wc_price( $this->get_cart_discount_total() ); + } else { + $discounts_before_tax = false; + } + return apply_filters( 'woocommerce_cart_discounts_before_tax', $discounts_before_tax, $this ); + } + + /** + * Get the total of all order discounts (after tax discounts). + * + * @deprecated Order discounts (after tax) removed in 2.3. + * @return int + */ + public function get_order_discount_total() { + wc_deprecated_function( 'get_order_discount_total', '2.3' ); + return 0; + } + + /** + * Function to apply cart discounts after tax. + * + * @deprecated Coupons can not be applied after tax. + * @param $values + * @param $price + */ + public function apply_cart_discounts_after_tax( $values, $price ) { + wc_deprecated_function( 'apply_cart_discounts_after_tax', '2.3' ); + } + + /** + * Function to apply product discounts after tax. + * + * @deprecated Coupons can not be applied after tax. + * + * @param $values + * @param $price + */ + public function apply_product_discounts_after_tax( $values, $price ) { + wc_deprecated_function( 'apply_product_discounts_after_tax', '2.3' ); + } + + /** + * Gets the order discount amount - these are applied after tax. + * + * @deprecated Coupons can not be applied after tax. + */ + public function get_discounts_after_tax() { + wc_deprecated_function( 'get_discounts_after_tax', '2.3' ); + } +} diff --git a/includes/wc-cart-functions.php b/includes/wc-cart-functions.php index 8eb336c7717..c9b44b75dd0 100644 --- a/includes/wc-cart-functions.php +++ b/includes/wc-cart-functions.php @@ -387,3 +387,69 @@ function wc_get_chosen_shipping_method_ids() { } return $method_ids; } + +/** + * Get chosen method for package from session. + * + * @since 3.2.0 + * @param int $key + * @param array $package + * @return string|bool + */ +function wc_get_chosen_shipping_method_for_package( $key, $package ) { + $chosen_methods = WC()->session->get( 'chosen_shipping_methods' ); + $chosen_method = isset( $chosen_methods[ $key ] ) ? $chosen_methods[ $key ] : false; + $changed = wc_shipping_methods_have_changed( $key, $package ); + // If not set, not available, or available methods have changed, set to the DEFAULT option + if ( ! $chosen_method || $changed || ! isset( $package['rates'][ $chosen_method ] ) ) { + $chosen_method = wc_get_default_shipping_method_for_package( $key, $package, $chosen_method ); + $chosen_methods[ $key ] = $chosen_method; + WC()->session->set( 'chosen_shipping_methods', $chosen_methods ); + do_action( 'woocommerce_shipping_method_chosen', $chosen_method ); + } + return $chosen_method; +} +/** + * Choose the default method for a package. + * + * @since 3.2.0 + * @param string $key + * @param array $package + * @return string + */ +function wc_get_default_shipping_method_for_package( $key, $package, $chosen_method ) { + $rate_keys = array_keys( $package['rates'] ); + $default = current( $rate_keys ); + $coupons = WC()->cart->get_coupons(); + foreach ( $coupons as $coupon ) { + if ( $coupon->get_free_shipping() ) { + foreach ( $rate_keys as $rate_key ) { + if ( 0 === stripos( $rate_key, 'free_shipping' ) ) { + $default = $rate_key; + break; + } + } + break; + } + } + return apply_filters( 'woocommerce_shipping_chosen_method', $default, $package['rates'], $chosen_method ); +} +/** + * See if the methods have changed since the last request. + * + * @since 3.2.0 + * @param int $key + * @param array $package + * @return bool + */ +function wc_shipping_methods_have_changed( $key, $package ) { + // Lookup previous methods from session. + $previous_shipping_methods = WC()->session->get( 'previous_shipping_methods' ); + // Get new and old rates. + $new_rates = array_keys( $package['rates'] ); + $prev_rates = isset( $previous_shipping_methods[ $key ] ) ? $previous_shipping_methods[ $key ] : false; + // Update session. + $previous_shipping_methods[ $key ] = $new_rates; + WC()->session->set( 'previous_shipping_methods', $previous_shipping_methods ); + return $new_rates != $prev_rates; +} diff --git a/includes/wc-core-functions.php b/includes/wc-core-functions.php index 3221f27da39..e0d8a59a714 100644 --- a/includes/wc-core-functions.php +++ b/includes/wc-core-functions.php @@ -1443,6 +1443,66 @@ function wc_get_rounding_precision() { return $precision; } +/** + * Add precision to a number and return an int. + * + * @since 3.2.0 + * @param float $value Number to add precision to. + * @return int + */ +function wc_add_number_precision( $value ) { + $precision = pow( 10, wc_get_price_decimals() ); + return $value * $precision; +} + +/** + * Remove precision from a number and return a float. + * + * @since 3.2.0 + * @param float $value Number to add precision to. + * @return float + */ +function wc_remove_number_precision( $value ) { + $precision = pow( 10, wc_get_price_decimals() ); + return wc_format_decimal( $value / $precision, wc_get_price_decimals() ); +} + +/** + * Add precision to an array of number and return an array of int. + * + * @since 3.2.0 + * @param array $value Number to add precision to. + * @return int + */ +function wc_add_number_precision_deep( $value ) { + if ( is_array( $value ) ) { + foreach ( $value as $key => $subvalue ) { + $value[ $key ] = wc_add_number_precision_deep( $subvalue ); + } + } else { + $value = wc_add_number_precision( $value ); + } + return $value; +} + +/** + * Remove precision from an array of number and return an array of int. + * + * @since 3.2.0 + * @param array $value Number to add precision to. + * @return int + */ +function wc_remove_number_precision_deep( $value ) { + if ( is_array( $value ) ) { + foreach ( $value as $key => $subvalue ) { + $value[ $key ] = wc_remove_number_precision_deep( $subvalue ); + } + } else { + $value = wc_remove_number_precision( $value ); + } + return $value; +} + /** * Get a shared logger instance. * diff --git a/includes/wc-formatting-functions.php b/includes/wc-formatting-functions.php index c4e7bbc98e4..c0d1ba2ff70 100644 --- a/includes/wc-formatting-functions.php +++ b/includes/wc-formatting-functions.php @@ -216,11 +216,12 @@ function wc_trim_zeros( $price ) { /** * Round a tax amount. * - * @param mixed $tax + * @param double $tax Amount to round. + * @param int $dp DP to round. Defaults to wc_get_price_decimals. * @return double */ -function wc_round_tax_total( $tax ) { - $dp = wc_get_price_decimals(); +function wc_round_tax_total( $tax, $dp = null ) { + $dp = is_null( $dp ) ? wc_get_price_decimals() : absint( $dp ); // @codeCoverageIgnoreStart if ( version_compare( phpversion(), '5.3', '<' ) ) { diff --git a/phpunit.xml b/phpunit.xml index f7c911553a9..9e915671956 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,8 +18,10 @@ . - ./apigen/ - ./i18n/ + ./apigen/ + ./assets/ + ./dummy-data/ + ./i18n/ ./includes/api/legacy/ ./includes/gateways/simplify-commerce-deprecated/ ./includes/gateways/simplify-commerce/includes/ @@ -33,6 +35,10 @@ ./includes/vendor/ ./includes/widgets/ ./templates/ + ./tests/ + ./vendor/ + ./.*/ + ./tmp/ ./tests/ ./tmp/ ./vendor/ diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000000..16455121568 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,44 @@ + + + + + ./tests/unit-tests + + + + + . + + ./apigen/ + ./assets/ + ./dummy-data/ + ./i18n/ + ./includes/api/legacy/ + ./includes/gateways/simplify-commerce-deprecated/ + ./includes/gateways/simplify-commerce/includes/ + ./includes/libraries/ + ./includes/shipping/legacy-flat-rate/ + ./includes/shipping/legacy-free-shipping/ + ./includes/shipping/legacy-international-delivery/ + ./includes/shipping/legacy-local-delivery/ + ./includes/shipping/legacy-local-pickup/ + ./includes/updates/ + ./includes/widgets/ + ./templates/ + ./tests/ + ./vendor/ + ./.*/ + ./tmp/ + + + + diff --git a/tests/framework/helpers/class-wc-helper-shipping.php b/tests/framework/helpers/class-wc-helper-shipping.php index 510ad2d3786..d78aef4a715 100644 --- a/tests/framework/helpers/class-wc-helper-shipping.php +++ b/tests/framework/helpers/class-wc-helper-shipping.php @@ -25,7 +25,7 @@ class WC_Helper_Shipping { update_option( 'woocommerce_flat_rate_settings', $flat_rate_settings ); update_option( 'woocommerce_flat_rate', array() ); WC_Cache_Helper::get_transient_version( 'shipping', true ); - WC()->shipping->unregister_shipping_methods(); + WC()->shipping->load_shipping_methods(); } /** diff --git a/tests/unit-tests/cart/cart.php b/tests/unit-tests/cart/cart.php index a00c180c622..5b82efb4c41 100644 --- a/tests/unit-tests/cart/cart.php +++ b/tests/unit-tests/cart/cart.php @@ -150,6 +150,75 @@ class WC_Tests_Cart extends WC_Unit_Test_Case { WC_Helper_Coupon::delete_coupon( $coupon->get_id() ); } + /** + * Test that calculation rounding is done correctly with and without taxes. + * + * @see https://github.com/woocommerce/woocommerce/issues/16305 + * @since 3.2 + */ + public function test_discount_cart_rounding() { + global $wpdb; + + # Test with no taxes. + WC()->cart->empty_cart(); + WC()->cart->remove_coupons(); + + $product = new WC_Product_Simple; + $product->set_regular_price( 51.86 ); + $product->save(); + + $coupon = new WC_Coupon; + $coupon->set_code( 'testpercent' ); + $coupon->set_discount_type( 'percent' ); + $coupon->set_amount( 40 ); + $coupon->save(); + + WC()->cart->add_to_cart( $product->get_id(), 1 ); + WC()->cart->add_discount( $coupon->get_code() ); + + WC()->cart->calculate_totals(); + $cart_item = current( WC()->cart->get_cart() ); + $this->assertEquals( '31.12', number_format( $cart_item['line_total'], 2, '.', '' ) ); + + // Clean up. + WC()->cart->empty_cart(); + WC()->cart->remove_coupons(); + + # Test with taxes. + update_option( 'woocommerce_prices_include_tax', 'no' ); + update_option( 'woocommerce_calc_taxes', 'yes' ); + + $tax_rate = array( + 'tax_rate_country' => '', + 'tax_rate_state' => '', + 'tax_rate' => '8.2500', + 'tax_rate_name' => 'TAX', + 'tax_rate_priority' => '1', + 'tax_rate_compound' => '0', + 'tax_rate_shipping' => '1', + 'tax_rate_order' => '1', + 'tax_rate_class' => '', + ); + WC_Tax::_insert_tax_rate( $tax_rate ); + + WC()->cart->add_to_cart( $product->get_id(), 1 ); + WC()->cart->add_discount( $coupon->get_code() ); + + WC()->cart->calculate_totals(); + $cart_item = current( WC()->cart->get_cart() ); + $this->assertEquals( '33.69', number_format( $cart_item['line_total'] + $cart_item['line_tax'], 2, '.', '' ) ); + + // Clean up. + WC()->cart->empty_cart(); + WC()->cart->remove_coupons(); + WC_Helper_Product::delete_product( $product->get_id() ); + WC_Helper_Coupon::delete_coupon( $coupon->get_id() ); + $wpdb->query( "DELETE FROM {$wpdb->prefix}woocommerce_tax_rates" ); + $wpdb->query( "DELETE FROM {$wpdb->prefix}woocommerce_tax_rate_locations" ); + update_option( 'woocommerce_prices_include_tax', 'no' ); + update_option( 'woocommerce_calc_taxes', 'no' ); + } + /** * Test get_remove_url. * diff --git a/tests/unit-tests/discounts/discount.php b/tests/unit-tests/discounts/discount.php new file mode 100644 index 00000000000..b1a900cc7fa --- /dev/null +++ b/tests/unit-tests/discounts/discount.php @@ -0,0 +1,36 @@ +set_amount( '10' ); + $this->assertEquals( '10', $discount->get_amount() ); + } + + public function test_get_set_type() { + $discount = new WC_Discount; + + $discount->set_discount_type( 'fixed' ); + $this->assertEquals( 'fixed', $discount->get_discount_type() ); + + $discount->set_discount_type( 'percent' ); + $this->assertEquals( 'percent', $discount->get_discount_type() ); + } + + /** + * Test get and set discount total. + */ + public function test_get_set_discount_total() { + $discount = new WC_Discount; + $discount->set_discount_total( 1000 ); + $this->assertEquals( 1000, $discount->get_discount_total() ); + } +} diff --git a/tests/unit-tests/discounts/discounts.php b/tests/unit-tests/discounts/discounts.php new file mode 100644 index 00000000000..01bb91e14e2 --- /dev/null +++ b/tests/unit-tests/discounts/discounts.php @@ -0,0 +1,459 @@ +cart->add_to_cart( $product->get_id(), 1 ); + + // Add product to a dummy order. + $order = new WC_Order(); + $order->add_product( $product, 1 ); + $order->calculate_totals(); + $order->save(); + + // Test setting items to the cart. + $discounts = new WC_Discounts(); + $discounts->set_items_from_cart( WC()->cart ); + $this->assertEquals( 1, count( $discounts->get_items() ) ); + + // Test setting items to an order. + $discounts = new WC_Discounts(); + $discounts->set_items_from_cart( WC()->cart ); + $this->assertEquals( 1, count( $discounts->get_items() ) ); + + // Empty array of items. + $discounts = new WC_Discounts(); + $discounts->set_items_from_cart( array() ); + $this->assertEquals( array(), $discounts->get_items() ); + + // Invalid items. + $discounts = new WC_Discounts(); + $discounts->set_items_from_cart( false ); + $this->assertEquals( array(), $discounts->get_items() ); + + // Cleanup. + WC()->cart->empty_cart(); + $product->delete( true ); + $order->delete( true ); + } + + /** + * 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 = WC_Helper_Coupon::create_coupon( 'test' ); + $coupon->set_amount( 10 ); + + // Apply a percent discount. + $coupon->set_discount_type( 'percent' ); + $discounts->set_items_from_cart( WC()->cart ); + $discounts->apply_discount( $coupon ); + $this->assertEquals( 9, $discounts->get_discounted_price( current( $discounts->get_items() ) ), print_r( $discounts->get_discounts(), true ) ); + + // Apply a fixed cart coupon. + $coupon->set_discount_type( 'fixed_cart' ); + $discounts->set_items_from_cart( WC()->cart ); + $discounts->apply_discount( $coupon ); + $this->assertEquals( 0, $discounts->get_discounted_price( current( $discounts->get_items() ) ), print_r( $discounts->get_discounts(), true ) ); + + // Apply a fixed product coupon. + $coupon->set_discount_type( 'fixed_product' ); + $discounts->set_items_from_cart( WC()->cart ); + $discounts->apply_discount( $coupon ); + $this->assertEquals( 0, $discounts->get_discounted_price( current( $discounts->get_items() ) ), print_r( $discounts->get_discounts(), true ) ); + + // Cleanup. + WC()->cart->empty_cart(); + $product->delete( true ); + $coupon->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' => '', + 'tax_rate' => '20.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 = WC_Tax::_insert_tax_rate( $tax_rate ); + update_option( 'woocommerce_calc_taxes', 'yes' ); + + $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, + ), + array( + 'prices_include_tax' => false, + 'cart' => array( + array( + 'price' => 1, + 'qty' => 1, + ), + array( + 'price' => 1, + 'qty' => 1, + ), + array( + 'price' => 1, + 'qty' => 1, + ), + ), + 'coupons' => array( + array( + 'code' => 'test', + 'discount_type' => 'fixed_cart', + 'amount' => '1', + ), + ), + 'expected_total_discount' => 1, + ), + array( + 'prices_include_tax' => false, + 'cart' => array( + array( + 'price' => 10, + 'qty' => 2, + ), + ), + 'coupons' => array( + array( + 'code' => 'test', + 'discount_type' => 'percent', + 'amount' => '10', + 'limit_usage_to_x_items' => 1, + ), + ), + 'expected_total_discount' => 1, + ), + array( + 'prices_include_tax' => false, + 'cart' => array( + array( + 'price' => 10, + 'qty' => 2, + ), + array( + 'price' => 10, + 'qty' => 2, + ), + ), + 'coupons' => array( + array( + 'code' => 'test', + 'discount_type' => 'percent', + 'amount' => '10', + 'limit_usage_to_x_items' => 1, + ), + ), + 'expected_total_discount' => 1, + ), + ); + + $coupon = WC_Helper_Coupon::create_coupon( 'test' ); + + foreach ( $tests as $test_index => $test ) { + $discounts = new WC_Discounts(); + $products = array(); + + 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->set_items_from_cart( WC()->cart ); + + foreach ( $test['coupons'] as $coupon_props ) { + $coupon->set_props( $coupon_props ); + $discounts->apply_discount( $coupon ); + } + + $all_discounts = $discounts->get_discounts(); + $this->assertEquals( $test['expected_total_discount'], array_sum( $all_discounts['test'] ), '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 ); + } + } + + WC_Tax::_delete_tax_rate( $tax_rate_id ); + update_option( 'woocommerce_calc_taxes', 'no' ); + $coupon->delete( true ); + } + + /** + * Test apply_discount method. + */ + public function test_apply_discount() { + $tax_rate = array( + 'tax_rate_country' => '', + 'tax_rate_state' => '', + 'tax_rate' => '20.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_rate2 = array( + 'tax_rate_country' => '', + 'tax_rate_state' => '', + 'tax_rate' => '20.0000', + 'tax_rate_name' => 'VAT', + 'tax_rate_priority' => '1', + 'tax_rate_compound' => '0', + 'tax_rate_shipping' => '1', + 'tax_rate_order' => '1', + 'tax_rate_class' => 'reduced-rate', + ); + $tax_rate_id = WC_Tax::_insert_tax_rate( $tax_rate ); + $tax_rate_id2 = WC_Tax::_insert_tax_rate( $tax_rate2 ); + update_option( 'woocommerce_calc_taxes', 'yes' ); + + $product = WC_Helper_Product::create_simple_product(); + $product2 = WC_Helper_Product::create_simple_product(); + + $product->set_tax_class( '' ); + $product2->set_tax_class( 'reduced-rate' ); + + $product->save(); + $product2->save(); + + // Add product to the cart. + WC()->cart->empty_cart(); + WC()->cart->add_to_cart( $product->get_id(), 1 ); + WC()->cart->add_to_cart( $product2->get_id(), 1 ); + + $discounts = new WC_Discounts(); + $discounts->set_items_from_cart( WC()->cart ); + + $discounts->apply_discount( '50%' ); + $all_discounts = $discounts->get_discounts(); + $this->assertEquals( 10, $all_discounts['discount-50%'], print_r( $all_discounts, true ) ); + + $discounts->apply_discount( '50%' ); + $all_discounts = $discounts->get_discounts(); + $this->assertEquals( 10, $all_discounts['discount-50%'], print_r( $all_discounts, true ) ); + $this->assertEquals( 5, $all_discounts['discount-50%-2'], print_r( $all_discounts, true ) ); + + // Test fixed discounts. + $discounts = new WC_Discounts(); + $discounts->set_items_from_cart( WC()->cart ); + + $discounts->apply_discount( '5' ); + $all_discounts = $discounts->get_discounts(); + $this->assertEquals( 5, $all_discounts['discount-500'] ); + + $discounts->apply_discount( '5' ); + $all_discounts = $discounts->get_discounts(); + $this->assertEquals( 5, $all_discounts['discount-500'], print_r( $all_discounts, true ) ); + $this->assertEquals( 5, $all_discounts['discount-500-2'], print_r( $all_discounts, true ) ); + + $discounts->apply_discount( '15' ); + $all_discounts = $discounts->get_discounts(); + $this->assertEquals( 5, $all_discounts['discount-500'], print_r( $all_discounts, true ) ); + $this->assertEquals( 5, $all_discounts['discount-500-2'], print_r( $all_discounts, true ) ); + $this->assertEquals( 10, $all_discounts['discount-1500'], print_r( $all_discounts, true ) ); + + // Cleanup. + WC()->cart->empty_cart(); + $product->delete( true ); + $product2->delete( true ); + WC_Tax::_delete_tax_rate( $tax_rate_id ); + WC_Tax::_delete_tax_rate( $tax_rate_id2 ); + update_option( 'woocommerce_calc_taxes', 'no' ); + } +} diff --git a/tests/unit-tests/totals/totals.php b/tests/unit-tests/totals/totals.php new file mode 100644 index 00000000000..ab43f98d7f6 --- /dev/null +++ b/tests/unit-tests/totals/totals.php @@ -0,0 +1,159 @@ +ids = array(); + + if ( ! defined( 'WOOCOMMERCE_CHECKOUT' ) ) { + define( 'WOOCOMMERCE_CHECKOUT', 1 ); + } + + $tax_rate = array( + 'tax_rate_country' => '', + 'tax_rate_state' => '', + 'tax_rate' => '20.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 = WC_Tax::_insert_tax_rate( $tax_rate ); + + update_option( 'woocommerce_calc_taxes', 'yes' ); + update_option( 'woocommerce_default_customer_address', 'base' ); + update_option( 'woocommerce_tax_based_on', 'base' ); + + $product = WC_Helper_Product::create_simple_product(); + $product2 = WC_Helper_Product::create_simple_product(); + + WC_Helper_Shipping::create_simple_flat_rate(); + WC()->session->set( 'chosen_shipping_methods', array( 'flat_rate' ) ); + + $coupon = new WC_Coupon; + $coupon->set_code( 'test-coupon-10' ); + $coupon->set_amount( 10 ); + $coupon->set_discount_type( 'percent' ); + $coupon->save(); + + $this->ids['tax_rate_ids'][] = $tax_rate_id; + $this->ids['products'][] = $product; + $this->ids['products'][] = $product2; + $this->ids['coupons'][] = $coupon; + + WC()->cart->add_to_cart( $product->get_id(), 1 ); + WC()->cart->add_to_cart( $product2->get_id(), 2 ); + WC()->cart->add_discount( $coupon->get_code() ); + + add_action( 'woocommerce_cart_calculate_fees', array( $this, 'add_cart_fees_callback' ) ); + + $this->totals = new WC_Cart_Totals( WC()->cart ); + } + + /** + * Add fees when the fees API is called. + */ + public function add_cart_fees_callback() { + WC()->cart->add_fee( 'test fee', 10, true ); + WC()->cart->add_fee( 'test fee 2', 20, true ); + WC()->cart->add_fee( 'test fee non-taxable', 10, false ); + } + + /** + * Clean up after test. + */ + public function tearDown() { + WC()->cart->empty_cart(); + WC()->session->set( 'chosen_shipping_methods', array() ); + WC_Helper_Shipping::delete_simple_flat_rate(); + update_option( 'woocommerce_calc_taxes', 'no' ); + remove_action( 'woocommerce_cart_calculate_fees', array( $this, 'add_cart_fees_callback' ) ); + + foreach ( $this->ids['products'] as $product ) { + $product->delete( true ); + } + + foreach ( $this->ids['coupons'] as $coupon ) { + $coupon->delete( true ); + wp_cache_delete( WC_Cache_Helper::get_cache_prefix( 'coupons' ) . 'coupon_id_from_code_' . $coupon->get_code(), 'coupons' ); + } + + foreach ( $this->ids['tax_rate_ids'] as $tax_rate_id ) { + WC_Tax::_delete_tax_rate( $tax_rate_id ); + } + + $this->ids = array(); + } + + /** + * Test get and set items. + */ + public function test_get_totals() { + $this->assertEquals( array( + 'fees_total' => 40.00, + 'fees_total_tax' => 6.00, + 'items_subtotal' => 30.00, + 'items_subtotal_tax' => 6.00, + 'items_total' => 27.00, + 'items_total_tax' => 5.40, + 'total' => 90.40, + 'taxes' => array( + $this->ids['tax_rate_ids'][0] => array( + 'tax_total' => 11.40, + 'shipping_tax_total' => 2.00, + ), + ), + 'tax_total' => 11.40, + 'shipping_total' => 10, + 'shipping_tax_total' => 2, + 'discounts_total' => 3.00, + 'discounts_tax_total' => 0.60, + ), $this->totals->get_totals() ); + } + + /** + * Test that cart totals get updated. + */ + public function test_cart_totals() { + $cart = WC()->cart; + + $this->assertEquals( 40.00, $cart->fee_total ); + $this->assertEquals( 27.00, $cart->cart_contents_total ); + $this->assertEquals( 90.40, $cart->total ); + $this->assertEquals( 36.00, $cart->subtotal ); + $this->assertEquals( 30.00, $cart->subtotal_ex_tax ); + $this->assertEquals( 11.40, $cart->tax_total ); + $this->assertEquals( 2.40, $cart->discount_cart ); + $this->assertEquals( 0.60, $cart->discount_cart_tax ); + $this->assertEquals( 40.00, $cart->fee_total ); + $this->assertEquals( 10, $cart->shipping_total ); + $this->assertEquals( 2, $cart->shipping_tax_total ); + } +}