diff --git a/includes/abstracts/abstract-wc-order.php b/includes/abstracts/abstract-wc-order.php index 0fead86f8f7..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(); diff --git a/includes/abstracts/abstract-wc-totals.php b/includes/abstracts/abstract-wc-totals.php deleted file mode 100644 index 1e5578311a1..00000000000 --- a/includes/abstracts/abstract-wc-totals.php +++ /dev/null @@ -1,511 +0,0 @@ - 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 $object Cart or order object to calculate totals for. - */ - public function __construct( &$object = null ) { - $this->precision = pow( 10, wc_get_price_decimals() ); - $this->object = $object; - } - - /** - * Run all calculations methods on the given items in sequence. @todo More documentation, and add other calculation methods for taxes and totals only? - * - * @since 3.2.0 - * @param bool $calculate_taxes Whether to calculate taxes (optional). - */ - public function calculate( $calculate_taxes = true ) { - $this->calculate_taxes = (bool) $calculate_taxes; - - $this->calculate_item_totals(); - $this->calculate_fee_totals(); - $this->calculate_shipping_totals(); - $this->calculate_totals(); - } - - /** - * Handles a cart or order object passed in for calculation. Normalises data - * into the same format for use by this class. - * - * @since 3.2.0 - */ - protected function set_items() { - $this->items = array(); - } - - /** - * Get and normalise fees. - * - * @since 3.2.0 - */ - protected function set_fees() { - $this->fees = array(); - } - - /** - * Get shipping methods and normalise. - * - * @since 3.2.0 - */ - protected function set_shipping() { - $this->shipping = array(); - } - - /** - * Set array of coupon objects from the cart or an order. - * - * @since 3.2.0 - */ - protected function set_coupons() { - $this->coupons = array(); - } - - /** - * Add precision (deep) to a price. - * - * @since 3.2.0 - * @param int|array $value Value to remove precision from. - * @return float - */ - protected function add_precision( $value ) { - if ( is_array( $value ) ) { - foreach ( $value as $key => $subvalue ) { - $value[ $key ] = $this->add_precision( $subvalue ); - } - } else { - $value = $value * $this->precision; - } - return $value; - } - - /** - * Remove precision (deep) from a price. - * - * @since 3.2.0 - * @param int|array $value Value to remove precision from. - * @return float - */ - protected function remove_precision( $value ) { - if ( is_array( $value ) ) { - foreach ( $value as $key => $subvalue ) { - $value[ $key ] = $this->remove_precision( $subvalue ); - } - } else { - $value = wc_format_decimal( $value / $this->precision, wc_get_price_decimals() ); - } - return $value; - } - - /** - * 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(), - ); - } - - /** - * 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 ); - $item_tax_rates = $this->get_item_tax_rates( $item ); - - 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() ); - } - - /** - * 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 : array_map( array( $this, 'remove_precision' ), $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 ( $this->calculate_taxes && wc_tax_enabled() && $item->product->is_taxable() ) { - $item->taxes = WC_Tax::calc_tax( $item->total, $this->get_item_tax_rates( $item ), $item->price_includes_tax ); - $item->total_tax = array_sum( $item->taxes ); - - 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' ) ) ) ); - if ( $this->calculate_taxes ) { - $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 ) { - 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_taxes && wc_tax_enabled() && $item->product->is_taxable() ) { - $subtotal_taxes = WC_Tax::calc_tax( $item->subtotal, $this->get_item_tax_rates( $item ), $item->price_includes_tax ); - $item->subtotal_tax = array_sum( $subtotal_taxes ); - - 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' ) ) ) ); - if ( $this->calculate_taxes ) { - $this->set_total( 'items_subtotal_tax', array_sum( array_values( wp_list_pluck( $this->items, 'subtotal_tax' ) ) ) ); - } - } - - /** - * 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_taxes && wc_tax_enabled() ) { - foreach ( $this->discount_totals as $cart_item_key => $discount ) { - $item = $this->items[ $cart_item_key ]; - if ( $item->product->is_taxable() ) { - $tax_rates = $this->get_item_tax_rates( $item ); - $taxes = WC_Tax::calc_tax( $discount, $tax_rates, false ); - $this->totals['discounts_tax_total'] += array_sum( $taxes ); - } - } - } - } - - /** - * 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' ) ) ); - if ( $this->calculate_taxes ) { - $this->set_total( 'fees_total_tax', array_sum( wp_list_pluck( $this->fees, 'total_tax' ) ) ); - } - } - - /** - * 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' ) ) ); - if ( $this->calculate_taxes ) { - $this->set_total( 'shipping_tax_total', array_sum( wp_list_pluck( $this->shipping, 'total_tax' ) ) ); - } - } - - /** - * Main cart totals. - * - * @since 3.2.0 - */ - protected function calculate_totals() { - if ( $this->calculate_taxes ) { - $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 ) ) ); - } - } -} diff --git a/includes/class-wc-cart-totals.php b/includes/class-wc-cart-totals.php index 802a7ff8039..c738b88b3bb 100644 --- a/includes/class-wc-cart-totals.php +++ b/includes/class-wc-cart-totals.php @@ -19,25 +19,156 @@ if ( ! defined( 'ABSPATH' ) ) { * @todo woocommerce_calculate_totals action for carts. * @todo woocommerce_calculated_total filter for carts. * @todo record coupon totals and counts for cart. + * @todo woocommerce_tax_round_at_subtotal option - how should we handle this with precision? + * @todo Manual discounts. * * @since 3.2.0 */ -final class WC_Cart_Totals extends WC_Totals { +final class WC_Cart_Totals { + + /** + * Reference to cart or order object. + * + * @since 3.2.0 + * @var array + */ + protected $object; + + /** + * Line items to calculate. + * + * @since 3.2.0 + * @var array + */ + protected $items = array(); + + /** + * Fees to calculate. + * + * @since 3.2.0 + * @var array + */ + protected $fees = array(); + + /** + * Shipping costs. + * + * @since 3.2.0 + * @var array + */ + protected $shipping = array(); + + /** + * Applied coupon objects. + * + * @since 3.2.0 + * @var array + */ + protected $coupons = array(); + + /** + * Discount amounts in cents after calculation for the cart. + * + * @since 3.2.0 + * @var array + */ + protected $discount_totals = array(); + + /** + * Stores totals. + * + * @since 3.2.0 + * @var array + */ + protected $totals = array( + 'fees_total' => 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 $object Cart or order object to calculate totals for. + * @param object $cart Cart object to calculate totals for. */ - public function __construct( &$object = null ) { - parent::__construct( $object ); + public function __construct( &$cart = null ) { + $this->object = $object; - if ( is_a( $object, 'WC_Cart' ) ) { + if ( is_a( $cart, 'WC_Cart' ) ) { $this->calculate(); } } + /** + * Run all calculations methods on the given items in sequence. @todo More documentation, and add other calculation methods for taxes and totals only? + * + * @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(), + ); + } + /** * Handles a cart or order object passed in for calculation. Normalises data * into the same format for use by this class. @@ -118,17 +249,219 @@ final class WC_Cart_Totals extends WC_Totals { } /** - * Totals are costs after discounts. + * 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 ); + $item_tax_rates = $this->get_item_tax_rates( $item ); + + 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() ); + } + + /** + * 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 : array_map( array( $this, 'remove_precision' ), $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() { - parent::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 ( wc_tax_enabled() && $item->product->is_taxable() ) { + $item->taxes = WC_Tax::calc_tax( $item->total, $this->get_item_tax_rates( $item ), $item->price_includes_tax ); + $item->total_tax = array_sum( $item->taxes ); + + 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 ( wc_tax_enabled() && $item->product->is_taxable() ) { + $subtotal_taxes = WC_Tax::calc_tax( $item->subtotal, $this->get_item_tax_rates( $item ), $item->price_includes_tax ); + $item->subtotal_tax = array_sum( $subtotal_taxes ); + + 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 ( wc_tax_enabled() ) { + foreach ( $this->discount_totals as $cart_item_key => $discount ) { + $item = $this->items[ $cart_item_key ]; + if ( $item->product->is_taxable() ) { + $tax_rates = $this->get_item_tax_rates( $item ); + $taxes = WC_Tax::calc_tax( $discount, $tax_rates, false ); + $this->totals['discounts_tax_total'] += array_sum( $taxes ); + } + } + } + } + /** * Triggers the cart fees API, grabs the list of fees, and calculates taxes. * @@ -137,7 +470,9 @@ final class WC_Cart_Totals extends WC_Totals { * @since 3.2.0 */ protected function calculate_fee_totals() { - parent::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 = $this->remove_precision( $fee->total_tax ); @@ -152,8 +487,9 @@ final class WC_Cart_Totals extends WC_Totals { * @since 3.2.0 */ protected function calculate_shipping_totals() { - parent::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' ); } @@ -164,9 +500,8 @@ final class WC_Cart_Totals extends WC_Totals { * @since 3.2.0 */ protected function calculate_totals() { - parent::calculate_totals(); - - $this->object->tax_total = $this->get_total( 'tax_total' ); - $this->object->total = $this->get_total( 'total' ); + $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 ) ) ); } } 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-order-totals.php b/includes/class-wc-order-totals.php deleted file mode 100644 index 8220d2d0b18..00000000000 --- a/includes/class-wc-order-totals.php +++ /dev/null @@ -1,126 +0,0 @@ -set_items(); - $this->set_fees(); - $this->set_shipping(); - $this->set_coupons(); - } - } - - /** - * Handles an order object passed in for calculation. Normalises data into the same format for use by this class. - * - * @since 3.2.0 - */ - protected function set_items() { - $this->items = array(); - - foreach ( $this->object->get_items() as $item_key => $item_object ) { - $item = $this->get_default_item_props(); - $item_taxes = $item_object->get_taxes(); - $item->object = $item_object; - $item->product = $item_object->get_product(); - $item->quantity = $item_object->get_quantity(); - $item->subtotal = $this->add_precision( $item_object->get_subtotal() ); - $item->subtotal_tax = $this->add_precision( $item_object->get_subtotal_tax() ); - $item->total = $this->add_precision( $item_object->get_total() ); - $item->total_tax = $this->add_precision( $item_object->get_total_tax() ); - $item->taxes = $this->add_precision( $item_taxes['total'] ); - $this->items[ $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(); - - foreach ( $this->object->get_fees() as $fee_key => $fee_object ) { - $fee = $this->get_default_fee_props(); - $fee->object = $fee_object; - $fee->total = $this->add_precision( $fee_object->get_total() ); - $fee->taxes = $this->add_precision( $fee_object->get_taxes() ); - $fee->total_tax = $this->add_precision( $fee_object->get_total_tax() ); - $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->get_shipping_methods() as $key => $shipping_object ) { - $shipping_line = $this->get_default_shipping_props(); - $shipping_line_taxes = $shipping_object->get_taxes(); - $shipping_line->object = $shipping_object; - $shipping_line->total = $this->add_precision( $shipping_object->get_total() ); - $shipping_line->taxes = $this->add_precision( $shipping_line_taxes['total'] ); - $shipping_line->total_tax = $this->add_precision( $shipping_object->get_total_tax() ); - $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(); @todo get_used_coupons = codes - } - - /** - * Main cart totals. Set all order totals here after calculation. - * - * @since 3.2.0 - */ - protected function calculate_totals() { - parent::calculate_totals(); - - $this->object->set_discount_total( $this->get_total( 'discounts_total' ) ); - $this->object->set_discount_tax( $this->get_total( 'discounts_tax_total' ) ); - $this->object->set_shipping_total( $this->get_total( 'shipping_total' ) ); - $this->object->set_shipping_tax( $this->get_total( 'shipping_tax_total' ) ); - $this->object->set_cart_tax( $this->get_total( 'tax_total' ) ); - $this->object->set_total( $this->get_total( 'total' ) ); - } -} diff --git a/includes/class-woocommerce.php b/includes/class-woocommerce.php index 3b6980597a7..0708a7ee6fc 100644 --- a/includes/class-woocommerce.php +++ b/includes/class-woocommerce.php @@ -303,7 +303,6 @@ final class WooCommerce { include_once( WC_ABSPATH . 'includes/abstracts/abstract-wc-log-handler.php' ); include_once( WC_ABSPATH . 'includes/abstracts/abstract-wc-deprecated-hooks.php' ); include_once( WC_ABSPATH . 'includes/abstracts/abstract-wc-session.php' ); - include_once( WC_ABSPATH . 'includes/abstracts/abstract-wc-totals.php' ); /** * Core classes. @@ -336,7 +335,6 @@ final class WooCommerce { 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' ); - include_once( WC_ABSPATH . 'includes/class-wc-order-totals.php' ); /** * Data stores - used to store and retrieve CRUD object data from the database. 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/tests/unit-tests/totals/order-totals.php b/tests/unit-tests/totals/order-totals.php deleted file mode 100644 index a1f64c4eeef..00000000000 --- a/tests/unit-tests/totals/order-totals.php +++ /dev/null @@ -1,161 +0,0 @@ -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(); - $shipping_taxes = WC_Tax::calc_shipping_tax( '10', WC_Tax::get_shipping_tax_rates() ); - $rate = new WC_Shipping_Rate( 'flat_rate_shipping', 'Flat rate shipping', '10', $shipping_taxes, 'flat_rate' ); - $shipping_item = new WC_Order_Item_Shipping(); - $shipping_item->set_props( array( - 'method_title' => $rate->label, - 'method_id' => $rate->id, - 'total' => wc_format_decimal( $rate->cost ), - 'taxes' => $rate->taxes, - ) ); - foreach ( $rate->get_meta_data() as $key => $value ) { - $shipping_item->add_meta_data( $key, $value, true ); - } - - $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; - - $this->order = new WC_Order(); - $this->order->add_product( $product, 1 ); - $this->order->add_product( $product2, 2 ); - $this->order->add_item( $shipping_item ); - - // @todo add coupon - // @todo add fee - - $this->order->save(); - - $this->totals = new WC_Order_Totals( $this->order ); - } - - /** - * Clean up after test. - */ - public function tearDown() { - $this->order->delete(); - 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 that order totals get updated. - */ - public function test_order_totals() { - $this->totals->calculate(); - - // $10 item + ($10*2) item + $10 shipping + (.2*$40) tax = $48.00 - // These tests will need to be updated when coupons + fees get added. - - $this->assertEquals( 48.00, $this->order->get_total() ); - $this->assertEquals( 10.00, $this->order->get_shipping_total() ); - $this->assertEquals( 2.00, $this->order->get_shipping_tax() ); - $this->assertEquals( 6.00, $this->order->get_cart_tax() ); - $this->assertEquals( 8.00, $this->order->get_total_tax() ); - $this->assertEquals( 0, $this->order->get_discount_total() ); - - // Calculating ex tax shouldn't modify existing tax fields. - $this->totals->calculate( false ); - - $this->assertEquals( 48.00, $this->order->get_total() ); - $this->assertEquals( 10.00, $this->order->get_shipping_total() ); - $this->assertEquals( 2.00, $this->order->get_shipping_tax() ); - $this->assertEquals( 6.00, $this->order->get_cart_tax() ); - $this->assertEquals( 8.00, $this->order->get_total_tax() ); - $this->assertEquals( 0, $this->order->get_discount_total() ); - } - - /** - * Test that non-tax order fields get updated in ex tax calculate. - */ - public function test_order_totals_ex_taxes() { - $this->totals->calculate( false ); - - $this->assertEquals( 10.00, $this->order->get_shipping_total() ); - $this->assertEquals( 40.00, $this->order->get_subtotal() ); - $this->assertEquals( 0, $this->order->get_discount_total() ); - - // These don't get calculated when not calculating taxes. - $this->assertEquals( 0, $this->order->get_shipping_tax() ); - $this->assertEquals( 0, $this->order->get_cart_tax() ); - $this->assertEquals( 0, $this->order->get_total_tax() ); - $this->assertEquals( 0, $this->order->get_total() ); - } -}