PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 1.3.21 1.3.20 1.3.19 All 49 releases
← All changes | app/Modules/Tax/TaxModule.php +182 -817 1.5.1 → 1.6.5 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 {
@@ -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 {
@@ -1186,8 +489,65 @@
1186 489 $fillData['cart_data'] = $lineItems;
1187 490 return $fillData;
1188 491 }
1189 492
493 + // Shipping tax is derived from each item's itemwise_shipping_charge, but the
494 + // authoritative selected shipping charge lives in checkout_data.shipping_data.
495 + // These desync when a recalc entry point (VAT apply/remove, order placement,
496 + // address patch) runs after the display path computed shipping without persisting
497 + // the per-item distribution. Left unfixed the tax calculator sees zero shipping and
498 + // drops the shipping tax (and, under reverse charge, its reversed portion). Repair
499 + // the distribution here — the single point all recalc paths share.
500 + $shipMethodId = Arr::get($checkoutData, 'shipping_data.shipping_method_id');
501 + $shipCharge = (int) Arr::get($checkoutData, 'shipping_data.shipping_charge', 0);
502 + $distributedShipping = 0;
503 + foreach ($lineItems as $cartLine) {
504 + $distributedShipping += (int) Arr::get($cartLine, 'itemwise_shipping_charge', 0);
505 + $distributedShipping += (int) Arr::get($cartLine, 'shipping_charge', 0);
506 + }
507 + if ($shipMethodId && $shipCharge > 0 && $distributedShipping === 0) {
508 + // A method is selected with a charge but no per-item share — redistribute it.
509 + $shipMethod = \FluentCart\App\Models\ShippingMethod::query()->find($shipMethodId);
510 + if ($shipMethod) {
511 + $redistributed = \FluentCart\App\Helpers\CartHelper::calculateShippingMethodCharge(
512 + $shipMethod, $lineItems, 'items'
513 + );
514 + $lineItems = Arr::get($redistributed, 'items', $lineItems);
515 + }
516 + } elseif (!$shipMethodId && $distributedShipping === 0) {
517 + // Nothing persisted in the stored cart, yet the checkout page auto-selects the
518 + // single available method for display. Logged-in customers whose default address
519 + // is pre-filled never fire the change event that would persist that selection, so
520 + // shipping_data stays empty and the tax calculator drops shipping entirely. Mirror
521 + // the renderer's single-method auto-select so VAT apply / placement tax the
522 + // shipping the customer already sees selected.
523 + $requiresShipping = false;
524 + foreach ($lineItems as $cartLine) {
525 + if (Arr::get($cartLine, 'fulfillment_type') === 'physical') {
526 + $requiresShipping = true;
527 + break;
528 + }
529 + }
530 + if ($requiresShipping) {
531 + $autoCountry = Arr::get($checkoutData, 'form_data.shipping_country')
532 + ?: Arr::get($checkoutData, 'form_data.billing_country');
533 + $autoState = Arr::get($checkoutData, 'form_data.shipping_state')
534 + ?: Arr::get($checkoutData, 'form_data.billing_state');
535 + if ($autoCountry) {
536 + $autoMethods = \FluentCart\App\Helpers\AddressHelper::getShippingMethods($autoCountry, $autoState);
537 + if ($autoMethods && !is_wp_error($autoMethods) && count($autoMethods) === 1) {
538 + $autoMethod = Arr::first($autoMethods);
539 + $autoCalc = \FluentCart\App\Helpers\CartHelper::calculateShippingMethodCharge(
540 + $autoMethod, $lineItems, 'items'
541 + );
542 + $lineItems = Arr::get($autoCalc, 'items', $lineItems);
543 + Arr::set($checkoutData, 'shipping_data.shipping_method_id', $autoMethod->id);
544 + Arr::set($checkoutData, 'shipping_data.shipping_charge', (int) Arr::get($autoCalc, 'shipping_amount', 0));
545 + }
546 + }
547 + }
548 + }
549 +
1190 550 $country = '';
1191 551 $state = '';
1192 552 $postCode = '';
1193 553
@@ -1278,21 +638,8 @@
1278 638
1279 639 $shouldApplyReverseCharge = $this->shouldApplyReverseCharge($checkoutData, $country, $lineItems);
1280 640 $rcMode = $this->getEffectiveRcMode();
1281 641
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 642 // Capture signup_fee_tax BEFORE the RC zeroing block sets it to 0.
1296 643 $signupFeeTaxesByIndex = [];
1297 644 if ($shouldApplyReverseCharge && $rcMode === 'dynamic') {
1298 645 foreach ($productLines as $idx => $pLine) {
@@ -1299,11 +646,12 @@
1299 646 $signupFeeTaxesByIndex[$idx] = (int) Arr::get($pLine, 'other_info.signup_fee_tax', 0);
1300 647 }
1301 648 }
1302 649
1303 - $inclusiveTaxAdjustment = 0;
1304 - $reversedTaxTotal = 0;
1305 - $reversedShippingTax = 0;
650 + $inclusiveTaxAdjustment = 0;
651 + $reversedTaxTotal = 0;
652 + $reversedShippingTax = 0;
653 + $reversedShippingTaxLines = [];
1306 654 if ($shouldApplyReverseCharge) {
1307 655 $inclusivePortion = $taxTotal - $exclusiveTaxTotal - $feeTax;
1308 656 $reversedInclusive = ($rcMode === 'dynamic') ? $inclusivePortion : 0;
1309 657 $reversedTaxTotal = $exclusiveTaxTotal + $feeTax + $shippingTax + $reversedInclusive;
@@ -1308,8 +656,9 @@
1308 656 $reversedInclusive = ($rcMode === 'dynamic') ? $inclusivePortion : 0;
1309 657 $reversedTaxTotal = $exclusiveTaxTotal + $feeTax + $shippingTax + $reversedInclusive;
1310 658 $inclusiveTaxAdjustment = $inclusivePortion;
1311 659 $reversedShippingTax = $shippingTax;
660 + $reversedShippingTaxLines = $shippingTaxLines;
1312 661 $taxTotal = 0;
1313 662 $exclusiveTaxTotal = 0;
1314 663 $shippingTax = 0;
1315 664 $shippingTaxLines = [];
@@ -1440,8 +789,9 @@
1440 789 $checkoutData['tax_data']['tax_country'] = $taxCountry;
1441 790 $checkoutData['tax_data']['shipping_tax'] = $shippingTax;
1442 791 $checkoutData['tax_data']['shipping_tax_lines'] = $shippingTaxLines;
1443 792 $checkoutData['tax_data']['reverse_charge_shipping_tax'] = $reversedShippingTax;
793 + $checkoutData['tax_data']['reverse_charge_shipping_tax_lines'] = $reversedShippingTaxLines;
1444 794 $checkoutData['tax_data']['reverse_charge_price_mode'] = $shouldApplyReverseCharge ? $this->getEffectiveRcMode() : 'fixed';
1445 795 $checkoutData['tax_data']['fee_tax'] = $feeTax;
1446 796 $checkoutData['tax_data']['fee_tax_lines'] = $feeTaxLines;
1447 797 $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal;
@@ -1490,14 +840,22 @@
1490 840
1491 841 return true;
1492 842 }
1493 843
1494 - protected function isReverseChargeCheckout($checkoutData)
844 + public function isReverseChargeCheckout($checkoutData)
1495 845 {
1496 846 return Arr::get($checkoutData, 'tax_data.valid', false)
1497 847 && (int) Arr::get($checkoutData, 'tax_data.tax_behavior', 2) === 0;
1498 848 }
1499 849
850 + protected function hasReverseChargeTaxData(array $taxData): bool
851 + {
852 + return (int) Arr::get($taxData, 'reverse_charge_tax_total', 0) > 0
853 + || (int) Arr::get($taxData, 'reverse_charge_shipping_tax', 0) > 0
854 + || (int) Arr::get($taxData, 'reverse_charge_inclusive_adjustment', 0) > 0
855 + || (int) Arr::get($taxData, 'tax_behavior', 2) === 0;
856 + }
857 +
1500 858 public function maybeRerenderEuVatField($fragments, $args)
1501 859 {
1502 860 $vatNumberEnabled = CheckoutFieldsSchema::isVatNumberEnabled();
1503 861
@@ -1720,14 +1078,8 @@
1720 1078 $legalRegId = sanitize_text_field(
1721 1079 Arr::get($requestData, 'billing_legal_registration_id', '')
1722 1080 ?: Arr::get($checkoutData, 'form_data.billing_legal_registration_id', '')
1723 1081 );
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 1082 if (!$taxNumber && !$companyName && !$legalRegId) {
1731 1083 return;
1732 1084 }
1733 1085
@@ -1749,11 +1101,8 @@
1749 1101 if (Arr::get($checkoutData, 'tax_data.valid', false)) {
1750 1102 $businessInfo['tax_number_validated'] = true;
1751 1103 $businessInfo['tax_number_country'] = sanitize_text_field(Arr::get($checkoutData, 'tax_data.country', ''));
1752 1104 $businessInfo['tax_number_name'] = sanitize_text_field(Arr::get($checkoutData, 'tax_data.name', ''));
1753 - if ($declarationNote !== '') {
1754 - $businessInfo['reverse_charge_declaration'] = $declarationNote;
1755 - }
1756 1105 }
1757 1106 }
1758 1107
1759 1108 $order->updateMeta('business_info', $businessInfo);
@@ -1787,8 +1136,9 @@
1787 1136 // off local_reverse_charge after the customer validated), recalculate now
1788 1137 // so the correct tax totals and Apply/Remove state render immediately.
1789 1138 if (
1790 1139 Arr::get($cart->checkout_data, 'tax_data.valid', false)
1140 + && $this->hasReverseChargeTaxData(Arr::get($cart->checkout_data, 'tax_data', []))
1791 1141 && !$this->canApplyVatValidation($taxApplicableCountry)
1792 1142 ) {
1793 1143 $this->recalculateTax($data);
1794 1144 }
@@ -1872,12 +1222,8 @@
1872 1222 if (!$countryCode || !in_array($countryCode, $euCountryCodes)) {
1873 1223 wp_send_json(['message' => __('VAT validation is only available for EU countries.', 'fluent-cart')], 422);
1874 1224 }
1875 1225
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 1226 $vatNumber = isset($_REQUEST['vat_number']) ? sanitize_text_field(wp_unslash($_REQUEST['vat_number'])) : '';
1881 1227
1882 1228 if (!$vatNumber) {
1883 1229 wp_send_json(['message' => __('Missing required data', 'fluent-cart')], 422);
@@ -1915,44 +1261,44 @@
1915 1261 }
1916 1262 }
1917 1263 }
1918 1264
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);
1265 + $appliesReverseCharge = !$isExcluded && ($localRc === 'yes' || $countryCode !== $storeCountry);
1923 1266
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);
1267 + if ($appliesReverseCharge) {
1268 + $cartTaxData = Arr::get($cart->checkout_data, 'tax_data', []);
1269 + $inclusiveAdj = (int) Arr::get($cartTaxData, 'reverse_charge_inclusive_adjustment', 0);
1270 +
1271 + $existingRcTotal = (int) Arr::get($cartTaxData, 'reverse_charge_tax_total', 0);
1272 + if ($existingRcTotal > 0) {
1273 + $taxData['reverse_charge_tax_total'] = $existingRcTotal;
1274 + $taxData['reverse_charge_shipping_tax'] = (int) Arr::get($cartTaxData, 'reverse_charge_shipping_tax', 0);
1275 + } else {
1276 + $rcExclusiveTax = (int) Arr::get($cartTaxData, 'exclusive_tax_total', 0);
1277 + $rcFeeTax = (int) Arr::get($cartTaxData, 'fee_tax', 0);
1278 + $rcShippingTax = (int) Arr::get($cartTaxData, 'shipping_tax', 0);
1279 + $rcTaxTotal = (int) Arr::get($cartTaxData, 'tax_total', 0);
1280 + $rcMode = $this->getEffectiveRcMode();
1281 + $rcInclPortion = max(0, $rcTaxTotal - $rcExclusiveTax - $rcFeeTax - $rcShippingTax);
1282 + $rcReversedIncl = ($rcMode === 'dynamic') ? $rcInclPortion : 0;
1283 + $taxData['reverse_charge_tax_total'] = $rcExclusiveTax + $rcFeeTax + $rcShippingTax + $rcReversedIncl;
1284 + $taxData['reverse_charge_shipping_tax'] = $rcShippingTax;
1954 1285 }
1286 + $taxData['reverse_charge_inclusive_adjustment'] = $inclusiveAdj;
1287 + $taxData['tax_total'] = 0;
1288 + $taxData['exclusive_tax_total'] = 0;
1289 + $taxData['shipping_tax'] = 0;
1290 + $taxData['tax_behavior'] = 0;
1291 + $taxData['fee_tax'] = 0;
1292 + $taxData['signup_fee_tax'] = 0;
1293 + $taxData['shipping_tax_lines'] = [];
1294 + $existingTaxLines = isset($cartTaxData['tax_lines']) && is_array($cartTaxData['tax_lines'])
1295 + ? $cartTaxData['tax_lines']
1296 + : [];
1297 + $taxData['tax_lines'] = array_map(function ($line) {
1298 + $line['tax_amount'] = 0;
1299 + return $line;
1300 + }, $existingTaxLines);
1955 1301 }
1956 1302
1957 1303 $checkoutData = $cart->checkout_data;
1958 1304 if (!isset($checkoutData['tax_data']) || !is_array($checkoutData['tax_data'])) {
@@ -1976,9 +1322,11 @@
1976 1322 $euVatView = ob_get_clean();
1977 1323
1978 1324 wp_send_json([
1979 1325 'success' => true,
1980 - 'message' => __('VAT has been applied successfully', 'fluent-cart'),
1326 + 'message' => $appliesReverseCharge
1327 + ? __('VAT has been applied successfully', 'fluent-cart')
1328 + : __('VAT number has been validated successfully', 'fluent-cart'),
1981 1329 'tax_data' => $taxData,
1982 1330 'fragments' => [
1983 1331 [
1984 1332 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
@@ -2024,8 +1372,9 @@
2024 1372 unset($checkoutData['tax_data']['country']);
2025 1373 unset($checkoutData['tax_data']['declaration_note']);
2026 1374 unset($checkoutData['tax_data']['reverse_charge_tax_total']);
2027 1375 unset($checkoutData['tax_data']['reverse_charge_shipping_tax']);
1376 + unset($checkoutData['tax_data']['reverse_charge_shipping_tax_lines']);
2028 1377 unset($checkoutData['tax_data']['reverse_charge_inclusive_adjustment']);
2029 1378 }
2030 1379
2031 1380 $cart->checkout_data = $checkoutData;
@@ -2235,9 +1584,9 @@
2235 1584 // Falls back to proportional split for admin order recalculation and legacy callers.
2236 1585 $shippingTaxByRateId = [];
2237 1586 foreach ($shippingTaxLines as $stl) {
2238 1587 $stlRateId = (int) Arr::get($stl, 'rate_id', 0);
2239 - if ($stlRateId > 0) {
1588 + if ($stlRateId !== 0) {
2240 1589 $shippingTaxByRateId[$stlRateId] = (int) Arr::get($stl, 'shipping_tax', 0);
2241 1590 }
2242 1591 }
2243 1592
@@ -2332,8 +1681,24 @@
2332 1681 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
2333 1682 $storeCountry = (new StoreSettings())->get('store_country');
2334 1683 return Arr::get($taxSettings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes'
2335 1684 || $countryCode !== $storeCountry;
1685 + }
1686 +
1687 + /**
1688 + * The legal reverse-charge notice shown on checkout, invoices, receipts and emails.
1689 + * EU VAT Directive 2006/112/EC Article 226(11a) requires the invoice to carry the
1690 + * literal mention "Reverse charge" when the customer is liable for the VAT, so the
1691 + * default string must always start with those exact words. Article 196 covers
1692 + * services; stores supplying intra-EU goods (Art. 138) or needing other
1693 + * jurisdiction-specific wording can override via the filter.
1694 + */
1695 + public static function getReverseChargeNoticeText()
1696 + {
1697 + return apply_filters(
1698 + 'fluent_cart/tax/reverse_charge_notice_text',
1699 + __('Reverse charge — Article 196, Council Directive 2006/112/EC. Customer is liable for VAT.', 'fluent-cart')
1700 + );
2336 1701 }
2337 1702
2338 1703 public static function euVatCountyOptions()
2339 1704 {