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 +163 -914 1.5.4 → 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,12 +155,21 @@
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 - if (Arr::get($data, 'scope') === 'price_range') {
159 + $variant = Arr::get($data, 'variant', null);
160 +
161 + // scope 'price_range' covers two cases: a single confirmed price
162 + // for a simple product (variant is set — resolve its suffix
163 + // normally) and the non-simple price summary (min/max, or a
164 + // single value when every variant is priced the same — variant
165 + // is always null there, deliberately skipped since no single
166 + // tax_inclusion applies to a multi-variant summary; see
167 + // PR #1767/#2271).
168 + if (Arr::get($data, 'scope') === 'price_range' && !$variant) {
149 169 return;
150 170 }
151 - $variant = Arr::get($data, 'variant', null);
171 +
152 172 $priceSuffix = $this->resolvePriceSuffix($variant);
153 173 $variantInclusion = $variant ? Arr::get($variant->other_info ?: [], 'tax_inclusion', '') : '';
154 174 $taxInclusion = $variantInclusion ?: Arr::get($this->taxSettings, 'tax_inclusion', '');
155 175
@@ -203,450 +223,19 @@
203 223 }
204 224
205 225 public function renderTaxRow($cart, $atts = '')
206 226 {
207 - if ($this->getCheckoutTaxBreakdownDisplayMode() === 'simplified') {
208 - return;
209 - }
210 -
211 - $taxAmount = (int) Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
212 - $reversedTaxAmount = (int) Arr::get($cart->checkout_data, 'tax_data.reverse_charge_tax_total', 0);
213 - $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
214 - $taxLines = Arr::get($cart->checkout_data, 'tax_data.tax_lines', []);
215 - $taxCountry = Arr::get($cart->checkout_data, 'tax_data.tax_country', '');
216 - $taxLabel = Arr::get($taxLines, '0.label', '');
217 -
218 - if (!$taxLabel) {
219 - $taxLabel = $taxCountry ? static::getCountryTaxTitle($taxCountry) : __('Tax', 'fluent-cart');
220 - }
221 - ?>
222 - <li <?php echo $atts; ?>>
223 - <span class="fct_summary_label">
224 - <?php echo esc_html($taxLabel); ?>
225 - </span>
226 - <span class="fct_summary_value">
227 - <?php if ($isReverseCharge && $taxAmount === 0) : ?>
228 - <?php
229 - /* translators: %1$s: formatted reversed tax amount */
230 - echo esc_html(sprintf(__('Tax reversed: %1$s', 'fluent-cart'), Helper::toDecimal($reversedTaxAmount)));
231 - ?>
232 - <?php else : ?>
233 - <?php echo esc_html(Helper::toDecimal($taxAmount)); ?>
234 - <?php endif; ?>
235 - </span>
236 - </li>
237 - <?php
227 + $this->renderer()->renderTaxRow($cart, $atts);
238 228 }
239 229
240 230 public function renderShippingTaxRow($cart, $atts = '')
241 231 {
242 - if ($this->getCheckoutTaxBreakdownDisplayMode() === 'simplified') {
243 - return '';
244 - }
245 -
246 - $shippingTax = (int) Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
247 - $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
248 - $rcShippingTax = (int) Arr::get($cart->checkout_data, 'tax_data.reverse_charge_shipping_tax', 0);
249 -
250 - if ($shippingTax <= 0 && (!$isReverseCharge || $rcShippingTax <= 0)) {
251 - return '';
252 - }
253 -
254 - $displayAmount = $isReverseCharge ? $rcShippingTax : $shippingTax;
255 - $storeBehavior = (int) Arr::get($cart->checkout_data, 'tax_data.store_tax_behavior',
256 - Arr::get($cart->checkout_data, 'tax_data.tax_behavior', 2));
257 - $isInclusive = $storeBehavior === 2;
258 - ?>
259 - <li data-fct-shipping-tax-row <?php echo $atts; ?>>
260 - <span class="fct_summary_label">
261 - <?php if ($isInclusive) : ?>
262 - <?php echo esc_html__('Shipping Tax (Included)', 'fluent-cart'); ?>
263 - <?php else : ?>
264 - <?php echo esc_html__('Shipping Tax (Excluded)', 'fluent-cart'); ?>
265 - <?php endif; ?>
266 - </span>
267 - <span class="fct_summary_value"<?php echo $isReverseCharge ? ' style="text-decoration:line-through;opacity:0.6;"' : ''; ?>>
268 - <?php echo esc_html(Helper::toDecimal($displayAmount)); ?>
269 - </span>
270 - </li>
271 - <?php
272 - return '';
232 + return $this->renderer()->renderShippingTaxRow($cart, $atts);
273 233 }
274 234
275 235 public function renderTaxSummaryBox($cart)
276 236 {
277 - $taxData = Arr::get($cart->checkout_data, 'tax_data', []);
278 - $taxTotal = (int) Arr::get($taxData, 'tax_total', 0);
279 - $exclusiveTaxTotal = (int) Arr::get($taxData, 'exclusive_tax_total', $taxTotal);
280 - $feeTaxLines = (array) Arr::get($taxData, 'fee_tax_lines', []);
281 - $shippingTax = (int) Arr::get($taxData, 'shipping_tax', 0);
282 - $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
283 - $reversedTaxTotalDisplay = (int) Arr::get($taxData, 'reverse_charge_tax_total', 0);
284 -
285 - $taxRateLines = (array) Arr::get($taxData, 'tax_lines', []);
286 - $shippingTaxLines = (array) Arr::get($taxData, 'shipping_tax_lines', []);
287 -
288 - $inclusiveFeeTax = 0;
289 - $exclusiveFeeTax = 0;
290 - foreach ($feeTaxLines as $feeTaxLine) {
291 - if (!empty($feeTaxLine['inclusive'])) {
292 - $inclusiveFeeTax += (int) Arr::get($feeTaxLine, 'tax_amount', 0);
293 - } else {
294 - $exclusiveFeeTax += (int) Arr::get($feeTaxLine, 'tax_amount', 0);
295 - }
296 - }
297 - // Fallback: if no fee_tax_lines (old cart data), use aggregate fee_tax as exclusive
298 - if (empty($feeTaxLines)) {
299 - $exclusiveFeeTax = (int) Arr::get($taxData, 'fee_tax', 0);
300 - }
301 -
302 - $inclusiveTax = max(0, $taxTotal - $exclusiveTaxTotal - $exclusiveFeeTax - $inclusiveFeeTax);
303 - $productExclusiveTax = max(0, $exclusiveTaxTotal);
304 -
305 - $isShippingInclusive = \FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper::isShippingTaxInclusiveFromTaxData(
306 - is_array($taxData) ? $taxData : []
307 - );
308 -
309 - $payableTax = $productExclusiveTax + $exclusiveFeeTax + ($isShippingInclusive ? 0 : $shippingTax);
310 - $totalOrderTax = $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0) + $payableTax;
311 -
312 - $feeRows = \FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper::buildFeeTaxLineRows($feeTaxLines);
313 - $feeCount = count($feeRows);
314 - if (empty($feeTaxLines) && (int) Arr::get($taxData, 'fee_tax', 0) > 0) {
315 - $feeCount = 1;
316 - }
317 - $productTaxRowCount = !empty($taxRateLines)
318 - ? count($taxRateLines)
319 - : (int) ($inclusiveTax > 0) + (int) ($productExclusiveTax > 0);
320 - $rowCount = $productTaxRowCount + $feeCount + (int) ($shippingTax > 0);
321 - $shouldShowBreakdown = !empty($taxRateLines)
322 - || !empty($shippingTaxLines)
323 - || $rowCount >= 2
324 - || ($rowCount === 1 && !($payableTax > 0 || $inclusiveTax > 0 || $inclusiveFeeTax > 0));
325 -
326 - // Build folded per-rate rows via shared helper. Cart tax_lines carry only `label`
327 - // (no percent), so we pre-augment each line with `rate_label` = "Label (X%)" so the
328 - // helper picks it up and produces the same display as the old inline block.
329 - $foldedSource = [];
330 - foreach ($taxRateLines as $rateKey => $rateLine) {
331 - $ratePercent = (float) Arr::get($rateLine, 'rate_percent', 0);
332 - $rateLine['rate_label'] = (string) Arr::get($rateLine, 'label', '');
333 - if ($ratePercent > 0) {
334 - $rateLine['rate_label'] .= ' (' . Helper::formatTaxRatePercent($ratePercent) . '%)';
335 - }
336 - $foldedSource[$rateKey] = $rateLine;
337 - }
338 - // Fixed-mode reverse charge leaves tax-inclusive prices (and their embedded VAT)
339 - // untouched — only dynamic mode reverses the inclusive portion. Exclude inclusive
340 - // lines from the map in fixed mode so the rate rows sum to the reversed total.
341 - $rcNonDynamic = $isReverseCharge
342 - && Arr::get($taxData, 'reverse_charge_price_mode', 'fixed') !== 'dynamic';
343 - $rateBaseMap = TaxSummaryHelper::computeRateBaseMap((array) ($cart->cart_data ?: []), $rcNonDynamic);
344 - if ($isReverseCharge) {
345 - // Under reverse charge the stored tax_lines amounts are zeroed at calc time,
346 - // but the original per-rate amounts survive in item line_meta. Restore them
347 - // (and the pre-zeroing shipping lines) so the box renders the same
348 - // breakdown-by-rate table as a normal order.
349 - foreach ($foldedSource as $rateKey => &$foldedLine) {
350 - $rid = (int) Arr::get($foldedLine, 'rate_id', $rateKey);
351 - if (isset($rateBaseMap[$rid])) {
352 - $foldedLine['tax_amount'] = (int) round($rateBaseMap[$rid]['tax']);
353 - }
354 - }
355 - unset($foldedLine);
356 - if ($rcNonDynamic) {
357 - // Rates present only on inclusive lines have nothing reversible in
358 - // fixed mode — drop them instead of rendering a zero row.
359 - foreach (array_keys($foldedSource) as $rateKey) {
360 - $rid = (int) Arr::get($foldedSource[$rateKey], 'rate_id', $rateKey);
361 - if (!isset($rateBaseMap[$rid])) {
362 - unset($foldedSource[$rateKey]);
363 - }
364 - }
365 - }
366 - $shippingTaxLines = (array) Arr::get($taxData, 'reverse_charge_shipping_tax_lines', []);
367 - }
368 - $checkoutRateRows = TaxSummaryHelper::buildFoldedRateRows(
369 - $foldedSource, $shippingTaxLines, 'tax_amount', $isShippingInclusive, $rateBaseMap
370 - );
371 - // Inclusive shipping tax follows the store global tax mode — when shipping is
372 - // priced inclusive its tax is baked into the shipping price, so it counts as
373 - // "of which included in prices". Keeps includedInPrices + payableTax === totalOrderTax.
374 - $includedInPrices = $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0);
375 -
376 - $displayMode = $this->getCheckoutTaxBreakdownDisplayMode();
377 - $isSimplified = $displayMode === 'simplified';
378 - $simpleLabel = $this->getTaxDisplayLabel();
379 - if ($isReverseCharge) {
380 - $simpleValue = __('Reverse charge', 'fluent-cart');
381 - } elseif ($payableTax === 0 && $totalOrderTax > 0) {
382 - $suffix = (string) Arr::get($this->getSettings(), 'price_suffix_included', '');
383 - if ($suffix === '') {
384 - $suffix = __('(incl.)', 'fluent-cart');
385 - }
386 - /* translators: %1$s: formatted tax amount, %2$s: inclusive suffix */
387 - $simpleValue = sprintf(__('%1$s %2$s', 'fluent-cart'), html_entity_decode(Helper::toDecimal($totalOrderTax), ENT_QUOTES, 'UTF-8'), $suffix);
388 - } else {
389 - $simpleValue = html_entity_decode(Helper::toDecimal($payableTax), ENT_QUOTES, 'UTF-8');
390 - }
391 -
392 - $tooltipId = 'fct-tax-summary-tooltip-' . Helper::getUidSerial();
393 - ?>
394 - <li class="fct_tax_summary_li" data-fct-tax-summary>
395 -<?php if ($isSimplified) : ?>
396 - <style>
397 - .fct_tax_simple_details > summary { list-style: none; cursor: pointer; }
398 - .fct_tax_simple_details > summary::-webkit-details-marker { display: none; }
399 - .fct_tax_simple_toggle { color: #2563eb; text-decoration: none; font-size: 11px; }
400 - .fct_tax_simple_label { color: inherit; }
401 - </style>
402 - <details class="fct_tax_simple_details">
403 - <summary class="fct_tax_simple_summary" style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;list-style:none;">
404 - <span class="fct_tax_simple_left">
405 - <span class="fct_tax_simple_label"><?php echo esc_html($simpleLabel); ?></span>
406 - <span class="fct_tax_simple_toggle"><?php esc_html_e('See details', 'fluent-cart'); ?> &#9662;</span>
407 - </span>
408 - <span class="fct_tax_simple_value"><?php echo esc_html($simpleValue); ?></span>
409 - </summary>
410 -<?php endif; ?>
411 - <div class="fct_tax_summary_box">
412 - <div class="fct_tax_summary_header">
413 - <span class="fct_tax_summary_heading">
414 - <?php echo !empty($checkoutRateRows)
415 - ? esc_html__('Tax breakdown by rate', 'fluent-cart')
416 - : esc_html__('TAX', 'fluent-cart'); ?>
417 - </span>
418 - <div class="fct_item_tax_hint">
419 - <button
420 - type="button"
421 - class="fct_item_tax_hint_button"
422 - aria-label="<?php esc_attr_e('Tax information', 'fluent-cart'); ?>"
423 - aria-describedby="<?php echo esc_attr($tooltipId); ?>"
424 - >
425 - <span aria-hidden="true">i</span>
426 - </button>
427 - <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
428 - <span class="fct_item_tax_tooltip_heading">
429 - <?php esc_html_e('About your tax', 'fluent-cart'); ?>
430 - </span>
431 - <?php if ($isReverseCharge) : ?>
432 - <span class="fct_item_tax_tooltip_line">
433 - <?php esc_html_e('Tax has been reversed for this order.', 'fluent-cart'); ?>
434 - </span>
435 - <?php else : ?>
436 - <?php if ($payableTax > 0) : ?>
437 - <span class="fct_item_tax_tooltip_line">
438 - <?php esc_html_e('"Total payable tax" is added on top of listed prices.', 'fluent-cart'); ?>
439 - </span>
440 - <?php endif; ?>
441 - <?php if ($inclusiveTax > 0) : ?>
442 - <span class="fct_item_tax_tooltip_line">
443 - <?php esc_html_e('"Included in item prices" is already built into product prices.', 'fluent-cart'); ?>
444 - </span>
445 - <?php endif; ?>
446 - <?php if ($payableTax === 0 && $inclusiveTax === 0) : ?>
447 - <span class="fct_item_tax_tooltip_line">
448 - <?php esc_html_e('No tax applies to this order.', 'fluent-cart'); ?>
449 - </span>
450 - <?php endif; ?>
451 - <?php endif; ?>
452 - </div>
453 - </div>
454 - </div>
455 - <div class="fct_tax_summary_rows">
456 - <?php if (!empty($checkoutRateRows)) : ?>
457 - <div class="fct_tax_summary_row fct_tax_summary_row--head" style="display:flex;justify-content:space-between;gap:8px;">
458 - <span style="flex:1;font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:#94a3b8;">
459 - <?php esc_html_e('Rate', 'fluent-cart'); ?>
460 - </span>
461 - <span style="min-width:88px;text-align:right;font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:#94a3b8;">
462 - <?php esc_html_e('Taxable base', 'fluent-cart'); ?>
463 - </span>
464 - <span style="min-width:64px;text-align:right;font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:#94a3b8;">
465 - <?php esc_html_e('Tax', 'fluent-cart'); ?>
466 - </span>
467 - </div>
468 - <?php foreach ($checkoutRateRows as $checkoutRateRow) : ?>
469 - <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;">
470 - <span class="fct_tax_summary_row_label" style="flex:1;">
471 - <?php echo esc_html($checkoutRateRow['label']); ?>
472 - </span>
473 - <span style="min-width:88px;text-align:right;color:#94a3b8;">
474 - <?php echo esc_html(Helper::toDecimal((int) $checkoutRateRow['base'])); ?>
475 - </span>
476 - <span class="fct_tax_summary_row_amount" style="min-width:64px;text-align:right;">
477 - <?php echo esc_html(Helper::toDecimal((int) $checkoutRateRow['tax'])); ?>
478 - </span>
479 - </div>
480 - <?php endforeach; ?>
481 - <?php endif; ?>
482 - <?php if ($isReverseCharge) : ?>
483 - <?php if (empty($checkoutRateRows)) : ?>
484 - <?php
485 - $rcShippingDisplay = (int) Arr::get($taxData, 'reverse_charge_shipping_tax', 0);
486 - $rcInclusiveAdj = (int) Arr::get($taxData, 'reverse_charge_inclusive_adjustment', 0);
487 - $rcExclusiveNonShip = max(0, $reversedTaxTotalDisplay - $rcShippingDisplay - $rcInclusiveAdj);
488 - $rcBreakdownCount = (int) ($rcInclusiveAdj > 0) + (int) ($rcExclusiveNonShip > 0) + (int) ($rcShippingDisplay > 0);
489 - ?>
490 - <?php if ($rcBreakdownCount >= 2) : ?>
491 - <?php if ($rcInclusiveAdj > 0) : ?>
492 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
493 - <span class="fct_tax_summary_row_label">
494 - <?php esc_html_e('Included in item prices', 'fluent-cart'); ?>
495 - </span>
496 - <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
497 - <?php echo esc_html(Helper::toDecimal($rcInclusiveAdj)); ?>
498 - </span>
499 - </div>
500 - <?php endif; ?>
501 - <?php if ($rcExclusiveNonShip > 0) : ?>
502 - <div class="fct_tax_summary_row">
503 - <span class="fct_tax_summary_row_label">
504 - <?php esc_html_e('Added on products', 'fluent-cart'); ?>
505 - </span>
506 - <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
507 - <?php echo esc_html(Helper::toDecimal($rcExclusiveNonShip)); ?>
508 - </span>
509 - </div>
510 - <?php endif; ?>
511 - <?php if ($rcShippingDisplay > 0) : ?>
512 - <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
513 - <span class="fct_tax_summary_row_label">
514 - <?php echo $isShippingInclusive ? esc_html__('Included in shipping prices', 'fluent-cart') : esc_html__('Added on shipping', 'fluent-cart'); ?>
515 - </span>
516 - <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
517 - <?php echo esc_html(Helper::toDecimal($rcShippingDisplay)); ?>
518 - </span>
519 - </div>
520 - <?php endif; ?>
521 - <?php elseif ($rcShippingDisplay > 0) : ?>
522 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
523 - <span class="fct_tax_summary_row_label">
524 - <?php echo $isShippingInclusive ? esc_html__('Included in shipping prices', 'fluent-cart') : esc_html__('Added on shipping', 'fluent-cart'); ?>
525 - </span>
526 - <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
527 - <?php echo esc_html(Helper::toDecimal($rcShippingDisplay)); ?>
528 - </span>
529 - </div>
530 - <?php endif; ?>
531 - <?php endif; ?>
532 - <div class="fct_tax_summary_row fct_tax_summary_row--total">
533 - <span class="fct_tax_summary_row_label">
534 - <?php esc_html_e('VAT reversed', 'fluent-cart'); ?>
535 - </span>
536 - <span class="fct_tax_summary_row_amount">
537 - <?php echo esc_html(Helper::toDecimal($reversedTaxTotalDisplay)); ?>
538 - </span>
539 - </div>
540 - <?php else : ?>
541 - <?php if (empty($checkoutRateRows) && $inclusiveTax > 0 && $shouldShowBreakdown) : ?>
542 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
543 - <span class="fct_tax_summary_row_label">
544 - <?php esc_html_e('Included in item prices', 'fluent-cart'); ?>
545 - </span>
546 - <span class="fct_tax_summary_row_amount">
547 - <?php echo esc_html(Helper::toDecimal($inclusiveTax)); ?>
548 - </span>
549 - </div>
550 - <?php endif; ?>
551 - <?php if (empty($checkoutRateRows) && $productExclusiveTax > 0 && $shouldShowBreakdown) : ?>
552 - <div class="fct_tax_summary_row">
553 - <span class="fct_tax_summary_row_label">
554 - <?php esc_html_e('Added on products', 'fluent-cart'); ?>
555 - </span>
556 - <span class="fct_tax_summary_row_amount">
557 - <?php echo esc_html(Helper::toDecimal($productExclusiveTax)); ?>
558 - </span>
559 - </div>
560 - <?php endif; ?>
561 - <?php if (empty($checkoutRateRows) && $shouldShowBreakdown) : ?>
562 - <?php foreach ($feeRows as $feeRow) : ?>
563 - <div class="fct_tax_summary_row<?php echo $feeRow['inclusive'] ? ' fct_tax_summary_row--muted' : ''; ?>">
564 - <span class="fct_tax_summary_row_label">
565 - <?php echo esc_html($feeRow['display_label']); ?>
566 - </span>
567 - <span class="fct_tax_summary_row_amount">
568 - <?php echo esc_html(Helper::toDecimal($feeRow['tax_amount'])); ?>
569 - </span>
570 - </div>
571 - <?php endforeach; ?>
572 - <?php endif; ?>
573 - <?php if (empty($checkoutRateRows) && empty($feeTaxLines) && (int) Arr::get($taxData, 'fee_tax', 0) > 0 && $shouldShowBreakdown) : ?>
574 - <div class="fct_tax_summary_row">
575 - <span class="fct_tax_summary_row_label">
576 - <?php esc_html_e('Added on fees', 'fluent-cart'); ?>
577 - </span>
578 - <span class="fct_tax_summary_row_amount">
579 - <?php echo esc_html(Helper::toDecimal((int) Arr::get($taxData, 'fee_tax', 0))); ?>
580 - </span>
581 - </div>
582 - <?php endif; ?>
583 - <?php if (empty($checkoutRateRows) && $shippingTax > 0 && $shouldShowBreakdown) : ?>
584 - <?php if (!empty($shippingTaxLines)) : ?>
585 - <?php foreach ($shippingTaxLines as $shippingTaxLine) : ?>
586 - <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
587 - <span class="fct_tax_summary_row_label">
588 - <?php echo esc_html($shippingTaxLine['label']); ?>
589 - </span>
590 - <span class="fct_tax_summary_row_amount">
591 - <?php echo esc_html(Helper::toDecimal((int) Arr::get($shippingTaxLine, 'shipping_tax', 0))); ?>
592 - </span>
593 - </div>
594 - <?php endforeach; ?>
595 - <?php else : ?>
596 - <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
597 - <span class="fct_tax_summary_row_label">
598 - <?php if ($isShippingInclusive) : ?>
599 - <?php esc_html_e('Included in shipping prices', 'fluent-cart'); ?>
600 - <?php else : ?>
601 - <?php esc_html_e('Added on shipping', 'fluent-cart'); ?>
602 - <?php endif; ?>
603 - </span>
604 - <span class="fct_tax_summary_row_amount">
605 - <?php echo esc_html(Helper::toDecimal($shippingTax)); ?>
606 - </span>
607 - </div>
608 - <?php endif; ?>
609 - <?php endif; ?>
610 - <?php if (!empty($checkoutRateRows)) : ?>
611 - <div class="fct_tax_summary_row fct_tax_summary_row--total">
612 - <span class="fct_tax_summary_row_label"><?php esc_html_e('Total tax', 'fluent-cart'); ?></span>
613 - <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($totalOrderTax)); ?></span>
614 - </div>
615 - <?php if ($includedInPrices > 0) : ?>
616 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
617 - <span class="fct_tax_summary_row_label"><?php esc_html_e('of which included in prices', 'fluent-cart'); ?></span>
618 - <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($includedInPrices)); ?></span>
619 - </div>
620 - <?php endif; ?>
621 - <?php if ($payableTax > 0 && $includedInPrices > 0) : ?>
622 - <div class="fct_tax_summary_row fct_tax_summary_row--total">
623 - <span class="fct_tax_summary_row_label"><?php esc_html_e('Payable now (added)', 'fluent-cart'); ?></span>
624 - <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($payableTax)); ?></span>
625 - </div>
626 - <?php endif; ?>
627 - <?php else : ?>
628 - <?php if ($payableTax > 0) : ?>
629 - <div class="fct_tax_summary_row fct_tax_summary_row--total">
630 - <span class="fct_tax_summary_row_label"><?php esc_html_e('Total payable tax', 'fluent-cart'); ?></span>
631 - <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($payableTax)); ?></span>
632 - </div>
633 - <?php endif; ?>
634 - <?php if ($inclusiveTax > 0 || $inclusiveFeeTax > 0) : ?>
635 - <div class="fct_tax_summary_row fct_tax_summary_row--muted">
636 - <span class="fct_tax_summary_row_label"><?php esc_html_e('Total tax in this order', 'fluent-cart'); ?></span>
637 - <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($totalOrderTax)); ?></span>
638 - </div>
639 - <?php endif; ?>
640 - <?php endif; ?>
641 - <?php endif; ?>
642 - </div>
643 - </div>
644 -<?php if ($isSimplified) : ?>
645 - </details>
646 -<?php endif; ?>
647 - </li>
648 - <?php
237 + $this->renderer()->renderTaxSummaryBox($cart);
649 238 }
650 239
651 240 public function maybeRestoreRcAdjustedPrices($data)
652 241 {
@@ -700,9 +289,9 @@
700 289
701 290 $fillData = $this->calculateCartTax([
702 291 'cart_data' => $cart->cart_data,
703 292 'checkout_data' => $checkoutData
704 - ]);
293 + ], $cart);
705 294
706 295 $cart->fill($fillData);
707 296 $cart->save();
708 297 }
@@ -754,9 +343,9 @@
754 343 'shipping_country' => Arr::get($data, 'prev_data.shipping_country', Arr::get($fillData, 'checkout_data.form_data.shipping_country', '')),
755 344 ]
756 345 );
757 346
758 - return $this->calculateCartTax($fillData);
347 + return $this->calculateCartTax($fillData, $cart);
759 348 }
760 349
761 350 public function maybeInvalidateVatValidationForCountryChange($checkoutData, $previousFormData = [])
762 351 {
@@ -819,511 +408,47 @@
819 408
820 409 return $this->taxSettings = $settings;
821 410 }
822 411
823 - private function getEffectiveRcMode()
412 + public function getEffectiveRcMode()
824 413 {
825 414 $settings = $this->getSettings();
826 415 return Arr::get($settings, 'eu_vat_settings.reverse_charge_price_mode', 'fixed');
827 416 }
828 417
829 - protected function getCheckoutTaxBreakdownDisplayMode()
830 - {
831 - // Backward compat: legacy stored values ('both', 'label', 'tooltip', or anything
832 - // else) all collapse to 'itemized'. Only an explicit 'simplified' stays simplified.
833 - $mode = Arr::get($this->getSettings(), 'checkout_tax_breakdown_display', 'itemized');
834 -
835 - return $mode === 'simplified' ? 'simplified' : 'itemized';
836 - }
837 -
838 - protected function getTaxDisplayLabel()
839 - {
840 - $label = trim((string) Arr::get($this->getSettings(), 'tax_display_label', ''));
841 - return $label !== '' ? $label : __('Tax', 'fluent-cart');
842 - }
843 -
844 - protected function normalizeCheckoutLineTaxRates($item)
845 - {
846 - $rates = Arr::get($item, 'line_meta.tax_config.rates', []);
847 - if (empty($rates) || !is_array($rates)) {
848 - return [];
849 - }
850 -
851 - $isInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
852 - $normalizedRates = [];
853 -
854 - foreach ($rates as $rate) {
855 - $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
856 - $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
857 - $taxableAmount = (int) Arr::get($rate, 'taxable_amount', 0);
858 -
859 - if ($taxAmount <= 0 && $ratePercent <= 0) {
860 - continue;
861 - }
862 -
863 - $normalizedRates[] = [
864 - 'label' => (string) Arr::get($rate, 'label', __('Tax', 'fluent-cart')),
865 - 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
866 - 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
867 - 'tax_amount' => $taxAmount,
868 - 'display_base' => $isInclusive ? max(0, $taxableAmount - $taxAmount) : $taxableAmount,
869 - 'inclusive' => $isInclusive,
870 - ];
871 - }
872 -
873 - return $normalizedRates;
874 - }
875 -
876 - protected function getCheckoutLineTaxShortLabel($label)
877 - {
878 - $label = trim((string) $label);
879 -
880 - if (!$label) {
881 - return __('Tax', 'fluent-cart');
882 - }
883 -
884 - return $label;
885 - }
886 -
887 - private function shouldRateStrikethrough($isReversed, $rateIsInclusive, $rcMode)
888 - {
889 - if (!$isReversed) {
890 - return false;
891 - }
892 - return !$rateIsInclusive || $rcMode === 'dynamic';
893 - }
894 -
895 418 public function renderCheckoutLineItemTaxLabel($data)
896 419 {
897 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
898 - if ($mode === 'simplified') {
899 - return;
900 - }
901 -
902 - $item = Arr::get($data, 'item', []);
903 - $rates = $this->normalizeCheckoutLineTaxRates($item);
904 - if (empty($rates)) {
905 - return;
906 - }
907 - $cart = CartHelper::getCart();
908 - $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
909 - $rcMode = $this->getEffectiveRcMode();
910 - $this->renderTaxBadges($rates, $isReversed, $rcMode);
420 + $this->renderer()->renderCheckoutLineItemTaxLabel($data);
911 421 }
912 422
913 423 public function renderCheckoutSetupFeeTaxLabel($data)
914 424 {
915 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
916 - if ($mode === 'simplified') {
917 - return;
918 - }
919 -
920 - $item = Arr::get($data, 'item', []);
921 - $signupFeeTaxConfig = Arr::get($item, 'signup_fee_tax_config', []);
922 - $rates = Arr::get($signupFeeTaxConfig, 'rates', []);
923 - if (empty($rates) || !is_array($rates)) {
924 - return;
925 - }
926 -
927 - $isInclusive = (bool) Arr::get($signupFeeTaxConfig, 'inclusive', false);
928 - $normalizedRates = [];
929 - foreach ($rates as $rate) {
930 - $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
931 - $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
932 - if ($taxAmount <= 0 && $ratePercent <= 0) {
933 - continue;
934 - }
935 - $normalizedRates[] = [
936 - 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
937 - 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
938 - 'inclusive' => $isInclusive,
939 - 'tax_amount' => $taxAmount,
940 - ];
941 - }
942 -
943 - if (empty($normalizedRates)) {
944 - return;
945 - }
946 - $cart = CartHelper::getCart();
947 - $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
948 - $rcMode = $this->getEffectiveRcMode();
949 - $this->renderTaxBadges($normalizedRates, $isReversed, $rcMode);
425 + $this->renderer()->renderCheckoutSetupFeeTaxLabel($data);
950 426 }
951 427
952 - private function renderTaxBadges(array $normalizedRates, $isReversed = false, $rcMode = 'fixed')
953 - {
954 - // Fixed-mode reverse charge: inclusive-priced lines keep their gross price and
955 - // no VAT is charged, so an "incl. X" badge would be misleading — hide those rates.
956 - if ($isReversed && $rcMode !== 'dynamic') {
957 - $normalizedRates = array_values(array_filter($normalizedRates, function ($rate) {
958 - return empty($rate['inclusive']);
959 - }));
960 - }
961 - if (empty($normalizedRates)) {
962 - return;
963 - }
964 - ?>
965 - <div class="fct_item_tax_badges" aria-label="<?php esc_attr_e('Tax breakdown', 'fluent-cart'); ?>">
966 - <?php foreach ($normalizedRates as $rate) : ?>
967 - <div class="fct_item_tax_badge_row">
968 - <span class="fct_item_tax_badge <?php echo esc_attr($rate['inclusive'] ? 'is-inclusive' : 'is-exclusive'); ?>">
969 - <span>
970 - <?php
971 - $badgeText = sprintf(
972 - /* translators: %1$s: tax label, %2$s: tax rate percent */
973 - __('%1$s (%2$s%%)', 'fluent-cart'),
974 - $rate['short_label'],
975 - $rate['formatted_rate']
976 - );
977 - echo esc_html($badgeText);
978 - ?>
979 - </span>
980 - </span>
981 - <?php if (!empty($rate['tax_amount']) && (int) $rate['tax_amount'] > 0) : ?>
982 - <?php $rateReversedClass = $this->shouldRateStrikethrough($isReversed, $rate['inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
983 - <span class="fct_item_tax_badge_amount <?php echo esc_attr($rate['inclusive'] ? 'is-inclusive' : 'is-exclusive'); ?><?php echo esc_attr($rateReversedClass); ?>">
984 - <?php
985 - $amountText = $rate['inclusive']
986 - ? sprintf(
987 - /* translators: %1$s: formatted tax amount */
988 - __('incl. %1$s', 'fluent-cart'),
989 - Helper::toDecimal((int) $rate['tax_amount'])
990 - )
991 - : sprintf(
992 - /* translators: %1$s: formatted tax amount */
993 - __('+ %1$s', 'fluent-cart'),
994 - Helper::toDecimal((int) $rate['tax_amount'])
995 - );
996 - echo esc_html($amountText);
997 - ?>
998 - </span>
999 - <?php endif; ?>
1000 - </div>
1001 - <?php endforeach; ?>
1002 - </div>
1003 - <?php
1004 - }
1005 -
1006 - private function getSetupFeeTaxData($item)
1007 - {
1008 - $signupFeeTaxConfig = Arr::get($item, 'signup_fee_tax_config', []);
1009 - $rawRates = Arr::get($signupFeeTaxConfig, 'rates', []);
1010 - if (empty($rawRates) || !is_array($rawRates)) {
1011 - return null;
1012 - }
1013 -
1014 - $isInclusive = (bool) Arr::get($signupFeeTaxConfig, 'inclusive', false);
1015 - $rates = [];
1016 - foreach ($rawRates as $rate) {
1017 - $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
1018 - $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
1019 - if ($taxAmount <= 0 && $ratePercent <= 0) {
1020 - continue;
1021 - }
1022 - $taxableAmount = (int) Arr::get($rate, 'taxable_amount', 0);
1023 - $rates[] = [
1024 - 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
1025 - 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
1026 - 'tax_amount' => $taxAmount,
1027 - 'display_base' => $isInclusive ? max(0, $taxableAmount - $taxAmount) : $taxableAmount,
1028 - ];
1029 - }
1030 -
1031 - if (empty($rates)) {
1032 - return null;
1033 - }
1034 -
1035 - $setupFee = (int) Arr::get($item, 'other_info.signup_fee', 0);
1036 - $totalTax = array_sum(array_map(function ($rate) {
1037 - return (int) $rate['tax_amount'];
1038 - }, $rates));
1039 -
1040 - return [
1041 - 'rates' => $rates,
1042 - 'is_inclusive' => $isInclusive,
1043 - 'total_tax' => $totalTax,
1044 - 'display_total' => $isInclusive ? $setupFee : $setupFee + $totalTax,
1045 - 'primary_label' => Arr::get($rates, '0.short_label', __('Tax', 'fluent-cart')),
1046 - ];
1047 - }
1048 -
1049 428 public function renderCheckoutSetupFeeTaxTooltip($data)
1050 429 {
1051 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
1052 - if ($mode === 'simplified') {
1053 - return;
1054 - }
1055 -
1056 - $item = Arr::get($data, 'item', []);
1057 - $taxData = $this->getSetupFeeTaxData($item);
1058 - if (!$taxData) {
1059 - return;
1060 - }
1061 -
1062 - $cart = CartHelper::getCart();
1063 - $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
1064 - $rcMode = $this->getEffectiveRcMode();
1065 -
1066 - $rates = $taxData['rates'];
1067 - $isInclusive = $taxData['is_inclusive'];
1068 - $displayTotal = $taxData['display_total'];
1069 - if ($isReversed) {
1070 - if ($taxData['is_inclusive'] && $rcMode === 'dynamic') {
1071 - $displayTotal = $taxData['display_total'] - $taxData['total_tax'];
1072 - } else {
1073 - $displayTotal = (int) Arr::get($data, 'item.other_info.signup_fee', 0);
1074 - }
1075 - }
1076 - $tooltipId = 'fct-item-tax-tooltip-' . Helper::getUidSerial();
1077 - ?>
1078 - <div class="fct_item_tax_hint">
1079 - <button
1080 - type="button"
1081 - class="fct_item_tax_hint_button"
1082 - aria-label="<?php esc_attr_e('View tax breakdown for this item', 'fluent-cart'); ?>"
1083 - aria-describedby="<?php echo esc_attr($tooltipId); ?>"
1084 - >
1085 - <span aria-hidden="true">i</span>
1086 - </button>
1087 - <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
1088 - <span class="fct_item_tax_tooltip_heading">
1089 - <?php echo esc_html($isInclusive ? __('Tax-inclusive price', 'fluent-cart') : __('Tax-exclusive price', 'fluent-cart')); ?>
1090 - </span>
1091 - <?php foreach ($rates as $rate) : ?>
1092 - <?php $lineReversedClass = $this->shouldRateStrikethrough($isReversed, isset($rate['inclusive']) ? (bool)$rate['inclusive'] : $taxData['is_inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
1093 - <span class="fct_item_tax_tooltip_line<?php echo esc_attr($lineReversedClass); ?>">
1094 - <?php
1095 - $lineText = sprintf(
1096 - /* translators: %1$s: tax base amount, %2$s: tax label, %3$s: tax rate percent, %4$s: tax amount */
1097 - __('Base %1$s + %2$s %3$s%% %4$s', 'fluent-cart'),
1098 - Helper::toDecimal($rate['display_base']),
1099 - $rate['short_label'],
1100 - $rate['formatted_rate'],
1101 - Helper::toDecimal($rate['tax_amount'])
1102 - );
1103 - echo esc_html($lineText);
1104 - ?>
1105 - </span>
1106 - <?php endforeach; ?>
1107 - <span class="fct_item_tax_tooltip_line is-total">
1108 - <?php
1109 - echo esc_html(sprintf(
1110 - /* translators: %1$s: line total amount */
1111 - __('Total %1$s', 'fluent-cart'),
1112 - Helper::toDecimal($displayTotal)
1113 - ));
1114 - ?>
1115 - </span>
1116 - </div>
1117 - </div>
1118 - <?php
430 + $this->renderer()->renderCheckoutSetupFeeTaxTooltip($data);
1119 431 }
1120 432
1121 433 public function renderCheckoutSetupFeeTaxInfo($data)
1122 434 {
1123 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
1124 - if ($mode === 'simplified') {
1125 - return;
1126 - }
1127 -
1128 - $item = Arr::get($data, 'item', []);
1129 - $taxData = $this->getSetupFeeTaxData($item);
1130 - if (!$taxData) {
1131 - return;
1132 - }
1133 -
1134 - $isInclusive = $taxData['is_inclusive'];
1135 - $totalTax = $taxData['total_tax'];
1136 - $primaryLabel = $taxData['primary_label'];
1137 -
1138 - $priceNote = $isInclusive
1139 - ? sprintf(
1140 - /* translators: %1$s: tax label */
1141 - __('%1$s incl.', 'fluent-cart'),
1142 - $primaryLabel
1143 - )
1144 - : sprintf(
1145 - /* translators: %1$s: tax amount, %2$s: tax label */
1146 - __('+ %1$s %2$s', 'fluent-cart'),
1147 - Helper::toDecimal($totalTax),
1148 - strtolower($primaryLabel)
1149 - );
1150 - ?>
1151 - <span class="fct_setup_fee_price_note"><?php echo esc_html($priceNote); ?></span>
1152 - <?php
435 + $this->renderer()->renderCheckoutSetupFeeTaxInfo($data);
1153 436 }
1154 437
1155 438 public function renderCheckoutLineItemTaxTooltip($data)
1156 439 {
1157 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
1158 - if ($mode === 'simplified') {
1159 - return;
1160 - }
1161 -
1162 - $item = Arr::get($data, 'item', []);
1163 - $rates = $this->normalizeCheckoutLineTaxRates($item);
1164 - if (empty($rates)) {
1165 - return;
1166 - }
1167 -
1168 - $cart = CartHelper::getCart();
1169 - $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
1170 - $rcMode = $this->getEffectiveRcMode();
1171 -
1172 - $isInclusive = (bool) Arr::get($rates, '0.inclusive', false);
1173 - $itemSubtotal = (int) Arr::get($item, 'line_total', Arr::get($item, 'subtotal', 0));
1174 - $itemTaxAmount = array_sum(array_map(function ($rate) {
1175 - return (int) Arr::get($rate, 'tax_amount', 0);
1176 - }, $rates));
1177 - if ($isReversed) {
1178 - if ($isInclusive && $rcMode === 'dynamic') {
1179 - $displayTotal = $itemSubtotal - $itemTaxAmount;
1180 - } else {
1181 - $displayTotal = $itemSubtotal;
1182 - }
1183 - } else {
1184 - $displayTotal = $isInclusive ? $itemSubtotal : $itemSubtotal + $itemTaxAmount;
1185 - }
1186 - $tooltipId = 'fct-item-tax-tooltip-' . Helper::getUidSerial();
1187 - ?>
1188 - <div class="fct_item_tax_hint">
1189 - <button
1190 - type="button"
1191 - class="fct_item_tax_hint_button"
1192 - aria-label="<?php esc_attr_e('View tax breakdown for this item', 'fluent-cart'); ?>"
1193 - aria-describedby="<?php echo esc_attr($tooltipId); ?>"
1194 - >
1195 - <span aria-hidden="true">i</span>
1196 - </button>
1197 - <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
1198 - <span class="fct_item_tax_tooltip_heading">
1199 - <?php echo esc_html($isInclusive ? __('Tax-inclusive price', 'fluent-cart') : __('Tax-exclusive price', 'fluent-cart')); ?>
1200 - </span>
1201 - <?php foreach ($rates as $rate) : ?>
1202 - <?php $lineReversedClass = $this->shouldRateStrikethrough($isReversed, $rate['inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
1203 - <span class="fct_item_tax_tooltip_line<?php echo esc_attr($lineReversedClass); ?>">
1204 - <?php
1205 - $lineText = sprintf(
1206 - /* translators: %1$s: tax base amount, %2$s: tax label, %3$s: tax rate percent, %4$s: tax amount */
1207 - __('Base %1$s + %2$s %3$s%% %4$s', 'fluent-cart'),
1208 - Helper::toDecimal($rate['display_base']),
1209 - $rate['short_label'],
1210 - $rate['formatted_rate'],
1211 - Helper::toDecimal($rate['tax_amount'])
1212 - );
1213 - echo esc_html($lineText);
1214 - ?>
1215 - </span>
1216 - <?php endforeach; ?>
1217 - <span class="fct_item_tax_tooltip_line is-total">
1218 - <?php
1219 - echo esc_html(sprintf(
1220 - /* translators: %1$s: line total amount */
1221 - __('Total %1$s', 'fluent-cart'),
1222 - Helper::toDecimal($displayTotal)
1223 - ));
1224 - ?>
1225 - </span>
1226 - </div>
1227 - </div>
1228 - <?php
440 + $this->renderer()->renderCheckoutLineItemTaxTooltip($data);
1229 441 }
1230 442
1231 443 public function renderCheckoutLineItemTaxInfo($data)
1232 444 {
1233 - $mode = $this->getCheckoutTaxBreakdownDisplayMode();
1234 - if ($mode === 'simplified') {
1235 - return;
1236 - }
1237 -
1238 - $item = Arr::get($data, 'item', []);
1239 - $rates = $this->normalizeCheckoutLineTaxRates($item);
1240 - if (empty($rates)) {
1241 - return;
1242 - }
1243 -
1244 - $isInclusive = (bool) Arr::get($rates, '0.inclusive', false);
1245 - $itemTaxAmount = array_sum(array_map(function ($rate) {
1246 - return (int) Arr::get($rate, 'tax_amount', 0);
1247 - }, $rates));
1248 - $primaryLabel = Arr::get($rates, '0.short_label', __('Tax', 'fluent-cart'));
1249 -
1250 - $priceNote = $isInclusive
1251 - ? sprintf(
1252 - /* translators: %1$s: tax label */
1253 - __('%1$s incl.', 'fluent-cart'),
1254 - $primaryLabel
1255 - )
1256 - : sprintf(
1257 - /* translators: %1$s: tax amount, %2$s: tax label */
1258 - __('+ %1$s %2$s', 'fluent-cart'),
1259 - Helper::toDecimal($itemTaxAmount),
1260 - strtolower($primaryLabel)
1261 - );
1262 - ?>
1263 - <div class="fct_item_tax_price_note"><?php echo esc_html($priceNote); ?></div>
1264 - <?php
445 + $this->renderer()->renderCheckoutLineItemTaxInfo($data);
1265 446 }
1266 447
1267 448 public function renderUnitPriceRoundingTooltip($data)
1268 449 {
1269 - $item = Arr::get($data, 'item', []);
1270 - $quantity = (int) Arr::get($item, 'quantity', 1);
1271 -
1272 - if ($quantity < 2) {
1273 - return;
1274 - }
1275 -
1276 - $unitPrice = (int) Arr::get($item, 'unit_price', 0);
1277 - $subtotal = (int) Arr::get($item, 'subtotal', 0);
1278 -
1279 - if ($unitPrice <= 0) {
1280 - return;
1281 - }
1282 -
1283 - $shouldShow = false;
1284 -
1285 - // Case 1 — TaxModule RC dynamic: the applied per-unit tax adjustment is rounded,
1286 - // so unit_price * qty may differ from the exact net by up to (qty - 1) cents.
1287 - $rcAdjustment = (int) Arr::get($item, 'line_meta.reverse_charge_adjustment', 0);
1288 - if ($rcAdjustment > 0) {
1289 - $rates = (array) Arr::get($item, 'line_meta.tax_config.rates', []);
1290 - $actualTaxTotal = (int) array_sum(array_map('intval', array_column($rates, 'tax_amount')));
1291 - $diff = abs($rcAdjustment - $actualTaxTotal);
1292 - if ($diff > 0 && $diff <= $quantity) {
1293 - $shouldShow = true;
1294 - }
1295 - }
1296 -
1297 - // Case 2 — generic: unit_price * qty already doesn't match subtotal
1298 - // (future dynamic pricing or any other module that stores a rounded unit_price)
1299 - if (!$shouldShow && $subtotal > 0) {
1300 - $diff = abs(($unitPrice * $quantity) - $subtotal);
1301 - if ($diff > 0 && $diff <= $quantity) {
1302 - $shouldShow = true;
1303 - }
1304 - }
1305 -
1306 - if (!$shouldShow) {
1307 - return;
1308 - }
1309 -
1310 - $tooltipId = 'fct-unit-price-rounding-' . Helper::getUidSerial();
1311 - ?>
1312 - <div class="fct_item_tax_hint">
1313 - <button
1314 - type="button"
1315 - class="fct_item_tax_hint_button"
1316 - aria-label="<?php esc_attr_e('Unit price rounding information', 'fluent-cart'); ?>"
1317 - aria-describedby="<?php echo esc_attr($tooltipId); ?>"
1318 - >
1319 - <span aria-hidden="true">i</span>
1320 - </button>
1321 - <div class="fct_item_tax_tooltip fct_unit_price_rounding_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
1322 - <?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'); ?>
1323 - </div>
1324 - </div>
1325 - <?php
450 + $this->renderer()->renderUnitPriceRoundingTooltip($data);
1326 451 }
1327 452
1328 453 public function isEnabled()
1329 454 {
@@ -1330,9 +455,15 @@
1330 455 $settings = $this->getSettings();
1331 456 return Arr::get($settings, 'enable_tax', 'no') === 'yes';
1332 457 }
1333 458
1334 - 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)
1335 466 {
1336 467 $lineItems = Arr::get($fillData, 'cart_data', []);
1337 468 $checkoutData = Arr::get($fillData, 'checkout_data', []);
1338 469
@@ -1421,8 +552,13 @@
1421 552 }
1422 553 }
1423 554 }
1424 555
556 + $unpricedLineItems = $lineItems;
557 + if ($cart instanceof Cart) {
558 + $lineItems = $this->applyPricingRules($lineItems, $checkoutData, $cart);
559 + }
560 +
1425 561 $country = '';
1426 562 $state = '';
1427 563 $postCode = '';
1428 564
@@ -1672,8 +808,12 @@
1672 808 $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal;
1673 809 $checkoutData['tax_data']['tax_lines'] = $taxLines;
1674 810 $fillData['checkout_data'] = $checkoutData;
1675 811
812 + if ($cart instanceof Cart) {
813 + $productLines = $this->restoreUnpricedValues($productLines, $unpricedLineItems);
814 + }
815 +
1676 816 // Only product lines go back into cart_data
1677 817 $fillData['cart_data'] = $productLines;
1678 818
1679 819 if (isset($fillData['hook_changes'])) {
@@ -1682,8 +822,117 @@
1682 822
1683 823 return $fillData;
1684 824 }
1685 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 +
1686 935 protected function shouldApplyReverseCharge($checkoutData, $taxApplicableCountry, $lineItems = [])
1687 936 {
1688 937 if (!Arr::get($checkoutData, 'tax_data.valid', false)) {
1689 938 return false;
@@ -1715,9 +964,9 @@
1715 964
1716 965 return true;
1717 966 }
1718 967
1719 - protected function isReverseChargeCheckout($checkoutData)
968 + public function isReverseChargeCheckout($checkoutData)
1720 969 {
1721 970 return Arr::get($checkoutData, 'tax_data.valid', false)
1722 971 && (int) Arr::get($checkoutData, 'tax_data.tax_behavior', 2) === 0;
1723 972 }
@@ -2183,9 +1432,9 @@
2183 1432 $cart->checkout_data = $checkoutData;
2184 1433 $fillData = $this->calculateCartTax([
2185 1434 'cart_data' => $cart->cart_data,
2186 1435 'checkout_data' => $cart->checkout_data,
2187 - ]);
1436 + ], $cart);
2188 1437 $cart->fill($fillData);
2189 1438 $cart->save();
2190 1439
2191 1440 ob_start();
@@ -2255,9 +1504,9 @@
2255 1504 $cart->checkout_data = $checkoutData;
2256 1505 $fillData = $this->calculateCartTax([
2257 1506 'cart_data' => $cart->cart_data,
2258 1507 'checkout_data' => $cart->checkout_data,
2259 - ]);
1508 + ], $cart);
2260 1509 $cart->fill($fillData);
2261 1510 $cart->save();
2262 1511
2263 1512 ob_start();