PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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 +136 -842 1.5.2 → 1.6.6 View file →
@@ -8,8 +8,9 @@
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;
14 15 use FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper;
15 16 use FluentCart\App\Services\Renderer\CheckoutFieldsSchema;
@@ -21,8 +22,18 @@
21 22 {
22 23
23 24 protected $taxSettings = [];
24 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 +
25 36 public function register()
26 37 {
27 38 $this->getSettings();
28 39
@@ -144,21 +155,41 @@
144 155
145 156 add_action('fluent_cart/checkout/prepare_other_data', [$this, 'prepareOtherData'], 10, 1);
146 157
147 158 add_action('fluent_cart/product/after_price', function ($data) {
148 - $variant = isset($data['variant']) ? $data['variant'] : null;
149 - $priceSuffix = $this->resolvePriceSuffix($variant);
150 - if ($priceSuffix) {
151 - echo '<span class="fct_price_suffix">' . wp_kses_post($priceSuffix) . '</span>';
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) {
169 + return;
152 170 }
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);
153 190 }, 10, 1);
154 191
155 - add_filter('fluent_cart/product/price_suffix_atts', function ($suffix, $context) {
156 - $variant = isset($context['variant']) ? $context['variant'] : null;
157 - $priceSuffix = $this->resolvePriceSuffix($variant);
158 - return $priceSuffix ?: $suffix;
159 - }, 10, 2);
160 -
161 192 add_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20, 2);
162 193
163 194 add_action('fluent_cart/cart/cart_data_items_updated', [$this, 'recalculateTax']);
164 195 }
@@ -192,375 +223,19 @@
192 223 }
193 224
194 225 public function renderTaxRow($cart, $atts = '')
195 226 {
196 - $taxAmount = (int) Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
197 - $reversedTaxAmount = (int) Arr::get($cart->checkout_data, 'tax_data.reverse_charge_tax_total', 0);
198 - $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
199 - $taxLines = Arr::get($cart->checkout_data, 'tax_data.tax_lines', []);
200 - $taxCountry = Arr::get($cart->checkout_data, 'tax_data.tax_country', '');
201 - $taxLabel = Arr::get($taxLines, '0.label', '');
202 -
203 - if (!$taxLabel) {
204 - $taxLabel = $taxCountry ? static::getCountryTaxTitle($taxCountry) : __('Tax', 'fluent-cart');
205 - }
206 - ?>
207 - <li <?php echo $atts; ?>>
208 - <span class="fct_summary_label">
209 - <?php echo esc_html($taxLabel); ?>
210 - </span>
211 - <span class="fct_summary_value">
212 - <?php if ($isReverseCharge && $taxAmount === 0) : ?>
213 - <?php
214 - /* translators: %1$s: formatted reversed tax amount */
215 - echo esc_html(sprintf(__('Tax reversed: %1$s', 'fluent-cart'), Helper::toDecimal($reversedTaxAmount)));
216 - ?>
217 - <?php else : ?>
218 - <?php echo esc_html(Helper::toDecimal($taxAmount)); ?>
219 - <?php endif; ?>
220 - </span>
221 - </li>
222 - <?php
227 + $this->renderer()->renderTaxRow($cart, $atts);
223 228 }
224 229
225 230 public function renderShippingTaxRow($cart, $atts = '')
226 231 {
227 - $shippingTax = (int) Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
228 - $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
229 - $rcShippingTax = (int) Arr::get($cart->checkout_data, 'tax_data.reverse_charge_shipping_tax', 0);
230 -
231 - if ($shippingTax <= 0 && (!$isReverseCharge || $rcShippingTax <= 0)) {
232 - return '';
233 - }
234 -
235 - $displayAmount = $isReverseCharge ? $rcShippingTax : $shippingTax;
236 - $storeBehavior = (int) Arr::get($cart->checkout_data, 'tax_data.store_tax_behavior',
237 - Arr::get($cart->checkout_data, 'tax_data.tax_behavior', 2));
238 - $isInclusive = $storeBehavior === 2;
239 - ?>
240 - <li data-fct-shipping-tax-row <?php echo $atts; ?>>
241 - <span class="fct_summary_label">
242 - <?php if ($isInclusive) : ?>
243 - <?php echo esc_html__('Shipping Tax (Included)', 'fluent-cart'); ?>
244 - <?php else : ?>
245 - <?php echo esc_html__('Shipping Tax (Excluded)', 'fluent-cart'); ?>
246 - <?php endif; ?>
247 - </span>
248 - <span class="fct_summary_value"<?php echo $isReverseCharge ? ' style="text-decoration:line-through;opacity:0.6;"' : ''; ?>>
249 - <?php echo esc_html(Helper::toDecimal($displayAmount)); ?>
250 - </span>
251 - </li>
252 - <?php
253 - return '';
232 + return $this->renderer()->renderShippingTaxRow($cart, $atts);
254 233 }
255 234
256 235 public function renderTaxSummaryBox($cart)
257 236 {
258 - $taxData = Arr::get($cart->checkout_data, 'tax_data', []);
259 - $taxTotal = (int) Arr::get($taxData, 'tax_total', 0);
260 - $exclusiveTaxTotal = (int) Arr::get($taxData, 'exclusive_tax_total', $taxTotal);
261 - $feeTaxLines = (array) Arr::get($taxData, 'fee_tax_lines', []);
262 - $shippingTax = (int) Arr::get($taxData, 'shipping_tax', 0);
263 - $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
264 - $reversedTaxTotalDisplay = (int) Arr::get($taxData, 'reverse_charge_tax_total', 0);
265 -
266 - $taxRateLines = (array) Arr::get($taxData, 'tax_lines', []);
267 - $shippingTaxLines = (array) Arr::get($taxData, 'shipping_tax_lines', []);
268 -
269 - $inclusiveFeeTax = 0;
270 - $exclusiveFeeTax = 0;
271 - foreach ($feeTaxLines as $feeTaxLine) {
272 - if (!empty($feeTaxLine['inclusive'])) {
273 - $inclusiveFeeTax += (int) Arr::get($feeTaxLine, 'tax_amount', 0);
274 - } else {
275 - $exclusiveFeeTax += (int) Arr::get($feeTaxLine, 'tax_amount', 0);
276 - }
277 - }
278 - // Fallback: if no fee_tax_lines (old cart data), use aggregate fee_tax as exclusive
279 - if (empty($feeTaxLines)) {
280 - $exclusiveFeeTax = (int) Arr::get($taxData, 'fee_tax', 0);
281 - }
282 -
283 - $inclusiveTax = max(0, $taxTotal - $exclusiveTaxTotal - $exclusiveFeeTax - $inclusiveFeeTax);
284 - $productExclusiveTax = max(0, $exclusiveTaxTotal);
285 -
286 - $isShippingInclusive = \FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper::isShippingTaxInclusiveFromTaxData(
287 - is_array($taxData) ? $taxData : []
288 - );
289 -
290 - $payableTax = $productExclusiveTax + $exclusiveFeeTax + ($isShippingInclusive ? 0 : $shippingTax);
291 - $totalOrderTax = $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0) + $payableTax;
292 -
293 - $feeRows = \FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper::buildFeeTaxLineRows($feeTaxLines);
294 - $feeCount = count($feeRows);
295 - if (empty($feeTaxLines) && (int) Arr::get($taxData, 'fee_tax', 0) > 0) {
296 - $feeCount = 1;
297 - }
298 - $productTaxRowCount = !empty($taxRateLines)
299 - ? count($taxRateLines)
300 - : (int) ($inclusiveTax > 0) + (int) ($productExclusiveTax > 0);
301 - $rowCount = $productTaxRowCount + $feeCount + (int) ($shippingTax > 0);
302 - $shouldShowBreakdown = !empty($taxRateLines)
303 - || !empty($shippingTaxLines)
304 - || $rowCount >= 2
305 - || ($rowCount === 1 && !($payableTax > 0 || $inclusiveTax > 0 || $inclusiveFeeTax > 0));
306 -
307 - // Build folded per-rate rows via shared helper. Cart tax_lines carry only `label`
308 - // (no percent), so we pre-augment each line with `rate_label` = "Label (X%)" so the
309 - // helper picks it up and produces the same display as the old inline block.
310 - $foldedSource = [];
311 - foreach ($taxRateLines as $rateKey => $rateLine) {
312 - $ratePercent = (float) Arr::get($rateLine, 'rate_percent', 0);
313 - $rateLine['rate_label'] = (string) Arr::get($rateLine, 'label', '');
314 - if ($ratePercent > 0) {
315 - $rateLine['rate_label'] .= ' (' . Helper::formatTaxRatePercent($ratePercent) . '%)';
316 - }
317 - $foldedSource[$rateKey] = $rateLine;
318 - }
319 - $checkoutRateRows = TaxSummaryHelper::buildFoldedRateRows(
320 - $foldedSource, $shippingTaxLines, 'tax_amount', $isShippingInclusive
321 - );
322 - // Inclusive shipping tax follows the store global tax mode — when shipping is
323 - // priced inclusive its tax is baked into the shipping price, so it counts as
324 - // "of which included in prices". Keeps includedInPrices + payableTax === totalOrderTax.
325 - $includedInPrices = $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0);
326 -
327 - $tooltipId = 'fct-tax-summary-tooltip-' . Helper::getUidSerial();
328 - ?>
329 - <li class="fct_tax_summary_li" data-fct-tax-summary>
330 - <div class="fct_tax_summary_box">
331 - <div class="fct_tax_summary_header">
332 - <span class="fct_tax_summary_heading">
333 - <?php echo (!empty($checkoutRateRows) && !$isReverseCharge)
334 - ? esc_html__('Tax breakdown by rate', 'fluent-cart')
335 - : esc_html__('TAX', 'fluent-cart'); ?>
336 - </span>
337 - <div class="fct_item_tax_hint">
338 - <button
339 - type="button"
340 - class="fct_item_tax_hint_button"
341 - aria-label="<?php esc_attr_e('Tax information', 'fluent-cart'); ?>"
342 - aria-describedby="<?php echo esc_attr($tooltipId); ?>"
343 - >
344 - <span aria-hidden="true">i</span>
345 - </button>
346 - <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
347 - <span class="fct_item_tax_tooltip_heading">
348 - <?php esc_html_e('About your tax', 'fluent-cart'); ?>
349 - </span>
350 - <?php if ($isReverseCharge) : ?>
351 - <span class="fct_item_tax_tooltip_line">
352 - <?php esc_html_e('Tax has been reversed for this order.', 'fluent-cart'); ?>
353 - </span>
354 - <?php else : ?>
355 - <?php if ($payableTax > 0) : ?>
356 - <span class="fct_item_tax_tooltip_line">
357 - <?php esc_html_e('"Total payable tax" is added on top of listed prices.', 'fluent-cart'); ?>
358 - </span>
359 - <?php endif; ?>
360 - <?php if ($inclusiveTax > 0) : ?>
361 - <span class="fct_item_tax_tooltip_line">
362 - <?php esc_html_e('"Included in item prices" is already built into product prices.', 'fluent-cart'); ?>
363 - </span>
364 - <?php endif; ?>
365 - <?php if ($payableTax === 0 && $inclusiveTax === 0) : ?>
366 - <span class="fct_item_tax_tooltip_line">
367 - <?php esc_html_e('No tax applies to this order.', 'fluent-cart'); ?>
368 - </span>
369 - <?php endif; ?>
370 - <?php endif; ?>
371 - </div>
372 - </div>
373 - </div>
374 - <div class="fct_tax_summary_rows">
375 - <?php if ($isReverseCharge) : ?>
376 - <?php
377 - $rcShippingDisplay = (int) Arr::get($taxData, 'reverse_charge_shipping_tax', 0);
378 - $rcInclusiveAdj = (int) Arr::get($taxData, 'reverse_charge_inclusive_adjustment', 0);
379 - $rcExclusiveNonShip = max(0, $reversedTaxTotalDisplay - $rcShippingDisplay - $rcInclusiveAdj);
380 - $rcBreakdownCount = (int) ($rcInclusiveAdj > 0) + (int) ($rcExclusiveNonShip > 0) + (int) ($rcShippingDisplay > 0);
381 - ?>
382 - <?php if ($rcBreakdownCount >= 2) : ?>
383 - <?php if ($rcInclusiveAdj > 0) : ?>
384 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
385 - <span class="fct_tax_summary_row_label">
386 - <?php esc_html_e('Included in item prices', 'fluent-cart'); ?>
387 - </span>
388 - <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
389 - <?php echo esc_html(Helper::toDecimal($rcInclusiveAdj)); ?>
390 - </span>
391 - </div>
392 - <?php endif; ?>
393 - <?php if ($rcExclusiveNonShip > 0) : ?>
394 - <div class="fct_tax_summary_row">
395 - <span class="fct_tax_summary_row_label">
396 - <?php esc_html_e('Added on products', 'fluent-cart'); ?>
397 - </span>
398 - <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
399 - <?php echo esc_html(Helper::toDecimal($rcExclusiveNonShip)); ?>
400 - </span>
401 - </div>
402 - <?php endif; ?>
403 - <?php if ($rcShippingDisplay > 0) : ?>
404 - <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
405 - <span class="fct_tax_summary_row_label">
406 - <?php echo $isShippingInclusive ? esc_html__('Included in shipping prices', 'fluent-cart') : esc_html__('Added on shipping', 'fluent-cart'); ?>
407 - </span>
408 - <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
409 - <?php echo esc_html(Helper::toDecimal($rcShippingDisplay)); ?>
410 - </span>
411 - </div>
412 - <?php endif; ?>
413 - <?php elseif ($rcShippingDisplay > 0) : ?>
414 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
415 - <span class="fct_tax_summary_row_label">
416 - <?php echo $isShippingInclusive ? esc_html__('Included in shipping prices', 'fluent-cart') : esc_html__('Added on shipping', 'fluent-cart'); ?>
417 - </span>
418 - <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
419 - <?php echo esc_html(Helper::toDecimal($rcShippingDisplay)); ?>
420 - </span>
421 - </div>
422 - <?php endif; ?>
423 - <div class="fct_tax_summary_row fct_tax_summary_row--total">
424 - <span class="fct_tax_summary_row_label">
425 - <?php esc_html_e('Tax reversed', 'fluent-cart'); ?>
426 - </span>
427 - <span class="fct_tax_summary_row_amount">
428 - <?php echo esc_html(Helper::toDecimal($reversedTaxTotalDisplay)); ?>
429 - </span>
430 - </div>
431 - <?php else : ?>
432 - <?php if (!empty($checkoutRateRows)) : ?>
433 - <div class="fct_tax_summary_row fct_tax_summary_row--head" style="display:flex;justify-content:space-between;gap:8px;">
434 - <span style="flex:1;font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:#94a3b8;">
435 - <?php esc_html_e('Rate', 'fluent-cart'); ?>
436 - </span>
437 - <span style="min-width:88px;text-align:right;font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:#94a3b8;">
438 - <?php esc_html_e('Taxable base', 'fluent-cart'); ?>
439 - </span>
440 - <span style="min-width:64px;text-align:right;font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:#94a3b8;">
441 - <?php esc_html_e('Tax', 'fluent-cart'); ?>
442 - </span>
443 - </div>
444 - <?php foreach ($checkoutRateRows as $checkoutRateRow) : ?>
445 - <div class="fct_tax_summary_row<?php echo !empty($checkoutRateRow['inclusive']) ? ' fct_tax_summary_row--muted' : ''; ?>" style="display:flex;justify-content:space-between;gap:8px;">
446 - <span class="fct_tax_summary_row_label" style="flex:1;">
447 - <?php echo esc_html($checkoutRateRow['label']); ?>
448 - </span>
449 - <span style="min-width:88px;text-align:right;color:#94a3b8;">
450 - <?php echo esc_html(Helper::toDecimal((int) $checkoutRateRow['base'])); ?>
451 - </span>
452 - <span class="fct_tax_summary_row_amount" style="min-width:64px;text-align:right;">
453 - <?php echo esc_html(Helper::toDecimal((int) $checkoutRateRow['tax'])); ?>
454 - </span>
455 - </div>
456 - <?php endforeach; ?>
457 - <?php endif; ?>
458 - <?php if (empty($checkoutRateRows) && $inclusiveTax > 0 && $shouldShowBreakdown) : ?>
459 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
460 - <span class="fct_tax_summary_row_label">
461 - <?php esc_html_e('Included in item prices', 'fluent-cart'); ?>
462 - </span>
463 - <span class="fct_tax_summary_row_amount">
464 - <?php echo esc_html(Helper::toDecimal($inclusiveTax)); ?>
465 - </span>
466 - </div>
467 - <?php endif; ?>
468 - <?php if (empty($checkoutRateRows) && $productExclusiveTax > 0 && $shouldShowBreakdown) : ?>
469 - <div class="fct_tax_summary_row">
470 - <span class="fct_tax_summary_row_label">
471 - <?php esc_html_e('Added on products', 'fluent-cart'); ?>
472 - </span>
473 - <span class="fct_tax_summary_row_amount">
474 - <?php echo esc_html(Helper::toDecimal($productExclusiveTax)); ?>
475 - </span>
476 - </div>
477 - <?php endif; ?>
478 - <?php if (empty($checkoutRateRows) && $shouldShowBreakdown) : ?>
479 - <?php foreach ($feeRows as $feeRow) : ?>
480 - <div class="fct_tax_summary_row<?php echo $feeRow['inclusive'] ? ' fct_tax_summary_row--muted' : ''; ?>">
481 - <span class="fct_tax_summary_row_label">
482 - <?php echo esc_html($feeRow['display_label']); ?>
483 - </span>
484 - <span class="fct_tax_summary_row_amount">
485 - <?php echo esc_html(Helper::toDecimal($feeRow['tax_amount'])); ?>
486 - </span>
487 - </div>
488 - <?php endforeach; ?>
489 - <?php endif; ?>
490 - <?php if (empty($checkoutRateRows) && empty($feeTaxLines) && (int) Arr::get($taxData, 'fee_tax', 0) > 0 && $shouldShowBreakdown) : ?>
491 - <div class="fct_tax_summary_row">
492 - <span class="fct_tax_summary_row_label">
493 - <?php esc_html_e('Added on fees', 'fluent-cart'); ?>
494 - </span>
495 - <span class="fct_tax_summary_row_amount">
496 - <?php echo esc_html(Helper::toDecimal((int) Arr::get($taxData, 'fee_tax', 0))); ?>
497 - </span>
498 - </div>
499 - <?php endif; ?>
500 - <?php if (empty($checkoutRateRows) && $shippingTax > 0 && $shouldShowBreakdown) : ?>
501 - <?php if (!empty($shippingTaxLines)) : ?>
502 - <?php foreach ($shippingTaxLines as $shippingTaxLine) : ?>
503 - <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
504 - <span class="fct_tax_summary_row_label">
505 - <?php echo esc_html($shippingTaxLine['label']); ?>
506 - </span>
507 - <span class="fct_tax_summary_row_amount">
508 - <?php echo esc_html(Helper::toDecimal((int) Arr::get($shippingTaxLine, 'shipping_tax', 0))); ?>
509 - </span>
510 - </div>
511 - <?php endforeach; ?>
512 - <?php else : ?>
513 - <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
514 - <span class="fct_tax_summary_row_label">
515 - <?php if ($isShippingInclusive) : ?>
516 - <?php esc_html_e('Included in shipping prices', 'fluent-cart'); ?>
517 - <?php else : ?>
518 - <?php esc_html_e('Added on shipping', 'fluent-cart'); ?>
519 - <?php endif; ?>
520 - </span>
521 - <span class="fct_tax_summary_row_amount">
522 - <?php echo esc_html(Helper::toDecimal($shippingTax)); ?>
523 - </span>
524 - </div>
525 - <?php endif; ?>
526 - <?php endif; ?>
527 - <?php if (!empty($checkoutRateRows)) : ?>
528 - <div class="fct_tax_summary_row fct_tax_summary_row--total">
529 - <span class="fct_tax_summary_row_label"><?php esc_html_e('Total tax', 'fluent-cart'); ?></span>
530 - <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($totalOrderTax)); ?></span>
531 - </div>
532 - <?php if ($includedInPrices > 0) : ?>
533 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
534 - <span class="fct_tax_summary_row_label"><?php esc_html_e('of which included in prices', 'fluent-cart'); ?></span>
535 - <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($includedInPrices)); ?></span>
536 - </div>
537 - <?php endif; ?>
538 - <?php if ($payableTax > 0 && $includedInPrices > 0) : ?>
539 - <div class="fct_tax_summary_row fct_tax_summary_row--total">
540 - <span class="fct_tax_summary_row_label"><?php esc_html_e('Payable now (added)', 'fluent-cart'); ?></span>
541 - <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($payableTax)); ?></span>
542 - </div>
543 - <?php endif; ?>
544 - <?php else : ?>
545 - <?php if ($payableTax > 0) : ?>
546 - <div class="fct_tax_summary_row fct_tax_summary_row--total">
547 - <span class="fct_tax_summary_row_label"><?php esc_html_e('Total payable tax', 'fluent-cart'); ?></span>
548 - <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($payableTax)); ?></span>
549 - </div>
550 - <?php endif; ?>
551 - <?php if ($inclusiveTax > 0 || $inclusiveFeeTax > 0) : ?>
552 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
553 - <span class="fct_tax_summary_row_label"><?php esc_html_e('Total tax in this order', 'fluent-cart'); ?></span>
554 - <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($totalOrderTax)); ?></span>
555 - </div>
556 - <?php endif; ?>
557 - <?php endif; ?>
558 - <?php endif; ?>
559 - </div>
560 - </div>
561 - </li>
562 - <?php
237 + $this->renderer()->renderTaxSummaryBox($cart);
563 238 }
564 239
565 240 public function maybeRestoreRcAdjustedPrices($data)
566 241 {
@@ -712,9 +387,10 @@
712 387 $defaultSettings = [
713 388 'tax_inclusion' => 'included',
714 389 'tax_calculation_basis' => 'shipping',
715 390 'tax_rounding' => 'item',
716 - 'checkout_tax_breakdown_display' => 'both',
391 + 'checkout_tax_breakdown_display' => 'itemized',
392 + 'tax_display_label' => 'Tax',
717 393 'enable_tax' => 'no',
718 394 'eu_vat_settings' => [
719 395 'require_vat_number' => 'no',
720 396 'local_reverse_charge' => 'no',
@@ -732,497 +408,47 @@
732 408
733 409 return $this->taxSettings = $settings;
734 410 }
735 411
736 - private function getEffectiveRcMode()
412 + public function getEffectiveRcMode()
737 413 {
738 414 $settings = $this->getSettings();
739 415 return Arr::get($settings, 'eu_vat_settings.reverse_charge_price_mode', 'fixed');
740 416 }
741 417
742 - protected function getCheckoutTaxBreakdownDisplayMode()
743 - {
744 - $mode = Arr::get($this->getSettings(), 'checkout_tax_breakdown_display', 'both');
745 -
746 - if (!in_array($mode, ['both', 'label', 'tooltip'], true)) {
747 - return 'both';
748 - }
749 -
750 - return $mode;
751 - }
752 -
753 - protected function normalizeCheckoutLineTaxRates($item)
754 - {
755 - $rates = Arr::get($item, 'line_meta.tax_config.rates', []);
756 - if (empty($rates) || !is_array($rates)) {
757 - return [];
758 - }
759 -
760 - $isInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
761 - $normalizedRates = [];
762 -
763 - foreach ($rates as $rate) {
764 - $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
765 - $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
766 - $taxableAmount = (int) Arr::get($rate, 'taxable_amount', 0);
767 -
768 - if ($taxAmount <= 0 && $ratePercent <= 0) {
769 - continue;
770 - }
771 -
772 - $normalizedRates[] = [
773 - 'label' => (string) Arr::get($rate, 'label', __('Tax', 'fluent-cart')),
774 - 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
775 - 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
776 - 'tax_amount' => $taxAmount,
777 - 'display_base' => $isInclusive ? max(0, $taxableAmount - $taxAmount) : $taxableAmount,
778 - 'inclusive' => $isInclusive,
779 - ];
780 - }
781 -
782 - return $normalizedRates;
783 - }
784 -
785 - protected function getCheckoutLineTaxShortLabel($label)
786 - {
787 - $label = trim((string) $label);
788 -
789 - if (!$label) {
790 - return __('Tax', 'fluent-cart');
791 - }
792 -
793 - return $label;
794 - }
795 -
796 - private function shouldRateStrikethrough($isReversed, $rateIsInclusive, $rcMode)
797 - {
798 - if (!$isReversed) {
799 - return false;
800 - }
801 - return !$rateIsInclusive || $rcMode === 'dynamic';
802 - }
803 -
804 418 public function renderCheckoutLineItemTaxLabel($data)
805 419 {
806 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
807 - if (!in_array($mode, ['both', 'label'], true)) {
808 - return;
809 - }
810 -
811 - $item = Arr::get($data, 'item', []);
812 - $rates = $this->normalizeCheckoutLineTaxRates($item);
813 - if (empty($rates)) {
814 - return;
815 - }
816 - $cart = CartHelper::getCart();
817 - $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
818 - $rcMode = $this->getEffectiveRcMode();
819 - $this->renderTaxBadges($rates, $isReversed, $rcMode);
420 + $this->renderer()->renderCheckoutLineItemTaxLabel($data);
820 421 }
821 422
822 423 public function renderCheckoutSetupFeeTaxLabel($data)
823 424 {
824 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
825 - if (!in_array($mode, ['both', 'label'], true)) {
826 - return;
827 - }
828 -
829 - $item = Arr::get($data, 'item', []);
830 - $signupFeeTaxConfig = Arr::get($item, 'signup_fee_tax_config', []);
831 - $rates = Arr::get($signupFeeTaxConfig, 'rates', []);
832 - if (empty($rates) || !is_array($rates)) {
833 - return;
834 - }
835 -
836 - $isInclusive = (bool) Arr::get($signupFeeTaxConfig, 'inclusive', false);
837 - $normalizedRates = [];
838 - foreach ($rates 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 - $normalizedRates[] = [
845 - 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
846 - 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
847 - 'inclusive' => $isInclusive,
848 - 'tax_amount' => $taxAmount,
849 - ];
850 - }
851 -
852 - if (empty($normalizedRates)) {
853 - return;
854 - }
855 - $cart = CartHelper::getCart();
856 - $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
857 - $rcMode = $this->getEffectiveRcMode();
858 - $this->renderTaxBadges($normalizedRates, $isReversed, $rcMode);
425 + $this->renderer()->renderCheckoutSetupFeeTaxLabel($data);
859 426 }
860 427
861 - private function renderTaxBadges(array $normalizedRates, $isReversed = false, $rcMode = 'fixed')
862 - {
863 - ?>
864 - <div class="fct_item_tax_badges" aria-label="<?php esc_attr_e('Tax breakdown', 'fluent-cart'); ?>">
865 - <?php foreach ($normalizedRates as $rate) : ?>
866 - <div class="fct_item_tax_badge_row">
867 - <span class="fct_item_tax_badge <?php echo esc_attr($rate['inclusive'] ? 'is-inclusive' : 'is-exclusive'); ?>">
868 - <span>
869 - <?php
870 - $badgeText = sprintf(
871 - /* translators: %1$s: tax label, %2$s: tax rate percent */
872 - __('%1$s (%2$s%%)', 'fluent-cart'),
873 - $rate['short_label'],
874 - $rate['formatted_rate']
875 - );
876 - echo esc_html($badgeText);
877 - ?>
878 - </span>
879 - </span>
880 - <?php if (!empty($rate['tax_amount']) && (int) $rate['tax_amount'] > 0) : ?>
881 - <?php $rateReversedClass = $this->shouldRateStrikethrough($isReversed, $rate['inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
882 - <span class="fct_item_tax_badge_amount <?php echo esc_attr($rate['inclusive'] ? 'is-inclusive' : 'is-exclusive'); ?><?php echo esc_attr($rateReversedClass); ?>">
883 - <?php
884 - $amountText = $rate['inclusive']
885 - ? sprintf(
886 - /* translators: %1$s: formatted tax amount */
887 - __('incl. %1$s', 'fluent-cart'),
888 - Helper::toDecimal((int) $rate['tax_amount'])
889 - )
890 - : sprintf(
891 - /* translators: %1$s: formatted tax amount */
892 - __('+ %1$s', 'fluent-cart'),
893 - Helper::toDecimal((int) $rate['tax_amount'])
894 - );
895 - echo esc_html($amountText);
896 - ?>
897 - </span>
898 - <?php endif; ?>
899 - </div>
900 - <?php endforeach; ?>
901 - </div>
902 - <?php
903 - }
904 -
905 - private function getSetupFeeTaxData($item)
906 - {
907 - $signupFeeTaxConfig = Arr::get($item, 'signup_fee_tax_config', []);
908 - $rawRates = Arr::get($signupFeeTaxConfig, 'rates', []);
909 - if (empty($rawRates) || !is_array($rawRates)) {
910 - return null;
911 - }
912 -
913 - $isInclusive = (bool) Arr::get($signupFeeTaxConfig, 'inclusive', false);
914 - $rates = [];
915 - foreach ($rawRates as $rate) {
916 - $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
917 - $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
918 - if ($taxAmount <= 0 && $ratePercent <= 0) {
919 - continue;
920 - }
921 - $taxableAmount = (int) Arr::get($rate, 'taxable_amount', 0);
922 - $rates[] = [
923 - 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
924 - 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
925 - 'tax_amount' => $taxAmount,
926 - 'display_base' => $isInclusive ? max(0, $taxableAmount - $taxAmount) : $taxableAmount,
927 - ];
928 - }
929 -
930 - if (empty($rates)) {
931 - return null;
932 - }
933 -
934 - $setupFee = (int) Arr::get($item, 'other_info.signup_fee', 0);
935 - $totalTax = array_sum(array_map(function ($rate) {
936 - return (int) $rate['tax_amount'];
937 - }, $rates));
938 -
939 - return [
940 - 'rates' => $rates,
941 - 'is_inclusive' => $isInclusive,
942 - 'total_tax' => $totalTax,
943 - 'display_total' => $isInclusive ? $setupFee : $setupFee + $totalTax,
944 - 'primary_label' => Arr::get($rates, '0.short_label', __('Tax', 'fluent-cart')),
945 - ];
946 - }
947 -
948 428 public function renderCheckoutSetupFeeTaxTooltip($data)
949 429 {
950 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
951 - if (!in_array($mode, ['both', 'tooltip'], true)) {
952 - return;
953 - }
954 -
955 - $item = Arr::get($data, 'item', []);
956 - $taxData = $this->getSetupFeeTaxData($item);
957 - if (!$taxData) {
958 - return;
959 - }
960 -
961 - $cart = CartHelper::getCart();
962 - $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
963 - $rcMode = $this->getEffectiveRcMode();
964 -
965 - $rates = $taxData['rates'];
966 - $isInclusive = $taxData['is_inclusive'];
967 - $displayTotal = $taxData['display_total'];
968 - if ($isReversed) {
969 - if ($taxData['is_inclusive'] && $rcMode === 'dynamic') {
970 - $displayTotal = $taxData['display_total'] - $taxData['total_tax'];
971 - } else {
972 - $displayTotal = (int) Arr::get($data, 'item.other_info.signup_fee', 0);
973 - }
974 - }
975 - $tooltipId = 'fct-item-tax-tooltip-' . Helper::getUidSerial();
976 - ?>
977 - <div class="fct_item_tax_hint">
978 - <button
979 - type="button"
980 - class="fct_item_tax_hint_button"
981 - aria-label="<?php esc_attr_e('View tax breakdown for this item', 'fluent-cart'); ?>"
982 - aria-describedby="<?php echo esc_attr($tooltipId); ?>"
983 - >
984 - <span aria-hidden="true">i</span>
985 - </button>
986 - <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
987 - <span class="fct_item_tax_tooltip_heading">
988 - <?php echo esc_html($isInclusive ? __('Tax-inclusive price', 'fluent-cart') : __('Tax-exclusive price', 'fluent-cart')); ?>
989 - </span>
990 - <?php foreach ($rates as $rate) : ?>
991 - <?php $lineReversedClass = $this->shouldRateStrikethrough($isReversed, isset($rate['inclusive']) ? (bool)$rate['inclusive'] : $taxData['is_inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
992 - <span class="fct_item_tax_tooltip_line<?php echo esc_attr($lineReversedClass); ?>">
993 - <?php
994 - $lineText = sprintf(
995 - /* translators: %1$s: tax base amount, %2$s: tax label, %3$s: tax rate percent, %4$s: tax amount */
996 - __('Base %1$s + %2$s %3$s%% %4$s', 'fluent-cart'),
997 - Helper::toDecimal($rate['display_base']),
998 - $rate['short_label'],
999 - $rate['formatted_rate'],
1000 - Helper::toDecimal($rate['tax_amount'])
1001 - );
1002 - echo esc_html($lineText);
1003 - ?>
1004 - </span>
1005 - <?php endforeach; ?>
1006 - <span class="fct_item_tax_tooltip_line is-total">
1007 - <?php
1008 - echo esc_html(sprintf(
1009 - /* translators: %1$s: line total amount */
1010 - __('Total %1$s', 'fluent-cart'),
1011 - Helper::toDecimal($displayTotal)
1012 - ));
1013 - ?>
1014 - </span>
1015 - </div>
1016 - </div>
1017 - <?php
430 + $this->renderer()->renderCheckoutSetupFeeTaxTooltip($data);
1018 431 }
1019 432
1020 433 public function renderCheckoutSetupFeeTaxInfo($data)
1021 434 {
1022 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
1023 - if (!in_array($mode, ['both', 'label'], true)) {
1024 - return;
1025 - }
1026 -
1027 - $item = Arr::get($data, 'item', []);
1028 - $taxData = $this->getSetupFeeTaxData($item);
1029 - if (!$taxData) {
1030 - return;
1031 - }
1032 -
1033 - $isInclusive = $taxData['is_inclusive'];
1034 - $totalTax = $taxData['total_tax'];
1035 - $primaryLabel = $taxData['primary_label'];
1036 -
1037 - $priceNote = $isInclusive
1038 - ? sprintf(
1039 - /* translators: %1$s: tax label */
1040 - __('%1$s incl.', 'fluent-cart'),
1041 - $primaryLabel
1042 - )
1043 - : sprintf(
1044 - /* translators: %1$s: tax amount, %2$s: tax label */
1045 - __('+ %1$s %2$s', 'fluent-cart'),
1046 - Helper::toDecimal($totalTax),
1047 - strtolower($primaryLabel)
1048 - );
1049 - ?>
1050 - <span class="fct_setup_fee_price_note"><?php echo esc_html($priceNote); ?></span>
1051 - <?php
435 + $this->renderer()->renderCheckoutSetupFeeTaxInfo($data);
1052 436 }
1053 437
1054 438 public function renderCheckoutLineItemTaxTooltip($data)
1055 439 {
1056 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
1057 - if (!in_array($mode, ['both', 'tooltip'], true)) {
1058 - return;
1059 - }
1060 -
1061 - $item = Arr::get($data, 'item', []);
1062 - $rates = $this->normalizeCheckoutLineTaxRates($item);
1063 - if (empty($rates)) {
1064 - return;
1065 - }
1066 -
1067 - $cart = CartHelper::getCart();
1068 - $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
1069 - $rcMode = $this->getEffectiveRcMode();
1070 -
1071 - $isInclusive = (bool) Arr::get($rates, '0.inclusive', false);
1072 - $itemSubtotal = (int) Arr::get($item, 'line_total', Arr::get($item, 'subtotal', 0));
1073 - $itemTaxAmount = array_sum(array_map(function ($rate) {
1074 - return (int) Arr::get($rate, 'tax_amount', 0);
1075 - }, $rates));
1076 - if ($isReversed) {
1077 - if ($isInclusive && $rcMode === 'dynamic') {
1078 - $displayTotal = $itemSubtotal - $itemTaxAmount;
1079 - } else {
1080 - $displayTotal = $itemSubtotal;
1081 - }
1082 - } else {
1083 - $displayTotal = $isInclusive ? $itemSubtotal : $itemSubtotal + $itemTaxAmount;
1084 - }
1085 - $tooltipId = 'fct-item-tax-tooltip-' . Helper::getUidSerial();
1086 - ?>
1087 - <div class="fct_item_tax_hint">
1088 - <button
1089 - type="button"
1090 - class="fct_item_tax_hint_button"
1091 - aria-label="<?php esc_attr_e('View tax breakdown for this item', 'fluent-cart'); ?>"
1092 - aria-describedby="<?php echo esc_attr($tooltipId); ?>"
1093 - >
1094 - <span aria-hidden="true">i</span>
1095 - </button>
1096 - <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
1097 - <span class="fct_item_tax_tooltip_heading">
1098 - <?php echo esc_html($isInclusive ? __('Tax-inclusive price', 'fluent-cart') : __('Tax-exclusive price', 'fluent-cart')); ?>
1099 - </span>
1100 - <?php foreach ($rates as $rate) : ?>
1101 - <?php $lineReversedClass = $this->shouldRateStrikethrough($isReversed, $rate['inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
1102 - <span class="fct_item_tax_tooltip_line<?php echo esc_attr($lineReversedClass); ?>">
1103 - <?php
1104 - $lineText = sprintf(
1105 - /* translators: %1$s: tax base amount, %2$s: tax label, %3$s: tax rate percent, %4$s: tax amount */
1106 - __('Base %1$s + %2$s %3$s%% %4$s', 'fluent-cart'),
1107 - Helper::toDecimal($rate['display_base']),
1108 - $rate['short_label'],
1109 - $rate['formatted_rate'],
1110 - Helper::toDecimal($rate['tax_amount'])
1111 - );
1112 - echo esc_html($lineText);
1113 - ?>
1114 - </span>
1115 - <?php endforeach; ?>
1116 - <span class="fct_item_tax_tooltip_line is-total">
1117 - <?php
1118 - echo esc_html(sprintf(
1119 - /* translators: %1$s: line total amount */
1120 - __('Total %1$s', 'fluent-cart'),
1121 - Helper::toDecimal($displayTotal)
1122 - ));
1123 - ?>
1124 - </span>
1125 - </div>
1126 - </div>
1127 - <?php
440 + $this->renderer()->renderCheckoutLineItemTaxTooltip($data);
1128 441 }
1129 442
1130 443 public function renderCheckoutLineItemTaxInfo($data)
1131 444 {
1132 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
1133 - if (!in_array($mode, ['both', 'label'], true)) {
1134 - return;
1135 - }
1136 -
1137 - $item = Arr::get($data, 'item', []);
1138 - $rates = $this->normalizeCheckoutLineTaxRates($item);
1139 - if (empty($rates)) {
1140 - return;
1141 - }
1142 -
1143 - $isInclusive = (bool) Arr::get($rates, '0.inclusive', false);
1144 - $itemTaxAmount = array_sum(array_map(function ($rate) {
1145 - return (int) Arr::get($rate, 'tax_amount', 0);
1146 - }, $rates));
1147 - $primaryLabel = Arr::get($rates, '0.short_label', __('Tax', 'fluent-cart'));
1148 -
1149 - $priceNote = $isInclusive
1150 - ? sprintf(
1151 - /* translators: %1$s: tax label */
1152 - __('%1$s incl.', 'fluent-cart'),
1153 - $primaryLabel
1154 - )
1155 - : sprintf(
1156 - /* translators: %1$s: tax amount, %2$s: tax label */
1157 - __('+ %1$s %2$s', 'fluent-cart'),
1158 - Helper::toDecimal($itemTaxAmount),
1159 - strtolower($primaryLabel)
1160 - );
1161 - ?>
1162 - <div class="fct_item_tax_price_note"><?php echo esc_html($priceNote); ?></div>
1163 - <?php
445 + $this->renderer()->renderCheckoutLineItemTaxInfo($data);
1164 446 }
1165 447
1166 448 public function renderUnitPriceRoundingTooltip($data)
1167 449 {
1168 - $item = Arr::get($data, 'item', []);
1169 - $quantity = (int) Arr::get($item, 'quantity', 1);
1170 -
1171 - if ($quantity < 2) {
1172 - return;
1173 - }
1174 -
1175 - $unitPrice = (int) Arr::get($item, 'unit_price', 0);
1176 - $subtotal = (int) Arr::get($item, 'subtotal', 0);
1177 -
1178 - if ($unitPrice <= 0) {
1179 - return;
1180 - }
1181 -
1182 - $shouldShow = false;
1183 -
1184 - // Case 1 — TaxModule RC dynamic: the applied per-unit tax adjustment is rounded,
1185 - // so unit_price * qty may differ from the exact net by up to (qty - 1) cents.
1186 - $rcAdjustment = (int) Arr::get($item, 'line_meta.reverse_charge_adjustment', 0);
1187 - if ($rcAdjustment > 0) {
1188 - $rates = (array) Arr::get($item, 'line_meta.tax_config.rates', []);
1189 - $actualTaxTotal = (int) array_sum(array_map('intval', array_column($rates, 'tax_amount')));
1190 - $diff = abs($rcAdjustment - $actualTaxTotal);
1191 - if ($diff > 0 && $diff <= $quantity) {
1192 - $shouldShow = true;
1193 - }
1194 - }
1195 -
1196 - // Case 2 — generic: unit_price * qty already doesn't match subtotal
1197 - // (future dynamic pricing or any other module that stores a rounded unit_price)
1198 - if (!$shouldShow && $subtotal > 0) {
1199 - $diff = abs(($unitPrice * $quantity) - $subtotal);
1200 - if ($diff > 0 && $diff <= $quantity) {
1201 - $shouldShow = true;
1202 - }
1203 - }
1204 -
1205 - if (!$shouldShow) {
1206 - return;
1207 - }
1208 -
1209 - $tooltipId = 'fct-unit-price-rounding-' . Helper::getUidSerial();
1210 - ?>
1211 - <div class="fct_item_tax_hint">
1212 - <button
1213 - type="button"
1214 - class="fct_item_tax_hint_button"
1215 - aria-label="<?php esc_attr_e('Unit price rounding information', 'fluent-cart'); ?>"
1216 - aria-describedby="<?php echo esc_attr($tooltipId); ?>"
1217 - >
1218 - <span aria-hidden="true">i</span>
1219 - </button>
1220 - <div class="fct_item_tax_tooltip fct_unit_price_rounding_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
1221 - <?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'); ?>
1222 - </div>
1223 - </div>
1224 - <?php
450 + $this->renderer()->renderUnitPriceRoundingTooltip($data);
1225 451 }
1226 452
1227 453 public function isEnabled()
1228 454 {
@@ -1263,8 +489,65 @@
1263 489 $fillData['cart_data'] = $lineItems;
1264 490 return $fillData;
1265 491 }
1266 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 +
1267 550 $country = '';
1268 551 $state = '';
1269 552 $postCode = '';
1270 553
@@ -1363,11 +646,12 @@
1363 646 $signupFeeTaxesByIndex[$idx] = (int) Arr::get($pLine, 'other_info.signup_fee_tax', 0);
1364 647 }
1365 648 }
1366 649
1367 - $inclusiveTaxAdjustment = 0;
1368 - $reversedTaxTotal = 0;
1369 - $reversedShippingTax = 0;
650 + $inclusiveTaxAdjustment = 0;
651 + $reversedTaxTotal = 0;
652 + $reversedShippingTax = 0;
653 + $reversedShippingTaxLines = [];
1370 654 if ($shouldApplyReverseCharge) {
1371 655 $inclusivePortion = $taxTotal - $exclusiveTaxTotal - $feeTax;
1372 656 $reversedInclusive = ($rcMode === 'dynamic') ? $inclusivePortion : 0;
1373 657 $reversedTaxTotal = $exclusiveTaxTotal + $feeTax + $shippingTax + $reversedInclusive;
@@ -1372,8 +656,9 @@
1372 656 $reversedInclusive = ($rcMode === 'dynamic') ? $inclusivePortion : 0;
1373 657 $reversedTaxTotal = $exclusiveTaxTotal + $feeTax + $shippingTax + $reversedInclusive;
1374 658 $inclusiveTaxAdjustment = $inclusivePortion;
1375 659 $reversedShippingTax = $shippingTax;
660 + $reversedShippingTaxLines = $shippingTaxLines;
1376 661 $taxTotal = 0;
1377 662 $exclusiveTaxTotal = 0;
1378 663 $shippingTax = 0;
1379 664 $shippingTaxLines = [];
@@ -1504,8 +789,9 @@
1504 789 $checkoutData['tax_data']['tax_country'] = $taxCountry;
1505 790 $checkoutData['tax_data']['shipping_tax'] = $shippingTax;
1506 791 $checkoutData['tax_data']['shipping_tax_lines'] = $shippingTaxLines;
1507 792 $checkoutData['tax_data']['reverse_charge_shipping_tax'] = $reversedShippingTax;
793 + $checkoutData['tax_data']['reverse_charge_shipping_tax_lines'] = $reversedShippingTaxLines;
1508 794 $checkoutData['tax_data']['reverse_charge_price_mode'] = $shouldApplyReverseCharge ? $this->getEffectiveRcMode() : 'fixed';
1509 795 $checkoutData['tax_data']['fee_tax'] = $feeTax;
1510 796 $checkoutData['tax_data']['fee_tax_lines'] = $feeTaxLines;
1511 797 $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal;
@@ -1554,9 +840,9 @@
1554 840
1555 841 return true;
1556 842 }
1557 843
1558 - protected function isReverseChargeCheckout($checkoutData)
844 + public function isReverseChargeCheckout($checkoutData)
1559 845 {
1560 846 return Arr::get($checkoutData, 'tax_data.valid', false)
1561 847 && (int) Arr::get($checkoutData, 'tax_data.tax_behavior', 2) === 0;
1562 848 }
@@ -1792,14 +1078,8 @@
1792 1078 $legalRegId = sanitize_text_field(
1793 1079 Arr::get($requestData, 'billing_legal_registration_id', '')
1794 1080 ?: Arr::get($checkoutData, 'form_data.billing_legal_registration_id', '')
1795 1081 );
1796 - $declarationNote = sanitize_text_field(Arr::get($checkoutData, 'tax_data.declaration_note', ''));
1797 -
1798 - if ($declarationNote === '') {
1799 - $declarationNote = sanitize_text_field(App::request()->get('fct_vat_declaration_note', ''));
1800 - }
1801 -
1802 1082 if (!$taxNumber && !$companyName && !$legalRegId) {
1803 1083 return;
1804 1084 }
1805 1085
@@ -1821,11 +1101,8 @@
1821 1101 if (Arr::get($checkoutData, 'tax_data.valid', false)) {
1822 1102 $businessInfo['tax_number_validated'] = true;
1823 1103 $businessInfo['tax_number_country'] = sanitize_text_field(Arr::get($checkoutData, 'tax_data.country', ''));
1824 1104 $businessInfo['tax_number_name'] = sanitize_text_field(Arr::get($checkoutData, 'tax_data.name', ''));
1825 - if ($declarationNote !== '') {
1826 - $businessInfo['reverse_charge_declaration'] = $declarationNote;
1827 - }
1828 1105 }
1829 1106 }
1830 1107
1831 1108 $order->updateMeta('business_info', $businessInfo);
@@ -2095,8 +1372,9 @@
2095 1372 unset($checkoutData['tax_data']['country']);
2096 1373 unset($checkoutData['tax_data']['declaration_note']);
2097 1374 unset($checkoutData['tax_data']['reverse_charge_tax_total']);
2098 1375 unset($checkoutData['tax_data']['reverse_charge_shipping_tax']);
1376 + unset($checkoutData['tax_data']['reverse_charge_shipping_tax_lines']);
2099 1377 unset($checkoutData['tax_data']['reverse_charge_inclusive_adjustment']);
2100 1378 }
2101 1379
2102 1380 $cart->checkout_data = $checkoutData;
@@ -2306,9 +1584,9 @@
2306 1584 // Falls back to proportional split for admin order recalculation and legacy callers.
2307 1585 $shippingTaxByRateId = [];
2308 1586 foreach ($shippingTaxLines as $stl) {
2309 1587 $stlRateId = (int) Arr::get($stl, 'rate_id', 0);
2310 - if ($stlRateId > 0) {
1588 + if ($stlRateId !== 0) {
2311 1589 $shippingTaxByRateId[$stlRateId] = (int) Arr::get($stl, 'shipping_tax', 0);
2312 1590 }
2313 1591 }
2314 1592
@@ -2403,8 +1681,24 @@
2403 1681 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
2404 1682 $storeCountry = (new StoreSettings())->get('store_country');
2405 1683 return Arr::get($taxSettings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes'
2406 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 + );
2407 1701 }
2408 1702
2409 1703 public static function euVatCountyOptions()
2410 1704 {