| @@ -155,12 +155,21 @@ | ||
| 155 | 155 | |
| 156 | 156 | add_action('fluent_cart/checkout/prepare_other_data', [$this, 'prepareOtherData'], 10, 1); |
| 157 | 157 | |
| 158 | 158 | add_action('fluent_cart/product/after_price', function ($data) { |
| 159 | - if (Arr::get($data, 'scope') === 'price_range') { | |
| 159 | + $variant = Arr::get($data, 'variant', null); | |
| 160 | + | |
| 161 | + // scope 'price_range' covers two cases: a single confirmed price | |
| 162 | + // for a simple product (variant is set — resolve its suffix | |
| 163 | + // normally) and the non-simple price summary (min/max, or a | |
| 164 | + // single value when every variant is priced the same — variant | |
| 165 | + // is always null there, deliberately skipped since no single | |
| 166 | + // tax_inclusion applies to a multi-variant summary; see | |
| 167 | + // PR #1767/#2271). | |
| 168 | + if (Arr::get($data, 'scope') === 'price_range' && !$variant) { | |
| 160 | 169 | return; |
| 161 | 170 | } |
| 162 | - $variant = Arr::get($data, 'variant', null); | |
| 171 | + | |
| 163 | 172 | $priceSuffix = $this->resolvePriceSuffix($variant); |
| 164 | 173 | $variantInclusion = $variant ? Arr::get($variant->other_info ?: [], 'tax_inclusion', '') : ''; |
| 165 | 174 | $taxInclusion = $variantInclusion ?: Arr::get($this->taxSettings, 'tax_inclusion', ''); |
| 166 | 175 | |
| @@ -280,9 +289,9 @@ | ||
| 280 | 289 | |
| 281 | 290 | $fillData = $this->calculateCartTax([ |
| 282 | 291 | 'cart_data' => $cart->cart_data, |
| 283 | 292 | 'checkout_data' => $checkoutData |
| 284 | - ]); | |
| 293 | + ], $cart); | |
| 285 | 294 | |
| 286 | 295 | $cart->fill($fillData); |
| 287 | 296 | $cart->save(); |
| 288 | 297 | } |
| @@ -334,9 +343,9 @@ | ||
| 334 | 343 | 'shipping_country' => Arr::get($data, 'prev_data.shipping_country', Arr::get($fillData, 'checkout_data.form_data.shipping_country', '')), |
| 335 | 344 | ] |
| 336 | 345 | ); |
| 337 | 346 | |
| 338 | - return $this->calculateCartTax($fillData); | |
| 347 | + return $this->calculateCartTax($fillData, $cart); | |
| 339 | 348 | } |
| 340 | 349 | |
| 341 | 350 | public function maybeInvalidateVatValidationForCountryChange($checkoutData, $previousFormData = []) |
| 342 | 351 | { |
| @@ -446,9 +455,15 @@ | ||
| 446 | 455 | $settings = $this->getSettings(); |
| 447 | 456 | return Arr::get($settings, 'enable_tax', 'no') === 'yes'; |
| 448 | 457 | } |
| 449 | 458 | |
| 450 | - 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) | |
| 451 | 466 | { |
| 452 | 467 | $lineItems = Arr::get($fillData, 'cart_data', []); |
| 453 | 468 | $checkoutData = Arr::get($fillData, 'checkout_data', []); |
| 454 | 469 | |
| @@ -537,8 +552,13 @@ | ||
| 537 | 552 | } |
| 538 | 553 | } |
| 539 | 554 | } |
| 540 | 555 | |
| 556 | + $unpricedLineItems = $lineItems; | |
| 557 | + if ($cart instanceof Cart) { | |
| 558 | + $lineItems = $this->applyPricingRules($lineItems, $checkoutData, $cart); | |
| 559 | + } | |
| 560 | + | |
| 541 | 561 | $country = ''; |
| 542 | 562 | $state = ''; |
| 543 | 563 | $postCode = ''; |
| 544 | 564 | |
| @@ -788,8 +808,12 @@ | ||
| 788 | 808 | $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal; |
| 789 | 809 | $checkoutData['tax_data']['tax_lines'] = $taxLines; |
| 790 | 810 | $fillData['checkout_data'] = $checkoutData; |
| 791 | 811 | |
| 812 | + if ($cart instanceof Cart) { | |
| 813 | + $productLines = $this->restoreUnpricedValues($productLines, $unpricedLineItems); | |
| 814 | + } | |
| 815 | + | |
| 792 | 816 | // Only product lines go back into cart_data |
| 793 | 817 | $fillData['cart_data'] = $productLines; |
| 794 | 818 | |
| 795 | 819 | if (isset($fillData['hook_changes'])) { |
| @@ -798,8 +822,117 @@ | ||
| 798 | 822 | |
| 799 | 823 | return $fillData; |
| 800 | 824 | } |
| 801 | 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 | + | |
| 802 | 935 | protected function shouldApplyReverseCharge($checkoutData, $taxApplicableCountry, $lineItems = []) |
| 803 | 936 | { |
| 804 | 937 | if (!Arr::get($checkoutData, 'tax_data.valid', false)) { |
| 805 | 938 | return false; |
| @@ -1299,9 +1432,9 @@ | ||
| 1299 | 1432 | $cart->checkout_data = $checkoutData; |
| 1300 | 1433 | $fillData = $this->calculateCartTax([ |
| 1301 | 1434 | 'cart_data' => $cart->cart_data, |
| 1302 | 1435 | 'checkout_data' => $cart->checkout_data, |
| 1303 | - ]); | |
| 1436 | + ], $cart); | |
| 1304 | 1437 | $cart->fill($fillData); |
| 1305 | 1438 | $cart->save(); |
| 1306 | 1439 | |
| 1307 | 1440 | ob_start(); |
| @@ -1371,9 +1504,9 @@ | ||
| 1371 | 1504 | $cart->checkout_data = $checkoutData; |
| 1372 | 1505 | $fillData = $this->calculateCartTax([ |
| 1373 | 1506 | 'cart_data' => $cart->cart_data, |
| 1374 | 1507 | 'checkout_data' => $cart->checkout_data, |
| 1375 | - ]); | |
| 1508 | + ], $cart); | |
| 1376 | 1509 | $cart->fill($fillData); |
| 1377 | 1510 | $cart->save(); |
| 1378 | 1511 | |
| 1379 | 1512 | ob_start(); |