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