woocommerce/includes/class-wc-totals.php

529 lines
16 KiB
PHP
Raw Normal View History

<?php
2017-07-25 13:05:49 +00:00
/**
* Order/cart totals calculation class.
*
* Methods are private and class is final to keep this as an internal API.
* May be opened in the future once structure is stable.
*
* @author Automattic
* @package WooCommerce/Classes
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
2017-07-25 13:05:49 +00:00
* WC_Totals class.
*
2017-07-25 13:05:49 +00:00
* @todo consider extending this for cart vs orders if lots of conditonal logic is needed.
* @todo Instead of setting cart totals from here, do it from a subclass.
* @todo woocommerce_tax_round_at_subtotal option - how should we handle this with precision?
* @todo woocommerce_calculate_totals action for carts.
* @todo woocommerce_calculated_total filter for carts.
* @since 3.2.0
*/
class WC_Totals {
/**
2017-07-24 16:21:08 +00:00
* Reference to cart or order object.
*
2017-07-24 16:21:08 +00:00
* @since 3.2.0
* @var array
*/
2017-07-25 13:05:49 +00:00
private $object;
/**
2017-07-24 16:21:08 +00:00
* Line items to calculate.
*
2017-07-24 16:21:08 +00:00
* @since 3.2.0
* @var array
*/
2017-07-25 13:05:49 +00:00
private $items = array();
2017-07-23 11:05:11 +00:00
/**
2017-07-25 13:05:49 +00:00
* Fees to calculate.
2017-07-23 11:05:11 +00:00
*
2017-07-24 16:21:08 +00:00
* @since 3.2.0
2017-07-23 11:05:11 +00:00
* @var array
*/
2017-07-25 13:05:49 +00:00
private $fees = array();
/**
* Shipping costs.
*
* @since 3.2.0
* @var array
*/
private $shipping = array();
/**
2017-07-25 13:05:49 +00:00
* Discount amounts in cents after calculation for the cart.
*
2017-07-24 16:21:08 +00:00
* @since 3.2.0
* @var array
*/
2017-07-25 13:05:49 +00:00
private $discount_totals = array();
/**
* Precision so we can work in cents.
*
2017-07-24 16:21:08 +00:00
* @since 3.2.0
* @var int
*/
2017-07-25 13:05:49 +00:00
private $precision = 1;
/**
* Stores totals.
*
* @since 3.2.0
* @var array
*/
private $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,
);
/**
2017-07-23 11:05:11 +00:00
* Sets up the items provided, and calculate totals.
2017-07-24 16:21:08 +00:00
*
* @since 3.2.0
2017-07-25 13:05:49 +00:00
* @param object $cart Cart or order object to calculate totals for.
*/
2017-07-24 16:21:08 +00:00
public function __construct( &$cart = null ) {
$this->precision = pow( 10, wc_get_price_decimals() );
2017-07-24 16:21:08 +00:00
$this->object = $cart;
$this->set_items();
2017-07-23 11:05:11 +00:00
$this->calculate();
}
/**
2017-07-25 13:05:49 +00:00
* Handles a cart or order object passed in for calculation. Normalises data
* into the same format for use by this class.
2017-07-24 16:21:08 +00:00
*
* @since 3.2.0
2017-07-23 11:05:11 +00:00
*/
2017-07-25 13:05:49 +00:00
private function set_items() {
2017-07-24 16:21:08 +00:00
if ( is_a( $this->object, 'WC_Cart' ) ) {
foreach ( $this->object->get_cart() as $cart_item_key => $cart_item ) {
$item = $this->get_default_item_props();
$item->key = $cart_item_key;
$item->quantity = $cart_item['quantity'];
$item->price = $this->add_precision( $cart_item['data']->get_price() ) * $cart_item['quantity'];
2017-07-24 16:21:08 +00:00
$item->product = $cart_item['data'];
$this->items[ $cart_item_key ] = $item;
}
2017-07-23 11:05:11 +00:00
}
}
/**
* Add precision (deep) to a price.
*
* @since 3.2.0
* @param int|array $value Value to remove precision from.
* @return float
*/
private 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;
}
/**
2017-07-25 13:05:49 +00:00
* Remove precision (deep) from a price.
*
2017-07-24 16:21:08 +00:00
* @since 3.2.0
2017-07-25 13:05:49 +00:00
* @param int|array $value Value to remove precision from.
* @return float
*/
2017-07-25 13:05:49 +00:00
private function remove_precision( $value ) {
2017-07-24 16:21:08 +00:00
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.
2017-07-24 16:21:08 +00:00
*
* @since 3.2.0
* @return array
*/
2017-07-25 13:05:49 +00:00
private function get_default_item_props() {
return (object) array(
2017-07-25 13:05:49 +00:00
'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,
);
}
2017-07-25 13:05:49 +00:00
/**
* Get default blank set of props used per fee.
*
* @since 3.2.0
* @return array
*/
private 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
*/
private function get_default_shipping_props() {
return (object) array(
'total' => 0,
'total_tax' => 0,
'taxes' => array(),
);
}
/**
2017-07-24 16:21:08 +00:00
* 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
2017-07-25 13:05:49 +00:00
* @param object $item Item to adjust the prices of.
* @return object
*/
2017-07-25 13:05:49 +00:00
private function adjust_non_base_location_price( $item ) {
2017-07-24 16:21:08 +00:00
$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 ) {
2017-07-25 13:05:49 +00:00
// Work out a new base price without the shop's base tax.
2017-07-24 16:21:08 +00:00
$taxes = WC_Tax::calc_tax( $item->price, $base_tax_rates, true, true );
2017-07-25 13:05:49 +00:00
// Now we have a new item price (excluding TAX).
2017-07-24 16:21:08 +00:00
$item->price = $item->price - array_sum( $taxes );
$item->price_includes_tax = false;
}
return $item;
}
/**
* Get discounted price of an item with precision (in cents).
*
* @since 3.2.0
2017-07-25 13:05:49 +00:00
* @param object $item Item to get the price of.
2017-07-24 16:21:08 +00:00
* @return int
*/
2017-07-25 13:05:49 +00:00
private function get_discounted_price_in_cents( $item ) {
return $item->price - $this->discount_totals[ $item->key ];
2017-07-24 16:21:08 +00:00
}
/**
* Get tax rates for an item. Caches rates in class to avoid multiple look ups.
*
2017-07-25 13:05:49 +00:00
* @param object $item Item to get tax rates for.
2017-07-24 16:21:08 +00:00
* @return array of taxes
*/
2017-07-25 13:05:49 +00:00
private function get_item_tax_rates( $item ) {
2017-07-24 16:21:08 +00:00
$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() );
}
/**
2017-07-24 16:21:08 +00:00
* Return array of coupon objects from the cart or an order.
*
* @since 3.2.0
* @return array
*/
2017-07-25 13:05:49 +00:00
private function get_coupons() {
2017-07-24 16:21:08 +00:00
if ( is_a( $this->object, 'WC_Cart' ) ) {
return $this->object->get_coupons();
}
}
/**
2017-07-24 16:21:08 +00:00
* Get a single total with or without precision (in cents).
*
2017-07-24 16:21:08 +00:00
* @since 3.2.0
* @param string $key Total to get.
2017-07-25 13:05:49 +00:00
* @param bool $in_cents Should the totals be returned in cents, or without precision.
2017-07-24 16:21:08 +00:00
* @return int|float
*/
2017-07-24 16:21:08 +00:00
public function get_total( $key = 'total', $in_cents = false ) {
$totals = $this->get_totals( $in_cents );
return isset( $totals[ $key ] ) ? $totals[ $key ] : 0;
}
/**
2017-07-24 16:21:08 +00:00
* Set a single total.
*
2017-07-24 16:21:08 +00:00
* @since 3.2.0
2017-07-25 13:05:49 +00:00
* @param string $key Total name you want to set.
* @param int $total Total to set.
*/
2017-07-25 13:05:49 +00:00
private function set_total( $key = 'total', $total ) {
2017-07-24 16:21:08 +00:00
$this->totals[ $key ] = $total;
}
2017-07-24 16:21:08 +00:00
/**
* Get all totals with or without precision (in cents).
*
* @since 3.2.0
2017-07-25 13:05:49 +00:00
* @param bool $in_cents Should the totals be returned in cents, or without precision.
2017-07-24 16:21:08 +00:00
* @return array.
*/
public function get_totals( $in_cents = false ) {
return $in_cents ? $this->totals : array_map( array( $this, 'remove_precision' ), $this->totals );
}
2017-07-24 16:21:08 +00:00
/**
* Get all tax rows from items (including shipping and product line items).
*
* @since 3.2.0
* @return array
*/
2017-07-25 13:05:49 +00:00
private function get_merged_taxes() {
2017-07-24 16:21:08 +00:00
$taxes = array();
2017-07-25 13:05:49 +00:00
foreach ( array_merge( $this->items, $this->fees, $this->shipping ) as $item ) {
2017-07-25 13:05:49 +00:00
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 ) {
2017-07-24 16:21:08 +00:00
foreach ( $item->taxes as $rate_id => $rate ) {
2017-07-25 13:05:49 +00:00
$taxes[ $rate_id ]['tax_total'] = $taxes[ $rate_id ]['tax_total'] + $rate;
}
}
foreach ( $this->shipping as $item ) {
2017-07-24 16:21:08 +00:00
foreach ( $item->taxes as $rate_id => $rate ) {
$taxes[ $rate_id ]['shipping_tax_total'] = $taxes[ $rate_id ]['shipping_tax_total'] + $rate;
}
}
return $taxes;
}
/*
|--------------------------------------------------------------------------
| Calculation methods.
|--------------------------------------------------------------------------
*/
/**
2017-07-25 13:05:49 +00:00
* Run all calculations methods on the given items in sequence.
2017-07-23 11:05:11 +00:00
*
2017-07-24 16:21:08 +00:00
* @since 3.2.0
*/
2017-07-25 13:05:49 +00:00
private function calculate() {
$this->calculate_item_subtotals();
2017-07-24 16:21:08 +00:00
$this->calculate_discounts();
$this->calculate_item_totals();
$this->calculate_fee_totals();
$this->calculate_shipping_totals();
$this->calculate_totals();
}
/**
* 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.
2017-07-24 16:21:08 +00:00
*
* @since 3.2.0
*/
2017-07-25 13:05:49 +00:00
private 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 );
}
2017-07-24 16:21:08 +00:00
$item->subtotal = $item->price;
$item->subtotal_tax = 0;
if ( wc_tax_enabled() && $item->product->is_taxable() ) {
$item->subtotal_taxes = WC_Tax::calc_tax( $item->subtotal, $this->get_item_tax_rates( $item ), $item->price_includes_tax );
$item->subtotal_tax = array_sum( $item->subtotal_taxes );
if ( $item->price_includes_tax ) {
$item->subtotal = $item->subtotal - $item->subtotal_tax;
}
}
}
2017-07-24 16:21:08 +00:00
$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' ) ) ) );
}
/**
2017-07-24 16:21:08 +00:00
* Calculate all discount and coupon amounts.
*
2017-07-25 13:05:49 +00:00
* @todo Manual discounts.
* @todo record coupon totals and counts for cart.
2017-07-25 13:05:49 +00:00
*
2017-07-24 16:21:08 +00:00
* @since 3.2.0
* @uses WC_Discounts class.
*/
2017-07-25 13:05:49 +00:00
private function calculate_discounts() {
2017-07-24 16:21:08 +00:00
$discounts = new WC_Discounts( $this->items );
2017-07-24 16:21:08 +00:00
foreach ( $this->get_coupons() as $coupon ) {
$discounts->apply_coupon( $coupon );
}
2017-07-25 16:25:06 +00:00
$this->discount_totals = $discounts->get_discounts( true );
2017-07-25 13:05:49 +00:00
$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 );
}
}
}
2017-07-24 16:21:08 +00:00
}
2017-07-24 16:21:08 +00:00
/**
2017-07-25 13:05:49 +00:00
* Totals are costs after discounts. @todo move cart specific setters to subclass?
2017-07-24 16:21:08 +00:00
*
* @since 3.2.0
*/
2017-07-25 13:05:49 +00:00
private function calculate_item_totals() {
2017-07-24 16:21:08 +00:00
foreach ( $this->items as $item ) {
$item->total = $this->get_discounted_price_in_cents( $item );
$item->total_tax = 0;
2017-07-24 16:21:08 +00:00
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;
}
}
}
2017-07-24 16:21:08 +00:00
$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' ) ) ) );
2017-07-25 13:05:49 +00:00
2017-07-25 21:44:54 +00:00
$this->object->subtotal = $this->get_total( 'items_total' ) + $this->get_total( 'items_total_tax' );
$this->object->subtotal_ex_tax = $this->get_total( 'items_total' );
}
/**
2017-07-25 13:05:49 +00:00
* 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.
2017-07-24 16:21:08 +00:00
*
* @since 3.2.0
2017-07-25 13:05:49 +00:00
* @todo logic is unqiue to carts.
*/
2017-07-25 13:05:49 +00:00
private function calculate_fee_totals() {
$this->fees = array();
2017-07-25 13:05:49 +00:00
$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 = $this->add_precision( $fee->object->amount );
2017-07-25 13:05:49 +00:00
if ( wc_tax_enabled() && $fee->object->taxable ) {
$fee->taxes = WC_Tax::calc_tax( $fee->total, WC_Tax::get_rates( $fee->object->tax_class ), false );
2017-07-24 16:21:08 +00:00
$fee->total_tax = array_sum( $fee->taxes );
}
2017-07-25 13:05:49 +00:00
$this->fees[ $fee_key ] = $fee;
}
2017-07-25 13:05:49 +00:00
// Store totals to self.
$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' ) ) );
// Transfer totals to the cart.
foreach ( $this->fees as $fee_key => $fee ) {
$this->object->fees[ $fee_key ]->tax = $this->remove_precision( $fee->total_tax );
$this->object->fees[ $fee_key ]->tax_data = $this->remove_precision( $fee->taxes );
}
$this->object->fee_total = $this->remove_precision( array_sum( wp_list_pluck( $this->fees, 'total' ) ) );
}
/**
* Calculate any shipping taxes.
2017-07-24 16:21:08 +00:00
*
* @since 3.2.0
*/
2017-07-25 13:05:49 +00:00
private function calculate_shipping_totals() {
$this->shipping = array();
foreach ( $this->object->calculate_shipping() as $key => $shipping_object ) {
$shipping_line = $this->get_default_shipping_props();
$shipping_line->total = $this->add_precision( $shipping_object->cost );
$shipping_line->taxes = array_map( array( $this, 'add_precision' ), $shipping_object->taxes );
$shipping_line->total_tax = array_sum( $shipping_object->taxes );
$this->shipping[ $key ] = $shipping_line;
}
$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' ) ) );
}
/**
* Main cart totals.
*
* @since 3.2.0
*/
2017-07-25 13:05:49 +00:00
private function calculate_totals() {
2017-07-24 16:21:08 +00:00
$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( 'shipping_tax_total', array_sum( wp_list_pluck( $this->get_total( 'taxes', true ), 'shipping_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 ) ) );
2017-07-25 21:44:54 +00:00
$this->object->total = $this->get_total( 'total' );
$this->object->tax_total = $this->get_total( 'tax_total' );
$this->object->shipping_total = $this->get_total( 'shipping_total' );
$this->object->shipping_tax_total = $this->get_total( 'shipping_tax_total' );
}
}