PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / app / Services / Renderer / TaxRenderer.php

TaxRenderer.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Services/Renderer/TaxRenderer.php

965 lines 47.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Renderer;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\Framework\Support\Arr;
7 use FluentCart\App\Helpers\CartHelper;
8 use FluentCart\App\Modules\Tax\TaxModule;
9 use FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper;
10
11 class TaxRenderer
12 {
13 protected $module;
14
15 public function __construct(TaxModule $module)
16 {
17 $this->module = $module;
18 }
19
20 public function renderTaxRow($cart, $atts = ''): void
21 {
22 if ($this->getCheckoutTaxBreakdownDisplayMode() === 'simplified') {
23 return;
24 }
25
26 $taxAmount = (int) Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
27 $reversedTaxAmount = (int) Arr::get($cart->checkout_data, 'tax_data.reverse_charge_tax_total', 0);
28 $isReverseCharge = $this->module->isReverseChargeCheckout($cart->checkout_data);
29 $taxLines = Arr::get($cart->checkout_data, 'tax_data.tax_lines', []);
30 $taxCountry = Arr::get($cart->checkout_data, 'tax_data.tax_country', '');
31 $taxLabel = Arr::get($taxLines, '0.label', '');
32
33 if (!$taxLabel) {
34 $taxLabel = $taxCountry ? TaxModule::getCountryTaxTitle($taxCountry) : __('Tax', 'fluent-cart');
35 }
36 ?>
37 <li <?php echo $atts; ?>>
38 <span class="fct_summary_label">
39 <?php echo esc_html($taxLabel); ?>
40 </span>
41 <span class="fct_summary_value">
42 <?php if ($isReverseCharge && $taxAmount === 0) : ?>
43 <?php
44 /* translators: %1$s: formatted reversed tax amount */
45 echo esc_html(sprintf(__('Tax reversed: %1$s', 'fluent-cart'), Helper::toDecimal($reversedTaxAmount)));
46 ?>
47 <?php else : ?>
48 <?php echo esc_html(Helper::toDecimal($taxAmount)); ?>
49 <?php endif; ?>
50 </span>
51 </li>
52 <?php
53 }
54
55 public function renderShippingTaxRow($cart, $atts = ''): string
56 {
57 if ($this->getCheckoutTaxBreakdownDisplayMode() === 'simplified') {
58 return '';
59 }
60
61 $shippingTax = (int) Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
62 $isReverseCharge = $this->module->isReverseChargeCheckout($cart->checkout_data);
63 $rcShippingTax = (int) Arr::get($cart->checkout_data, 'tax_data.reverse_charge_shipping_tax', 0);
64
65 if ($shippingTax <= 0 && (!$isReverseCharge || $rcShippingTax <= 0)) {
66 return '';
67 }
68
69 $displayAmount = $isReverseCharge ? $rcShippingTax : $shippingTax;
70 $storeBehavior = (int) Arr::get($cart->checkout_data, 'tax_data.store_tax_behavior',
71 Arr::get($cart->checkout_data, 'tax_data.tax_behavior', 2));
72 $isInclusive = $storeBehavior === 2;
73 ?>
74 <li data-fct-shipping-tax-row <?php echo $atts; ?>>
75 <span class="fct_summary_label">
76 <?php if ($isInclusive) : ?>
77 <?php echo esc_html__('Shipping Tax (Included)', 'fluent-cart'); ?>
78 <?php else : ?>
79 <?php echo esc_html__('Shipping Tax (Excluded)', 'fluent-cart'); ?>
80 <?php endif; ?>
81 </span>
82 <span class="fct_summary_value"<?php echo $isReverseCharge ? ' style="text-decoration:line-through;opacity:0.6;"' : ''; ?>>
83 <?php echo esc_html(Helper::toDecimal($displayAmount)); ?>
84 </span>
85 </li>
86 <?php
87 return '';
88 }
89
90 public function renderTaxSummaryBox($cart): void
91 {
92 $taxData = Arr::get($cart->checkout_data, 'tax_data', []);
93 $taxTotal = (int) Arr::get($taxData, 'tax_total', 0);
94 $exclusiveTaxTotal = (int) Arr::get($taxData, 'exclusive_tax_total', $taxTotal);
95 $feeTaxLines = (array) Arr::get($taxData, 'fee_tax_lines', []);
96 $shippingTax = (int) Arr::get($taxData, 'shipping_tax', 0);
97 $isReverseCharge = $this->module->isReverseChargeCheckout($cart->checkout_data);
98 $reversedTaxTotalDisplay = (int) Arr::get($taxData, 'reverse_charge_tax_total', 0);
99
100 $taxRateLines = (array) Arr::get($taxData, 'tax_lines', []);
101 $shippingTaxLines = (array) Arr::get($taxData, 'shipping_tax_lines', []);
102
103 $inclusiveFeeTax = 0;
104 $exclusiveFeeTax = 0;
105 foreach ($feeTaxLines as $feeTaxLine) {
106 if (!empty($feeTaxLine['inclusive'])) {
107 $inclusiveFeeTax += (int) Arr::get($feeTaxLine, 'tax_amount', 0);
108 } else {
109 $exclusiveFeeTax += (int) Arr::get($feeTaxLine, 'tax_amount', 0);
110 }
111 }
112 // Fallback: if no fee_tax_lines (old cart data), use aggregate fee_tax as exclusive
113 if (empty($feeTaxLines)) {
114 $exclusiveFeeTax = (int) Arr::get($taxData, 'fee_tax', 0);
115 }
116
117 $inclusiveTax = max(0, $taxTotal - $exclusiveTaxTotal - $exclusiveFeeTax - $inclusiveFeeTax);
118 $productExclusiveTax = max(0, $exclusiveTaxTotal);
119
120 $isShippingInclusive = TaxSummaryHelper::isShippingTaxInclusiveFromTaxData(
121 is_array($taxData) ? $taxData : []
122 );
123
124 $payableTax = $productExclusiveTax + $exclusiveFeeTax + ($isShippingInclusive ? 0 : $shippingTax);
125 $totalOrderTax = $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0) + $payableTax;
126
127 $feeRows = TaxSummaryHelper::buildFeeTaxLineRows($feeTaxLines);
128 $feeCount = count($feeRows);
129 if (empty($feeTaxLines) && (int) Arr::get($taxData, 'fee_tax', 0) > 0) {
130 $feeCount = 1;
131 }
132 $productTaxRowCount = !empty($taxRateLines)
133 ? count($taxRateLines)
134 : (int) ($inclusiveTax > 0) + (int) ($productExclusiveTax > 0);
135 $rowCount = $productTaxRowCount + $feeCount + (int) ($shippingTax > 0);
136 $shouldShowBreakdown = !empty($taxRateLines)
137 || !empty($shippingTaxLines)
138 || $rowCount >= 2
139 || ($rowCount === 1 && !($payableTax > 0 || $inclusiveTax > 0 || $inclusiveFeeTax > 0));
140
141 // Build folded per-rate rows via shared helper. Cart tax_lines carry only `label`
142 // (no percent), so we pre-augment each line with `rate_label` = "Label (X%)" so the
143 // helper picks it up and produces the same display as the old inline block.
144 $foldedSource = [];
145 foreach ($taxRateLines as $rateKey => $rateLine) {
146 $ratePercent = (float) Arr::get($rateLine, 'rate_percent', 0);
147 $rateLine['rate_label'] = (string) Arr::get($rateLine, 'label', '');
148 if ($ratePercent > 0) {
149 $rateLine['rate_label'] .= ' (' . Helper::formatTaxRatePercent($ratePercent) . '%)';
150 }
151 $foldedSource[$rateKey] = $rateLine;
152 }
153 // Fixed-mode reverse charge leaves tax-inclusive prices (and their embedded VAT)
154 // untouched — only dynamic mode reverses the inclusive portion. Exclude inclusive
155 // lines from the map in fixed mode so the rate rows sum to the reversed total.
156 $rcNonDynamic = $isReverseCharge
157 && Arr::get($taxData, 'reverse_charge_price_mode', 'fixed') !== 'dynamic';
158 $rateBaseMap = TaxSummaryHelper::computeRateBaseMap((array) ($cart->cart_data ?: []), $rcNonDynamic);
159 if ($isReverseCharge) {
160 // Under reverse charge the stored tax_lines amounts are zeroed at calc time,
161 // but the original per-rate amounts survive in item line_meta. Restore them
162 // (and the pre-zeroing shipping lines) so the box renders the same
163 // breakdown-by-rate table as a normal order.
164 foreach ($foldedSource as $rateKey => &$foldedLine) {
165 $rid = (int) Arr::get($foldedLine, 'rate_id', $rateKey);
166 if (isset($rateBaseMap[$rid])) {
167 $foldedLine['tax_amount'] = (int) round($rateBaseMap[$rid]['tax']);
168 }
169 }
170 unset($foldedLine);
171 if ($rcNonDynamic) {
172 // Rates present only on inclusive lines have nothing reversible in
173 // fixed mode — drop them instead of rendering a zero row.
174 foreach (array_keys($foldedSource) as $rateKey) {
175 $rid = (int) Arr::get($foldedSource[$rateKey], 'rate_id', $rateKey);
176 if (!isset($rateBaseMap[$rid])) {
177 unset($foldedSource[$rateKey]);
178 }
179 }
180 }
181 $shippingTaxLines = (array) Arr::get($taxData, 'reverse_charge_shipping_tax_lines', []);
182 }
183 $checkoutRateRows = TaxSummaryHelper::buildFoldedRateRows(
184 $foldedSource, $shippingTaxLines, 'tax_amount', $isShippingInclusive, $rateBaseMap
185 );
186 // Inclusive shipping tax follows the store global tax mode — when shipping is
187 // priced inclusive its tax is baked into the shipping price, so it counts as
188 // "of which included in prices". Keeps includedInPrices + payableTax === totalOrderTax.
189 $includedInPrices = $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0);
190
191 $displayMode = $this->getCheckoutTaxBreakdownDisplayMode();
192 $isSimplified = $displayMode === 'simplified';
193 $simpleLabel = $this->getTaxDisplayLabel();
194 if ($isReverseCharge) {
195 $simpleValue = __('Reverse charge', 'fluent-cart');
196 } elseif ($payableTax === 0 && $totalOrderTax > 0) {
197 $suffix = (string) Arr::get($this->module->getSettings(), 'price_suffix_included', '');
198 if ($suffix === '') {
199 $suffix = __('(incl.)', 'fluent-cart');
200 }
201 /* translators: %1$s: formatted tax amount, %2$s: inclusive suffix */
202 $simpleValue = sprintf(__('%1$s %2$s', 'fluent-cart'), html_entity_decode(Helper::toDecimal($totalOrderTax), ENT_QUOTES, 'UTF-8'), $suffix);
203 } else {
204 $simpleValue = html_entity_decode(Helper::toDecimal($payableTax), ENT_QUOTES, 'UTF-8');
205 }
206
207 $tooltipId = 'fct-tax-summary-tooltip-' . Helper::getUidSerial();
208 ?>
209 <li class="fct_tax_summary_li" data-fct-tax-summary>
210 <?php if ($isSimplified) : ?>
211 <style>
212 .fct_tax_simple_details > summary { list-style: none; cursor: pointer; }
213 .fct_tax_simple_details > summary::-webkit-details-marker { display: none; }
214 .fct_tax_simple_toggle { color: #2563eb; text-decoration: none; font-size: 11px; }
215 .fct_tax_simple_label { color: inherit; }
216 </style>
217 <details class="fct_tax_simple_details">
218 <summary class="fct_tax_simple_summary" style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;list-style:none;">
219 <span class="fct_tax_simple_left">
220 <span class="fct_tax_simple_label"><?php echo esc_html($simpleLabel); ?></span>
221 <span class="fct_tax_simple_toggle"><?php esc_html_e('See details', 'fluent-cart'); ?> &#9662;</span>
222 </span>
223 <span class="fct_tax_simple_value"><?php echo esc_html($simpleValue); ?></span>
224 </summary>
225 <?php endif; ?>
226 <div class="fct_tax_summary_box">
227 <div class="fct_tax_summary_header">
228 <span class="fct_tax_summary_heading">
229 <?php echo !empty($checkoutRateRows)
230 ? esc_html__('Tax breakdown by rate', 'fluent-cart')
231 : esc_html__('TAX', 'fluent-cart'); ?>
232 </span>
233 <div class="fct_item_tax_hint">
234 <button
235 type="button"
236 class="fct_item_tax_hint_button"
237 aria-label="<?php esc_attr_e('Tax information', 'fluent-cart'); ?>"
238 aria-describedby="<?php echo esc_attr($tooltipId); ?>"
239 >
240 <span aria-hidden="true">i</span>
241 </button>
242 <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
243 <span class="fct_item_tax_tooltip_heading">
244 <?php esc_html_e('About your tax', 'fluent-cart'); ?>
245 </span>
246 <?php if ($isReverseCharge) : ?>
247 <span class="fct_item_tax_tooltip_line">
248 <?php esc_html_e('Tax has been reversed for this order.', 'fluent-cart'); ?>
249 </span>
250 <?php else : ?>
251 <?php if ($payableTax > 0) : ?>
252 <span class="fct_item_tax_tooltip_line">
253 <?php esc_html_e('"Total payable tax" is added on top of listed prices.', 'fluent-cart'); ?>
254 </span>
255 <?php endif; ?>
256 <?php if ($inclusiveTax > 0) : ?>
257 <span class="fct_item_tax_tooltip_line">
258 <?php esc_html_e('"Included in item prices" is already built into product prices.', 'fluent-cart'); ?>
259 </span>
260 <?php endif; ?>
261 <?php if ($payableTax === 0 && $inclusiveTax === 0) : ?>
262 <span class="fct_item_tax_tooltip_line">
263 <?php esc_html_e('No tax applies to this order.', 'fluent-cart'); ?>
264 </span>
265 <?php endif; ?>
266 <?php endif; ?>
267 </div>
268 </div>
269 </div>
270 <div class="fct_tax_summary_rows">
271 <?php if (!empty($checkoutRateRows)) : ?>
272 <div class="fct_tax_summary_row fct_tax_summary_row--head" style="display:flex;justify-content:space-between;gap:8px;">
273 <span style="flex:1;font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:#94a3b8;">
274 <?php esc_html_e('Rate', 'fluent-cart'); ?>
275 </span>
276 <span style="min-width:88px;text-align:right;font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:#94a3b8;">
277 <?php esc_html_e('Taxable base', 'fluent-cart'); ?>
278 </span>
279 <span style="min-width:64px;text-align:right;font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:#94a3b8;">
280 <?php esc_html_e('Tax', 'fluent-cart'); ?>
281 </span>
282 </div>
283 <?php foreach ($checkoutRateRows as $checkoutRateRow) : ?>
284 <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;">
285 <span class="fct_tax_summary_row_label" style="flex:1;">
286 <?php echo esc_html($checkoutRateRow['label']); ?>
287 </span>
288 <span style="min-width:88px;text-align:right;color:#94a3b8;">
289 <?php echo esc_html(Helper::toDecimal((int) $checkoutRateRow['base'])); ?>
290 </span>
291 <span class="fct_tax_summary_row_amount" style="min-width:64px;text-align:right;">
292 <?php echo esc_html(Helper::toDecimal((int) $checkoutRateRow['tax'])); ?>
293 </span>
294 </div>
295 <?php endforeach; ?>
296 <?php endif; ?>
297 <?php if ($isReverseCharge) : ?>
298 <?php if (empty($checkoutRateRows)) : ?>
299 <?php
300 $rcShippingDisplay = (int) Arr::get($taxData, 'reverse_charge_shipping_tax', 0);
301 $rcInclusiveAdj = (int) Arr::get($taxData, 'reverse_charge_inclusive_adjustment', 0);
302 $rcExclusiveNonShip = max(0, $reversedTaxTotalDisplay - $rcShippingDisplay - $rcInclusiveAdj);
303 $rcBreakdownCount = (int) ($rcInclusiveAdj > 0) + (int) ($rcExclusiveNonShip > 0) + (int) ($rcShippingDisplay > 0);
304 ?>
305 <?php if ($rcBreakdownCount >= 2) : ?>
306 <?php if ($rcInclusiveAdj > 0) : ?>
307 <div class="fct_tax_summary_row fct_tax_summary_row--muted">
308 <span class="fct_tax_summary_row_label">
309 <?php esc_html_e('Included in item prices', 'fluent-cart'); ?>
310 </span>
311 <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
312 <?php echo esc_html(Helper::toDecimal($rcInclusiveAdj)); ?>
313 </span>
314 </div>
315 <?php endif; ?>
316 <?php if ($rcExclusiveNonShip > 0) : ?>
317 <div class="fct_tax_summary_row">
318 <span class="fct_tax_summary_row_label">
319 <?php esc_html_e('Added on products', 'fluent-cart'); ?>
320 </span>
321 <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
322 <?php echo esc_html(Helper::toDecimal($rcExclusiveNonShip)); ?>
323 </span>
324 </div>
325 <?php endif; ?>
326 <?php if ($rcShippingDisplay > 0) : ?>
327 <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
328 <span class="fct_tax_summary_row_label">
329 <?php echo $isShippingInclusive ? esc_html__('Included in shipping prices', 'fluent-cart') : esc_html__('Added on shipping', 'fluent-cart'); ?>
330 </span>
331 <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
332 <?php echo esc_html(Helper::toDecimal($rcShippingDisplay)); ?>
333 </span>
334 </div>
335 <?php endif; ?>
336 <?php elseif ($rcShippingDisplay > 0) : ?>
337 <div class="fct_tax_summary_row fct_tax_summary_row--muted">
338 <span class="fct_tax_summary_row_label">
339 <?php echo $isShippingInclusive ? esc_html__('Included in shipping prices', 'fluent-cart') : esc_html__('Added on shipping', 'fluent-cart'); ?>
340 </span>
341 <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
342 <?php echo esc_html(Helper::toDecimal($rcShippingDisplay)); ?>
343 </span>
344 </div>
345 <?php endif; ?>
346 <?php endif; ?>
347 <div class="fct_tax_summary_row fct_tax_summary_row--total">
348 <span class="fct_tax_summary_row_label">
349 <?php esc_html_e('VAT reversed', 'fluent-cart'); ?>
350 </span>
351 <span class="fct_tax_summary_row_amount">
352 <?php echo esc_html(Helper::toDecimal($reversedTaxTotalDisplay)); ?>
353 </span>
354 </div>
355 <?php else : ?>
356 <?php if (empty($checkoutRateRows) && $inclusiveTax > 0 && $shouldShowBreakdown) : ?>
357 <div class="fct_tax_summary_row fct_tax_summary_row--muted">
358 <span class="fct_tax_summary_row_label">
359 <?php esc_html_e('Included in item prices', 'fluent-cart'); ?>
360 </span>
361 <span class="fct_tax_summary_row_amount">
362 <?php echo esc_html(Helper::toDecimal($inclusiveTax)); ?>
363 </span>
364 </div>
365 <?php endif; ?>
366 <?php if (empty($checkoutRateRows) && $productExclusiveTax > 0 && $shouldShowBreakdown) : ?>
367 <div class="fct_tax_summary_row">
368 <span class="fct_tax_summary_row_label">
369 <?php esc_html_e('Added on products', 'fluent-cart'); ?>
370 </span>
371 <span class="fct_tax_summary_row_amount">
372 <?php echo esc_html(Helper::toDecimal($productExclusiveTax)); ?>
373 </span>
374 </div>
375 <?php endif; ?>
376 <?php if (empty($checkoutRateRows) && $shouldShowBreakdown) : ?>
377 <?php foreach ($feeRows as $feeRow) : ?>
378 <div class="fct_tax_summary_row<?php echo $feeRow['inclusive'] ? ' fct_tax_summary_row--muted' : ''; ?>">
379 <span class="fct_tax_summary_row_label">
380 <?php echo esc_html($feeRow['display_label']); ?>
381 </span>
382 <span class="fct_tax_summary_row_amount">
383 <?php echo esc_html(Helper::toDecimal($feeRow['tax_amount'])); ?>
384 </span>
385 </div>
386 <?php endforeach; ?>
387 <?php endif; ?>
388 <?php if (empty($checkoutRateRows) && empty($feeTaxLines) && (int) Arr::get($taxData, 'fee_tax', 0) > 0 && $shouldShowBreakdown) : ?>
389 <div class="fct_tax_summary_row">
390 <span class="fct_tax_summary_row_label">
391 <?php esc_html_e('Added on fees', 'fluent-cart'); ?>
392 </span>
393 <span class="fct_tax_summary_row_amount">
394 <?php echo esc_html(Helper::toDecimal((int) Arr::get($taxData, 'fee_tax', 0))); ?>
395 </span>
396 </div>
397 <?php endif; ?>
398 <?php if (empty($checkoutRateRows) && $shippingTax > 0 && $shouldShowBreakdown) : ?>
399 <?php if (!empty($shippingTaxLines)) : ?>
400 <?php foreach ($shippingTaxLines as $shippingTaxLine) : ?>
401 <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
402 <span class="fct_tax_summary_row_label">
403 <?php echo esc_html($shippingTaxLine['label']); ?>
404 </span>
405 <span class="fct_tax_summary_row_amount">
406 <?php echo esc_html(Helper::toDecimal((int) Arr::get($shippingTaxLine, 'shipping_tax', 0))); ?>
407 </span>
408 </div>
409 <?php endforeach; ?>
410 <?php else : ?>
411 <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
412 <span class="fct_tax_summary_row_label">
413 <?php if ($isShippingInclusive) : ?>
414 <?php esc_html_e('Included in shipping prices', 'fluent-cart'); ?>
415 <?php else : ?>
416 <?php esc_html_e('Added on shipping', 'fluent-cart'); ?>
417 <?php endif; ?>
418 </span>
419 <span class="fct_tax_summary_row_amount">
420 <?php echo esc_html(Helper::toDecimal($shippingTax)); ?>
421 </span>
422 </div>
423 <?php endif; ?>
424 <?php endif; ?>
425 <?php if (!empty($checkoutRateRows)) : ?>
426 <div class="fct_tax_summary_row fct_tax_summary_row--total">
427 <span class="fct_tax_summary_row_label"><?php esc_html_e('Total tax', 'fluent-cart'); ?></span>
428 <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($totalOrderTax)); ?></span>
429 </div>
430 <?php if ($includedInPrices > 0) : ?>
431 <div class="fct_tax_summary_row fct_tax_summary_row--muted">
432 <span class="fct_tax_summary_row_label"><?php esc_html_e('of which included in prices', 'fluent-cart'); ?></span>
433 <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($includedInPrices)); ?></span>
434 </div>
435 <?php endif; ?>
436 <?php if ($payableTax > 0 && $includedInPrices > 0) : ?>
437 <div class="fct_tax_summary_row fct_tax_summary_row--total">
438 <span class="fct_tax_summary_row_label"><?php esc_html_e('Payable now (added)', 'fluent-cart'); ?></span>
439 <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($payableTax)); ?></span>
440 </div>
441 <?php endif; ?>
442 <?php else : ?>
443 <?php if ($payableTax > 0) : ?>
444 <div class="fct_tax_summary_row fct_tax_summary_row--total">
445 <span class="fct_tax_summary_row_label"><?php esc_html_e('Total payable tax', 'fluent-cart'); ?></span>
446 <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($payableTax)); ?></span>
447 </div>
448 <?php endif; ?>
449 <?php if ($inclusiveTax > 0 || $inclusiveFeeTax > 0) : ?>
450 <div class="fct_tax_summary_row fct_tax_summary_row--muted">
451 <span class="fct_tax_summary_row_label"><?php esc_html_e('Total tax in this order', 'fluent-cart'); ?></span>
452 <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($totalOrderTax)); ?></span>
453 </div>
454 <?php endif; ?>
455 <?php endif; ?>
456 <?php endif; ?>
457 </div>
458 </div>
459 <?php if ($isSimplified) : ?>
460 </details>
461 <?php endif; ?>
462 </li>
463 <?php
464 }
465
466 public function renderCheckoutLineItemTaxLabel($data): void
467 {
468 $mode = $this->getCheckoutTaxBreakdownDisplayMode();
469 if ($mode === 'simplified') {
470 return;
471 }
472
473 $item = Arr::get($data, 'item', []);
474 $rates = $this->normalizeCheckoutLineTaxRates($item);
475 if (empty($rates)) {
476 return;
477 }
478 $cart = CartHelper::getCart();
479 $isReversed = $cart ? $this->module->isReverseChargeCheckout($cart->checkout_data) : false;
480 $rcMode = $this->module->getEffectiveRcMode();
481 $this->renderTaxBadges($rates, $isReversed, $rcMode);
482 }
483
484 public function renderCheckoutSetupFeeTaxLabel($data): void
485 {
486 $mode = $this->getCheckoutTaxBreakdownDisplayMode();
487 if ($mode === 'simplified') {
488 return;
489 }
490
491 $item = Arr::get($data, 'item', []);
492 $signupFeeTaxConfig = Arr::get($item, 'signup_fee_tax_config', []);
493 $rates = Arr::get($signupFeeTaxConfig, 'rates', []);
494 if (empty($rates) || !is_array($rates)) {
495 return;
496 }
497
498 $isInclusive = (bool) Arr::get($signupFeeTaxConfig, 'inclusive', false);
499 $normalizedRates = [];
500 foreach ($rates as $rate) {
501 $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
502 $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
503 if ($taxAmount <= 0 && $ratePercent <= 0) {
504 continue;
505 }
506 $normalizedRates[] = [
507 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
508 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
509 'inclusive' => $isInclusive,
510 'tax_amount' => $taxAmount,
511 ];
512 }
513
514 if (empty($normalizedRates)) {
515 return;
516 }
517 $cart = CartHelper::getCart();
518 $isReversed = $cart ? $this->module->isReverseChargeCheckout($cart->checkout_data) : false;
519 $rcMode = $this->module->getEffectiveRcMode();
520 $this->renderTaxBadges($normalizedRates, $isReversed, $rcMode);
521 }
522
523 private function renderTaxBadges(array $normalizedRates, $isReversed = false, $rcMode = 'fixed'): void
524 {
525 // Fixed-mode reverse charge: inclusive-priced lines keep their gross price and
526 // no VAT is charged, so an "incl. X" badge would be misleading — hide those rates.
527 if ($isReversed && $rcMode !== 'dynamic') {
528 $normalizedRates = array_values(array_filter($normalizedRates, function ($rate) {
529 return empty($rate['inclusive']);
530 }));
531 }
532 if (empty($normalizedRates)) {
533 return;
534 }
535 ?>
536 <div class="fct_item_tax_badges" aria-label="<?php esc_attr_e('Tax breakdown', 'fluent-cart'); ?>">
537 <?php foreach ($normalizedRates as $rate) : ?>
538 <div class="fct_item_tax_badge_row">
539 <span class="fct_item_tax_badge <?php echo esc_attr($rate['inclusive'] ? 'is-inclusive' : 'is-exclusive'); ?>">
540 <span>
541 <?php
542 $badgeText = sprintf(
543 /* translators: %1$s: tax label, %2$s: tax rate percent */
544 __('%1$s (%2$s%%)', 'fluent-cart'),
545 $rate['short_label'],
546 $rate['formatted_rate']
547 );
548 echo esc_html($badgeText);
549 ?>
550 </span>
551 </span>
552 <?php if (!empty($rate['tax_amount']) && (int) $rate['tax_amount'] > 0) : ?>
553 <?php $rateReversedClass = $this->shouldRateStrikethrough($isReversed, $rate['inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
554 <span class="fct_item_tax_badge_amount <?php echo esc_attr($rate['inclusive'] ? 'is-inclusive' : 'is-exclusive'); ?><?php echo esc_attr($rateReversedClass); ?>">
555 <?php
556 $amountText = $rate['inclusive']
557 ? sprintf(
558 /* translators: %1$s: formatted tax amount */
559 __('incl. %1$s', 'fluent-cart'),
560 Helper::toDecimal((int) $rate['tax_amount'])
561 )
562 : sprintf(
563 /* translators: %1$s: formatted tax amount */
564 __('+ %1$s', 'fluent-cart'),
565 Helper::toDecimal((int) $rate['tax_amount'])
566 );
567 echo esc_html($amountText);
568 ?>
569 </span>
570 <?php endif; ?>
571 </div>
572 <?php endforeach; ?>
573 </div>
574 <?php
575 }
576
577 private function getSetupFeeTaxData($item): ?array
578 {
579 $signupFeeTaxConfig = Arr::get($item, 'signup_fee_tax_config', []);
580 $rawRates = Arr::get($signupFeeTaxConfig, 'rates', []);
581 if (empty($rawRates) || !is_array($rawRates)) {
582 return null;
583 }
584
585 $isInclusive = (bool) Arr::get($signupFeeTaxConfig, 'inclusive', false);
586 $rates = [];
587 foreach ($rawRates as $rate) {
588 $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
589 $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
590 if ($taxAmount <= 0 && $ratePercent <= 0) {
591 continue;
592 }
593 $taxableAmount = (int) Arr::get($rate, 'taxable_amount', 0);
594 $rates[] = [
595 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
596 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
597 'tax_amount' => $taxAmount,
598 'display_base' => $isInclusive ? max(0, $taxableAmount - $taxAmount) : $taxableAmount,
599 ];
600 }
601
602 if (empty($rates)) {
603 return null;
604 }
605
606 $setupFee = (int) Arr::get($item, 'other_info.signup_fee', 0);
607 $totalTax = array_sum(array_map(function ($rate) {
608 return (int) $rate['tax_amount'];
609 }, $rates));
610
611 return [
612 'rates' => $rates,
613 'is_inclusive' => $isInclusive,
614 'total_tax' => $totalTax,
615 'display_total' => $isInclusive ? $setupFee : $setupFee + $totalTax,
616 'primary_label' => Arr::get($rates, '0.short_label', __('Tax', 'fluent-cart')),
617 ];
618 }
619
620 public function renderCheckoutSetupFeeTaxTooltip($data): void
621 {
622 $mode = $this->getCheckoutTaxBreakdownDisplayMode();
623 if ($mode === 'simplified') {
624 return;
625 }
626
627 $item = Arr::get($data, 'item', []);
628 $taxData = $this->getSetupFeeTaxData($item);
629 if (!$taxData) {
630 return;
631 }
632
633 $cart = CartHelper::getCart();
634 $isReversed = $cart ? $this->module->isReverseChargeCheckout($cart->checkout_data) : false;
635 $rcMode = $this->module->getEffectiveRcMode();
636
637 $rates = $taxData['rates'];
638 $isInclusive = $taxData['is_inclusive'];
639 $displayTotal = $taxData['display_total'];
640 if ($isReversed) {
641 if ($taxData['is_inclusive'] && $rcMode === 'dynamic') {
642 $displayTotal = $taxData['display_total'] - $taxData['total_tax'];
643 } else {
644 $displayTotal = (int) Arr::get($data, 'item.other_info.signup_fee', 0);
645 }
646 }
647 $tooltipId = 'fct-item-tax-tooltip-' . Helper::getUidSerial();
648 ?>
649 <div class="fct_item_tax_hint">
650 <button
651 type="button"
652 class="fct_item_tax_hint_button"
653 aria-label="<?php esc_attr_e('View tax breakdown for this item', 'fluent-cart'); ?>"
654 aria-describedby="<?php echo esc_attr($tooltipId); ?>"
655 >
656 <span aria-hidden="true">i</span>
657 </button>
658 <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
659 <span class="fct_item_tax_tooltip_heading">
660 <?php echo esc_html($isInclusive ? __('Tax-inclusive price', 'fluent-cart') : __('Tax-exclusive price', 'fluent-cart')); ?>
661 </span>
662 <?php foreach ($rates as $rate) : ?>
663 <?php $lineReversedClass = $this->shouldRateStrikethrough($isReversed, isset($rate['inclusive']) ? (bool)$rate['inclusive'] : $taxData['is_inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
664 <span class="fct_item_tax_tooltip_line<?php echo esc_attr($lineReversedClass); ?>">
665 <?php
666 $lineText = sprintf(
667 /* translators: %1$s: tax base amount, %2$s: tax label, %3$s: tax rate percent, %4$s: tax amount */
668 __('Base %1$s + %2$s %3$s%% %4$s', 'fluent-cart'),
669 Helper::toDecimal($rate['display_base']),
670 $rate['short_label'],
671 $rate['formatted_rate'],
672 Helper::toDecimal($rate['tax_amount'])
673 );
674 echo esc_html($lineText);
675 ?>
676 </span>
677 <?php endforeach; ?>
678 <span class="fct_item_tax_tooltip_line is-total">
679 <?php
680 echo esc_html(sprintf(
681 /* translators: %1$s: line total amount */
682 __('Total %1$s', 'fluent-cart'),
683 Helper::toDecimal($displayTotal)
684 ));
685 ?>
686 </span>
687 </div>
688 </div>
689 <?php
690 }
691
692 public function renderCheckoutSetupFeeTaxInfo($data): void
693 {
694 $mode = $this->getCheckoutTaxBreakdownDisplayMode();
695 if ($mode === 'simplified') {
696 return;
697 }
698
699 $item = Arr::get($data, 'item', []);
700 $taxData = $this->getSetupFeeTaxData($item);
701 if (!$taxData) {
702 return;
703 }
704
705 $isInclusive = $taxData['is_inclusive'];
706 $totalTax = $taxData['total_tax'];
707 $primaryLabel = $taxData['primary_label'];
708
709 $priceNote = $isInclusive
710 ? sprintf(
711 /* translators: %1$s: tax label */
712 __('%1$s incl.', 'fluent-cart'),
713 $primaryLabel
714 )
715 : sprintf(
716 /* translators: %1$s: tax amount, %2$s: tax label */
717 __('+ %1$s %2$s', 'fluent-cart'),
718 Helper::toDecimal($totalTax),
719 strtolower($primaryLabel)
720 );
721 ?>
722 <span class="fct_setup_fee_price_note"><?php echo esc_html($priceNote); ?></span>
723 <?php
724 }
725
726 public function renderCheckoutLineItemTaxTooltip($data): void
727 {
728 $mode = $this->getCheckoutTaxBreakdownDisplayMode();
729 if ($mode === 'simplified') {
730 return;
731 }
732
733 $item = Arr::get($data, 'item', []);
734 $rates = $this->normalizeCheckoutLineTaxRates($item);
735 if (empty($rates)) {
736 return;
737 }
738
739 $cart = CartHelper::getCart();
740 $isReversed = $cart ? $this->module->isReverseChargeCheckout($cart->checkout_data) : false;
741 $rcMode = $this->module->getEffectiveRcMode();
742
743 $isInclusive = (bool) Arr::get($rates, '0.inclusive', false);
744 $itemSubtotal = (int) Arr::get($item, 'line_total', Arr::get($item, 'subtotal', 0));
745 $itemTaxAmount = array_sum(array_map(function ($rate) {
746 return (int) Arr::get($rate, 'tax_amount', 0);
747 }, $rates));
748 if ($isReversed) {
749 if ($isInclusive && $rcMode === 'dynamic') {
750 $displayTotal = $itemSubtotal - $itemTaxAmount;
751 } else {
752 $displayTotal = $itemSubtotal;
753 }
754 } else {
755 $displayTotal = $isInclusive ? $itemSubtotal : $itemSubtotal + $itemTaxAmount;
756 }
757 $tooltipId = 'fct-item-tax-tooltip-' . Helper::getUidSerial();
758 ?>
759 <div class="fct_item_tax_hint">
760 <button
761 type="button"
762 class="fct_item_tax_hint_button"
763 aria-label="<?php esc_attr_e('View tax breakdown for this item', 'fluent-cart'); ?>"
764 aria-describedby="<?php echo esc_attr($tooltipId); ?>"
765 >
766 <span aria-hidden="true">i</span>
767 </button>
768 <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
769 <span class="fct_item_tax_tooltip_heading">
770 <?php echo esc_html($isInclusive ? __('Tax-inclusive price', 'fluent-cart') : __('Tax-exclusive price', 'fluent-cart')); ?>
771 </span>
772 <?php foreach ($rates as $rate) : ?>
773 <?php $lineReversedClass = $this->shouldRateStrikethrough($isReversed, $rate['inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
774 <span class="fct_item_tax_tooltip_line<?php echo esc_attr($lineReversedClass); ?>">
775 <?php
776 $lineText = sprintf(
777 /* translators: %1$s: tax base amount, %2$s: tax label, %3$s: tax rate percent, %4$s: tax amount */
778 __('Base %1$s + %2$s %3$s%% %4$s', 'fluent-cart'),
779 Helper::toDecimal($rate['display_base']),
780 $rate['short_label'],
781 $rate['formatted_rate'],
782 Helper::toDecimal($rate['tax_amount'])
783 );
784 echo esc_html($lineText);
785 ?>
786 </span>
787 <?php endforeach; ?>
788 <span class="fct_item_tax_tooltip_line is-total">
789 <?php
790 echo esc_html(sprintf(
791 /* translators: %1$s: line total amount */
792 __('Total %1$s', 'fluent-cart'),
793 Helper::toDecimal($displayTotal)
794 ));
795 ?>
796 </span>
797 </div>
798 </div>
799 <?php
800 }
801
802 public function renderCheckoutLineItemTaxInfo($data): void
803 {
804 $mode = $this->getCheckoutTaxBreakdownDisplayMode();
805 if ($mode === 'simplified') {
806 return;
807 }
808
809 $item = Arr::get($data, 'item', []);
810 $rates = $this->normalizeCheckoutLineTaxRates($item);
811 if (empty($rates)) {
812 return;
813 }
814
815 $isInclusive = (bool) Arr::get($rates, '0.inclusive', false);
816 $itemTaxAmount = array_sum(array_map(function ($rate) {
817 return (int) Arr::get($rate, 'tax_amount', 0);
818 }, $rates));
819 $primaryLabel = Arr::get($rates, '0.short_label', __('Tax', 'fluent-cart'));
820
821 $priceNote = $isInclusive
822 ? sprintf(
823 /* translators: %1$s: tax label */
824 __('%1$s incl.', 'fluent-cart'),
825 $primaryLabel
826 )
827 : sprintf(
828 /* translators: %1$s: tax amount, %2$s: tax label */
829 __('+ %1$s %2$s', 'fluent-cart'),
830 Helper::toDecimal($itemTaxAmount),
831 strtolower($primaryLabel)
832 );
833 ?>
834 <div class="fct_item_tax_price_note"><?php echo esc_html($priceNote); ?></div>
835 <?php
836 }
837
838 public function renderUnitPriceRoundingTooltip($data): void
839 {
840 $item = Arr::get($data, 'item', []);
841 $quantity = (int) Arr::get($item, 'quantity', 1);
842
843 if ($quantity < 2) {
844 return;
845 }
846
847 $unitPrice = (int) Arr::get($item, 'unit_price', 0);
848 $subtotal = (int) Arr::get($item, 'subtotal', 0);
849
850 if ($unitPrice <= 0) {
851 return;
852 }
853
854 $shouldShow = false;
855
856 // Case 1 — TaxModule RC dynamic: the applied per-unit tax adjustment is rounded,
857 // so unit_price * qty may differ from the exact net by up to (qty - 1) cents.
858 $rcAdjustment = (int) Arr::get($item, 'line_meta.reverse_charge_adjustment', 0);
859 if ($rcAdjustment > 0) {
860 $rates = (array) Arr::get($item, 'line_meta.tax_config.rates', []);
861 $actualTaxTotal = (int) array_sum(array_map('intval', array_column($rates, 'tax_amount')));
862 $diff = abs($rcAdjustment - $actualTaxTotal);
863 if ($diff > 0 && $diff <= $quantity) {
864 $shouldShow = true;
865 }
866 }
867
868 // Case 2 — generic: unit_price * qty already doesn't match subtotal
869 // (future dynamic pricing or any other module that stores a rounded unit_price)
870 if (!$shouldShow && $subtotal > 0) {
871 $diff = abs(($unitPrice * $quantity) - $subtotal);
872 if ($diff > 0 && $diff <= $quantity) {
873 $shouldShow = true;
874 }
875 }
876
877 if (!$shouldShow) {
878 return;
879 }
880
881 $tooltipId = 'fct-unit-price-rounding-' . Helper::getUidSerial();
882 ?>
883 <div class="fct_item_tax_hint">
884 <button
885 type="button"
886 class="fct_item_tax_hint_button"
887 aria-label="<?php esc_attr_e('Unit price rounding information', 'fluent-cart'); ?>"
888 aria-describedby="<?php echo esc_attr($tooltipId); ?>"
889 >
890 <span aria-hidden="true">i</span>
891 </button>
892 <div class="fct_item_tax_tooltip fct_unit_price_rounding_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
893 <?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'); ?>
894 </div>
895 </div>
896 <?php
897 }
898
899 protected function getCheckoutTaxBreakdownDisplayMode(): string
900 {
901 // Backward compat: legacy stored values ('both', 'label', 'tooltip', or anything
902 // else) all collapse to 'itemized'. Only an explicit 'simplified' stays simplified.
903 $mode = Arr::get($this->module->getSettings(), 'checkout_tax_breakdown_display', 'itemized');
904
905 return $mode === 'simplified' ? 'simplified' : 'itemized';
906 }
907
908 protected function getTaxDisplayLabel(): string
909 {
910 $label = trim((string) Arr::get($this->module->getSettings(), 'tax_display_label', ''));
911 return $label !== '' ? $label : __('Tax', 'fluent-cart');
912 }
913
914 protected function normalizeCheckoutLineTaxRates($item): array
915 {
916 $rates = Arr::get($item, 'line_meta.tax_config.rates', []);
917 if (empty($rates) || !is_array($rates)) {
918 return [];
919 }
920
921 $isInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
922 $normalizedRates = [];
923
924 foreach ($rates as $rate) {
925 $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
926 $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
927 $taxableAmount = (int) Arr::get($rate, 'taxable_amount', 0);
928
929 if ($taxAmount <= 0 && $ratePercent <= 0) {
930 continue;
931 }
932
933 $normalizedRates[] = [
934 'label' => (string) Arr::get($rate, 'label', __('Tax', 'fluent-cart')),
935 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
936 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
937 'tax_amount' => $taxAmount,
938 'display_base' => $isInclusive ? max(0, $taxableAmount - $taxAmount) : $taxableAmount,
939 'inclusive' => $isInclusive,
940 ];
941 }
942
943 return $normalizedRates;
944 }
945
946 protected function getCheckoutLineTaxShortLabel($label): string
947 {
948 $label = trim((string) $label);
949
950 if (!$label) {
951 return __('Tax', 'fluent-cart');
952 }
953
954 return $label;
955 }
956
957 private function shouldRateStrikethrough($isReversed, $rateIsInclusive, $rcMode): bool
958 {
959 if (!$isReversed) {
960 return false;
961 }
962 return !$rateIsInclusive || $rcMode === 'dynamic';
963 }
964 }
965