PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.8.0 1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 All 52 releases
← All changes | app/Modules/Tax/TaxModule.php +265 -847 1.5.2 → 1.8.0 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 {
@@ -614,9 +289,9 @@
614 289
615 290 $fillData = $this->calculateCartTax([
616 291 'cart_data' => $cart->cart_data,
617 292 'checkout_data' => $checkoutData
618 - ]);
293 + ], $cart);
619 294
620 295 $cart->fill($fillData);
621 296 $cart->save();
622 297 }
@@ -668,9 +343,9 @@
668 343 'shipping_country' => Arr::get($data, 'prev_data.shipping_country', Arr::get($fillData, 'checkout_data.form_data.shipping_country', '')),
669 344 ]
670 345 );
671 346
672 - return $this->calculateCartTax($fillData);
347 + return $this->calculateCartTax($fillData, $cart);
673 348 }
674 349
675 350 public function maybeInvalidateVatValidationForCountryChange($checkoutData, $previousFormData = [])
676 351 {
@@ -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 {
@@ -1229,9 +455,15 @@
1229 455 $settings = $this->getSettings();
1230 456 return Arr::get($settings, 'enable_tax', 'no') === 'yes';
1231 457 }
1232 458
1233 - public function calculateCartTax($fillData)
459 + /**
460 + * @param array $fillData ['cart_data' => array, 'checkout_data' => array]
461 + * @param Cart|null $cart Pass it while cart_data holds no rule discounts yet, so tax
462 + * is worked out on the price after dynamic pricing rules.
463 + * @return array
464 + */
465 + public function calculateCartTax($fillData, $cart = null)
1234 466 {
1235 467 $lineItems = Arr::get($fillData, 'cart_data', []);
1236 468 $checkoutData = Arr::get($fillData, 'checkout_data', []);
1237 469
@@ -1263,8 +495,70 @@
1263 495 $fillData['cart_data'] = $lineItems;
1264 496 return $fillData;
1265 497 }
1266 498
499 + // Shipping tax is derived from each item's itemwise_shipping_charge, but the
500 + // authoritative selected shipping charge lives in checkout_data.shipping_data.
501 + // These desync when a recalc entry point (VAT apply/remove, order placement,
502 + // address patch) runs after the display path computed shipping without persisting
503 + // the per-item distribution. Left unfixed the tax calculator sees zero shipping and
504 + // drops the shipping tax (and, under reverse charge, its reversed portion). Repair
505 + // the distribution here — the single point all recalc paths share.
506 + $shipMethodId = Arr::get($checkoutData, 'shipping_data.shipping_method_id');
507 + $shipCharge = (int) Arr::get($checkoutData, 'shipping_data.shipping_charge', 0);
508 + $distributedShipping = 0;
509 + foreach ($lineItems as $cartLine) {
510 + $distributedShipping += (int) Arr::get($cartLine, 'itemwise_shipping_charge', 0);
511 + $distributedShipping += (int) Arr::get($cartLine, 'shipping_charge', 0);
512 + }
513 + if ($shipMethodId && $shipCharge > 0 && $distributedShipping === 0) {
514 + // A method is selected with a charge but no per-item share — redistribute it.
515 + $shipMethod = \FluentCart\App\Models\ShippingMethod::query()->find($shipMethodId);
516 + if ($shipMethod) {
517 + $redistributed = \FluentCart\App\Helpers\CartHelper::calculateShippingMethodCharge(
518 + $shipMethod, $lineItems, 'items'
519 + );
520 + $lineItems = Arr::get($redistributed, 'items', $lineItems);
521 + }
522 + } elseif (!$shipMethodId && $distributedShipping === 0) {
523 + // Nothing persisted in the stored cart, yet the checkout page auto-selects the
524 + // single available method for display. Logged-in customers whose default address
525 + // is pre-filled never fire the change event that would persist that selection, so
526 + // shipping_data stays empty and the tax calculator drops shipping entirely. Mirror
527 + // the renderer's single-method auto-select so VAT apply / placement tax the
528 + // shipping the customer already sees selected.
529 + $requiresShipping = false;
530 + foreach ($lineItems as $cartLine) {
531 + if (Arr::get($cartLine, 'fulfillment_type') === 'physical') {
532 + $requiresShipping = true;
533 + break;
534 + }
535 + }
536 + if ($requiresShipping) {
537 + $autoCountry = Arr::get($checkoutData, 'form_data.shipping_country')
538 + ?: Arr::get($checkoutData, 'form_data.billing_country');
539 + $autoState = Arr::get($checkoutData, 'form_data.shipping_state')
540 + ?: Arr::get($checkoutData, 'form_data.billing_state');
541 + if ($autoCountry) {
542 + $autoMethods = \FluentCart\App\Helpers\AddressHelper::getShippingMethods($autoCountry, $autoState);
543 + if ($autoMethods && !is_wp_error($autoMethods) && count($autoMethods) === 1) {
544 + $autoMethod = Arr::first($autoMethods);
545 + $autoCalc = \FluentCart\App\Helpers\CartHelper::calculateShippingMethodCharge(
546 + $autoMethod, $lineItems, 'items'
547 + );
548 + $lineItems = Arr::get($autoCalc, 'items', $lineItems);
549 + Arr::set($checkoutData, 'shipping_data.shipping_method_id', $autoMethod->id);
550 + Arr::set($checkoutData, 'shipping_data.shipping_charge', (int) Arr::get($autoCalc, 'shipping_amount', 0));
551 + }
552 + }
553 + }
554 + }
555 +
556 + $unpricedLineItems = $lineItems;
557 + if ($cart instanceof Cart) {
558 + $lineItems = $this->applyPricingRules($lineItems, $checkoutData, $cart);
559 + }
560 +
1267 561 $country = '';
1268 562 $state = '';
1269 563 $postCode = '';
1270 564
@@ -1363,11 +657,12 @@
1363 657 $signupFeeTaxesByIndex[$idx] = (int) Arr::get($pLine, 'other_info.signup_fee_tax', 0);
1364 658 }
1365 659 }
1366 660
1367 - $inclusiveTaxAdjustment = 0;
1368 - $reversedTaxTotal = 0;
1369 - $reversedShippingTax = 0;
661 + $inclusiveTaxAdjustment = 0;
662 + $reversedTaxTotal = 0;
663 + $reversedShippingTax = 0;
664 + $reversedShippingTaxLines = [];
1370 665 if ($shouldApplyReverseCharge) {
1371 666 $inclusivePortion = $taxTotal - $exclusiveTaxTotal - $feeTax;
1372 667 $reversedInclusive = ($rcMode === 'dynamic') ? $inclusivePortion : 0;
1373 668 $reversedTaxTotal = $exclusiveTaxTotal + $feeTax + $shippingTax + $reversedInclusive;
@@ -1372,8 +667,9 @@
1372 667 $reversedInclusive = ($rcMode === 'dynamic') ? $inclusivePortion : 0;
1373 668 $reversedTaxTotal = $exclusiveTaxTotal + $feeTax + $shippingTax + $reversedInclusive;
1374 669 $inclusiveTaxAdjustment = $inclusivePortion;
1375 670 $reversedShippingTax = $shippingTax;
671 + $reversedShippingTaxLines = $shippingTaxLines;
1376 672 $taxTotal = 0;
1377 673 $exclusiveTaxTotal = 0;
1378 674 $shippingTax = 0;
1379 675 $shippingTaxLines = [];
@@ -1504,8 +800,9 @@
1504 800 $checkoutData['tax_data']['tax_country'] = $taxCountry;
1505 801 $checkoutData['tax_data']['shipping_tax'] = $shippingTax;
1506 802 $checkoutData['tax_data']['shipping_tax_lines'] = $shippingTaxLines;
1507 803 $checkoutData['tax_data']['reverse_charge_shipping_tax'] = $reversedShippingTax;
804 + $checkoutData['tax_data']['reverse_charge_shipping_tax_lines'] = $reversedShippingTaxLines;
1508 805 $checkoutData['tax_data']['reverse_charge_price_mode'] = $shouldApplyReverseCharge ? $this->getEffectiveRcMode() : 'fixed';
1509 806 $checkoutData['tax_data']['fee_tax'] = $feeTax;
1510 807 $checkoutData['tax_data']['fee_tax_lines'] = $feeTaxLines;
1511 808 $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal;
@@ -1511,8 +808,12 @@
1511 808 $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal;
1512 809 $checkoutData['tax_data']['tax_lines'] = $taxLines;
1513 810 $fillData['checkout_data'] = $checkoutData;
1514 811
812 + if ($cart instanceof Cart) {
813 + $productLines = $this->restoreUnpricedValues($productLines, $unpricedLineItems);
814 + }
815 +
1515 816 // Only product lines go back into cart_data
1516 817 $fillData['cart_data'] = $productLines;
1517 818
1518 819 if (isset($fillData['hook_changes'])) {
@@ -1521,8 +822,117 @@
1521 822
1522 823 return $fillData;
1523 824 }
1524 825
826 + /**
827 + * The lines as the checkout prices them: rule discounts on the items and on each
828 + * line's shipping share. Uses the same hooks as the summary and placeOrder(), so
829 + * tax is charged on the amount the customer pays.
830 + *
831 + * @param array $lineItems
832 + * @param array $checkoutData
833 + * @param Cart $cart
834 + * @return array
835 + */
836 + private function applyPricingRules(array $lineItems, array $checkoutData, Cart $cart): array
837 + {
838 + // A copy, so rule conditions read the address being patched without touching the cart.
839 + $pricedCart = clone $cart;
840 + $pricedCart->cart_data = $lineItems;
841 + $pricedCart->checkout_data = $checkoutData;
842 +
843 + $priced = apply_filters('fluent_cart/cart/item_dynamic_discount', $lineItems, [
844 + 'cart' => $pricedCart,
845 + ]);
846 + $lineItems = is_array($priced) ? $priced : $lineItems;
847 +
848 + $shipping = 0;
849 + foreach ($lineItems as $lineItem) {
850 + $shipping += (int) Arr::get($lineItem, 'shipping_charge', 0)
851 + + (int) Arr::get($lineItem, 'itemwise_shipping_charge', 0);
852 + }
853 +
854 + if ($shipping <= 0) {
855 + return $lineItems;
856 + }
857 +
858 + $discounted = (int) apply_filters('fluent_cart/checkout/order_shipping_charge', $shipping, [
859 + 'cart' => $pricedCart,
860 + ]);
861 + $discounted = max(0, min($discounted, $shipping));
862 +
863 + if ($discounted === $shipping) {
864 + return $lineItems;
865 + }
866 +
867 + // Floor each share, then give the leftover cents to the largest remainders, so the
868 + // shares add up to exactly the discounted charge and none goes below zero.
869 + $left = $discounted;
870 + $remainders = [];
871 + foreach ($lineItems as $key => $lineItem) {
872 + foreach (['shipping_charge', 'itemwise_shipping_charge'] as $field) {
873 + $share = (int) Arr::get($lineItem, $field, 0);
874 + if ($share <= 0) {
875 + continue;
876 + }
877 + $scaled = intdiv($share * $discounted, $shipping);
878 + $lineItems[$key][$field] = $scaled;
879 + $left -= $scaled;
880 + $remainders[] = [$key, $field, ($share * $discounted) % $shipping];
881 + }
882 + }
883 +
884 + usort($remainders, function ($first, $second) {
885 + return $second[2] <=> $first[2];
886 + });
887 +
888 + foreach ($remainders as $remainder) {
889 + if ($left <= 0) {
890 + break;
891 + }
892 + $lineItems[$remainder[0]][$remainder[1]]++;
893 + $left--;
894 + }
895 +
896 + return $lineItems;
897 + }
898 +
899 + /**
900 + * Put back what applyPricingRules() changed, keeping the tax worked out on it.
901 + * cart_data must stay free of rule discounts: the summary applies them on every
902 + * render, and the order writes them in at before_order_processing.
903 + *
904 + * @param array $productLines Taxed lines, in the order of $unpricedLineItems.
905 + * @param array $unpricedLineItems
906 + * @return array
907 + */
908 + private function restoreUnpricedValues(array $productLines, array $unpricedLineItems): array
909 + {
910 + $unpricedLineItems = array_values($unpricedLineItems);
911 +
912 + foreach ($productLines as $index => $line) {
913 + if (!isset($unpricedLineItems[$index])) {
914 + continue;
915 + }
916 + $unpriced = $unpricedLineItems[$index];
917 +
918 + foreach (['manual_discount', 'discount_total', 'shipping_charge', 'itemwise_shipping_charge'] as $field) {
919 + if (array_key_exists($field, $unpriced)) {
920 + $line[$field] = $unpriced[$field];
921 + } else {
922 + unset($line[$field]);
923 + }
924 + }
925 +
926 + // From the taxed subtotal: dynamic reverse charge may have lowered it.
927 + $line['line_total'] = max(0, (int) Arr::get($line, 'subtotal', 0) - (int) Arr::get($line, 'discount_total', 0));
928 +
929 + $productLines[$index] = $line;
930 + }
931 +
932 + return $productLines;
933 + }
934 +
1525 935 protected function shouldApplyReverseCharge($checkoutData, $taxApplicableCountry, $lineItems = [])
1526 936 {
1527 937 if (!Arr::get($checkoutData, 'tax_data.valid', false)) {
1528 938 return false;
@@ -1554,9 +964,9 @@
1554 964
1555 965 return true;
1556 966 }
1557 967
1558 - protected function isReverseChargeCheckout($checkoutData)
968 + public function isReverseChargeCheckout($checkoutData)
1559 969 {
1560 970 return Arr::get($checkoutData, 'tax_data.valid', false)
1561 971 && (int) Arr::get($checkoutData, 'tax_data.tax_behavior', 2) === 0;
1562 972 }
@@ -1792,14 +1202,8 @@
1792 1202 $legalRegId = sanitize_text_field(
1793 1203 Arr::get($requestData, 'billing_legal_registration_id', '')
1794 1204 ?: Arr::get($checkoutData, 'form_data.billing_legal_registration_id', '')
1795 1205 );
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 1206 if (!$taxNumber && !$companyName && !$legalRegId) {
1803 1207 return;
1804 1208 }
1805 1209
@@ -1821,11 +1225,8 @@
1821 1225 if (Arr::get($checkoutData, 'tax_data.valid', false)) {
1822 1226 $businessInfo['tax_number_validated'] = true;
1823 1227 $businessInfo['tax_number_country'] = sanitize_text_field(Arr::get($checkoutData, 'tax_data.country', ''));
1824 1228 $businessInfo['tax_number_name'] = sanitize_text_field(Arr::get($checkoutData, 'tax_data.name', ''));
1825 - if ($declarationNote !== '') {
1826 - $businessInfo['reverse_charge_declaration'] = $declarationNote;
1827 - }
1828 1229 }
1829 1230 }
1830 1231
1831 1232 $order->updateMeta('business_info', $businessInfo);
@@ -2031,9 +1432,9 @@
2031 1432 $cart->checkout_data = $checkoutData;
2032 1433 $fillData = $this->calculateCartTax([
2033 1434 'cart_data' => $cart->cart_data,
2034 1435 'checkout_data' => $cart->checkout_data,
2035 - ]);
1436 + ], $cart);
2036 1437 $cart->fill($fillData);
2037 1438 $cart->save();
2038 1439
2039 1440 ob_start();
@@ -2095,8 +1496,9 @@
2095 1496 unset($checkoutData['tax_data']['country']);
2096 1497 unset($checkoutData['tax_data']['declaration_note']);
2097 1498 unset($checkoutData['tax_data']['reverse_charge_tax_total']);
2098 1499 unset($checkoutData['tax_data']['reverse_charge_shipping_tax']);
1500 + unset($checkoutData['tax_data']['reverse_charge_shipping_tax_lines']);
2099 1501 unset($checkoutData['tax_data']['reverse_charge_inclusive_adjustment']);
2100 1502 }
2101 1503
2102 1504 $cart->checkout_data = $checkoutData;
@@ -2102,9 +1504,9 @@
2102 1504 $cart->checkout_data = $checkoutData;
2103 1505 $fillData = $this->calculateCartTax([
2104 1506 'cart_data' => $cart->cart_data,
2105 1507 'checkout_data' => $cart->checkout_data,
2106 - ]);
1508 + ], $cart);
2107 1509 $cart->fill($fillData);
2108 1510 $cart->save();
2109 1511
2110 1512 ob_start();
@@ -2306,9 +1708,9 @@
2306 1708 // Falls back to proportional split for admin order recalculation and legacy callers.
2307 1709 $shippingTaxByRateId = [];
2308 1710 foreach ($shippingTaxLines as $stl) {
2309 1711 $stlRateId = (int) Arr::get($stl, 'rate_id', 0);
2310 - if ($stlRateId > 0) {
1712 + if ($stlRateId !== 0) {
2311 1713 $shippingTaxByRateId[$stlRateId] = (int) Arr::get($stl, 'shipping_tax', 0);
2312 1714 }
2313 1715 }
2314 1716
@@ -2403,8 +1805,24 @@
2403 1805 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
2404 1806 $storeCountry = (new StoreSettings())->get('store_country');
2405 1807 return Arr::get($taxSettings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes'
2406 1808 || $countryCode !== $storeCountry;
1809 + }
1810 +
1811 + /**
1812 + * The legal reverse-charge notice shown on checkout, invoices, receipts and emails.
1813 + * EU VAT Directive 2006/112/EC Article 226(11a) requires the invoice to carry the
1814 + * literal mention "Reverse charge" when the customer is liable for the VAT, so the
1815 + * default string must always start with those exact words. Article 196 covers
1816 + * services; stores supplying intra-EU goods (Art. 138) or needing other
1817 + * jurisdiction-specific wording can override via the filter.
1818 + */
1819 + public static function getReverseChargeNoticeText()
1820 + {
1821 + return apply_filters(
1822 + 'fluent_cart/tax/reverse_charge_notice_text',
1823 + __('Reverse charge — Article 196, Council Directive 2006/112/EC. Customer is liable for VAT.', 'fluent-cart')
1824 + );
2407 1825 }
2408 1826
2409 1827 public static function euVatCountyOptions()
2410 1828 {