PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.8.0 1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 All 52 releases
← All changes | app/Modules/Tax/TaxModule.php +311 -822 1.5.0 → 1.8.0 View file →
@@ -8,10 +8,12 @@
8 8 use FluentCart\Framework\Support\Arr;
9 9 use FluentCart\App\Helpers\CartHelper;
10 10 use FluentCart\App\Models\Cart;
11 11 use FluentCart\App\Models\OrderTaxRate;
12 +use FluentCart\App\Services\Renderer\TaxRenderer;
12 13 use FluentCart\App\Services\Renderer\VatFieldRenderer;
13 14 use FluentCart\App\Services\Renderer\CartSummaryRender;
15 +use FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper;
14 16 use FluentCart\App\Services\Renderer\CheckoutFieldsSchema;
15 17 use FluentCart\Api\Resource\FrontendResource\CartResource;
16 18 use FluentCart\App\Services\Localization\LocalizationManager;
17 19 use FluentCart\App\Services\Tax\TaxManager;
@@ -20,8 +22,18 @@
20 22 {
21 23
22 24 protected $taxSettings = [];
23 25
26 + protected $renderer = null;
27 +
28 + private function renderer(): TaxRenderer
29 + {
30 + if (!$this->renderer) {
31 + $this->renderer = new TaxRenderer($this);
32 + }
33 + return $this->renderer;
34 + }
35 +
24 36 public function register()
25 37 {
26 38 $this->getSettings();
27 39
@@ -143,24 +155,41 @@
143 155
144 156 add_action('fluent_cart/checkout/prepare_other_data', [$this, 'prepareOtherData'], 10, 1);
145 157
146 158 add_action('fluent_cart/product/after_price', function ($data) {
147 - 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) {
148 169 return;
149 170 }
150 - $variant = isset($data['variant']) ? $data['variant'] : null;
151 - $priceSuffix = $this->resolvePriceSuffix($variant);
152 - if ($priceSuffix) {
153 - echo '<span class="fct_price_suffix">' . wp_kses_post($priceSuffix) . '</span>';
154 - }
171 +
172 + $priceSuffix = $this->resolvePriceSuffix($variant);
173 + $variantInclusion = $variant ? Arr::get($variant->other_info ?: [], 'tax_inclusion', '') : '';
174 + $taxInclusion = $variantInclusion ?: Arr::get($this->taxSettings, 'tax_inclusion', '');
175 +
176 + $ctxCb = null;
177 + $ctxCb = function ($ctx) use ($taxInclusion, &$ctxCb) {
178 + remove_filter('fluent_cart/product/price_suffix_context', $ctxCb, 1);
179 + $ctx['tax_inclusion'] = $taxInclusion;
180 + return $ctx;
181 + };
182 + add_filter('fluent_cart/product/price_suffix_context', $ctxCb, 1);
183 +
184 + $sfxCb = null;
185 + $sfxCb = function ($suffix) use ($priceSuffix, &$sfxCb) {
186 + remove_filter('fluent_cart/product/price_suffix_atts', $sfxCb, 1);
187 + return $priceSuffix ?: $suffix;
188 + };
189 + add_filter('fluent_cart/product/price_suffix_atts', $sfxCb, 1, 1);
155 190 }, 10, 1);
156 191
157 - add_filter('fluent_cart/product/price_suffix_atts', function ($suffix, $context) {
158 - $variant = isset($context['variant']) ? $context['variant'] : null;
159 - $priceSuffix = $this->resolvePriceSuffix($variant);
160 - return $priceSuffix ?: $suffix;
161 - }, 10, 2);
162 -
163 192 add_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20, 2);
164 193
165 194 add_action('fluent_cart/cart/cart_data_items_updated', [$this, 'recalculateTax']);
166 195 }
@@ -194,296 +223,19 @@
194 223 }
195 224
196 225 public function renderTaxRow($cart, $atts = '')
197 226 {
198 - $taxAmount = (int) Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
199 - $reversedTaxAmount = (int) Arr::get($cart->checkout_data, 'tax_data.reverse_charge_tax_total', 0);
200 - $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
201 - $taxLines = Arr::get($cart->checkout_data, 'tax_data.tax_lines', []);
202 - $taxCountry = Arr::get($cart->checkout_data, 'tax_data.tax_country', '');
203 - $taxLabel = Arr::get($taxLines, '0.label', '');
204 -
205 - if (!$taxLabel) {
206 - $taxLabel = $taxCountry ? static::getCountryTaxTitle($taxCountry) : __('Tax', 'fluent-cart');
207 - }
208 - ?>
209 - <li <?php echo $atts; ?>>
210 - <span class="fct_summary_label">
211 - <?php echo esc_html($taxLabel); ?>
212 - </span>
213 - <span class="fct_summary_value">
214 - <?php if ($isReverseCharge && $taxAmount === 0) : ?>
215 - <?php
216 - /* translators: %1$s: formatted reversed tax amount */
217 - echo esc_html(sprintf(__('Tax reversed: %1$s', 'fluent-cart'), Helper::toDecimal($reversedTaxAmount)));
218 - ?>
219 - <?php else : ?>
220 - <?php echo esc_html(Helper::toDecimal($taxAmount)); ?>
221 - <?php endif; ?>
222 - </span>
223 - </li>
224 - <?php
227 + $this->renderer()->renderTaxRow($cart, $atts);
225 228 }
226 229
227 230 public function renderShippingTaxRow($cart, $atts = '')
228 231 {
229 - $shippingTax = (int) Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
230 - $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
231 - $rcShippingTax = (int) Arr::get($cart->checkout_data, 'tax_data.reverse_charge_shipping_tax', 0);
232 -
233 - if ($shippingTax <= 0 && (!$isReverseCharge || $rcShippingTax <= 0)) {
234 - return '';
235 - }
236 -
237 - $displayAmount = $isReverseCharge ? $rcShippingTax : $shippingTax;
238 - $storeBehavior = (int) Arr::get($cart->checkout_data, 'tax_data.store_tax_behavior',
239 - Arr::get($cart->checkout_data, 'tax_data.tax_behavior', 2));
240 - $isInclusive = $storeBehavior === 2;
241 - ?>
242 - <li data-fct-shipping-tax-row <?php echo $atts; ?>>
243 - <span class="fct_summary_label">
244 - <?php if ($isInclusive) : ?>
245 - <?php echo esc_html__('Shipping Tax (Included)', 'fluent-cart'); ?>
246 - <?php else : ?>
247 - <?php echo esc_html__('Shipping Tax (Excluded)', 'fluent-cart'); ?>
248 - <?php endif; ?>
249 - </span>
250 - <span class="fct_summary_value"<?php echo $isReverseCharge ? ' style="text-decoration:line-through;opacity:0.6;"' : ''; ?>>
251 - <?php echo esc_html(Helper::toDecimal($displayAmount)); ?>
252 - </span>
253 - </li>
254 - <?php
255 - return '';
232 + return $this->renderer()->renderShippingTaxRow($cart, $atts);
256 233 }
257 234
258 235 public function renderTaxSummaryBox($cart)
259 236 {
260 - $taxData = Arr::get($cart->checkout_data, 'tax_data', []);
261 - $taxTotal = (int) Arr::get($taxData, 'tax_total', 0);
262 - $exclusiveTaxTotal = (int) Arr::get($taxData, 'exclusive_tax_total', $taxTotal);
263 - $feeTaxLines = (array) Arr::get($taxData, 'fee_tax_lines', []);
264 - $shippingTax = (int) Arr::get($taxData, 'shipping_tax', 0);
265 - $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
266 - $reversedTaxTotalDisplay = (int) Arr::get($taxData, 'reverse_charge_tax_total', 0);
267 -
268 - $inclusiveFeeTax = 0;
269 - $exclusiveFeeTax = 0;
270 - foreach ($feeTaxLines as $feeTaxLine) {
271 - if (!empty($feeTaxLine['inclusive'])) {
272 - $inclusiveFeeTax += (int) Arr::get($feeTaxLine, 'tax_amount', 0);
273 - } else {
274 - $exclusiveFeeTax += (int) Arr::get($feeTaxLine, 'tax_amount', 0);
275 - }
276 - }
277 - // Fallback: if no fee_tax_lines (old cart data), use aggregate fee_tax as exclusive
278 - if (empty($feeTaxLines)) {
279 - $exclusiveFeeTax = (int) Arr::get($taxData, 'fee_tax', 0);
280 - }
281 -
282 - $inclusiveTax = max(0, $taxTotal - $exclusiveTaxTotal - $exclusiveFeeTax - $inclusiveFeeTax);
283 - $productExclusiveTax = max(0, $exclusiveTaxTotal);
284 -
285 - $isShippingInclusive = \FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper::isShippingTaxInclusiveFromTaxData(
286 - is_array($taxData) ? $taxData : []
287 - );
288 -
289 - $payableTax = $productExclusiveTax + $exclusiveFeeTax + ($isShippingInclusive ? 0 : $shippingTax);
290 - $totalOrderTax = $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0) + $payableTax;
291 -
292 - $feeRows = \FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper::buildFeeTaxLineRows($feeTaxLines);
293 - $feeCount = count($feeRows);
294 - if (empty($feeTaxLines) && (int) Arr::get($taxData, 'fee_tax', 0) > 0) {
295 - $feeCount = 1;
296 - }
297 - $rowCount = (int) ($inclusiveTax > 0) + (int) ($productExclusiveTax > 0) + $feeCount + (int) ($shippingTax > 0);
298 - // Show breakdowns when multiple rows exist, or when the single row has no visible "total" row to represent it
299 - // (e.g. inclusive-shipping-only: payableTax=0, inclusiveTax=0 → neither total row shows → must show the breakdown row)
300 - $shouldShowBreakdown = $rowCount >= 2 || ($rowCount === 1 && !($payableTax > 0 || $inclusiveTax > 0 || $inclusiveFeeTax > 0));
301 -
302 - $tooltipId = 'fct-tax-summary-tooltip-' . Helper::getUidSerial();
303 - ?>
304 - <li class="fct_tax_summary_li" data-fct-tax-summary>
305 - <div class="fct_tax_summary_box">
306 - <div class="fct_tax_summary_header">
307 - <span class="fct_tax_summary_heading">
308 - <?php esc_html_e('TAX', 'fluent-cart'); ?>
309 - </span>
310 - <div class="fct_item_tax_hint">
311 - <button
312 - type="button"
313 - class="fct_item_tax_hint_button"
314 - aria-label="<?php esc_attr_e('Tax information', 'fluent-cart'); ?>"
315 - aria-describedby="<?php echo esc_attr($tooltipId); ?>"
316 - >
317 - <span aria-hidden="true">i</span>
318 - </button>
319 - <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
320 - <span class="fct_item_tax_tooltip_heading">
321 - <?php esc_html_e('About your tax', 'fluent-cart'); ?>
322 - </span>
323 - <?php if ($isReverseCharge) : ?>
324 - <span class="fct_item_tax_tooltip_line">
325 - <?php esc_html_e('Tax has been reversed for this order.', 'fluent-cart'); ?>
326 - </span>
327 - <?php else : ?>
328 - <?php if ($payableTax > 0) : ?>
329 - <span class="fct_item_tax_tooltip_line">
330 - <?php esc_html_e('"Total payable tax" is added on top of listed prices.', 'fluent-cart'); ?>
331 - </span>
332 - <?php endif; ?>
333 - <?php if ($inclusiveTax > 0) : ?>
334 - <span class="fct_item_tax_tooltip_line">
335 - <?php esc_html_e('"Included in item prices" is already built into product prices.', 'fluent-cart'); ?>
336 - </span>
337 - <?php endif; ?>
338 - <?php if ($payableTax === 0 && $inclusiveTax === 0) : ?>
339 - <span class="fct_item_tax_tooltip_line">
340 - <?php esc_html_e('No tax applies to this order.', 'fluent-cart'); ?>
341 - </span>
342 - <?php endif; ?>
343 - <?php endif; ?>
344 - </div>
345 - </div>
346 - </div>
347 - <div class="fct_tax_summary_rows">
348 - <?php if ($isReverseCharge) : ?>
349 - <?php
350 - $rcShippingDisplay = (int) Arr::get($taxData, 'reverse_charge_shipping_tax', 0);
351 - $rcInclusiveAdj = (int) Arr::get($taxData, 'reverse_charge_inclusive_adjustment', 0);
352 - $rcExclusiveNonShip = max(0, $reversedTaxTotalDisplay - $rcShippingDisplay - $rcInclusiveAdj);
353 - $rcBreakdownCount = (int) ($rcInclusiveAdj > 0) + (int) ($rcExclusiveNonShip > 0) + (int) ($rcShippingDisplay > 0);
354 - ?>
355 - <?php if ($rcBreakdownCount >= 2) : ?>
356 - <?php if ($rcInclusiveAdj > 0) : ?>
357 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
358 - <span class="fct_tax_summary_row_label">
359 - <?php esc_html_e('Included in item prices', 'fluent-cart'); ?>
360 - </span>
361 - <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
362 - <?php echo esc_html(Helper::toDecimal($rcInclusiveAdj)); ?>
363 - </span>
364 - </div>
365 - <?php endif; ?>
366 - <?php if ($rcExclusiveNonShip > 0) : ?>
367 - <div class="fct_tax_summary_row">
368 - <span class="fct_tax_summary_row_label">
369 - <?php esc_html_e('Added on products', 'fluent-cart'); ?>
370 - </span>
371 - <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
372 - <?php echo esc_html(Helper::toDecimal($rcExclusiveNonShip)); ?>
373 - </span>
374 - </div>
375 - <?php endif; ?>
376 - <?php if ($rcShippingDisplay > 0) : ?>
377 - <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
378 - <span class="fct_tax_summary_row_label">
379 - <?php echo $isShippingInclusive ? esc_html__('Included in shipping prices', 'fluent-cart') : esc_html__('Added on shipping', 'fluent-cart'); ?>
380 - </span>
381 - <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
382 - <?php echo esc_html(Helper::toDecimal($rcShippingDisplay)); ?>
383 - </span>
384 - </div>
385 - <?php endif; ?>
386 - <?php elseif ($rcShippingDisplay > 0) : ?>
387 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
388 - <span class="fct_tax_summary_row_label">
389 - <?php echo $isShippingInclusive ? esc_html__('Included in shipping prices', 'fluent-cart') : esc_html__('Added on shipping', 'fluent-cart'); ?>
390 - </span>
391 - <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
392 - <?php echo esc_html(Helper::toDecimal($rcShippingDisplay)); ?>
393 - </span>
394 - </div>
395 - <?php endif; ?>
396 - <div class="fct_tax_summary_row fct_tax_summary_row--total">
397 - <span class="fct_tax_summary_row_label">
398 - <?php esc_html_e('Tax reversed', 'fluent-cart'); ?>
399 - </span>
400 - <span class="fct_tax_summary_row_amount">
401 - <?php echo esc_html(Helper::toDecimal($reversedTaxTotalDisplay)); ?>
402 - </span>
403 - </div>
404 - <?php else : ?>
405 - <?php if ($inclusiveTax > 0 && $shouldShowBreakdown) : ?>
406 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
407 - <span class="fct_tax_summary_row_label">
408 - <?php esc_html_e('Included in item prices', 'fluent-cart'); ?>
409 - </span>
410 - <span class="fct_tax_summary_row_amount">
411 - <?php echo esc_html(Helper::toDecimal($inclusiveTax)); ?>
412 - </span>
413 - </div>
414 - <?php endif; ?>
415 - <?php if ($productExclusiveTax > 0 && $shouldShowBreakdown) : ?>
416 - <div class="fct_tax_summary_row">
417 - <span class="fct_tax_summary_row_label">
418 - <?php esc_html_e('Added on products', 'fluent-cart'); ?>
419 - </span>
420 - <span class="fct_tax_summary_row_amount">
421 - <?php echo esc_html(Helper::toDecimal($productExclusiveTax)); ?>
422 - </span>
423 - </div>
424 - <?php endif; ?>
425 - <?php if ($shouldShowBreakdown) : ?>
426 - <?php foreach ($feeRows as $feeRow) : ?>
427 - <div class="fct_tax_summary_row<?php echo $feeRow['inclusive'] ? ' fct_tax_summary_row--muted' : ''; ?>">
428 - <span class="fct_tax_summary_row_label">
429 - <?php echo esc_html($feeRow['display_label']); ?>
430 - </span>
431 - <span class="fct_tax_summary_row_amount">
432 - <?php echo esc_html(Helper::toDecimal($feeRow['tax_amount'])); ?>
433 - </span>
434 - </div>
435 - <?php endforeach; ?>
436 - <?php endif; ?>
437 - <?php if (empty($feeTaxLines) && (int) Arr::get($taxData, 'fee_tax', 0) > 0 && $shouldShowBreakdown) : ?>
438 - <div class="fct_tax_summary_row">
439 - <span class="fct_tax_summary_row_label">
440 - <?php esc_html_e('Added on fees', 'fluent-cart'); ?>
441 - </span>
442 - <span class="fct_tax_summary_row_amount">
443 - <?php echo esc_html(Helper::toDecimal((int) Arr::get($taxData, 'fee_tax', 0))); ?>
444 - </span>
445 - </div>
446 - <?php endif; ?>
447 - <?php if ($shippingTax > 0 && $shouldShowBreakdown) : ?>
448 - <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
449 - <span class="fct_tax_summary_row_label">
450 - <?php if ($isShippingInclusive) : ?>
451 - <?php esc_html_e('Included in shipping prices', 'fluent-cart'); ?>
452 - <?php else : ?>
453 - <?php esc_html_e('Added on shipping', 'fluent-cart'); ?>
454 - <?php endif; ?>
455 - </span>
456 - <span class="fct_tax_summary_row_amount">
457 - <?php echo esc_html(Helper::toDecimal($shippingTax)); ?>
458 - </span>
459 - </div>
460 - <?php endif; ?>
461 - <?php if ($payableTax > 0) : ?>
462 - <div class="fct_tax_summary_row fct_tax_summary_row--total">
463 - <span class="fct_tax_summary_row_label">
464 - <?php esc_html_e('Total payable tax', 'fluent-cart'); ?>
465 - </span>
466 - <span class="fct_tax_summary_row_amount">
467 - <?php echo esc_html(Helper::toDecimal($payableTax)); ?>
468 - </span>
469 - </div>
470 - <?php endif; ?>
471 - <?php if ($inclusiveTax > 0 || $inclusiveFeeTax > 0) : ?>
472 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
473 - <span class="fct_tax_summary_row_label">
474 - <?php esc_html_e('Total tax in this order', 'fluent-cart'); ?>
475 - </span>
476 - <span class="fct_tax_summary_row_amount">
477 - <?php echo esc_html(Helper::toDecimal($totalOrderTax)); ?>
478 - </span>
479 - </div>
480 - <?php endif; ?>
481 - <?php endif; ?>
482 - </div>
483 - </div>
484 - </li>
485 - <?php
237 + $this->renderer()->renderTaxSummaryBox($cart);
486 238 }
487 239
488 240 public function maybeRestoreRcAdjustedPrices($data)
489 241 {
@@ -537,9 +289,9 @@
537 289
538 290 $fillData = $this->calculateCartTax([
539 291 'cart_data' => $cart->cart_data,
540 292 'checkout_data' => $checkoutData
541 - ]);
293 + ], $cart);
542 294
543 295 $cart->fill($fillData);
544 296 $cart->save();
545 297 }
@@ -591,9 +343,9 @@
591 343 'shipping_country' => Arr::get($data, 'prev_data.shipping_country', Arr::get($fillData, 'checkout_data.form_data.shipping_country', '')),
592 344 ]
593 345 );
594 346
595 - return $this->calculateCartTax($fillData);
347 + return $this->calculateCartTax($fillData, $cart);
596 348 }
597 349
598 350 public function maybeInvalidateVatValidationForCountryChange($checkoutData, $previousFormData = [])
599 351 {
@@ -635,9 +387,10 @@
635 387 $defaultSettings = [
636 388 'tax_inclusion' => 'included',
637 389 'tax_calculation_basis' => 'shipping',
638 390 'tax_rounding' => 'item',
639 - 'checkout_tax_breakdown_display' => 'both',
391 + 'checkout_tax_breakdown_display' => 'itemized',
392 + 'tax_display_label' => 'Tax',
640 393 'enable_tax' => 'no',
641 394 'eu_vat_settings' => [
642 395 'require_vat_number' => 'no',
643 396 'local_reverse_charge' => 'no',
@@ -655,497 +408,47 @@
655 408
656 409 return $this->taxSettings = $settings;
657 410 }
658 411
659 - private function getEffectiveRcMode()
412 + public function getEffectiveRcMode()
660 413 {
661 414 $settings = $this->getSettings();
662 415 return Arr::get($settings, 'eu_vat_settings.reverse_charge_price_mode', 'fixed');
663 416 }
664 417
665 - protected function getCheckoutTaxBreakdownDisplayMode()
666 - {
667 - $mode = Arr::get($this->getSettings(), 'checkout_tax_breakdown_display', 'both');
668 -
669 - if (!in_array($mode, ['both', 'label', 'tooltip'], true)) {
670 - return 'both';
671 - }
672 -
673 - return $mode;
674 - }
675 -
676 - protected function normalizeCheckoutLineTaxRates($item)
677 - {
678 - $rates = Arr::get($item, 'line_meta.tax_config.rates', []);
679 - if (empty($rates) || !is_array($rates)) {
680 - return [];
681 - }
682 -
683 - $isInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
684 - $normalizedRates = [];
685 -
686 - foreach ($rates as $rate) {
687 - $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
688 - $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
689 - $taxableAmount = (int) Arr::get($rate, 'taxable_amount', 0);
690 -
691 - if ($taxAmount <= 0 && $ratePercent <= 0) {
692 - continue;
693 - }
694 -
695 - $normalizedRates[] = [
696 - 'label' => (string) Arr::get($rate, 'label', __('Tax', 'fluent-cart')),
697 - 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
698 - 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
699 - 'tax_amount' => $taxAmount,
700 - 'display_base' => $isInclusive ? max(0, $taxableAmount - $taxAmount) : $taxableAmount,
701 - 'inclusive' => $isInclusive,
702 - ];
703 - }
704 -
705 - return $normalizedRates;
706 - }
707 -
708 - protected function getCheckoutLineTaxShortLabel($label)
709 - {
710 - $label = trim((string) $label);
711 -
712 - if (!$label) {
713 - return __('Tax', 'fluent-cart');
714 - }
715 -
716 - return $label;
717 - }
718 -
719 - private function shouldRateStrikethrough($isReversed, $rateIsInclusive, $rcMode)
720 - {
721 - if (!$isReversed) {
722 - return false;
723 - }
724 - return !$rateIsInclusive || $rcMode === 'dynamic';
725 - }
726 -
727 418 public function renderCheckoutLineItemTaxLabel($data)
728 419 {
729 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
730 - if (!in_array($mode, ['both', 'label'], true)) {
731 - return;
732 - }
733 -
734 - $item = Arr::get($data, 'item', []);
735 - $rates = $this->normalizeCheckoutLineTaxRates($item);
736 - if (empty($rates)) {
737 - return;
738 - }
739 - $cart = CartHelper::getCart();
740 - $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
741 - $rcMode = $this->getEffectiveRcMode();
742 - $this->renderTaxBadges($rates, $isReversed, $rcMode);
420 + $this->renderer()->renderCheckoutLineItemTaxLabel($data);
743 421 }
744 422
745 423 public function renderCheckoutSetupFeeTaxLabel($data)
746 424 {
747 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
748 - if (!in_array($mode, ['both', 'label'], true)) {
749 - return;
750 - }
751 -
752 - $item = Arr::get($data, 'item', []);
753 - $signupFeeTaxConfig = Arr::get($item, 'signup_fee_tax_config', []);
754 - $rates = Arr::get($signupFeeTaxConfig, 'rates', []);
755 - if (empty($rates) || !is_array($rates)) {
756 - return;
757 - }
758 -
759 - $isInclusive = (bool) Arr::get($signupFeeTaxConfig, 'inclusive', false);
760 - $normalizedRates = [];
761 - foreach ($rates as $rate) {
762 - $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
763 - $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
764 - if ($taxAmount <= 0 && $ratePercent <= 0) {
765 - continue;
766 - }
767 - $normalizedRates[] = [
768 - 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
769 - 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
770 - 'inclusive' => $isInclusive,
771 - 'tax_amount' => $taxAmount,
772 - ];
773 - }
774 -
775 - if (empty($normalizedRates)) {
776 - return;
777 - }
778 - $cart = CartHelper::getCart();
779 - $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
780 - $rcMode = $this->getEffectiveRcMode();
781 - $this->renderTaxBadges($normalizedRates, $isReversed, $rcMode);
425 + $this->renderer()->renderCheckoutSetupFeeTaxLabel($data);
782 426 }
783 427
784 - private function renderTaxBadges(array $normalizedRates, $isReversed = false, $rcMode = 'fixed')
785 - {
786 - ?>
787 - <div class="fct_item_tax_badges" aria-label="<?php esc_attr_e('Tax breakdown', 'fluent-cart'); ?>">
788 - <?php foreach ($normalizedRates as $rate) : ?>
789 - <div class="fct_item_tax_badge_row">
790 - <span class="fct_item_tax_badge <?php echo esc_attr($rate['inclusive'] ? 'is-inclusive' : 'is-exclusive'); ?>">
791 - <span>
792 - <?php
793 - $badgeText = sprintf(
794 - /* translators: %1$s: tax label, %2$s: tax rate percent */
795 - __('%1$s (%2$s%%)', 'fluent-cart'),
796 - $rate['short_label'],
797 - $rate['formatted_rate']
798 - );
799 - echo esc_html($badgeText);
800 - ?>
801 - </span>
802 - </span>
803 - <?php if (!empty($rate['tax_amount']) && (int) $rate['tax_amount'] > 0) : ?>
804 - <?php $rateReversedClass = $this->shouldRateStrikethrough($isReversed, $rate['inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
805 - <span class="fct_item_tax_badge_amount <?php echo esc_attr($rate['inclusive'] ? 'is-inclusive' : 'is-exclusive'); ?><?php echo esc_attr($rateReversedClass); ?>">
806 - <?php
807 - $amountText = $rate['inclusive']
808 - ? sprintf(
809 - /* translators: %1$s: formatted tax amount */
810 - __('incl. %1$s', 'fluent-cart'),
811 - Helper::toDecimal((int) $rate['tax_amount'])
812 - )
813 - : sprintf(
814 - /* translators: %1$s: formatted tax amount */
815 - __('+ %1$s', 'fluent-cart'),
816 - Helper::toDecimal((int) $rate['tax_amount'])
817 - );
818 - echo esc_html($amountText);
819 - ?>
820 - </span>
821 - <?php endif; ?>
822 - </div>
823 - <?php endforeach; ?>
824 - </div>
825 - <?php
826 - }
827 -
828 - private function getSetupFeeTaxData($item)
829 - {
830 - $signupFeeTaxConfig = Arr::get($item, 'signup_fee_tax_config', []);
831 - $rawRates = Arr::get($signupFeeTaxConfig, 'rates', []);
832 - if (empty($rawRates) || !is_array($rawRates)) {
833 - return null;
834 - }
835 -
836 - $isInclusive = (bool) Arr::get($signupFeeTaxConfig, 'inclusive', false);
837 - $rates = [];
838 - foreach ($rawRates as $rate) {
839 - $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
840 - $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
841 - if ($taxAmount <= 0 && $ratePercent <= 0) {
842 - continue;
843 - }
844 - $taxableAmount = (int) Arr::get($rate, 'taxable_amount', 0);
845 - $rates[] = [
846 - 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
847 - 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
848 - 'tax_amount' => $taxAmount,
849 - 'display_base' => $isInclusive ? max(0, $taxableAmount - $taxAmount) : $taxableAmount,
850 - ];
851 - }
852 -
853 - if (empty($rates)) {
854 - return null;
855 - }
856 -
857 - $setupFee = (int) Arr::get($item, 'other_info.signup_fee', 0);
858 - $totalTax = array_sum(array_map(function ($rate) {
859 - return (int) $rate['tax_amount'];
860 - }, $rates));
861 -
862 - return [
863 - 'rates' => $rates,
864 - 'is_inclusive' => $isInclusive,
865 - 'total_tax' => $totalTax,
866 - 'display_total' => $isInclusive ? $setupFee : $setupFee + $totalTax,
867 - 'primary_label' => Arr::get($rates, '0.short_label', __('Tax', 'fluent-cart')),
868 - ];
869 - }
870 -
871 428 public function renderCheckoutSetupFeeTaxTooltip($data)
872 429 {
873 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
874 - if (!in_array($mode, ['both', 'tooltip'], true)) {
875 - return;
876 - }
877 -
878 - $item = Arr::get($data, 'item', []);
879 - $taxData = $this->getSetupFeeTaxData($item);
880 - if (!$taxData) {
881 - return;
882 - }
883 -
884 - $cart = CartHelper::getCart();
885 - $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
886 - $rcMode = $this->getEffectiveRcMode();
887 -
888 - $rates = $taxData['rates'];
889 - $isInclusive = $taxData['is_inclusive'];
890 - $displayTotal = $taxData['display_total'];
891 - if ($isReversed) {
892 - if ($taxData['is_inclusive'] && $rcMode === 'dynamic') {
893 - $displayTotal = $taxData['display_total'] - $taxData['total_tax'];
894 - } else {
895 - $displayTotal = (int) Arr::get($data, 'item.other_info.signup_fee', 0);
896 - }
897 - }
898 - $tooltipId = 'fct-item-tax-tooltip-' . Helper::getUidSerial();
899 - ?>
900 - <div class="fct_item_tax_hint">
901 - <button
902 - type="button"
903 - class="fct_item_tax_hint_button"
904 - aria-label="<?php esc_attr_e('View tax breakdown for this item', 'fluent-cart'); ?>"
905 - aria-describedby="<?php echo esc_attr($tooltipId); ?>"
906 - >
907 - <span aria-hidden="true">i</span>
908 - </button>
909 - <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
910 - <span class="fct_item_tax_tooltip_heading">
911 - <?php echo esc_html($isInclusive ? __('Tax-inclusive price', 'fluent-cart') : __('Tax-exclusive price', 'fluent-cart')); ?>
912 - </span>
913 - <?php foreach ($rates as $rate) : ?>
914 - <?php $lineReversedClass = $this->shouldRateStrikethrough($isReversed, isset($rate['inclusive']) ? (bool)$rate['inclusive'] : $taxData['is_inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
915 - <span class="fct_item_tax_tooltip_line<?php echo esc_attr($lineReversedClass); ?>">
916 - <?php
917 - $lineText = sprintf(
918 - /* translators: %1$s: tax base amount, %2$s: tax label, %3$s: tax rate percent, %4$s: tax amount */
919 - __('Base %1$s + %2$s %3$s%% %4$s', 'fluent-cart'),
920 - Helper::toDecimal($rate['display_base']),
921 - $rate['short_label'],
922 - $rate['formatted_rate'],
923 - Helper::toDecimal($rate['tax_amount'])
924 - );
925 - echo esc_html($lineText);
926 - ?>
927 - </span>
928 - <?php endforeach; ?>
929 - <span class="fct_item_tax_tooltip_line is-total">
930 - <?php
931 - echo esc_html(sprintf(
932 - /* translators: %1$s: line total amount */
933 - __('Total %1$s', 'fluent-cart'),
934 - Helper::toDecimal($displayTotal)
935 - ));
936 - ?>
937 - </span>
938 - </div>
939 - </div>
940 - <?php
430 + $this->renderer()->renderCheckoutSetupFeeTaxTooltip($data);
941 431 }
942 432
943 433 public function renderCheckoutSetupFeeTaxInfo($data)
944 434 {
945 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
946 - if (!in_array($mode, ['both', 'label'], true)) {
947 - return;
948 - }
949 -
950 - $item = Arr::get($data, 'item', []);
951 - $taxData = $this->getSetupFeeTaxData($item);
952 - if (!$taxData) {
953 - return;
954 - }
955 -
956 - $isInclusive = $taxData['is_inclusive'];
957 - $totalTax = $taxData['total_tax'];
958 - $primaryLabel = $taxData['primary_label'];
959 -
960 - $priceNote = $isInclusive
961 - ? sprintf(
962 - /* translators: %1$s: tax label */
963 - __('%1$s incl.', 'fluent-cart'),
964 - $primaryLabel
965 - )
966 - : sprintf(
967 - /* translators: %1$s: tax amount, %2$s: tax label */
968 - __('+ %1$s %2$s', 'fluent-cart'),
969 - Helper::toDecimal($totalTax),
970 - strtolower($primaryLabel)
971 - );
972 - ?>
973 - <span class="fct_setup_fee_price_note"><?php echo esc_html($priceNote); ?></span>
974 - <?php
435 + $this->renderer()->renderCheckoutSetupFeeTaxInfo($data);
975 436 }
976 437
977 438 public function renderCheckoutLineItemTaxTooltip($data)
978 439 {
979 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
980 - if (!in_array($mode, ['both', 'tooltip'], true)) {
981 - return;
982 - }
983 -
984 - $item = Arr::get($data, 'item', []);
985 - $rates = $this->normalizeCheckoutLineTaxRates($item);
986 - if (empty($rates)) {
987 - return;
988 - }
989 -
990 - $cart = CartHelper::getCart();
991 - $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
992 - $rcMode = $this->getEffectiveRcMode();
993 -
994 - $isInclusive = (bool) Arr::get($rates, '0.inclusive', false);
995 - $itemSubtotal = (int) Arr::get($item, 'line_total', Arr::get($item, 'subtotal', 0));
996 - $itemTaxAmount = array_sum(array_map(function ($rate) {
997 - return (int) Arr::get($rate, 'tax_amount', 0);
998 - }, $rates));
999 - if ($isReversed) {
1000 - if ($isInclusive && $rcMode === 'dynamic') {
1001 - $displayTotal = $itemSubtotal - $itemTaxAmount;
1002 - } else {
1003 - $displayTotal = $itemSubtotal;
1004 - }
1005 - } else {
1006 - $displayTotal = $isInclusive ? $itemSubtotal : $itemSubtotal + $itemTaxAmount;
1007 - }
1008 - $tooltipId = 'fct-item-tax-tooltip-' . Helper::getUidSerial();
1009 - ?>
1010 - <div class="fct_item_tax_hint">
1011 - <button
1012 - type="button"
1013 - class="fct_item_tax_hint_button"
1014 - aria-label="<?php esc_attr_e('View tax breakdown for this item', 'fluent-cart'); ?>"
1015 - aria-describedby="<?php echo esc_attr($tooltipId); ?>"
1016 - >
1017 - <span aria-hidden="true">i</span>
1018 - </button>
1019 - <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
1020 - <span class="fct_item_tax_tooltip_heading">
1021 - <?php echo esc_html($isInclusive ? __('Tax-inclusive price', 'fluent-cart') : __('Tax-exclusive price', 'fluent-cart')); ?>
1022 - </span>
1023 - <?php foreach ($rates as $rate) : ?>
1024 - <?php $lineReversedClass = $this->shouldRateStrikethrough($isReversed, $rate['inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
1025 - <span class="fct_item_tax_tooltip_line<?php echo esc_attr($lineReversedClass); ?>">
1026 - <?php
1027 - $lineText = sprintf(
1028 - /* translators: %1$s: tax base amount, %2$s: tax label, %3$s: tax rate percent, %4$s: tax amount */
1029 - __('Base %1$s + %2$s %3$s%% %4$s', 'fluent-cart'),
1030 - Helper::toDecimal($rate['display_base']),
1031 - $rate['short_label'],
1032 - $rate['formatted_rate'],
1033 - Helper::toDecimal($rate['tax_amount'])
1034 - );
1035 - echo esc_html($lineText);
1036 - ?>
1037 - </span>
1038 - <?php endforeach; ?>
1039 - <span class="fct_item_tax_tooltip_line is-total">
1040 - <?php
1041 - echo esc_html(sprintf(
1042 - /* translators: %1$s: line total amount */
1043 - __('Total %1$s', 'fluent-cart'),
1044 - Helper::toDecimal($displayTotal)
1045 - ));
1046 - ?>
1047 - </span>
1048 - </div>
1049 - </div>
1050 - <?php
440 + $this->renderer()->renderCheckoutLineItemTaxTooltip($data);
1051 441 }
1052 442
1053 443 public function renderCheckoutLineItemTaxInfo($data)
1054 444 {
1055 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
1056 - if (!in_array($mode, ['both', 'label'], true)) {
1057 - return;
1058 - }
1059 -
1060 - $item = Arr::get($data, 'item', []);
1061 - $rates = $this->normalizeCheckoutLineTaxRates($item);
1062 - if (empty($rates)) {
1063 - return;
1064 - }
1065 -
1066 - $isInclusive = (bool) Arr::get($rates, '0.inclusive', false);
1067 - $itemTaxAmount = array_sum(array_map(function ($rate) {
1068 - return (int) Arr::get($rate, 'tax_amount', 0);
1069 - }, $rates));
1070 - $primaryLabel = Arr::get($rates, '0.short_label', __('Tax', 'fluent-cart'));
1071 -
1072 - $priceNote = $isInclusive
1073 - ? sprintf(
1074 - /* translators: %1$s: tax label */
1075 - __('%1$s incl.', 'fluent-cart'),
1076 - $primaryLabel
1077 - )
1078 - : sprintf(
1079 - /* translators: %1$s: tax amount, %2$s: tax label */
1080 - __('+ %1$s %2$s', 'fluent-cart'),
1081 - Helper::toDecimal($itemTaxAmount),
1082 - strtolower($primaryLabel)
1083 - );
1084 - ?>
1085 - <div class="fct_item_tax_price_note"><?php echo esc_html($priceNote); ?></div>
1086 - <?php
445 + $this->renderer()->renderCheckoutLineItemTaxInfo($data);
1087 446 }
1088 447
1089 448 public function renderUnitPriceRoundingTooltip($data)
1090 449 {
1091 - $item = Arr::get($data, 'item', []);
1092 - $quantity = (int) Arr::get($item, 'quantity', 1);
1093 -
1094 - if ($quantity < 2) {
1095 - return;
1096 - }
1097 -
1098 - $unitPrice = (int) Arr::get($item, 'unit_price', 0);
1099 - $subtotal = (int) Arr::get($item, 'subtotal', 0);
1100 -
1101 - if ($unitPrice <= 0) {
1102 - return;
1103 - }
1104 -
1105 - $shouldShow = false;
1106 -
1107 - // Case 1 — TaxModule RC dynamic: the applied per-unit tax adjustment is rounded,
1108 - // so unit_price * qty may differ from the exact net by up to (qty - 1) cents.
1109 - $rcAdjustment = (int) Arr::get($item, 'line_meta.reverse_charge_adjustment', 0);
1110 - if ($rcAdjustment > 0) {
1111 - $rates = (array) Arr::get($item, 'line_meta.tax_config.rates', []);
1112 - $actualTaxTotal = (int) array_sum(array_map('intval', array_column($rates, 'tax_amount')));
1113 - $diff = abs($rcAdjustment - $actualTaxTotal);
1114 - if ($diff > 0 && $diff <= $quantity) {
1115 - $shouldShow = true;
1116 - }
1117 - }
1118 -
1119 - // Case 2 — generic: unit_price * qty already doesn't match subtotal
1120 - // (future dynamic pricing or any other module that stores a rounded unit_price)
1121 - if (!$shouldShow && $subtotal > 0) {
1122 - $diff = abs(($unitPrice * $quantity) - $subtotal);
1123 - if ($diff > 0 && $diff <= $quantity) {
1124 - $shouldShow = true;
1125 - }
1126 - }
1127 -
1128 - if (!$shouldShow) {
1129 - return;
1130 - }
1131 -
1132 - $tooltipId = 'fct-unit-price-rounding-' . Helper::getUidSerial();
1133 - ?>
1134 - <div class="fct_item_tax_hint">
1135 - <button
1136 - type="button"
1137 - class="fct_item_tax_hint_button"
1138 - aria-label="<?php esc_attr_e('Unit price rounding information', 'fluent-cart'); ?>"
1139 - aria-describedby="<?php echo esc_attr($tooltipId); ?>"
1140 - >
1141 - <span aria-hidden="true">i</span>
1142 - </button>
1143 - <div class="fct_item_tax_tooltip fct_unit_price_rounding_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
1144 - <?php esc_html_e('Unit price is rounded for display. The line total is calculated at full precision, so it always reconciles exactly.', 'fluent-cart'); ?>
1145 - </div>
1146 - </div>
1147 - <?php
450 + $this->renderer()->renderUnitPriceRoundingTooltip($data);
1148 451 }
1149 452
1150 453 public function isEnabled()
1151 454 {
@@ -1152,9 +455,15 @@
1152 455 $settings = $this->getSettings();
1153 456 return Arr::get($settings, 'enable_tax', 'no') === 'yes';
1154 457 }
1155 458
1156 - 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)
1157 466 {
1158 467 $lineItems = Arr::get($fillData, 'cart_data', []);
1159 468 $checkoutData = Arr::get($fillData, 'checkout_data', []);
1160 469
@@ -1186,8 +495,70 @@
1186 495 $fillData['cart_data'] = $lineItems;
1187 496 return $fillData;
1188 497 }
1189 498
499 + // Shipping tax is derived from each item's itemwise_shipping_charge, but the
500 + // authoritative selected shipping charge lives in checkout_data.shipping_data.
501 + // These desync when a recalc entry point (VAT apply/remove, order placement,
502 + // address patch) runs after the display path computed shipping without persisting
503 + // the per-item distribution. Left unfixed the tax calculator sees zero shipping and
504 + // drops the shipping tax (and, under reverse charge, its reversed portion). Repair
505 + // the distribution here — the single point all recalc paths share.
506 + $shipMethodId = Arr::get($checkoutData, 'shipping_data.shipping_method_id');
507 + $shipCharge = (int) Arr::get($checkoutData, 'shipping_data.shipping_charge', 0);
508 + $distributedShipping = 0;
509 + foreach ($lineItems as $cartLine) {
510 + $distributedShipping += (int) Arr::get($cartLine, 'itemwise_shipping_charge', 0);
511 + $distributedShipping += (int) Arr::get($cartLine, 'shipping_charge', 0);
512 + }
513 + if ($shipMethodId && $shipCharge > 0 && $distributedShipping === 0) {
514 + // A method is selected with a charge but no per-item share — redistribute it.
515 + $shipMethod = \FluentCart\App\Models\ShippingMethod::query()->find($shipMethodId);
516 + if ($shipMethod) {
517 + $redistributed = \FluentCart\App\Helpers\CartHelper::calculateShippingMethodCharge(
518 + $shipMethod, $lineItems, 'items'
519 + );
520 + $lineItems = Arr::get($redistributed, 'items', $lineItems);
521 + }
522 + } elseif (!$shipMethodId && $distributedShipping === 0) {
523 + // Nothing persisted in the stored cart, yet the checkout page auto-selects the
524 + // single available method for display. Logged-in customers whose default address
525 + // is pre-filled never fire the change event that would persist that selection, so
526 + // shipping_data stays empty and the tax calculator drops shipping entirely. Mirror
527 + // the renderer's single-method auto-select so VAT apply / placement tax the
528 + // shipping the customer already sees selected.
529 + $requiresShipping = false;
530 + foreach ($lineItems as $cartLine) {
531 + if (Arr::get($cartLine, 'fulfillment_type') === 'physical') {
532 + $requiresShipping = true;
533 + break;
534 + }
535 + }
536 + if ($requiresShipping) {
537 + $autoCountry = Arr::get($checkoutData, 'form_data.shipping_country')
538 + ?: Arr::get($checkoutData, 'form_data.billing_country');
539 + $autoState = Arr::get($checkoutData, 'form_data.shipping_state')
540 + ?: Arr::get($checkoutData, 'form_data.billing_state');
541 + if ($autoCountry) {
542 + $autoMethods = \FluentCart\App\Helpers\AddressHelper::getShippingMethods($autoCountry, $autoState);
543 + if ($autoMethods && !is_wp_error($autoMethods) && count($autoMethods) === 1) {
544 + $autoMethod = Arr::first($autoMethods);
545 + $autoCalc = \FluentCart\App\Helpers\CartHelper::calculateShippingMethodCharge(
546 + $autoMethod, $lineItems, 'items'
547 + );
548 + $lineItems = Arr::get($autoCalc, 'items', $lineItems);
549 + Arr::set($checkoutData, 'shipping_data.shipping_method_id', $autoMethod->id);
550 + Arr::set($checkoutData, 'shipping_data.shipping_charge', (int) Arr::get($autoCalc, 'shipping_amount', 0));
551 + }
552 + }
553 + }
554 + }
555 +
556 + $unpricedLineItems = $lineItems;
557 + if ($cart instanceof Cart) {
558 + $lineItems = $this->applyPricingRules($lineItems, $checkoutData, $cart);
559 + }
560 +
1190 561 $country = '';
1191 562 $state = '';
1192 563 $postCode = '';
1193 564
@@ -1278,21 +649,8 @@
1278 649
1279 650 $shouldApplyReverseCharge = $this->shouldApplyReverseCharge($checkoutData, $country, $lineItems);
1280 651 $rcMode = $this->getEffectiveRcMode();
1281 652
1282 - // If the customer previously validated a VAT number but local_reverse_charge
1283 - // was turned off by the admin mid-session, strip the validation state now.
1284 - // This ensures the UI re-renders without the Remove button and subsequent
1285 - // order placement also sees a clean non-RC cart.
1286 - if (!$shouldApplyReverseCharge
1287 - && $country
1288 - && Arr::get($checkoutData, 'tax_data.valid', false)
1289 - && !$this->canApplyVatValidation($country)
1290 - ) {
1291 - $checkoutData['tax_data']['valid'] = false;
1292 - unset($checkoutData['tax_data']['name'], $checkoutData['tax_data']['country'], $checkoutData['tax_data']['address']);
1293 - }
1294 -
1295 653 // Capture signup_fee_tax BEFORE the RC zeroing block sets it to 0.
1296 654 $signupFeeTaxesByIndex = [];
1297 655 if ($shouldApplyReverseCharge && $rcMode === 'dynamic') {
1298 656 foreach ($productLines as $idx => $pLine) {
@@ -1299,11 +657,12 @@
1299 657 $signupFeeTaxesByIndex[$idx] = (int) Arr::get($pLine, 'other_info.signup_fee_tax', 0);
1300 658 }
1301 659 }
1302 660
1303 - $inclusiveTaxAdjustment = 0;
1304 - $reversedTaxTotal = 0;
1305 - $reversedShippingTax = 0;
661 + $inclusiveTaxAdjustment = 0;
662 + $reversedTaxTotal = 0;
663 + $reversedShippingTax = 0;
664 + $reversedShippingTaxLines = [];
1306 665 if ($shouldApplyReverseCharge) {
1307 666 $inclusivePortion = $taxTotal - $exclusiveTaxTotal - $feeTax;
1308 667 $reversedInclusive = ($rcMode === 'dynamic') ? $inclusivePortion : 0;
1309 668 $reversedTaxTotal = $exclusiveTaxTotal + $feeTax + $shippingTax + $reversedInclusive;
@@ -1308,8 +667,9 @@
1308 667 $reversedInclusive = ($rcMode === 'dynamic') ? $inclusivePortion : 0;
1309 668 $reversedTaxTotal = $exclusiveTaxTotal + $feeTax + $shippingTax + $reversedInclusive;
1310 669 $inclusiveTaxAdjustment = $inclusivePortion;
1311 670 $reversedShippingTax = $shippingTax;
671 + $reversedShippingTaxLines = $shippingTaxLines;
1312 672 $taxTotal = 0;
1313 673 $exclusiveTaxTotal = 0;
1314 674 $shippingTax = 0;
1315 675 $shippingTaxLines = [];
@@ -1440,8 +800,9 @@
1440 800 $checkoutData['tax_data']['tax_country'] = $taxCountry;
1441 801 $checkoutData['tax_data']['shipping_tax'] = $shippingTax;
1442 802 $checkoutData['tax_data']['shipping_tax_lines'] = $shippingTaxLines;
1443 803 $checkoutData['tax_data']['reverse_charge_shipping_tax'] = $reversedShippingTax;
804 + $checkoutData['tax_data']['reverse_charge_shipping_tax_lines'] = $reversedShippingTaxLines;
1444 805 $checkoutData['tax_data']['reverse_charge_price_mode'] = $shouldApplyReverseCharge ? $this->getEffectiveRcMode() : 'fixed';
1445 806 $checkoutData['tax_data']['fee_tax'] = $feeTax;
1446 807 $checkoutData['tax_data']['fee_tax_lines'] = $feeTaxLines;
1447 808 $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal;
@@ -1447,8 +808,12 @@
1447 808 $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal;
1448 809 $checkoutData['tax_data']['tax_lines'] = $taxLines;
1449 810 $fillData['checkout_data'] = $checkoutData;
1450 811
812 + if ($cart instanceof Cart) {
813 + $productLines = $this->restoreUnpricedValues($productLines, $unpricedLineItems);
814 + }
815 +
1451 816 // Only product lines go back into cart_data
1452 817 $fillData['cart_data'] = $productLines;
1453 818
1454 819 if (isset($fillData['hook_changes'])) {
@@ -1457,8 +822,117 @@
1457 822
1458 823 return $fillData;
1459 824 }
1460 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 +
1461 935 protected function shouldApplyReverseCharge($checkoutData, $taxApplicableCountry, $lineItems = [])
1462 936 {
1463 937 if (!Arr::get($checkoutData, 'tax_data.valid', false)) {
1464 938 return false;
@@ -1490,14 +964,22 @@
1490 964
1491 965 return true;
1492 966 }
1493 967
1494 - protected function isReverseChargeCheckout($checkoutData)
968 + public function isReverseChargeCheckout($checkoutData)
1495 969 {
1496 970 return Arr::get($checkoutData, 'tax_data.valid', false)
1497 971 && (int) Arr::get($checkoutData, 'tax_data.tax_behavior', 2) === 0;
1498 972 }
1499 973
974 + protected function hasReverseChargeTaxData(array $taxData): bool
975 + {
976 + return (int) Arr::get($taxData, 'reverse_charge_tax_total', 0) > 0
977 + || (int) Arr::get($taxData, 'reverse_charge_shipping_tax', 0) > 0
978 + || (int) Arr::get($taxData, 'reverse_charge_inclusive_adjustment', 0) > 0
979 + || (int) Arr::get($taxData, 'tax_behavior', 2) === 0;
980 + }
981 +
1500 982 public function maybeRerenderEuVatField($fragments, $args)
1501 983 {
1502 984 $vatNumberEnabled = CheckoutFieldsSchema::isVatNumberEnabled();
1503 985
@@ -1720,14 +1202,8 @@
1720 1202 $legalRegId = sanitize_text_field(
1721 1203 Arr::get($requestData, 'billing_legal_registration_id', '')
1722 1204 ?: Arr::get($checkoutData, 'form_data.billing_legal_registration_id', '')
1723 1205 );
1724 - $declarationNote = sanitize_text_field(Arr::get($checkoutData, 'tax_data.declaration_note', ''));
1725 -
1726 - if ($declarationNote === '') {
1727 - $declarationNote = sanitize_text_field(App::request()->get('fct_vat_declaration_note', ''));
1728 - }
1729 -
1730 1206 if (!$taxNumber && !$companyName && !$legalRegId) {
1731 1207 return;
1732 1208 }
1733 1209
@@ -1749,11 +1225,8 @@
1749 1225 if (Arr::get($checkoutData, 'tax_data.valid', false)) {
1750 1226 $businessInfo['tax_number_validated'] = true;
1751 1227 $businessInfo['tax_number_country'] = sanitize_text_field(Arr::get($checkoutData, 'tax_data.country', ''));
1752 1228 $businessInfo['tax_number_name'] = sanitize_text_field(Arr::get($checkoutData, 'tax_data.name', ''));
1753 - if ($declarationNote !== '') {
1754 - $businessInfo['reverse_charge_declaration'] = $declarationNote;
1755 - }
1756 1229 }
1757 1230 }
1758 1231
1759 1232 $order->updateMeta('business_info', $businessInfo);
@@ -1787,8 +1260,9 @@
1787 1260 // off local_reverse_charge after the customer validated), recalculate now
1788 1261 // so the correct tax totals and Apply/Remove state render immediately.
1789 1262 if (
1790 1263 Arr::get($cart->checkout_data, 'tax_data.valid', false)
1264 + && $this->hasReverseChargeTaxData(Arr::get($cart->checkout_data, 'tax_data', []))
1791 1265 && !$this->canApplyVatValidation($taxApplicableCountry)
1792 1266 ) {
1793 1267 $this->recalculateTax($data);
1794 1268 }
@@ -1872,12 +1346,8 @@
1872 1346 if (!$countryCode || !in_array($countryCode, $euCountryCodes)) {
1873 1347 wp_send_json(['message' => __('VAT validation is only available for EU countries.', 'fluent-cart')], 422);
1874 1348 }
1875 1349
1876 - if (!$this->canApplyVatValidation($countryCode)) {
1877 - wp_send_json(['message' => __('VAT reverse charge is not available for this country.', 'fluent-cart')], 422);
1878 - }
1879 -
1880 1350 $vatNumber = isset($_REQUEST['vat_number']) ? sanitize_text_field(wp_unslash($_REQUEST['vat_number'])) : '';
1881 1351
1882 1352 if (!$vatNumber) {
1883 1353 wp_send_json(['message' => __('Missing required data', 'fluent-cart')], 422);
@@ -1915,44 +1385,44 @@
1915 1385 }
1916 1386 }
1917 1387 }
1918 1388
1919 - if (!$isExcluded) {
1920 - if ($localRc === 'yes' || $countryCode !== $storeCountry) {
1921 - $cartTaxData = Arr::get($cart->checkout_data, 'tax_data', []);
1922 - $inclusiveAdj = (int) Arr::get($cartTaxData, 'reverse_charge_inclusive_adjustment', 0);
1389 + $appliesReverseCharge = !$isExcluded && ($localRc === 'yes' || $countryCode !== $storeCountry);
1923 1390
1924 - $existingRcTotal = (int) Arr::get($cartTaxData, 'reverse_charge_tax_total', 0);
1925 - if ($existingRcTotal > 0) {
1926 - $taxData['reverse_charge_tax_total'] = $existingRcTotal;
1927 - $taxData['reverse_charge_shipping_tax'] = (int) Arr::get($cartTaxData, 'reverse_charge_shipping_tax', 0);
1928 - } else {
1929 - $rcExclusiveTax = (int) Arr::get($cartTaxData, 'exclusive_tax_total', 0);
1930 - $rcFeeTax = (int) Arr::get($cartTaxData, 'fee_tax', 0);
1931 - $rcShippingTax = (int) Arr::get($cartTaxData, 'shipping_tax', 0);
1932 - $rcTaxTotal = (int) Arr::get($cartTaxData, 'tax_total', 0);
1933 - $rcMode = $this->getEffectiveRcMode();
1934 - $rcInclPortion = max(0, $rcTaxTotal - $rcExclusiveTax - $rcFeeTax - $rcShippingTax);
1935 - $rcReversedIncl = ($rcMode === 'dynamic') ? $rcInclPortion : 0;
1936 - $taxData['reverse_charge_tax_total'] = $rcExclusiveTax + $rcFeeTax + $rcShippingTax + $rcReversedIncl;
1937 - $taxData['reverse_charge_shipping_tax'] = $rcShippingTax;
1938 - }
1939 - $taxData['reverse_charge_inclusive_adjustment'] = $inclusiveAdj;
1940 - $taxData['tax_total'] = 0;
1941 - $taxData['exclusive_tax_total'] = 0;
1942 - $taxData['shipping_tax'] = 0;
1943 - $taxData['tax_behavior'] = 0;
1944 - $taxData['fee_tax'] = 0;
1945 - $taxData['signup_fee_tax'] = 0;
1946 - $taxData['shipping_tax_lines'] = [];
1947 - $existingTaxLines = isset($cartTaxData['tax_lines']) && is_array($cartTaxData['tax_lines'])
1948 - ? $cartTaxData['tax_lines']
1949 - : [];
1950 - $taxData['tax_lines'] = array_map(function ($line) {
1951 - $line['tax_amount'] = 0;
1952 - return $line;
1953 - }, $existingTaxLines);
1391 + if ($appliesReverseCharge) {
1392 + $cartTaxData = Arr::get($cart->checkout_data, 'tax_data', []);
1393 + $inclusiveAdj = (int) Arr::get($cartTaxData, 'reverse_charge_inclusive_adjustment', 0);
1394 +
1395 + $existingRcTotal = (int) Arr::get($cartTaxData, 'reverse_charge_tax_total', 0);
1396 + if ($existingRcTotal > 0) {
1397 + $taxData['reverse_charge_tax_total'] = $existingRcTotal;
1398 + $taxData['reverse_charge_shipping_tax'] = (int) Arr::get($cartTaxData, 'reverse_charge_shipping_tax', 0);
1399 + } else {
1400 + $rcExclusiveTax = (int) Arr::get($cartTaxData, 'exclusive_tax_total', 0);
1401 + $rcFeeTax = (int) Arr::get($cartTaxData, 'fee_tax', 0);
1402 + $rcShippingTax = (int) Arr::get($cartTaxData, 'shipping_tax', 0);
1403 + $rcTaxTotal = (int) Arr::get($cartTaxData, 'tax_total', 0);
1404 + $rcMode = $this->getEffectiveRcMode();
1405 + $rcInclPortion = max(0, $rcTaxTotal - $rcExclusiveTax - $rcFeeTax - $rcShippingTax);
1406 + $rcReversedIncl = ($rcMode === 'dynamic') ? $rcInclPortion : 0;
1407 + $taxData['reverse_charge_tax_total'] = $rcExclusiveTax + $rcFeeTax + $rcShippingTax + $rcReversedIncl;
1408 + $taxData['reverse_charge_shipping_tax'] = $rcShippingTax;
1954 1409 }
1410 + $taxData['reverse_charge_inclusive_adjustment'] = $inclusiveAdj;
1411 + $taxData['tax_total'] = 0;
1412 + $taxData['exclusive_tax_total'] = 0;
1413 + $taxData['shipping_tax'] = 0;
1414 + $taxData['tax_behavior'] = 0;
1415 + $taxData['fee_tax'] = 0;
1416 + $taxData['signup_fee_tax'] = 0;
1417 + $taxData['shipping_tax_lines'] = [];
1418 + $existingTaxLines = isset($cartTaxData['tax_lines']) && is_array($cartTaxData['tax_lines'])
1419 + ? $cartTaxData['tax_lines']
1420 + : [];
1421 + $taxData['tax_lines'] = array_map(function ($line) {
1422 + $line['tax_amount'] = 0;
1423 + return $line;
1424 + }, $existingTaxLines);
1955 1425 }
1956 1426
1957 1427 $checkoutData = $cart->checkout_data;
1958 1428 if (!isset($checkoutData['tax_data']) || !is_array($checkoutData['tax_data'])) {
@@ -1962,9 +1432,9 @@
1962 1432 $cart->checkout_data = $checkoutData;
1963 1433 $fillData = $this->calculateCartTax([
1964 1434 'cart_data' => $cart->cart_data,
1965 1435 'checkout_data' => $cart->checkout_data,
1966 - ]);
1436 + ], $cart);
1967 1437 $cart->fill($fillData);
1968 1438 $cart->save();
1969 1439
1970 1440 ob_start();
@@ -1976,9 +1446,11 @@
1976 1446 $euVatView = ob_get_clean();
1977 1447
1978 1448 wp_send_json([
1979 1449 'success' => true,
1980 - 'message' => __('VAT has been applied successfully', 'fluent-cart'),
1450 + 'message' => $appliesReverseCharge
1451 + ? __('VAT has been applied successfully', 'fluent-cart')
1452 + : __('VAT number has been validated successfully', 'fluent-cart'),
1981 1453 'tax_data' => $taxData,
1982 1454 'fragments' => [
1983 1455 [
1984 1456 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
@@ -2024,8 +1496,9 @@
2024 1496 unset($checkoutData['tax_data']['country']);
2025 1497 unset($checkoutData['tax_data']['declaration_note']);
2026 1498 unset($checkoutData['tax_data']['reverse_charge_tax_total']);
2027 1499 unset($checkoutData['tax_data']['reverse_charge_shipping_tax']);
1500 + unset($checkoutData['tax_data']['reverse_charge_shipping_tax_lines']);
2028 1501 unset($checkoutData['tax_data']['reverse_charge_inclusive_adjustment']);
2029 1502 }
2030 1503
2031 1504 $cart->checkout_data = $checkoutData;
@@ -2031,9 +1504,9 @@
2031 1504 $cart->checkout_data = $checkoutData;
2032 1505 $fillData = $this->calculateCartTax([
2033 1506 'cart_data' => $cart->cart_data,
2034 1507 'checkout_data' => $cart->checkout_data,
2035 - ]);
1508 + ], $cart);
2036 1509 $cart->fill($fillData);
2037 1510 $cart->save();
2038 1511
2039 1512 ob_start();
@@ -2235,9 +1708,9 @@
2235 1708 // Falls back to proportional split for admin order recalculation and legacy callers.
2236 1709 $shippingTaxByRateId = [];
2237 1710 foreach ($shippingTaxLines as $stl) {
2238 1711 $stlRateId = (int) Arr::get($stl, 'rate_id', 0);
2239 - if ($stlRateId > 0) {
1712 + if ($stlRateId !== 0) {
2240 1713 $shippingTaxByRateId[$stlRateId] = (int) Arr::get($stl, 'shipping_tax', 0);
2241 1714 }
2242 1715 }
2243 1716
@@ -2332,8 +1805,24 @@
2332 1805 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
2333 1806 $storeCountry = (new StoreSettings())->get('store_country');
2334 1807 return Arr::get($taxSettings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes'
2335 1808 || $countryCode !== $storeCountry;
1809 + }
1810 +
1811 + /**
1812 + * The legal reverse-charge notice shown on checkout, invoices, receipts and emails.
1813 + * EU VAT Directive 2006/112/EC Article 226(11a) requires the invoice to carry the
1814 + * literal mention "Reverse charge" when the customer is liable for the VAT, so the
1815 + * default string must always start with those exact words. Article 196 covers
1816 + * services; stores supplying intra-EU goods (Art. 138) or needing other
1817 + * jurisdiction-specific wording can override via the filter.
1818 + */
1819 + public static function getReverseChargeNoticeText()
1820 + {
1821 + return apply_filters(
1822 + 'fluent_cart/tax/reverse_charge_notice_text',
1823 + __('Reverse charge — Article 196, Council Directive 2006/112/EC. Customer is liable for VAT.', 'fluent-cart')
1824 + );
2336 1825 }
2337 1826
2338 1827 public static function euVatCountyOptions()
2339 1828 {