diff --git a/includes/abstracts/abstract-wc-order.php b/includes/abstracts/abstract-wc-order.php index a9e77d0c16e..1eba91fc7ff 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-item.php b/includes/class-wc-cart-item.php index 2ba4afe31bb..694a87cd652 100644 --- a/includes/class-wc-cart-item.php +++ b/includes/class-wc-cart-item.php @@ -51,7 +51,7 @@ class WC_Cart_Item implements ArrayAccess { } /** - * Gets price of the product. + * Gets weight of the product. * @return float */ public function get_weight() { @@ -59,7 +59,7 @@ class WC_Cart_Item implements ArrayAccess { } /** - * Gets price of the product. + * Gets tax class of the product. * @return float */ public function get_tax_class() { diff --git a/includes/class-wc-cart-totals.php b/includes/class-wc-cart-totals.php new file mode 100644 index 00000000000..a12fa3ffd45 --- /dev/null +++ b/includes/class-wc-cart-totals.php @@ -0,0 +1,578 @@ + 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. + * @param object $customer Customer who owns this cart. + */ + public function __construct( &$cart = null, &$customer = null ) { + $this->object = $cart; + $this->calculate_tax = wc_tax_enabled() && ! $customer->get_is_vat_exempt(); + + if ( is_a( $cart, 'WC_Cart' ) ) { + $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->customer ), false ); + $fee->total_tax = array_sum( $fee->taxes ); + + if ( ! $this->round_at_subtotal() ) { + $fee->total_tax = wc_round_tax_total( $fee->total_tax, 0 ); + } + } + + $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, 0 ); + } + + $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. + * + * @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->tax_class ); + + 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 ) { + return $this->items[ $item_key ]->subtotal - $this->discount_totals[ $item_key ]; + } + + /** + * 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->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, 0 ); + } + + if ( $item->price_includes_tax ) { + $item->total = $item->total - $item->total_tax; + } else { + $item->total = $item->total; + } + } + } + + $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' ) ) ) ); + + $this->object->subtotal = $this->get_total( 'items_total' ) + $this->get_total( 'items_total_tax' ); + $this->object->subtotal_ex_tax = $this->get_total( 'items_total' ); + } + + /** + * 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 ) { + 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, 0 ); + } + + if ( $item->price_includes_tax ) { + $item->subtotal = $item->subtotal - $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_total' ) + $this->get_total( 'items_total_tax' ); + $this->object->subtotal_ex_tax = $this->get_total( 'items_total' ); + } + + /** + * 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->items ); + + foreach ( $this->coupons as $coupon ) { + $discounts->apply_coupon( $coupon ); + } + + $this->discount_totals = $discounts->get_discounts( true ); + $this->totals['discounts_total'] = array_sum( $this->discount_totals ); + + // See how much tax was 'discounted'. + if ( $this->calculate_tax ) { + foreach ( $this->discount_totals as $cart_item_key => $discount ) { + $item = $this->items[ $cart_item_key ]; + if ( $item->product->is_taxable() ) { + $taxes = WC_Tax::calc_tax( $discount, $item->tax_rates, false ); + $this->totals['discounts_tax_total'] += $this->round_at_subtotal() ? array_sum( $taxes ) : wc_round_tax_total( array_sum( $taxes ), 0 ); + } + } + } + + $applied_coupons = $discounts->get_applied_coupons(); + $this->object->coupon_discount_amounts = wp_list_pluck( $applied_coupons, 'discount' ); + $this->object->coupon_discount_tax_amounts = wp_list_pluck( $applied_coupons, 'discount_tax' ); + } + + /** + * 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->tax_total = $this->get_total( 'tax_total' ); + $this->object->total = $this->get_total( '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 23e17377e94..3db24df3ed9 100644 --- a/includes/class-wc-cart.php +++ b/includes/class-wc-cart.php @@ -18,6 +18,12 @@ if ( ! defined( 'ABSPATH' ) ) { */ class WC_Cart { + /** + * This stores the chosen shipping methods for the cart item packages. + * @var array + */ + protected $shipping_methods; + /** @var array Contains an array of cart items. */ public $cart_contents = array(); @@ -295,6 +301,7 @@ class WC_Cart { */ 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 ); @@ -1452,15 +1459,32 @@ 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 + * @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; } /** diff --git a/includes/class-wc-coupon.php b/includes/class-wc-coupon.php index 16cbb28ec0a..df1420eb2a9 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 ); } /* @@ -763,6 +741,7 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * Ensure coupon exists or throw exception. * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_exists. * @throws Exception */ private function validate_exists() { @@ -774,6 +753,7 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * Ensure coupon usage limit is valid or throw exception. * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_usage_limit. * @throws Exception */ private function validate_usage_limit() { @@ -788,6 +768,7 @@ class WC_Coupon extends WC_Legacy_Coupon { * 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(). * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_user_usage_limit. * @param int $user_id * @throws Exception */ @@ -806,6 +787,7 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * Ensure coupon date is valid or throw exception. * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_expiry_date. * @throws Exception */ private function validate_expiry_date() { @@ -817,6 +799,7 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * Ensure coupon amount is valid or throw exception. * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_minimum_amount. * @throws Exception */ private function validate_minimum_amount() { @@ -828,6 +811,7 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * Ensure coupon amount is valid or throw exception. * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_maximum_amount. * @throws Exception */ private function validate_maximum_amount() { @@ -839,6 +823,7 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * Ensure coupon is valid for products in the cart is valid or throw exception. * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_product_ids. * @throws Exception */ private function validate_product_ids() { @@ -860,6 +845,7 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * Ensure coupon is valid for product categories in the cart is valid or throw exception. * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_product_categories. * @throws Exception */ private function validate_product_categories() { @@ -887,6 +873,7 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * Ensure coupon is valid for sale items in the cart is valid or throw exception. * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_sale_items. * @throws Exception */ private function validate_sale_items() { @@ -910,6 +897,9 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * All exclusion rules must pass at the same time for a product coupon to be valid. + * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_excluded_items. + * @throws Exception */ private function validate_excluded_items() { if ( ! WC()->cart->is_empty() && $this->is_type( wc_get_product_coupon_types() ) ) { @@ -930,6 +920,8 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * Cart discounts cannot be added if non-eligible product is found in cart. + * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_eligible_items. */ private function validate_cart_excluded_items() { if ( ! $this->is_type( wc_get_product_coupon_types() ) ) { @@ -941,10 +933,11 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * Exclude products from cart. * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_excluded_product_ids. * @throws Exception */ private function validate_cart_excluded_product_ids() { - // Exclude Products + // Exclude Products. if ( sizeof( $this->get_excluded_product_ids() ) > 0 ) { $valid_for_cart = true; if ( ! WC()->cart->is_empty() ) { @@ -963,6 +956,7 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * Exclude categories from cart. * + * @deprecated 3.2.0 In favor of WC_Discounts->validate_coupon_excluded_product_categories. * @throws Exception */ private function validate_cart_excluded_product_categories() { @@ -989,10 +983,13 @@ class WC_Coupon extends WC_Legacy_Coupon { /** * Check if a coupon is valid. * - * @return boolean validity + * @deprecated 3.2.0 In favor of WC_Discounts->is_coupon_valid. * @throws Exception + * @return bool Validity. */ public function is_valid() { + wc_deprecated_function( 'is_valid', '3.2', 'WC_Discounts->is_coupon_valid' ); + try { $this->validate_exists(); $this->validate_usage_limit(); diff --git a/includes/class-wc-discounts.php b/includes/class-wc-discounts.php index ff6e934ba63..2c82cf860d9 100644 --- a/includes/class-wc-discounts.php +++ b/includes/class-wc-discounts.php @@ -8,10 +8,12 @@ * @since 3.2.0 */ +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + /** * Discounts class. - * - * @todo this class will need to be called instead get_discounted_price, in the cart? */ class WC_Discounts { @@ -36,18 +38,13 @@ class WC_Discounts { */ protected $applied_coupons = array(); - /** - * Precision so we can work in cents. - * - * @var int - */ - protected $precision = 1; - /** * Constructor. + * + * @param array $items Items to discount. */ - public function __construct() { - $this->precision = pow( 10, wc_get_price_decimals() ); + public function __construct( $items = array() ) { + $this->set_items( $items ); } /** @@ -64,38 +61,40 @@ class WC_Discounts { * Get discount by key without precision. * * @since 3.2.0 + * @param string $key name of discount row to return. * @return array */ public function get_discount( $key ) { - return isset( $this->discounts[ $key ] ) ? $this->remove_precision( $this->discounts[ $key ] ) : 0; + return isset( $this->discounts[ $key ] ) ? wc_remove_number_precision_deep( $this->discounts[ $key ] ) : 0; } /** - * Get all discount totals without precision. + * Get all discount totals with precision. * * @since 3.2.0 + * @param bool $in_cents Should the totals be returned in cents, or without precision. * @return array */ - public function get_discounts() { - return array_map( array( $this, 'remove_precision' ), $this->discounts ); + public function get_discounts( $in_cents = false ) { + return $in_cents ? $this->discounts : wc_remove_number_precision_deep ( $this->discounts ); } /** * Get discounted price of an item without precision. * * @since 3.2.0 - * @param object $item + * @param object $item Get data for this item. * @return float */ public function get_discounted_price( $item ) { - return $this->remove_precision( $this->get_discounted_price_in_cents( $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 + * @param object $item Get data for this item. * @return float */ public function get_discounted_price_in_cents( $item ) { @@ -110,49 +109,30 @@ class WC_Discounts { * @return array */ public function get_applied_coupons() { - return array_map( array( $this, 'remove_precision' ), $this->applied_coupons ); + return wc_remove_number_precision_deep( $this->applied_coupons ); } /** * Set cart/order items which will be discounted. * * @since 3.2.0 - * @param array $raw_items List of raw cart or order items. + * @param array $items List items. */ - public function set_items( $raw_items ) { + public function set_items( $items ) { $this->items = array(); $this->discounts = array(); $this->applied_coupons = array(); - if ( ! empty( $raw_items ) && is_array( $raw_items ) ) { - foreach ( $raw_items as $raw_item ) { - $item = (object) array( - 'price' => 0, // Line price without discounts, in cents. - 'quantity' => 0, // Line qty. - 'product' => false, - ); - if ( is_a( $raw_item, 'WC_Cart_Item' ) ) { - //$item->quantity = $raw_item->get_quantity(); - //$item->price = $raw_item->get_price() * $raw_item->get_quantity(); - //$item->is_taxable = $raw_item->is_taxable(); - //$item->tax_class = $raw_item->get_tax_class(); - // @todo - } elseif ( is_a( $raw_item, 'WC_Order_Item_Product' ) ) { - $item->key = $raw_item->get_id(); - $item->quantity = $raw_item->get_quantity(); - $item->price = $raw_item->get_subtotal() * $this->precision; - $item->product = $raw_item->get_product(); - } else { - $item->key = $raw_item['key']; - $item->quantity = $raw_item['quantity']; - $item->price = $raw_item['data']->get_price() * $this->precision * $raw_item['quantity']; - $item->product = $raw_item['data']; - } - $this->items[ $item->key ] = $item; - $this->discounts[ $item->key ] = 0; + if ( ! empty( $items ) && is_array( $items ) ) { + foreach ( $items as $key => $item ) { + $this->items[ $key ] = $item; + $this->items[ $key ]->key = $key; + $this->items[ $key ]->price = $item->subtotal; } - uasort( $this->items, array( $this, 'sort_by_price' ) ); + $this->discounts = array_fill_keys( array_keys( $items ), 0 ); } + + uasort( $this->items, array( $this, 'sort_by_price' ) ); } /** @@ -204,59 +184,77 @@ class WC_Discounts { /** * Apply a discount to all items using a coupon. * - * @todo Coupon class has lots of WC()->cart calls and needs decoupling. This makes 'is valid' hard to use here. - * @todo is_valid_for_product accepts values - how can we deal with that? - * * @since 3.2.0 - * @param WC_Coupon $coupon - * @return bool True if applied. + * @param WC_Coupon $coupon Coupon object being applied to the items. + * @return bool|WP_Error True if applied or WP_Error instance in failure. */ public function apply_coupon( $coupon ) { if ( ! is_a( $coupon, 'WC_Coupon' ) ) { return false; } - if ( ! isset( $this->applied_coupons[ $coupon->get_code() ] ) ) { - $this->applied_coupons[ $coupon->get_code() ] = 0; + $is_coupon_valid = $this->is_coupon_valid( $coupon ); + + if ( is_wp_error( $is_coupon_valid ) ) { + return $is_coupon_valid; } - // @todo how can we support the old woocommerce_coupon_get_discount_amount filter? - // @todo is valid for product - filter items here and pass to function? - $items_to_apply = $this->get_items_to_apply_coupon( $coupon ); + if ( ! isset( $this->applied_coupons[ $coupon->get_code() ] ) ) { + $this->applied_coupons[ $coupon->get_code() ] = array( + 'discount' => 0, + 'discount_tax' => 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->applied_coupons[ $coupon->get_code() ] += $this->apply_percentage_discount( $items_to_apply, $coupon->get_amount() ); + $this->apply_percentage_discount( $items_to_apply, $coupon->get_amount(), $coupon ); break; case 'fixed_product' : - $this->applied_coupons[ $coupon->get_code() ] += $this->apply_fixed_product_discount( $items_to_apply, $coupon->get_amount() * $this->precision ); + $this->apply_fixed_product_discount( $items_to_apply, wc_add_number_precision( $coupon->get_amount() ), $coupon ); break; case 'fixed_cart' : - $this->applied_coupons[ $coupon->get_code() ] += $this->apply_fixed_cart_discount( $items_to_apply, $coupon->get_amount() * $this->precision ); + $this->apply_fixed_cart_discount( $items_to_apply, wc_add_number_precision( $coupon->get_amount() ), $coupon ); + break; + default : + if ( has_action( 'woocommerce_discounts_apply_coupon_' . $coupon_type ) ) { + // Allow custom coupon types to control this in their class per item, unless the new action is used. + do_action( 'woocommerce_discounts_apply_coupon_' . $coupon_type, $coupon, $items_to_apply, $this ); + } else { + // Fallback to old coupon-logic. + foreach ( $items_to_apply as $item ) { + $discounted_price = $this->get_discounted_price_in_cents( $item ); + $price_to_discount = ( 'yes' === get_option( 'woocommerce_calc_discounts_sequentially', 'no' ) ) ? $item->price : $discounted_price; + $discount = min( $discounted_price, $coupon->get_discount_amount( $price_to_discount, $item->object ) ); + $discount_tax = $this->get_item_discount_tax( $item, $discount ); + + // Store totals. + $this->discounts[ $item->key ] += $discount; + if ( $coupon ) { + $this->applied_coupons[ $coupon->get_code() ]['discount'] += $discount; + $this->applied_coupons[ $coupon->get_code() ]['discount_tax'] += $discount_tax; + } + } + } break; } } - /** - * Remove precision from a price. - * - * @param int $value - * @return float - */ - protected function remove_precision( $value ) { - return wc_format_decimal( $value / $this->precision, wc_get_price_decimals() ); - } - /** * Sort by price. * - * @param array $a - * @param array $b + * @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;; + $price_2 = $b->price * $b->quantity; if ( $price_1 === $price_2 ) { return 0; } @@ -267,7 +265,8 @@ class WC_Discounts { * Filter out all products which have been fully discounted to 0. * Used as array_filter callback. * - * @param object $item + * @since 3.2.0 + * @param object $item Get data for this item. * @return bool */ protected function filter_products_with_price( $item ) { @@ -277,7 +276,8 @@ class WC_Discounts { /** * Get items which the coupon should be applied to. * - * @param object $coupon + * @since 3.2.0 + * @param object $coupon Coupon object. * @return array */ protected function get_items_to_apply_coupon( $coupon ) { @@ -293,7 +293,7 @@ class WC_Discounts { if ( 0 === $this->get_discounted_price_in_cents( $item ) ) { continue; } - if ( ! $coupon->is_valid_for_product( $item->product ) && ! $coupon->is_valid_for_cart() ) { // @todo is this enough? + 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 ) { @@ -314,36 +314,38 @@ class WC_Discounts { } /** - * Apply a discount amount to an item and ensure it does not go negative. + * Apply percent discount to items and return an array of discounts granted. * * @since 3.2.0 - * @param object $item - * @param int $discount - * @return int Amount discounted. + * @param array $items_to_apply Array of items to apply the coupon to. + * @param int $amount Amount of discount. + * @param WC_Coupon $coupon Coupon object if appliable. Passed through filters. + * @return int Total discounted. */ - protected function add_item_discount( &$item, $discount ) { - $discounted_price = $this->get_discounted_price_in_cents( $item ); - $discount = $discount > $discounted_price ? $discounted_price : $discount; - $this->discounts[ $item->key ] = $this->discounts[ $item->key ] + $discount; - return $discount; - } - - /** - * Apply percent discount to items. - * - * @since 3.2.0 - * @param array $items_to_apply Array of items to apply the coupon to. - * @param int $amount - * @return int total discounted in cents - */ - protected function apply_percentage_discount( $items_to_apply, $amount ) { - $total_discounted = 0; + protected function apply_percentage_discount( $items_to_apply, $amount, $coupon = null ) { + $total_discount = 0; foreach ( $items_to_apply as $item ) { - $total_discounted += $this->add_item_discount( $item, $amount * ( $this->get_discounted_price_in_cents( $item ) / 100 ) ); - } + // Find out how much price is available to discount for the item. + $discounted_price = $this->get_discounted_price_in_cents( $item ); - return $total_discounted; + // 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 * ( $price_to_discount / 100 ); + $discount = min( $discounted_price, apply_filters( 'woocommerce_coupon_get_discount_amount', $discount, $price_to_discount, $item->object, false, $coupon ) ); + $discount_tax = $this->get_item_discount_tax( $item, $discount ); + + // Store totals. + $total_discount += $discount; + $this->discounts[ $item->key ] += $discount; + if ( $coupon ) { + $this->applied_coupons[ $coupon->get_code() ]['discount'] += $discount; + $this->applied_coupons[ $coupon->get_code() ]['discount_tax'] += $discount_tax; + } + } + return $total_discount; } /** @@ -351,17 +353,34 @@ class WC_Discounts { * * @since 3.2.0 * @param array $items_to_apply Array of items to apply the coupon to. - * @param int $amount - * @return int total discounted in cents + * @param int $amount Amount of discount. + * @param WC_Coupon $coupon Coupon object if appliable. Passed through filters. + * @return int Total discounted. */ - protected function apply_fixed_product_discount( $items_to_apply, $discount ) { - $total_discounted = 0; + protected function apply_fixed_product_discount( $items_to_apply, $amount, $coupon = null ) { + $total_discount = 0; foreach ( $items_to_apply as $item ) { - $total_discounted += $this->add_item_discount( $item, $discount * $item->quantity ); - } + // Find out how much price is available to discount for the item. + $discounted_price = $this->get_discounted_price_in_cents( $item ); - return $total_discounted; + // 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 ) ); + $discount_tax = $this->get_item_discount_tax( $item, $discount ); + + // Store totals. + $total_discount += $discount; + $this->discounts[ $item->key ] += $discount; + if ( $coupon ) { + $this->applied_coupons[ $coupon->get_code() ]['discount'] += $discount; + $this->applied_coupons[ $coupon->get_code() ]['discount_tax'] += $discount_tax; + } + } + return $total_discount; } /** @@ -369,50 +388,466 @@ class WC_Discounts { * * @since 3.2.0 * @param array $items_to_apply Array of items to apply the coupon to. - * @param int $cart_discount - * @return int total discounted in cents + * @param int $cart_discount Fixed discount amount to apply. + * @param WC_Coupon $coupon Coupon object if appliable. Passed through filters. + * @return int Total discounted. */ - protected function apply_fixed_cart_discount( $items_to_apply, $cart_discount ) { - $items_to_apply = array_filter( $items_to_apply, array( $this, 'filter_products_with_price' ) ); + protected function apply_fixed_cart_discount( $items_to_apply, $cart_discount, $coupon = null ) { + $total_discount = 0; + $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 0; + return $total_discount; } - $per_item_discount = floor( $cart_discount / $item_count ); // round it down to the nearest cent number. - $amount_discounted = 0; + $per_item_discount = floor( $cart_discount / $item_count ); // round it down to the nearest cent. if ( $per_item_discount > 0 ) { - foreach ( $items_to_apply as $item ) { - $amount_discounted += $this->add_item_discount( $item, $per_item_discount * $item->quantity ); - } + $total_discounted = $this->apply_fixed_product_discount( $items_to_apply, $per_item_discount, $coupon ); /** * If there is still discount remaining, repeat the process. */ - if ( $amount_discounted > 0 && $amount_discounted < $cart_discount ) { - $amount_discounted += $this->apply_fixed_cart_discount( $items_to_apply, $cart_discount - $amount_discounted ); + if ( $total_discounted > 0 && $total_discounted < $cart_discount ) { + $total_discounted = $total_discounted + $this->apply_fixed_cart_discount( $items_to_apply, $cart_discount - $total_discounted ); } - /** - * Deal with remaining fractional discounts by splitting it over items - * until the amount is expired, discounting 1 cent at a time. - */ - } elseif ( $cart_discount > 0 ) { - foreach ( $items_to_apply as $item ) { - for ( $i = 0; $i < $item->quantity; $i ++ ) { - $amount_discounted += $this->add_item_discount( $item, 1 ); + } elseif ( $cart_discount > 0 ) { + $total_discounted = $this->apply_fixed_cart_discount_remainder( $items_to_apply, $cart_discount, $coupon ); + } + return $total_discount; + } - if ( $amount_discounted >= $cart_discount ) { - break 2; - } + /** + * 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 array $items_to_apply Array of items to apply the coupon to. + * @param int $cart_discount Fixed discount amount to apply. + * @param WC_Coupon $coupon Coupon object if appliable. Passed through filters. + * @return int Total discounted. + */ + protected function apply_fixed_cart_discount_remainder( $items_to_apply, $remaining_discount, $coupon = null ) { + $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 ); + $discount_tax = $this->get_item_discount_tax( $item, $discount ); + + // Store totals. + $total_discount += $discount; + $this->discounts[ $item->key ] += $discount; + if ( $coupon ) { + $this->applied_coupons[ $coupon->get_code() ]['discount'] += $discount; + $this->applied_coupons[ $coupon->get_code() ]['discount_tax'] += $discount_tax; } - if ( $amount_discounted >= $cart_discount ) { - break; + + if ( $total_discount >= $remaining_discount ) { + break 2; } } + if ( $total_discount >= $remaining_discount ) { + break; + } + } + return $total_discount; + } + + /** + * 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; + } + + /* + |-------------------------------------------------------------------------- + | 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 $amount_discounted; + 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_discount_is_coupon_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 7132f694eb4..f0973e1d8dc 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 58876dd9f54..0708a7ee6fc 100644 --- a/includes/class-woocommerce.php +++ b/includes/class-woocommerce.php @@ -334,6 +334,7 @@ final class WooCommerce { 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/wc-cart-functions.php b/includes/wc-cart-functions.php index 2f69aa152b9..386424062ff 100644 --- a/includes/wc-cart-functions.php +++ b/includes/wc-cart-functions.php @@ -388,3 +388,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 e0b52734630..05543405ed3 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/tests/unit-tests/discounts/discounts.php b/tests/unit-tests/discounts/discounts.php index 5941aa91ad4..30cde832a0a 100644 --- a/tests/unit-tests/discounts/discounts.php +++ b/tests/unit-tests/discounts/discounts.php @@ -6,6 +6,34 @@ */ class WC_Tests_Discounts extends WC_Unit_Test_Case { + protected function get_items_for_discounts_class() { + $items = array(); + $precision = pow( 10, wc_get_price_decimals() ); + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + $item = (object) array( + 'key' => '', + 'quantity' => 0, + 'price' => 0, + 'product' => false, + 'price_includes_tax' => wc_prices_include_tax(), + 'subtotal' => 0, + 'subtotal_tax' => 0, + 'subtotal_taxes' => array(), + 'total' => 0, + 'total_tax' => 0, + 'taxes' => array(), + 'discounted_price' => 0, + ); + $item->object = $cart_item; + $item->quantity = $cart_item['quantity']; + $item->subtotal = $cart_item['data']->get_price() * $precision * $cart_item['quantity']; + $item->product = $cart_item['data']; + $item->tax_rates = WC_Tax::get_rates( $item->product->get_tax_class() ); + $items[ $cart_item_key ] = $item; + } + return $items; + } + /** * Test get and set items. */ @@ -24,12 +52,12 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { // Test setting items to the cart. $discounts = new WC_Discounts(); - $discounts->set_items( WC()->cart->get_cart() ); + $discounts->set_items( $this->get_items_for_discounts_class() ); $this->assertEquals( 1, count( $discounts->get_items() ) ); // Test setting items to an order. $discounts = new WC_Discounts(); - $discounts->set_items( $order->get_items() ); + $discounts->set_items( $this->get_items_for_discounts_class() ); $this->assertEquals( 1, count( $discounts->get_items() ) ); // Empty array of items. @@ -136,45 +164,50 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { $discounts = new WC_Discounts(); $product = WC_Helper_Product::create_simple_product(); WC()->cart->add_to_cart( $product->get_id(), 1 ); - $discounts->set_items( WC()->cart->get_cart() ); + $discounts->set_items( $this->get_items_for_discounts_class() ); // Test applying multiple coupons and getting totals. - $coupon = new WC_Coupon; - $coupon->set_code( 'test' ); + $coupon = WC_Helper_Coupon::create_coupon( 'test' ); $coupon->set_amount( 50 ); $coupon->set_discount_type( 'percent' ); + $coupon->save(); $discounts->apply_coupon( $coupon ); - $this->assertEquals( array( 'test' => 5 ), $discounts->get_applied_coupons() ); + $this->assertEquals( array( 'test' => array( 'discount' => 5, 'discount_tax' => 0 ) ), $discounts->get_applied_coupons() ); - $coupon2 = new WC_Coupon; + $coupon2 = WC_Helper_Coupon::create_coupon( 'test2' ); $coupon2->set_code( 'test2' ); $coupon2->set_amount( 50 ); $coupon2->set_discount_type( 'percent' ); + $coupon->save(); $discounts->apply_coupon( $coupon2 ); - $this->assertEquals( array( 'test' => 5, 'test2' => 2.50 ), $discounts->get_applied_coupons() ); + $this->assertEquals( array( 'test' => array( 'discount' => 5, 'discount_tax' => 0 ), 'test2' => array( 'discount' => 2.50, 'discount_tax' => 0 ) ), $discounts->get_applied_coupons() ); $discounts->apply_coupon( $coupon ); - $this->assertEquals( array( 'test' => 6.25, 'test2' => 2.50 ), $discounts->get_applied_coupons() ); + $this->assertEquals( array( 'test' => array( 'discount' => 6.25, 'discount_tax' => 0 ), 'test2' => array( 'discount' => 2.50, 'discount_tax' => 0 ) ), $discounts->get_applied_coupons() ); // Test different coupon types. WC()->cart->empty_cart(); WC()->cart->add_to_cart( $product->get_id(), 2 ); $coupon->set_discount_type( 'fixed_product' ); $coupon->set_amount( 2 ); - $discounts->set_items( WC()->cart->get_cart() ); + $coupon->save(); + $discounts->set_items( $this->get_items_for_discounts_class() ); $discounts->apply_coupon( $coupon ); - $this->assertEquals( array( 'test' => 4 ), $discounts->get_applied_coupons() ); + $this->assertEquals( array( 'test' => array( 'discount' => 4, 'discount_tax' => 0 ) ), $discounts->get_applied_coupons() ); $coupon->set_discount_type( 'fixed_cart' ); - $discounts->set_items( WC()->cart->get_cart() ); + $coupon->save(); + $discounts->set_items( $this->get_items_for_discounts_class() ); $discounts->apply_coupon( $coupon ); - $this->assertEquals( array( 'test' => 2 ), $discounts->get_applied_coupons() ); + $this->assertEquals( array( 'test' => array( 'discount' => 2, 'discount_tax' => 0 ) ), $discounts->get_applied_coupons() ); // Cleanup. WC()->cart->empty_cart(); $product->delete( true ); + $coupon->delete( true ); + $coupon2->delete( true ); } /** @@ -189,31 +222,31 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { $product->save(); WC()->cart->empty_cart(); WC()->cart->add_to_cart( $product->get_id(), 1 ); - $coupon = new WC_Coupon; - $coupon->set_code( 'test' ); + $coupon = WC_Helper_Coupon::create_coupon( 'test' ); $coupon->set_amount( 10 ); // Apply a percent discount. $coupon->set_discount_type( 'percent' ); - $discounts->set_items( WC()->cart->get_cart() ); + $discounts->set_items( $this->get_items_for_discounts_class() ); $discounts->apply_coupon( $coupon ); $this->assertEquals( 9, $discounts->get_discounted_price( current( $discounts->get_items() ) ) ); // Apply a fixed cart coupon. $coupon->set_discount_type( 'fixed_cart' ); - $discounts->set_items( WC()->cart->get_cart() ); + $discounts->set_items( $this->get_items_for_discounts_class() ); $discounts->apply_coupon( $coupon ); $this->assertEquals( 0, $discounts->get_discounted_price( current( $discounts->get_items() ) ) ); // Apply a fixed product coupon. $coupon->set_discount_type( 'fixed_product' ); - $discounts->set_items( WC()->cart->get_cart() ); + $discounts->set_items( $this->get_items_for_discounts_class() ); $discounts->apply_coupon( $coupon ); $this->assertEquals( 0, $discounts->get_discounted_price( current( $discounts->get_items() ) ) ); // Cleanup. WC()->cart->empty_cart(); $product->delete( true ); + $coupon->delete( true ); } /** @@ -241,14 +274,14 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { array( 'price' => 10, 'qty' => 1, - ) + ), ), 'coupons' => array( array( 'code' => 'test', 'discount_type' => 'percent', 'amount' => '20', - ) + ), ), 'expected_total_discount' => 2, ), @@ -258,14 +291,14 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { array( 'price' => 10, 'qty' => 2, - ) + ), ), 'coupons' => array( array( 'code' => 'test', 'discount_type' => 'fixed_cart', 'amount' => '10', - ) + ), ), 'expected_total_discount' => 10, ), @@ -279,14 +312,14 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { array( 'price' => 10, 'qty' => 1, - ) + ), ), 'coupons' => array( array( 'code' => 'test', 'discount_type' => 'fixed_cart', 'amount' => '10', - ) + ), ), 'expected_total_discount' => 10, ), @@ -304,14 +337,14 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { array( 'price' => 10, 'qty' => 1, - ) + ), ), 'coupons' => array( array( 'code' => 'test', 'discount_type' => 'fixed_cart', 'amount' => '10', - ) + ), ), 'expected_total_discount' => 10, ), @@ -329,14 +362,14 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { array( 'price' => 10, 'qty' => 2, - ) + ), ), 'coupons' => array( array( 'code' => 'test', 'discount_type' => 'fixed_cart', 'amount' => '10', - ) + ), ), 'expected_total_discount' => 10, ), @@ -386,14 +419,14 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { array( 'price' => 10, 'qty' => 1, - ) + ), ), 'coupons' => array( array( 'code' => 'test', 'discount_type' => 'fixed_cart', 'amount' => '10', - ) + ), ), 'expected_total_discount' => 10, ), @@ -411,14 +444,14 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { array( 'price' => 1, 'qty' => 1, - ) + ), ), 'coupons' => array( array( 'code' => 'test', 'discount_type' => 'fixed_cart', 'amount' => '1', - ) + ), ), 'expected_total_discount' => 1, ), @@ -428,7 +461,7 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { array( 'price' => 10, 'qty' => 2, - ) + ), ), 'coupons' => array( array( @@ -436,7 +469,7 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { 'discount_type' => 'percent', 'amount' => '10', 'limit_usage_to_x_items' => 1, - ) + ), ), 'expected_total_discount' => 1, ), @@ -450,7 +483,7 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { array( 'price' => 10, 'qty' => 2, - ) + ), ), 'coupons' => array( array( @@ -458,12 +491,14 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { '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(); @@ -477,10 +512,9 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { $products[] = $product; } - $discounts->set_items( WC()->cart->get_cart() ); + $discounts->set_items( $this->get_items_for_discounts_class() ); foreach ( $test['coupons'] as $coupon_props ) { - $coupon = new WC_Coupon; $coupon->set_props( $coupon_props ); $discounts->apply_coupon( $coupon ); } @@ -497,5 +531,6 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case { WC_Tax::_delete_tax_rate( $tax_rate_id ); update_option( 'woocommerce_calc_taxes', 'no' ); + $coupon->delete( true ); } } diff --git a/tests/unit-tests/totals/totals.php b/tests/unit-tests/totals/totals.php new file mode 100644 index 00000000000..b3b197807cf --- /dev/null +++ b/tests/unit-tests/totals/totals.php @@ -0,0 +1,150 @@ +ids = array(); + + $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' ); + + $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, WC()->customer ); + } + + /** + * 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 ); + } + } + + /** + * 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( 32.40, $cart->subtotal ); + $this->assertEquals( 27.00, $cart->subtotal_ex_tax ); + $this->assertEquals( 11.40, $cart->tax_total ); + $this->assertEquals( 3.00, $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 ); + } +}