| @@ -289,9 +289,9 @@ | ||
| 289 | 289 | |
| 290 | 290 | $fillData = $this->calculateCartTax([ |
| 291 | 291 | 'cart_data' => $cart->cart_data, |
| 292 | 292 | 'checkout_data' => $checkoutData |
| 293 | - ]); | |
| 293 | + ], $cart); | |
| 294 | 294 | |
| 295 | 295 | $cart->fill($fillData); |
| 296 | 296 | $cart->save(); |
| 297 | 297 | } |
| @@ -343,9 +343,9 @@ | ||
| 343 | 343 | 'shipping_country' => Arr::get($data, 'prev_data.shipping_country', Arr::get($fillData, 'checkout_data.form_data.shipping_country', '')), |
| 344 | 344 | ] |
| 345 | 345 | ); |
| 346 | 346 | |
| 347 | - return $this->calculateCartTax($fillData); | |
| 347 | + return $this->calculateCartTax($fillData, $cart); | |
| 348 | 348 | } |
| 349 | 349 | |
| 350 | 350 | public function maybeInvalidateVatValidationForCountryChange($checkoutData, $previousFormData = []) |
| 351 | 351 | { |
| @@ -455,9 +455,15 @@ | ||
| 455 | 455 | $settings = $this->getSettings(); |
| 456 | 456 | return Arr::get($settings, 'enable_tax', 'no') === 'yes'; |
| 457 | 457 | } |
| 458 | 458 | |
| 459 | - public function calculateCartTax($fillData) | |
| 459 | + /** | |
| 460 | + * @param array $fillData ['cart_data' => array, 'checkout_data' => array] | |
| 461 | + * @param Cart|null $cart Pass it while cart_data holds no rule discounts yet, so tax | |
| 462 | + * is worked out on the price after dynamic pricing rules. | |
| 463 | + * @return array | |
| 464 | + */ | |
| 465 | + public function calculateCartTax($fillData, $cart = null) | |
| 460 | 466 | { |
| 461 | 467 | $lineItems = Arr::get($fillData, 'cart_data', []); |
| 462 | 468 | $checkoutData = Arr::get($fillData, 'checkout_data', []); |
| 463 | 469 | |
| @@ -546,8 +552,13 @@ | ||
| 546 | 552 | } |
| 547 | 553 | } |
| 548 | 554 | } |
| 549 | 555 | |
| 556 | + $unpricedLineItems = $lineItems; | |
| 557 | + if ($cart instanceof Cart) { | |
| 558 | + $lineItems = $this->applyPricingRules($lineItems, $checkoutData, $cart); | |
| 559 | + } | |
| 560 | + | |
| 550 | 561 | $country = ''; |
| 551 | 562 | $state = ''; |
| 552 | 563 | $postCode = ''; |
| 553 | 564 | |
| @@ -797,8 +808,12 @@ | ||
| 797 | 808 | $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal; |
| 798 | 809 | $checkoutData['tax_data']['tax_lines'] = $taxLines; |
| 799 | 810 | $fillData['checkout_data'] = $checkoutData; |
| 800 | 811 | |
| 812 | + if ($cart instanceof Cart) { | |
| 813 | + $productLines = $this->restoreUnpricedValues($productLines, $unpricedLineItems); | |
| 814 | + } | |
| 815 | + | |
| 801 | 816 | // Only product lines go back into cart_data |
| 802 | 817 | $fillData['cart_data'] = $productLines; |
| 803 | 818 | |
| 804 | 819 | if (isset($fillData['hook_changes'])) { |
| @@ -807,8 +822,117 @@ | ||
| 807 | 822 | |
| 808 | 823 | return $fillData; |
| 809 | 824 | } |
| 810 | 825 | |
| 826 | + /** | |
| 827 | + * The lines as the checkout prices them: rule discounts on the items and on each | |
| 828 | + * line's shipping share. Uses the same hooks as the summary and placeOrder(), so | |
| 829 | + * tax is charged on the amount the customer pays. | |
| 830 | + * | |
| 831 | + * @param array $lineItems | |
| 832 | + * @param array $checkoutData | |
| 833 | + * @param Cart $cart | |
| 834 | + * @return array | |
| 835 | + */ | |
| 836 | + private function applyPricingRules(array $lineItems, array $checkoutData, Cart $cart): array | |
| 837 | + { | |
| 838 | + // A copy, so rule conditions read the address being patched without touching the cart. | |
| 839 | + $pricedCart = clone $cart; | |
| 840 | + $pricedCart->cart_data = $lineItems; | |
| 841 | + $pricedCart->checkout_data = $checkoutData; | |
| 842 | + | |
| 843 | + $priced = apply_filters('fluent_cart/cart/item_dynamic_discount', $lineItems, [ | |
| 844 | + 'cart' => $pricedCart, | |
| 845 | + ]); | |
| 846 | + $lineItems = is_array($priced) ? $priced : $lineItems; | |
| 847 | + | |
| 848 | + $shipping = 0; | |
| 849 | + foreach ($lineItems as $lineItem) { | |
| 850 | + $shipping += (int) Arr::get($lineItem, 'shipping_charge', 0) | |
| 851 | + + (int) Arr::get($lineItem, 'itemwise_shipping_charge', 0); | |
| 852 | + } | |
| 853 | + | |
| 854 | + if ($shipping <= 0) { | |
| 855 | + return $lineItems; | |
| 856 | + } | |
| 857 | + | |
| 858 | + $discounted = (int) apply_filters('fluent_cart/checkout/order_shipping_charge', $shipping, [ | |
| 859 | + 'cart' => $pricedCart, | |
| 860 | + ]); | |
| 861 | + $discounted = max(0, min($discounted, $shipping)); | |
| 862 | + | |
| 863 | + if ($discounted === $shipping) { | |
| 864 | + return $lineItems; | |
| 865 | + } | |
| 866 | + | |
| 867 | + // Floor each share, then give the leftover cents to the largest remainders, so the | |
| 868 | + // shares add up to exactly the discounted charge and none goes below zero. | |
| 869 | + $left = $discounted; | |
| 870 | + $remainders = []; | |
| 871 | + foreach ($lineItems as $key => $lineItem) { | |
| 872 | + foreach (['shipping_charge', 'itemwise_shipping_charge'] as $field) { | |
| 873 | + $share = (int) Arr::get($lineItem, $field, 0); | |
| 874 | + if ($share <= 0) { | |
| 875 | + continue; | |
| 876 | + } | |
| 877 | + $scaled = intdiv($share * $discounted, $shipping); | |
| 878 | + $lineItems[$key][$field] = $scaled; | |
| 879 | + $left -= $scaled; | |
| 880 | + $remainders[] = [$key, $field, ($share * $discounted) % $shipping]; | |
| 881 | + } | |
| 882 | + } | |
| 883 | + | |
| 884 | + usort($remainders, function ($first, $second) { | |
| 885 | + return $second[2] <=> $first[2]; | |
| 886 | + }); | |
| 887 | + | |
| 888 | + foreach ($remainders as $remainder) { | |
| 889 | + if ($left <= 0) { | |
| 890 | + break; | |
| 891 | + } | |
| 892 | + $lineItems[$remainder[0]][$remainder[1]]++; | |
| 893 | + $left--; | |
| 894 | + } | |
| 895 | + | |
| 896 | + return $lineItems; | |
| 897 | + } | |
| 898 | + | |
| 899 | + /** | |
| 900 | + * Put back what applyPricingRules() changed, keeping the tax worked out on it. | |
| 901 | + * cart_data must stay free of rule discounts: the summary applies them on every | |
| 902 | + * render, and the order writes them in at before_order_processing. | |
| 903 | + * | |
| 904 | + * @param array $productLines Taxed lines, in the order of $unpricedLineItems. | |
| 905 | + * @param array $unpricedLineItems | |
| 906 | + * @return array | |
| 907 | + */ | |
| 908 | + private function restoreUnpricedValues(array $productLines, array $unpricedLineItems): array | |
| 909 | + { | |
| 910 | + $unpricedLineItems = array_values($unpricedLineItems); | |
| 911 | + | |
| 912 | + foreach ($productLines as $index => $line) { | |
| 913 | + if (!isset($unpricedLineItems[$index])) { | |
| 914 | + continue; | |
| 915 | + } | |
| 916 | + $unpriced = $unpricedLineItems[$index]; | |
| 917 | + | |
| 918 | + foreach (['manual_discount', 'discount_total', 'shipping_charge', 'itemwise_shipping_charge'] as $field) { | |
| 919 | + if (array_key_exists($field, $unpriced)) { | |
| 920 | + $line[$field] = $unpriced[$field]; | |
| 921 | + } else { | |
| 922 | + unset($line[$field]); | |
| 923 | + } | |
| 924 | + } | |
| 925 | + | |
| 926 | + // From the taxed subtotal: dynamic reverse charge may have lowered it. | |
| 927 | + $line['line_total'] = max(0, (int) Arr::get($line, 'subtotal', 0) - (int) Arr::get($line, 'discount_total', 0)); | |
| 928 | + | |
| 929 | + $productLines[$index] = $line; | |
| 930 | + } | |
| 931 | + | |
| 932 | + return $productLines; | |
| 933 | + } | |
| 934 | + | |
| 811 | 935 | protected function shouldApplyReverseCharge($checkoutData, $taxApplicableCountry, $lineItems = []) |
| 812 | 936 | { |
| 813 | 937 | if (!Arr::get($checkoutData, 'tax_data.valid', false)) { |
| 814 | 938 | return false; |
| @@ -1308,9 +1432,9 @@ | ||
| 1308 | 1432 | $cart->checkout_data = $checkoutData; |
| 1309 | 1433 | $fillData = $this->calculateCartTax([ |
| 1310 | 1434 | 'cart_data' => $cart->cart_data, |
| 1311 | 1435 | 'checkout_data' => $cart->checkout_data, |
| 1312 | - ]); | |
| 1436 | + ], $cart); | |
| 1313 | 1437 | $cart->fill($fillData); |
| 1314 | 1438 | $cart->save(); |
| 1315 | 1439 | |
| 1316 | 1440 | ob_start(); |
| @@ -1380,9 +1504,9 @@ | ||
| 1380 | 1504 | $cart->checkout_data = $checkoutData; |
| 1381 | 1505 | $fillData = $this->calculateCartTax([ |
| 1382 | 1506 | 'cart_data' => $cart->cart_data, |
| 1383 | 1507 | 'checkout_data' => $cart->checkout_data, |
| 1384 | - ]); | |
| 1508 | + ], $cart); | |
| 1385 | 1509 | $cart->fill($fillData); |
| 1386 | 1510 | $cart->save(); |
| 1387 | 1511 | |
| 1388 | 1512 | ob_start(); |