PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.2
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.2
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Modules / Tax / TaxModule.php

TaxModule.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.2, at app/Modules/Tax/TaxModule.php

2,525 lines 114.6 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\Modules\Tax;
4
5 use FluentCart\App\App;
6 use FluentCart\Api\StoreSettings;
7 use FluentCart\App\Helpers\Helper;
8 use FluentCart\Framework\Support\Arr;
9 use FluentCart\App\Helpers\CartHelper;
10 use FluentCart\App\Models\Cart;
11 use FluentCart\App\Models\OrderTaxRate;
12 use FluentCart\App\Services\Renderer\VatFieldRenderer;
13 use FluentCart\App\Services\Renderer\CartSummaryRender;
14 use FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper;
15 use FluentCart\App\Services\Renderer\CheckoutFieldsSchema;
16 use FluentCart\Api\Resource\FrontendResource\CartResource;
17 use FluentCart\App\Services\Localization\LocalizationManager;
18 use FluentCart\App\Services\Tax\TaxManager;
19
20 class TaxModule
21 {
22
23 protected $taxSettings = [];
24
25 public function register()
26 {
27 $this->getSettings();
28
29 add_action('fluent_cart/checkout/prepare_other_data', [$this, 'storeBusinessInfoOnOrder'], 9, 1);
30
31 add_filter('fluent_cart/checkout/after_patch_checkout_data_fragments', [$this, 'maybeRerenderEuVatField'], 10, 2);
32 $this->initCheckoutActions();
33 $this->registerAjaxHandlers();
34
35 // Registered unconditionally so that stale RC price adjustments are restored
36 // when tax is disabled after RC was active. No-op when tax is enabled
37 // (recalculateTax registered below runs the full recalculation instead).
38 add_action('fluent_cart/cart/cart_data_items_updated', [$this, 'maybeRestoreRcAdjustedPrices']);
39 // Registered unconditionally: Case 2 catches non-tax rounding from any module;
40 // Case 1 (RC) fast-exits when tax is off (no rcAdjustment stored).
41 add_action('fluent_cart/cart/line_item/price_note', [$this, 'renderUnitPriceRoundingTooltip'], 20, 1);
42 add_action('fluent_cart/cart/line_item/unit_price_hint', [$this, 'renderUnitPriceRoundingTooltip'], 10, 1);
43
44 if (!$this->isEnabled()) {
45 return;
46 }
47
48 add_action('fluent_cart/cart/line_item/footer_start', [$this, 'renderCheckoutLineItemTaxLabel'], 10, 1);
49 add_action('fluent_cart/cart/line_item/after_setup_fee_info', [$this, 'renderCheckoutSetupFeeTaxLabel'], 10, 1);
50 add_action('fluent_cart/cart/line_item/setup_fee_price_info', [$this, 'renderCheckoutSetupFeeTaxTooltip'], 10, 1);
51 add_action('fluent_cart/cart/line_item/setup_fee_price_note', [$this, 'renderCheckoutSetupFeeTaxInfo'], 10, 1);
52 add_action('fluent_cart/cart/line_item/after_total', [$this, 'renderCheckoutLineItemTaxTooltip'], 10, 1);
53 add_action('fluent_cart/cart/line_item/price_note', [$this, 'renderCheckoutLineItemTaxInfo'], 10, 1);
54
55 add_filter('fluent_cart/cart/estimated_total', function ($total, $data) {
56 $cart = $data['cart'];
57 $taxData = Arr::get($cart->checkout_data, 'tax_data', []);
58 if (is_array($taxData) && array_key_exists('exclusive_tax_total', $taxData)) {
59 $total += (int) $taxData['exclusive_tax_total'];
60
61 // Shipping and fees always use the store-level inclusive flag.
62 // Fall back to tax_behavior if store_tax_behavior absent (old cart data).
63 $storeBehavior = (int) Arr::get($taxData, 'store_tax_behavior',
64 Arr::get($taxData, 'tax_behavior', 0));
65
66 if ($storeBehavior === 1) {
67 $total += (int) Arr::get($taxData, 'shipping_tax', 0);
68 // fee_tax is also exclusive when store is exclusive
69 $total += (int) Arr::get($taxData, 'fee_tax', 0);
70 }
71
72 $rcMode = $this->getEffectiveRcMode();
73 if ($rcMode === 'dynamic' && $this->isReverseChargeCheckout($cart->checkout_data)) {
74 $inclusiveAdj = (int) Arr::get($taxData, 'reverse_charge_inclusive_adjustment', 0);
75 if ($inclusiveAdj > 0) {
76 $total -= $inclusiveAdj;
77 }
78 // Inclusive shipping is now reduced at source via fluent_cart/cart/shipping_total,
79 // so getShippingTotal() already returns the net amount — no further adjustment here.
80 }
81 } else {
82 // Backward compat: old tax_data without exclusive_tax_total.
83 if (Arr::get($taxData, 'tax_behavior', 0) == 1) {
84 $total += (int) Arr::get($taxData, 'tax_total', 0);
85 $total += (int) Arr::get($taxData, 'shipping_tax', 0);
86 }
87 }
88 return $total;
89 }, 10, 2);
90
91 // Reduce the raw shipping_charge by the inclusive shipping VAT for dynamic RC orders.
92 // This makes getShippingTotal() return the net amount, so the actual gateway charge
93 // and the stored shipping_total on the order are correct (not overcharged).
94 add_filter('fluent_cart/cart/shipping_total', function ($shippingTotal, $data) {
95 $cart = Arr::get($data, 'cart');
96 if (!$cart || $shippingTotal <= 0) {
97 return $shippingTotal;
98 }
99 $taxData = Arr::get($cart->checkout_data, 'tax_data', []);
100 if (!is_array($taxData)) {
101 return $shippingTotal;
102 }
103 $rcMode = $this->getEffectiveRcMode();
104 if ($rcMode !== 'dynamic' || !$this->isReverseChargeCheckout($cart->checkout_data)) {
105 return $shippingTotal;
106 }
107 $storeBehavior = (int) Arr::get($taxData, 'store_tax_behavior',
108 Arr::get($taxData, 'tax_behavior', 2));
109 if ($storeBehavior !== 2) {
110 return $shippingTotal;
111 }
112 $rcShippingTax = (int) Arr::get($taxData, 'reverse_charge_shipping_tax', 0);
113 if ($rcShippingTax > 0) {
114 $shippingTotal = max(0, $shippingTotal - $rcShippingTax);
115 }
116 return $shippingTotal;
117 }, 10, 2);
118
119 //new hook to get changes
120 add_filter('fluent_cart/checkout/before_patch_checkout_data', [$this, 'maybeRecalculateTaxAmount'], 10, 2);
121
122 add_filter('fluent_cart/cart/tax_behavior', function ($behavior, $data) {
123 $cart = $data['cart'];
124 return Arr::get($cart->checkout_data, 'tax_data.tax_behavior', $behavior);
125 }, 10, 2);
126
127 add_action('fluent_cart/checkout/before_summary_total', function ($data) {
128 $cart = $data['cart'];
129
130 if (empty($cart->checkout_data['tax_data'])) {
131 return;
132 }
133
134 $taxAmount = (int) Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
135 $shippingTax = (int) Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
136 $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
137
138 if (!$taxAmount && !$isReverseCharge && !$shippingTax) {
139 return;
140 }
141
142 $this->renderTaxSummaryBox($cart);
143 });
144
145 add_action('fluent_cart/checkout/prepare_other_data', [$this, 'prepareOtherData'], 10, 1);
146
147 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>';
152 }
153 }, 10, 1);
154
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 add_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20, 2);
162
163 add_action('fluent_cart/cart/cart_data_items_updated', [$this, 'recalculateTax']);
164 }
165
166 private function resolvePriceSuffix($variant)
167 {
168 $includedSuffix = Arr::get($this->taxSettings, 'price_suffix_included', '');
169 $excludedSuffix = Arr::get($this->taxSettings, 'price_suffix_excluded', '');
170
171 if ($variant !== null) {
172 $taxInclusion = Arr::get($variant->other_info ?: [], 'tax_inclusion', '');
173 } else {
174 $taxInclusion = '';
175 }
176
177 if ($taxInclusion === 'included') {
178 $isInclusive = true;
179 } elseif ($taxInclusion === 'excluded') {
180 $isInclusive = false;
181 } else {
182 $isInclusive = Arr::get($this->taxSettings, 'tax_inclusion') === 'included';
183 }
184
185 $suffix = $isInclusive ? $includedSuffix : $excludedSuffix;
186
187 if (!$suffix) {
188 $suffix = Arr::get($this->taxSettings, 'price_suffix', '');
189 }
190
191 return $suffix;
192 }
193
194 public function renderTaxRow($cart, $atts = '')
195 {
196 $taxAmount = (int) Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
197 $reversedTaxAmount = (int) Arr::get($cart->checkout_data, 'tax_data.reverse_charge_tax_total', 0);
198 $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
199 $taxLines = Arr::get($cart->checkout_data, 'tax_data.tax_lines', []);
200 $taxCountry = Arr::get($cart->checkout_data, 'tax_data.tax_country', '');
201 $taxLabel = Arr::get($taxLines, '0.label', '');
202
203 if (!$taxLabel) {
204 $taxLabel = $taxCountry ? static::getCountryTaxTitle($taxCountry) : __('Tax', 'fluent-cart');
205 }
206 ?>
207 <li <?php echo $atts; ?>>
208 <span class="fct_summary_label">
209 <?php echo esc_html($taxLabel); ?>
210 </span>
211 <span class="fct_summary_value">
212 <?php if ($isReverseCharge && $taxAmount === 0) : ?>
213 <?php
214 /* translators: %1$s: formatted reversed tax amount */
215 echo esc_html(sprintf(__('Tax reversed: %1$s', 'fluent-cart'), Helper::toDecimal($reversedTaxAmount)));
216 ?>
217 <?php else : ?>
218 <?php echo esc_html(Helper::toDecimal($taxAmount)); ?>
219 <?php endif; ?>
220 </span>
221 </li>
222 <?php
223 }
224
225 public function renderShippingTaxRow($cart, $atts = '')
226 {
227 $shippingTax = (int) Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
228 $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
229 $rcShippingTax = (int) Arr::get($cart->checkout_data, 'tax_data.reverse_charge_shipping_tax', 0);
230
231 if ($shippingTax <= 0 && (!$isReverseCharge || $rcShippingTax <= 0)) {
232 return '';
233 }
234
235 $displayAmount = $isReverseCharge ? $rcShippingTax : $shippingTax;
236 $storeBehavior = (int) Arr::get($cart->checkout_data, 'tax_data.store_tax_behavior',
237 Arr::get($cart->checkout_data, 'tax_data.tax_behavior', 2));
238 $isInclusive = $storeBehavior === 2;
239 ?>
240 <li data-fct-shipping-tax-row <?php echo $atts; ?>>
241 <span class="fct_summary_label">
242 <?php if ($isInclusive) : ?>
243 <?php echo esc_html__('Shipping Tax (Included)', 'fluent-cart'); ?>
244 <?php else : ?>
245 <?php echo esc_html__('Shipping Tax (Excluded)', 'fluent-cart'); ?>
246 <?php endif; ?>
247 </span>
248 <span class="fct_summary_value"<?php echo $isReverseCharge ? ' style="text-decoration:line-through;opacity:0.6;"' : ''; ?>>
249 <?php echo esc_html(Helper::toDecimal($displayAmount)); ?>
250 </span>
251 </li>
252 <?php
253 return '';
254 }
255
256 public function renderTaxSummaryBox($cart)
257 {
258 $taxData = Arr::get($cart->checkout_data, 'tax_data', []);
259 $taxTotal = (int) Arr::get($taxData, 'tax_total', 0);
260 $exclusiveTaxTotal = (int) Arr::get($taxData, 'exclusive_tax_total', $taxTotal);
261 $feeTaxLines = (array) Arr::get($taxData, 'fee_tax_lines', []);
262 $shippingTax = (int) Arr::get($taxData, 'shipping_tax', 0);
263 $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
264 $reversedTaxTotalDisplay = (int) Arr::get($taxData, 'reverse_charge_tax_total', 0);
265
266 $taxRateLines = (array) Arr::get($taxData, 'tax_lines', []);
267 $shippingTaxLines = (array) Arr::get($taxData, 'shipping_tax_lines', []);
268
269 $inclusiveFeeTax = 0;
270 $exclusiveFeeTax = 0;
271 foreach ($feeTaxLines as $feeTaxLine) {
272 if (!empty($feeTaxLine['inclusive'])) {
273 $inclusiveFeeTax += (int) Arr::get($feeTaxLine, 'tax_amount', 0);
274 } else {
275 $exclusiveFeeTax += (int) Arr::get($feeTaxLine, 'tax_amount', 0);
276 }
277 }
278 // Fallback: if no fee_tax_lines (old cart data), use aggregate fee_tax as exclusive
279 if (empty($feeTaxLines)) {
280 $exclusiveFeeTax = (int) Arr::get($taxData, 'fee_tax', 0);
281 }
282
283 $inclusiveTax = max(0, $taxTotal - $exclusiveTaxTotal - $exclusiveFeeTax - $inclusiveFeeTax);
284 $productExclusiveTax = max(0, $exclusiveTaxTotal);
285
286 $isShippingInclusive = \FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper::isShippingTaxInclusiveFromTaxData(
287 is_array($taxData) ? $taxData : []
288 );
289
290 $payableTax = $productExclusiveTax + $exclusiveFeeTax + ($isShippingInclusive ? 0 : $shippingTax);
291 $totalOrderTax = $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0) + $payableTax;
292
293 $feeRows = \FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper::buildFeeTaxLineRows($feeTaxLines);
294 $feeCount = count($feeRows);
295 if (empty($feeTaxLines) && (int) Arr::get($taxData, 'fee_tax', 0) > 0) {
296 $feeCount = 1;
297 }
298 $productTaxRowCount = !empty($taxRateLines)
299 ? count($taxRateLines)
300 : (int) ($inclusiveTax > 0) + (int) ($productExclusiveTax > 0);
301 $rowCount = $productTaxRowCount + $feeCount + (int) ($shippingTax > 0);
302 $shouldShowBreakdown = !empty($taxRateLines)
303 || !empty($shippingTaxLines)
304 || $rowCount >= 2
305 || ($rowCount === 1 && !($payableTax > 0 || $inclusiveTax > 0 || $inclusiveFeeTax > 0));
306
307 // Build folded per-rate rows via shared helper. Cart tax_lines carry only `label`
308 // (no percent), so we pre-augment each line with `rate_label` = "Label (X%)" so the
309 // helper picks it up and produces the same display as the old inline block.
310 $foldedSource = [];
311 foreach ($taxRateLines as $rateKey => $rateLine) {
312 $ratePercent = (float) Arr::get($rateLine, 'rate_percent', 0);
313 $rateLine['rate_label'] = (string) Arr::get($rateLine, 'label', '');
314 if ($ratePercent > 0) {
315 $rateLine['rate_label'] .= ' (' . Helper::formatTaxRatePercent($ratePercent) . '%)';
316 }
317 $foldedSource[$rateKey] = $rateLine;
318 }
319 $checkoutRateRows = TaxSummaryHelper::buildFoldedRateRows(
320 $foldedSource, $shippingTaxLines, 'tax_amount', $isShippingInclusive
321 );
322 // Inclusive shipping tax follows the store global tax mode — when shipping is
323 // priced inclusive its tax is baked into the shipping price, so it counts as
324 // "of which included in prices". Keeps includedInPrices + payableTax === totalOrderTax.
325 $includedInPrices = $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0);
326
327 $tooltipId = 'fct-tax-summary-tooltip-' . Helper::getUidSerial();
328 ?>
329 <li class="fct_tax_summary_li" data-fct-tax-summary>
330 <div class="fct_tax_summary_box">
331 <div class="fct_tax_summary_header">
332 <span class="fct_tax_summary_heading">
333 <?php echo (!empty($checkoutRateRows) && !$isReverseCharge)
334 ? esc_html__('Tax breakdown by rate', 'fluent-cart')
335 : esc_html__('TAX', 'fluent-cart'); ?>
336 </span>
337 <div class="fct_item_tax_hint">
338 <button
339 type="button"
340 class="fct_item_tax_hint_button"
341 aria-label="<?php esc_attr_e('Tax information', 'fluent-cart'); ?>"
342 aria-describedby="<?php echo esc_attr($tooltipId); ?>"
343 >
344 <span aria-hidden="true">i</span>
345 </button>
346 <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
347 <span class="fct_item_tax_tooltip_heading">
348 <?php esc_html_e('About your tax', 'fluent-cart'); ?>
349 </span>
350 <?php if ($isReverseCharge) : ?>
351 <span class="fct_item_tax_tooltip_line">
352 <?php esc_html_e('Tax has been reversed for this order.', 'fluent-cart'); ?>
353 </span>
354 <?php else : ?>
355 <?php if ($payableTax > 0) : ?>
356 <span class="fct_item_tax_tooltip_line">
357 <?php esc_html_e('"Total payable tax" is added on top of listed prices.', 'fluent-cart'); ?>
358 </span>
359 <?php endif; ?>
360 <?php if ($inclusiveTax > 0) : ?>
361 <span class="fct_item_tax_tooltip_line">
362 <?php esc_html_e('"Included in item prices" is already built into product prices.', 'fluent-cart'); ?>
363 </span>
364 <?php endif; ?>
365 <?php if ($payableTax === 0 && $inclusiveTax === 0) : ?>
366 <span class="fct_item_tax_tooltip_line">
367 <?php esc_html_e('No tax applies to this order.', 'fluent-cart'); ?>
368 </span>
369 <?php endif; ?>
370 <?php endif; ?>
371 </div>
372 </div>
373 </div>
374 <div class="fct_tax_summary_rows">
375 <?php if ($isReverseCharge) : ?>
376 <?php
377 $rcShippingDisplay = (int) Arr::get($taxData, 'reverse_charge_shipping_tax', 0);
378 $rcInclusiveAdj = (int) Arr::get($taxData, 'reverse_charge_inclusive_adjustment', 0);
379 $rcExclusiveNonShip = max(0, $reversedTaxTotalDisplay - $rcShippingDisplay - $rcInclusiveAdj);
380 $rcBreakdownCount = (int) ($rcInclusiveAdj > 0) + (int) ($rcExclusiveNonShip > 0) + (int) ($rcShippingDisplay > 0);
381 ?>
382 <?php if ($rcBreakdownCount >= 2) : ?>
383 <?php if ($rcInclusiveAdj > 0) : ?>
384 <div class="fct_tax_summary_row fct_tax_summary_row--muted">
385 <span class="fct_tax_summary_row_label">
386 <?php esc_html_e('Included in item prices', 'fluent-cart'); ?>
387 </span>
388 <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
389 <?php echo esc_html(Helper::toDecimal($rcInclusiveAdj)); ?>
390 </span>
391 </div>
392 <?php endif; ?>
393 <?php if ($rcExclusiveNonShip > 0) : ?>
394 <div class="fct_tax_summary_row">
395 <span class="fct_tax_summary_row_label">
396 <?php esc_html_e('Added on products', 'fluent-cart'); ?>
397 </span>
398 <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
399 <?php echo esc_html(Helper::toDecimal($rcExclusiveNonShip)); ?>
400 </span>
401 </div>
402 <?php endif; ?>
403 <?php if ($rcShippingDisplay > 0) : ?>
404 <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
405 <span class="fct_tax_summary_row_label">
406 <?php echo $isShippingInclusive ? esc_html__('Included in shipping prices', 'fluent-cart') : esc_html__('Added on shipping', 'fluent-cart'); ?>
407 </span>
408 <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
409 <?php echo esc_html(Helper::toDecimal($rcShippingDisplay)); ?>
410 </span>
411 </div>
412 <?php endif; ?>
413 <?php elseif ($rcShippingDisplay > 0) : ?>
414 <div class="fct_tax_summary_row fct_tax_summary_row--muted">
415 <span class="fct_tax_summary_row_label">
416 <?php echo $isShippingInclusive ? esc_html__('Included in shipping prices', 'fluent-cart') : esc_html__('Added on shipping', 'fluent-cart'); ?>
417 </span>
418 <span class="fct_tax_summary_row_amount" style="text-decoration:line-through;opacity:0.6;">
419 <?php echo esc_html(Helper::toDecimal($rcShippingDisplay)); ?>
420 </span>
421 </div>
422 <?php endif; ?>
423 <div class="fct_tax_summary_row fct_tax_summary_row--total">
424 <span class="fct_tax_summary_row_label">
425 <?php esc_html_e('Tax reversed', 'fluent-cart'); ?>
426 </span>
427 <span class="fct_tax_summary_row_amount">
428 <?php echo esc_html(Helper::toDecimal($reversedTaxTotalDisplay)); ?>
429 </span>
430 </div>
431 <?php else : ?>
432 <?php if (!empty($checkoutRateRows)) : ?>
433 <div class="fct_tax_summary_row fct_tax_summary_row--head" style="display:flex;justify-content:space-between;gap:8px;">
434 <span style="flex:1;font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:#94a3b8;">
435 <?php esc_html_e('Rate', 'fluent-cart'); ?>
436 </span>
437 <span style="min-width:88px;text-align:right;font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:#94a3b8;">
438 <?php esc_html_e('Taxable base', 'fluent-cart'); ?>
439 </span>
440 <span style="min-width:64px;text-align:right;font-size:11px;text-transform:uppercase;letter-spacing:0.04em;color:#94a3b8;">
441 <?php esc_html_e('Tax', 'fluent-cart'); ?>
442 </span>
443 </div>
444 <?php foreach ($checkoutRateRows as $checkoutRateRow) : ?>
445 <div class="fct_tax_summary_row<?php echo !empty($checkoutRateRow['inclusive']) ? ' fct_tax_summary_row--muted' : ''; ?>" style="display:flex;justify-content:space-between;gap:8px;">
446 <span class="fct_tax_summary_row_label" style="flex:1;">
447 <?php echo esc_html($checkoutRateRow['label']); ?>
448 </span>
449 <span style="min-width:88px;text-align:right;color:#94a3b8;">
450 <?php echo esc_html(Helper::toDecimal((int) $checkoutRateRow['base'])); ?>
451 </span>
452 <span class="fct_tax_summary_row_amount" style="min-width:64px;text-align:right;">
453 <?php echo esc_html(Helper::toDecimal((int) $checkoutRateRow['tax'])); ?>
454 </span>
455 </div>
456 <?php endforeach; ?>
457 <?php endif; ?>
458 <?php if (empty($checkoutRateRows) && $inclusiveTax > 0 && $shouldShowBreakdown) : ?>
459 <div class="fct_tax_summary_row fct_tax_summary_row--muted">
460 <span class="fct_tax_summary_row_label">
461 <?php esc_html_e('Included in item prices', 'fluent-cart'); ?>
462 </span>
463 <span class="fct_tax_summary_row_amount">
464 <?php echo esc_html(Helper::toDecimal($inclusiveTax)); ?>
465 </span>
466 </div>
467 <?php endif; ?>
468 <?php if (empty($checkoutRateRows) && $productExclusiveTax > 0 && $shouldShowBreakdown) : ?>
469 <div class="fct_tax_summary_row">
470 <span class="fct_tax_summary_row_label">
471 <?php esc_html_e('Added on products', 'fluent-cart'); ?>
472 </span>
473 <span class="fct_tax_summary_row_amount">
474 <?php echo esc_html(Helper::toDecimal($productExclusiveTax)); ?>
475 </span>
476 </div>
477 <?php endif; ?>
478 <?php if (empty($checkoutRateRows) && $shouldShowBreakdown) : ?>
479 <?php foreach ($feeRows as $feeRow) : ?>
480 <div class="fct_tax_summary_row<?php echo $feeRow['inclusive'] ? ' fct_tax_summary_row--muted' : ''; ?>">
481 <span class="fct_tax_summary_row_label">
482 <?php echo esc_html($feeRow['display_label']); ?>
483 </span>
484 <span class="fct_tax_summary_row_amount">
485 <?php echo esc_html(Helper::toDecimal($feeRow['tax_amount'])); ?>
486 </span>
487 </div>
488 <?php endforeach; ?>
489 <?php endif; ?>
490 <?php if (empty($checkoutRateRows) && empty($feeTaxLines) && (int) Arr::get($taxData, 'fee_tax', 0) > 0 && $shouldShowBreakdown) : ?>
491 <div class="fct_tax_summary_row">
492 <span class="fct_tax_summary_row_label">
493 <?php esc_html_e('Added on fees', 'fluent-cart'); ?>
494 </span>
495 <span class="fct_tax_summary_row_amount">
496 <?php echo esc_html(Helper::toDecimal((int) Arr::get($taxData, 'fee_tax', 0))); ?>
497 </span>
498 </div>
499 <?php endif; ?>
500 <?php if (empty($checkoutRateRows) && $shippingTax > 0 && $shouldShowBreakdown) : ?>
501 <?php if (!empty($shippingTaxLines)) : ?>
502 <?php foreach ($shippingTaxLines as $shippingTaxLine) : ?>
503 <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
504 <span class="fct_tax_summary_row_label">
505 <?php echo esc_html($shippingTaxLine['label']); ?>
506 </span>
507 <span class="fct_tax_summary_row_amount">
508 <?php echo esc_html(Helper::toDecimal((int) Arr::get($shippingTaxLine, 'shipping_tax', 0))); ?>
509 </span>
510 </div>
511 <?php endforeach; ?>
512 <?php else : ?>
513 <div class="fct_tax_summary_row<?php echo $isShippingInclusive ? ' fct_tax_summary_row--muted' : ''; ?>">
514 <span class="fct_tax_summary_row_label">
515 <?php if ($isShippingInclusive) : ?>
516 <?php esc_html_e('Included in shipping prices', 'fluent-cart'); ?>
517 <?php else : ?>
518 <?php esc_html_e('Added on shipping', 'fluent-cart'); ?>
519 <?php endif; ?>
520 </span>
521 <span class="fct_tax_summary_row_amount">
522 <?php echo esc_html(Helper::toDecimal($shippingTax)); ?>
523 </span>
524 </div>
525 <?php endif; ?>
526 <?php endif; ?>
527 <?php if (!empty($checkoutRateRows)) : ?>
528 <div class="fct_tax_summary_row fct_tax_summary_row--total">
529 <span class="fct_tax_summary_row_label"><?php esc_html_e('Total tax', 'fluent-cart'); ?></span>
530 <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($totalOrderTax)); ?></span>
531 </div>
532 <?php if ($includedInPrices > 0) : ?>
533 <div class="fct_tax_summary_row fct_tax_summary_row--muted">
534 <span class="fct_tax_summary_row_label"><?php esc_html_e('of which included in prices', 'fluent-cart'); ?></span>
535 <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($includedInPrices)); ?></span>
536 </div>
537 <?php endif; ?>
538 <?php if ($payableTax > 0 && $includedInPrices > 0) : ?>
539 <div class="fct_tax_summary_row fct_tax_summary_row--total">
540 <span class="fct_tax_summary_row_label"><?php esc_html_e('Payable now (added)', 'fluent-cart'); ?></span>
541 <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($payableTax)); ?></span>
542 </div>
543 <?php endif; ?>
544 <?php else : ?>
545 <?php if ($payableTax > 0) : ?>
546 <div class="fct_tax_summary_row fct_tax_summary_row--total">
547 <span class="fct_tax_summary_row_label"><?php esc_html_e('Total payable tax', 'fluent-cart'); ?></span>
548 <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($payableTax)); ?></span>
549 </div>
550 <?php endif; ?>
551 <?php if ($inclusiveTax > 0 || $inclusiveFeeTax > 0) : ?>
552 <div class="fct_tax_summary_row fct_tax_summary_row--muted">
553 <span class="fct_tax_summary_row_label"><?php esc_html_e('Total tax in this order', 'fluent-cart'); ?></span>
554 <span class="fct_tax_summary_row_amount"><?php echo esc_html(Helper::toDecimal($totalOrderTax)); ?></span>
555 </div>
556 <?php endif; ?>
557 <?php endif; ?>
558 <?php endif; ?>
559 </div>
560 </div>
561 </li>
562 <?php
563 }
564
565 public function maybeRestoreRcAdjustedPrices($data)
566 {
567 if ($this->isEnabled()) {
568 return; // full recalculation handled by recalculateTax registered below
569 }
570
571 $cart = Arr::get($data, 'cart');
572 $cartLines = (array) $cart->cart_data;
573 $checkoutData = $cart->checkout_data;
574
575 $hasRcMeta = !empty(Arr::get($checkoutData, 'tax_data.rc_adjusted_fees'));
576
577 if (!$hasRcMeta) {
578 foreach ($cartLines as $line) {
579 if (
580 Arr::get($line, 'line_meta.original_unit_price') !== null ||
581 Arr::get($line, 'other_info.original_signup_fee') !== null
582 ) {
583 $hasRcMeta = true;
584 break;
585 }
586 }
587 }
588
589 if (!$hasRcMeta) {
590 return;
591 }
592
593 $this->recalculateTax($data);
594 }
595
596 public function recalculateTax($data)
597 {
598 $cart = Arr::get($data, 'cart');
599
600 // Get all fees (stored + dynamic) at gross (pre-RC) amounts so dynamic
601 // fees added via fluent_cart/cart/fees are included in the tax pipeline.
602 // Temporarily remove applyRcFeeAdjustments so it cannot override dynamic
603 // fee amounts with previously-stored RC-net values; calculateCartTax()
604 // recomputes the RC adjustment from scratch if RC is still active.
605 $checkoutData = $cart->checkout_data;
606 remove_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20);
607 $cart->clearFeeCache();
608 try {
609 $checkoutData['fees'] = $cart->getFees();
610 } finally {
611 add_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20, 2);
612 $cart->clearFeeCache();
613 }
614
615 $fillData = $this->calculateCartTax([
616 'cart_data' => $cart->cart_data,
617 'checkout_data' => $checkoutData
618 ]);
619
620 $cart->fill($fillData);
621 $cart->save();
622 }
623
624 public function maybeRecalculateTaxAmount($fillData, $data)
625 {
626 $changes = Arr::get($data, 'changes', []);
627
628 $watchings = array_filter($changes, function ($value, $key) {
629 return preg_match('/^(billing_|shipping_|ship_to_|fct_billing_tax|is_business)/i', $key);
630 }, ARRAY_FILTER_USE_BOTH);
631
632 if (empty($watchings)) {
633 return $fillData;
634 }
635
636 if (isset($changes['is_business']) && $changes['is_business'] === 'no') {
637 $checkoutData = Arr::get($fillData, 'checkout_data', []);
638 if (Arr::get($checkoutData, 'tax_data.valid', false)) {
639 $checkoutData['tax_data']['valid'] = false;
640 unset($checkoutData['tax_data']['name']);
641 unset($checkoutData['tax_data']['address']);
642 unset($checkoutData['tax_data']['country']);
643 $fillData['checkout_data'] = $checkoutData;
644 }
645 }
646
647 // Persist dynamic fees so tax pipeline can see them. Use gross (pre-RC)
648 // amounts to prevent compounding when the address changes while RC is active.
649 $cart = Arr::get($data, 'cart');
650 if ($cart) {
651 $checkoutData = Arr::get($fillData, 'checkout_data', []);
652 remove_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20);
653 $cart->clearFeeCache();
654 try {
655 $checkoutData['fees'] = $cart->getFees();
656 } finally {
657 add_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20, 2);
658 $cart->clearFeeCache();
659 }
660 $fillData['checkout_data'] = $checkoutData;
661 }
662
663 $fillData['checkout_data'] = $this->maybeInvalidateVatValidationForCountryChange(
664 Arr::get($fillData, 'checkout_data', []),
665 [
666 'ship_to_different' => Arr::get($data, 'prev_data.ship_to_different', Arr::get($fillData, 'checkout_data.form_data.ship_to_different', 'no')),
667 'billing_country' => Arr::get($data, 'prev_data.billing_country', Arr::get($fillData, 'checkout_data.form_data.billing_country', '')),
668 'shipping_country' => Arr::get($data, 'prev_data.shipping_country', Arr::get($fillData, 'checkout_data.form_data.shipping_country', '')),
669 ]
670 );
671
672 return $this->calculateCartTax($fillData);
673 }
674
675 public function maybeInvalidateVatValidationForCountryChange($checkoutData, $previousFormData = [])
676 {
677 if (!Arr::get($checkoutData, 'tax_data.valid', false)) {
678 return $checkoutData;
679 }
680
681 $taxCalculationBasis = Arr::get($this->taxSettings, 'tax_calculation_basis', 'shipping');
682
683 $previousApplicableCountry = $this->getTaxApplicableCountry($taxCalculationBasis, [
684 'ship_to_different' => Arr::get($previousFormData, 'ship_to_different', Arr::get($checkoutData, 'form_data.ship_to_different', 'no')),
685 'billing_country' => Arr::get($previousFormData, 'billing_country', Arr::get($checkoutData, 'form_data.billing_country', '')),
686 'shipping_country' => Arr::get($previousFormData, 'shipping_country', Arr::get($checkoutData, 'form_data.shipping_country', '')),
687 ]);
688
689 $currentApplicableCountry = $this->getTaxApplicableCountry(
690 $taxCalculationBasis,
691 Arr::get($checkoutData, 'form_data', [])
692 );
693
694 if ($previousApplicableCountry === $currentApplicableCountry) {
695 return $checkoutData;
696 }
697
698 unset($checkoutData['tax_data']['valid']);
699 unset($checkoutData['tax_data']['name']);
700 unset($checkoutData['tax_data']['address']);
701 unset($checkoutData['tax_data']['country']);
702
703 return $checkoutData;
704 }
705
706 public function getSettings()
707 {
708 if (!empty($this->taxSettings)) {
709 return $this->taxSettings;
710 }
711
712 $defaultSettings = [
713 'tax_inclusion' => 'included',
714 'tax_calculation_basis' => 'shipping',
715 'tax_rounding' => 'item',
716 'checkout_tax_breakdown_display' => 'both',
717 'enable_tax' => 'no',
718 'eu_vat_settings' => [
719 'require_vat_number' => 'no',
720 'local_reverse_charge' => 'no',
721 'reverse_charge_price_mode' => 'fixed',
722 'vat_reverse_excluded_categories' => []
723 ]
724 ];
725
726 $savedSettings = get_option('fluent_cart_tax_configuration_settings', []);
727 $settings = wp_parse_args($savedSettings, $defaultSettings);
728
729 // country_registrations live in fct_meta — inject so all consumers see one shape.
730 $registrations = TaxManager::getInstance()->getEuVatRegistrations();
731 $settings['eu_vat_settings']['country_registrations'] = $registrations;
732
733 return $this->taxSettings = $settings;
734 }
735
736 private function getEffectiveRcMode()
737 {
738 $settings = $this->getSettings();
739 return Arr::get($settings, 'eu_vat_settings.reverse_charge_price_mode', 'fixed');
740 }
741
742 protected function getCheckoutTaxBreakdownDisplayMode()
743 {
744 $mode = Arr::get($this->getSettings(), 'checkout_tax_breakdown_display', 'both');
745
746 if (!in_array($mode, ['both', 'label', 'tooltip'], true)) {
747 return 'both';
748 }
749
750 return $mode;
751 }
752
753 protected function normalizeCheckoutLineTaxRates($item)
754 {
755 $rates = Arr::get($item, 'line_meta.tax_config.rates', []);
756 if (empty($rates) || !is_array($rates)) {
757 return [];
758 }
759
760 $isInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
761 $normalizedRates = [];
762
763 foreach ($rates as $rate) {
764 $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
765 $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
766 $taxableAmount = (int) Arr::get($rate, 'taxable_amount', 0);
767
768 if ($taxAmount <= 0 && $ratePercent <= 0) {
769 continue;
770 }
771
772 $normalizedRates[] = [
773 'label' => (string) Arr::get($rate, 'label', __('Tax', 'fluent-cart')),
774 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
775 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
776 'tax_amount' => $taxAmount,
777 'display_base' => $isInclusive ? max(0, $taxableAmount - $taxAmount) : $taxableAmount,
778 'inclusive' => $isInclusive,
779 ];
780 }
781
782 return $normalizedRates;
783 }
784
785 protected function getCheckoutLineTaxShortLabel($label)
786 {
787 $label = trim((string) $label);
788
789 if (!$label) {
790 return __('Tax', 'fluent-cart');
791 }
792
793 return $label;
794 }
795
796 private function shouldRateStrikethrough($isReversed, $rateIsInclusive, $rcMode)
797 {
798 if (!$isReversed) {
799 return false;
800 }
801 return !$rateIsInclusive || $rcMode === 'dynamic';
802 }
803
804 public function renderCheckoutLineItemTaxLabel($data)
805 {
806 $mode = $this->getCheckoutTaxBreakdownDisplayMode();
807 if (!in_array($mode, ['both', 'label'], true)) {
808 return;
809 }
810
811 $item = Arr::get($data, 'item', []);
812 $rates = $this->normalizeCheckoutLineTaxRates($item);
813 if (empty($rates)) {
814 return;
815 }
816 $cart = CartHelper::getCart();
817 $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
818 $rcMode = $this->getEffectiveRcMode();
819 $this->renderTaxBadges($rates, $isReversed, $rcMode);
820 }
821
822 public function renderCheckoutSetupFeeTaxLabel($data)
823 {
824 $mode = $this->getCheckoutTaxBreakdownDisplayMode();
825 if (!in_array($mode, ['both', 'label'], true)) {
826 return;
827 }
828
829 $item = Arr::get($data, 'item', []);
830 $signupFeeTaxConfig = Arr::get($item, 'signup_fee_tax_config', []);
831 $rates = Arr::get($signupFeeTaxConfig, 'rates', []);
832 if (empty($rates) || !is_array($rates)) {
833 return;
834 }
835
836 $isInclusive = (bool) Arr::get($signupFeeTaxConfig, 'inclusive', false);
837 $normalizedRates = [];
838 foreach ($rates as $rate) {
839 $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
840 $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
841 if ($taxAmount <= 0 && $ratePercent <= 0) {
842 continue;
843 }
844 $normalizedRates[] = [
845 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
846 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
847 'inclusive' => $isInclusive,
848 'tax_amount' => $taxAmount,
849 ];
850 }
851
852 if (empty($normalizedRates)) {
853 return;
854 }
855 $cart = CartHelper::getCart();
856 $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
857 $rcMode = $this->getEffectiveRcMode();
858 $this->renderTaxBadges($normalizedRates, $isReversed, $rcMode);
859 }
860
861 private function renderTaxBadges(array $normalizedRates, $isReversed = false, $rcMode = 'fixed')
862 {
863 ?>
864 <div class="fct_item_tax_badges" aria-label="<?php esc_attr_e('Tax breakdown', 'fluent-cart'); ?>">
865 <?php foreach ($normalizedRates as $rate) : ?>
866 <div class="fct_item_tax_badge_row">
867 <span class="fct_item_tax_badge <?php echo esc_attr($rate['inclusive'] ? 'is-inclusive' : 'is-exclusive'); ?>">
868 <span>
869 <?php
870 $badgeText = sprintf(
871 /* translators: %1$s: tax label, %2$s: tax rate percent */
872 __('%1$s (%2$s%%)', 'fluent-cart'),
873 $rate['short_label'],
874 $rate['formatted_rate']
875 );
876 echo esc_html($badgeText);
877 ?>
878 </span>
879 </span>
880 <?php if (!empty($rate['tax_amount']) && (int) $rate['tax_amount'] > 0) : ?>
881 <?php $rateReversedClass = $this->shouldRateStrikethrough($isReversed, $rate['inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
882 <span class="fct_item_tax_badge_amount <?php echo esc_attr($rate['inclusive'] ? 'is-inclusive' : 'is-exclusive'); ?><?php echo esc_attr($rateReversedClass); ?>">
883 <?php
884 $amountText = $rate['inclusive']
885 ? sprintf(
886 /* translators: %1$s: formatted tax amount */
887 __('incl. %1$s', 'fluent-cart'),
888 Helper::toDecimal((int) $rate['tax_amount'])
889 )
890 : sprintf(
891 /* translators: %1$s: formatted tax amount */
892 __('+ %1$s', 'fluent-cart'),
893 Helper::toDecimal((int) $rate['tax_amount'])
894 );
895 echo esc_html($amountText);
896 ?>
897 </span>
898 <?php endif; ?>
899 </div>
900 <?php endforeach; ?>
901 </div>
902 <?php
903 }
904
905 private function getSetupFeeTaxData($item)
906 {
907 $signupFeeTaxConfig = Arr::get($item, 'signup_fee_tax_config', []);
908 $rawRates = Arr::get($signupFeeTaxConfig, 'rates', []);
909 if (empty($rawRates) || !is_array($rawRates)) {
910 return null;
911 }
912
913 $isInclusive = (bool) Arr::get($signupFeeTaxConfig, 'inclusive', false);
914 $rates = [];
915 foreach ($rawRates as $rate) {
916 $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
917 $ratePercent = (float) Arr::get($rate, 'rate_percent', 0);
918 if ($taxAmount <= 0 && $ratePercent <= 0) {
919 continue;
920 }
921 $taxableAmount = (int) Arr::get($rate, 'taxable_amount', 0);
922 $rates[] = [
923 'short_label' => $this->getCheckoutLineTaxShortLabel((string) Arr::get($rate, 'label', '')),
924 'formatted_rate' => Helper::formatTaxRatePercent((float) $ratePercent),
925 'tax_amount' => $taxAmount,
926 'display_base' => $isInclusive ? max(0, $taxableAmount - $taxAmount) : $taxableAmount,
927 ];
928 }
929
930 if (empty($rates)) {
931 return null;
932 }
933
934 $setupFee = (int) Arr::get($item, 'other_info.signup_fee', 0);
935 $totalTax = array_sum(array_map(function ($rate) {
936 return (int) $rate['tax_amount'];
937 }, $rates));
938
939 return [
940 'rates' => $rates,
941 'is_inclusive' => $isInclusive,
942 'total_tax' => $totalTax,
943 'display_total' => $isInclusive ? $setupFee : $setupFee + $totalTax,
944 'primary_label' => Arr::get($rates, '0.short_label', __('Tax', 'fluent-cart')),
945 ];
946 }
947
948 public function renderCheckoutSetupFeeTaxTooltip($data)
949 {
950 $mode = $this->getCheckoutTaxBreakdownDisplayMode();
951 if (!in_array($mode, ['both', 'tooltip'], true)) {
952 return;
953 }
954
955 $item = Arr::get($data, 'item', []);
956 $taxData = $this->getSetupFeeTaxData($item);
957 if (!$taxData) {
958 return;
959 }
960
961 $cart = CartHelper::getCart();
962 $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
963 $rcMode = $this->getEffectiveRcMode();
964
965 $rates = $taxData['rates'];
966 $isInclusive = $taxData['is_inclusive'];
967 $displayTotal = $taxData['display_total'];
968 if ($isReversed) {
969 if ($taxData['is_inclusive'] && $rcMode === 'dynamic') {
970 $displayTotal = $taxData['display_total'] - $taxData['total_tax'];
971 } else {
972 $displayTotal = (int) Arr::get($data, 'item.other_info.signup_fee', 0);
973 }
974 }
975 $tooltipId = 'fct-item-tax-tooltip-' . Helper::getUidSerial();
976 ?>
977 <div class="fct_item_tax_hint">
978 <button
979 type="button"
980 class="fct_item_tax_hint_button"
981 aria-label="<?php esc_attr_e('View tax breakdown for this item', 'fluent-cart'); ?>"
982 aria-describedby="<?php echo esc_attr($tooltipId); ?>"
983 >
984 <span aria-hidden="true">i</span>
985 </button>
986 <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
987 <span class="fct_item_tax_tooltip_heading">
988 <?php echo esc_html($isInclusive ? __('Tax-inclusive price', 'fluent-cart') : __('Tax-exclusive price', 'fluent-cart')); ?>
989 </span>
990 <?php foreach ($rates as $rate) : ?>
991 <?php $lineReversedClass = $this->shouldRateStrikethrough($isReversed, isset($rate['inclusive']) ? (bool)$rate['inclusive'] : $taxData['is_inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
992 <span class="fct_item_tax_tooltip_line<?php echo esc_attr($lineReversedClass); ?>">
993 <?php
994 $lineText = sprintf(
995 /* translators: %1$s: tax base amount, %2$s: tax label, %3$s: tax rate percent, %4$s: tax amount */
996 __('Base %1$s + %2$s %3$s%% %4$s', 'fluent-cart'),
997 Helper::toDecimal($rate['display_base']),
998 $rate['short_label'],
999 $rate['formatted_rate'],
1000 Helper::toDecimal($rate['tax_amount'])
1001 );
1002 echo esc_html($lineText);
1003 ?>
1004 </span>
1005 <?php endforeach; ?>
1006 <span class="fct_item_tax_tooltip_line is-total">
1007 <?php
1008 echo esc_html(sprintf(
1009 /* translators: %1$s: line total amount */
1010 __('Total %1$s', 'fluent-cart'),
1011 Helper::toDecimal($displayTotal)
1012 ));
1013 ?>
1014 </span>
1015 </div>
1016 </div>
1017 <?php
1018 }
1019
1020 public function renderCheckoutSetupFeeTaxInfo($data)
1021 {
1022 $mode = $this->getCheckoutTaxBreakdownDisplayMode();
1023 if (!in_array($mode, ['both', 'label'], true)) {
1024 return;
1025 }
1026
1027 $item = Arr::get($data, 'item', []);
1028 $taxData = $this->getSetupFeeTaxData($item);
1029 if (!$taxData) {
1030 return;
1031 }
1032
1033 $isInclusive = $taxData['is_inclusive'];
1034 $totalTax = $taxData['total_tax'];
1035 $primaryLabel = $taxData['primary_label'];
1036
1037 $priceNote = $isInclusive
1038 ? sprintf(
1039 /* translators: %1$s: tax label */
1040 __('%1$s incl.', 'fluent-cart'),
1041 $primaryLabel
1042 )
1043 : sprintf(
1044 /* translators: %1$s: tax amount, %2$s: tax label */
1045 __('+ %1$s %2$s', 'fluent-cart'),
1046 Helper::toDecimal($totalTax),
1047 strtolower($primaryLabel)
1048 );
1049 ?>
1050 <span class="fct_setup_fee_price_note"><?php echo esc_html($priceNote); ?></span>
1051 <?php
1052 }
1053
1054 public function renderCheckoutLineItemTaxTooltip($data)
1055 {
1056 $mode = $this->getCheckoutTaxBreakdownDisplayMode();
1057 if (!in_array($mode, ['both', 'tooltip'], true)) {
1058 return;
1059 }
1060
1061 $item = Arr::get($data, 'item', []);
1062 $rates = $this->normalizeCheckoutLineTaxRates($item);
1063 if (empty($rates)) {
1064 return;
1065 }
1066
1067 $cart = CartHelper::getCart();
1068 $isReversed = $cart ? $this->isReverseChargeCheckout($cart->checkout_data) : false;
1069 $rcMode = $this->getEffectiveRcMode();
1070
1071 $isInclusive = (bool) Arr::get($rates, '0.inclusive', false);
1072 $itemSubtotal = (int) Arr::get($item, 'line_total', Arr::get($item, 'subtotal', 0));
1073 $itemTaxAmount = array_sum(array_map(function ($rate) {
1074 return (int) Arr::get($rate, 'tax_amount', 0);
1075 }, $rates));
1076 if ($isReversed) {
1077 if ($isInclusive && $rcMode === 'dynamic') {
1078 $displayTotal = $itemSubtotal - $itemTaxAmount;
1079 } else {
1080 $displayTotal = $itemSubtotal;
1081 }
1082 } else {
1083 $displayTotal = $isInclusive ? $itemSubtotal : $itemSubtotal + $itemTaxAmount;
1084 }
1085 $tooltipId = 'fct-item-tax-tooltip-' . Helper::getUidSerial();
1086 ?>
1087 <div class="fct_item_tax_hint">
1088 <button
1089 type="button"
1090 class="fct_item_tax_hint_button"
1091 aria-label="<?php esc_attr_e('View tax breakdown for this item', 'fluent-cart'); ?>"
1092 aria-describedby="<?php echo esc_attr($tooltipId); ?>"
1093 >
1094 <span aria-hidden="true">i</span>
1095 </button>
1096 <div class="fct_item_tax_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
1097 <span class="fct_item_tax_tooltip_heading">
1098 <?php echo esc_html($isInclusive ? __('Tax-inclusive price', 'fluent-cart') : __('Tax-exclusive price', 'fluent-cart')); ?>
1099 </span>
1100 <?php foreach ($rates as $rate) : ?>
1101 <?php $lineReversedClass = $this->shouldRateStrikethrough($isReversed, $rate['inclusive'], $rcMode) ? ' is-reversed' : ''; ?>
1102 <span class="fct_item_tax_tooltip_line<?php echo esc_attr($lineReversedClass); ?>">
1103 <?php
1104 $lineText = sprintf(
1105 /* translators: %1$s: tax base amount, %2$s: tax label, %3$s: tax rate percent, %4$s: tax amount */
1106 __('Base %1$s + %2$s %3$s%% %4$s', 'fluent-cart'),
1107 Helper::toDecimal($rate['display_base']),
1108 $rate['short_label'],
1109 $rate['formatted_rate'],
1110 Helper::toDecimal($rate['tax_amount'])
1111 );
1112 echo esc_html($lineText);
1113 ?>
1114 </span>
1115 <?php endforeach; ?>
1116 <span class="fct_item_tax_tooltip_line is-total">
1117 <?php
1118 echo esc_html(sprintf(
1119 /* translators: %1$s: line total amount */
1120 __('Total %1$s', 'fluent-cart'),
1121 Helper::toDecimal($displayTotal)
1122 ));
1123 ?>
1124 </span>
1125 </div>
1126 </div>
1127 <?php
1128 }
1129
1130 public function renderCheckoutLineItemTaxInfo($data)
1131 {
1132 $mode = $this->getCheckoutTaxBreakdownDisplayMode();
1133 if (!in_array($mode, ['both', 'label'], true)) {
1134 return;
1135 }
1136
1137 $item = Arr::get($data, 'item', []);
1138 $rates = $this->normalizeCheckoutLineTaxRates($item);
1139 if (empty($rates)) {
1140 return;
1141 }
1142
1143 $isInclusive = (bool) Arr::get($rates, '0.inclusive', false);
1144 $itemTaxAmount = array_sum(array_map(function ($rate) {
1145 return (int) Arr::get($rate, 'tax_amount', 0);
1146 }, $rates));
1147 $primaryLabel = Arr::get($rates, '0.short_label', __('Tax', 'fluent-cart'));
1148
1149 $priceNote = $isInclusive
1150 ? sprintf(
1151 /* translators: %1$s: tax label */
1152 __('%1$s incl.', 'fluent-cart'),
1153 $primaryLabel
1154 )
1155 : sprintf(
1156 /* translators: %1$s: tax amount, %2$s: tax label */
1157 __('+ %1$s %2$s', 'fluent-cart'),
1158 Helper::toDecimal($itemTaxAmount),
1159 strtolower($primaryLabel)
1160 );
1161 ?>
1162 <div class="fct_item_tax_price_note"><?php echo esc_html($priceNote); ?></div>
1163 <?php
1164 }
1165
1166 public function renderUnitPriceRoundingTooltip($data)
1167 {
1168 $item = Arr::get($data, 'item', []);
1169 $quantity = (int) Arr::get($item, 'quantity', 1);
1170
1171 if ($quantity < 2) {
1172 return;
1173 }
1174
1175 $unitPrice = (int) Arr::get($item, 'unit_price', 0);
1176 $subtotal = (int) Arr::get($item, 'subtotal', 0);
1177
1178 if ($unitPrice <= 0) {
1179 return;
1180 }
1181
1182 $shouldShow = false;
1183
1184 // Case 1 — TaxModule RC dynamic: the applied per-unit tax adjustment is rounded,
1185 // so unit_price * qty may differ from the exact net by up to (qty - 1) cents.
1186 $rcAdjustment = (int) Arr::get($item, 'line_meta.reverse_charge_adjustment', 0);
1187 if ($rcAdjustment > 0) {
1188 $rates = (array) Arr::get($item, 'line_meta.tax_config.rates', []);
1189 $actualTaxTotal = (int) array_sum(array_map('intval', array_column($rates, 'tax_amount')));
1190 $diff = abs($rcAdjustment - $actualTaxTotal);
1191 if ($diff > 0 && $diff <= $quantity) {
1192 $shouldShow = true;
1193 }
1194 }
1195
1196 // Case 2 — generic: unit_price * qty already doesn't match subtotal
1197 // (future dynamic pricing or any other module that stores a rounded unit_price)
1198 if (!$shouldShow && $subtotal > 0) {
1199 $diff = abs(($unitPrice * $quantity) - $subtotal);
1200 if ($diff > 0 && $diff <= $quantity) {
1201 $shouldShow = true;
1202 }
1203 }
1204
1205 if (!$shouldShow) {
1206 return;
1207 }
1208
1209 $tooltipId = 'fct-unit-price-rounding-' . Helper::getUidSerial();
1210 ?>
1211 <div class="fct_item_tax_hint">
1212 <button
1213 type="button"
1214 class="fct_item_tax_hint_button"
1215 aria-label="<?php esc_attr_e('Unit price rounding information', 'fluent-cart'); ?>"
1216 aria-describedby="<?php echo esc_attr($tooltipId); ?>"
1217 >
1218 <span aria-hidden="true">i</span>
1219 </button>
1220 <div class="fct_item_tax_tooltip fct_unit_price_rounding_tooltip" id="<?php echo esc_attr($tooltipId); ?>" role="tooltip">
1221 <?php esc_html_e('Unit price is rounded for display. The line total is calculated at full precision, so it always reconciles exactly.', 'fluent-cart'); ?>
1222 </div>
1223 </div>
1224 <?php
1225 }
1226
1227 public function isEnabled()
1228 {
1229 $settings = $this->getSettings();
1230 return Arr::get($settings, 'enable_tax', 'no') === 'yes';
1231 }
1232
1233 public function calculateCartTax($fillData)
1234 {
1235 $lineItems = Arr::get($fillData, 'cart_data', []);
1236 $checkoutData = Arr::get($fillData, 'checkout_data', []);
1237
1238 // Pre-restore: undo any previous dynamic RC unit_price adjustment before TaxCalculator
1239 // runs, so it always sees original gross prices. Fixes: rounding residual in subtotal,
1240 // incorrect tax badge amounts after RC removal, and signup_fee_tax being zeroed too early.
1241 foreach ($lineItems as &$lineItem) {
1242 if (isset($lineItem['line_meta']['original_unit_price'])) {
1243 $qty = max(1, (int) Arr::get($lineItem, 'quantity', 1));
1244 $lineItem['unit_price'] = (int) $lineItem['line_meta']['original_unit_price'];
1245 $lineItem['subtotal'] = $lineItem['unit_price'] * $qty;
1246 $lineItem['line_total'] = max(0, $lineItem['subtotal'] - (int) Arr::get($lineItem, 'discount_total', 0));
1247 unset($lineItem['line_meta']['original_unit_price'], $lineItem['line_meta']['reverse_charge_adjustment']);
1248 }
1249 if (isset($lineItem['other_info']['original_signup_fee'])) {
1250 $lineItem['other_info']['signup_fee'] = (int) $lineItem['other_info']['original_signup_fee'];
1251 unset($lineItem['other_info']['original_signup_fee']);
1252 }
1253 }
1254 unset($lineItem);
1255
1256 // Tax is disabled — return the pre-restored line items untouched.
1257 // Pre-restore above already reverted any dynamic RC unit_price adjustments,
1258 // so this also handles the case where RC was applied before tax was disabled.
1259 if (!$this->isEnabled()) {
1260 if (!empty($fillData['checkout_data']['tax_data']['valid'])) {
1261 $fillData['checkout_data']['tax_data']['valid'] = false;
1262 }
1263 $fillData['cart_data'] = $lineItems;
1264 return $fillData;
1265 }
1266
1267 $country = '';
1268 $state = '';
1269 $postCode = '';
1270
1271 $taxSettings = $this->getSettings();
1272
1273 $taxCalculationBasis = Arr::get($taxSettings, 'tax_calculation_basis', 'shipping');
1274
1275 //$checkoutData = $cart->checkout_data;
1276 if ($taxCalculationBasis === 'shipping' && Arr::get($checkoutData, 'form_data.ship_to_different', '') !== 'yes') {
1277 $taxCalculationBasis = 'billing';
1278 }
1279
1280 if ($taxCalculationBasis === 'shipping') {
1281 $country = Arr::get($checkoutData, 'form_data.shipping_country', '');
1282 $state = Arr::get($checkoutData, 'form_data.shipping_state', '');
1283 $city = Arr::get($checkoutData, 'form_data.shipping_city', '');
1284 $postCode = Arr::get($checkoutData, 'form_data.shipping_postcode', '');
1285 } elseif ($taxCalculationBasis === 'billing') {
1286 $country = Arr::get($checkoutData, 'form_data.billing_country', '');
1287 $state = Arr::get($checkoutData, 'form_data.billing_state', '');
1288 $city = Arr::get($checkoutData, 'form_data.billing_city', '');
1289 $postCode = Arr::get($checkoutData, 'form_data.billing_postcode', '');
1290 } elseif ($taxCalculationBasis === 'store') {
1291 $storeSettings = new StoreSettings();
1292 $country = $storeSettings->get('store_country');
1293 $state = $storeSettings->get('store_state');
1294 $city = $storeSettings->get('store_city');
1295 $postCode = $storeSettings->get('store_postcode');
1296 }
1297
1298 $fees = (array)Arr::get($checkoutData, 'fees', []);
1299 $feeItems = [];
1300 foreach ($fees as $fee) {
1301 if (!empty($fee['taxable']) && !empty($fee['amount'])) {
1302 $feeItems[] = Cart::buildFeeCartItem($fee);
1303 }
1304 }
1305
1306 $allItems = array_merge($lineItems, $feeItems);
1307
1308 $taxCalculator = new TaxCalculator($allItems, [
1309 'inclusive' => false,
1310 'country' => $country,
1311 'state' => $state,
1312 'city' => $city,
1313 'postcode' => $postCode,
1314 'tax_rounding' => Arr::get($taxSettings, 'tax_rounding', 'item'),
1315 ]);
1316
1317 if (empty($checkoutData['tax_data'])) {
1318 $checkoutData['tax_data'] = [];
1319 }
1320
1321 $taxTotal = $taxCalculator->getTotalTax();
1322 $exclusiveTaxTotal = $taxCalculator->getExclusiveTaxTotal();
1323 $shippingTax = $taxCalculator->getShippingTax();
1324 $shippingTaxLines = $taxCalculator->getShippingTaxByRates();
1325 $taxCountry = $taxCalculator->getTaxCountry();
1326 $taxLines = $taxCalculator->getTaxLinesByRates();
1327
1328
1329 // Separate product and fee items from taxed lines
1330 $allTaxedLines = $taxCalculator->getTaxedLines();
1331
1332 $productLines = [];
1333 $feeTax = 0;
1334 $feeTaxLines = [];
1335 foreach ($allTaxedLines as $taxedLine) {
1336 if (!empty($taxedLine['is_fee'])) {
1337 $taxAmount = (int) Arr::get($taxedLine, 'tax_amount', 0);
1338 $feeTax += $taxAmount;
1339 $feeTaxLines[] = [
1340 'label' => Arr::get($taxedLine, 'title', ''),
1341 'tax_amount' => $taxAmount,
1342 'inclusive' => (bool) Arr::get($taxedLine, 'line_meta.tax_config.inclusive', false),
1343 ];
1344 } else {
1345 $productLines[] = $taxedLine;
1346 }
1347 }
1348
1349 $signupFeeTaxTotal = 0;
1350 foreach ($productLines as $taxedLine) {
1351 if (Arr::get($taxedLine, 'other_info.payment_type') === 'subscription') {
1352 $signupFeeTaxTotal += (int) Arr::get($taxedLine, 'other_info.signup_fee_tax', 0);
1353 }
1354 }
1355
1356 $shouldApplyReverseCharge = $this->shouldApplyReverseCharge($checkoutData, $country, $lineItems);
1357 $rcMode = $this->getEffectiveRcMode();
1358
1359 // Capture signup_fee_tax BEFORE the RC zeroing block sets it to 0.
1360 $signupFeeTaxesByIndex = [];
1361 if ($shouldApplyReverseCharge && $rcMode === 'dynamic') {
1362 foreach ($productLines as $idx => $pLine) {
1363 $signupFeeTaxesByIndex[$idx] = (int) Arr::get($pLine, 'other_info.signup_fee_tax', 0);
1364 }
1365 }
1366
1367 $inclusiveTaxAdjustment = 0;
1368 $reversedTaxTotal = 0;
1369 $reversedShippingTax = 0;
1370 if ($shouldApplyReverseCharge) {
1371 $inclusivePortion = $taxTotal - $exclusiveTaxTotal - $feeTax;
1372 $reversedInclusive = ($rcMode === 'dynamic') ? $inclusivePortion : 0;
1373 $reversedTaxTotal = $exclusiveTaxTotal + $feeTax + $shippingTax + $reversedInclusive;
1374 $inclusiveTaxAdjustment = $inclusivePortion;
1375 $reversedShippingTax = $shippingTax;
1376 $taxTotal = 0;
1377 $exclusiveTaxTotal = 0;
1378 $shippingTax = 0;
1379 $shippingTaxLines = [];
1380 $feeTax = 0;
1381 $feeTaxLines = [];
1382 $signupFeeTaxTotal = 0;
1383 $taxLines = array_map(function ($taxLine) {
1384 $taxLine['tax_amount'] = 0;
1385 return $taxLine;
1386 }, $taxLines);
1387 // Also zero out tax_amount in product lines to prevent order items from having non-zero tax
1388 $productLines = array_map(function ($productLine) {
1389 $productLine['tax_amount'] = 0;
1390 if (Arr::get($productLine, 'other_info.payment_type') === 'subscription') {
1391 $productLine['other_info']['signup_fee_tax'] = 0;
1392 $productLine['other_info']['first_iteration_tax'] = 0;
1393 }
1394 return $productLine;
1395 }, $productLines);
1396 }
1397
1398 if ($shouldApplyReverseCharge && $rcMode === 'dynamic') {
1399
1400 // --- Product lines: adjust unit_price to net for inclusive-tax lines ---
1401 // Pre-restore guarantees gross prices and cleared meta on every pass — no idempotency guard needed.
1402 foreach ($productLines as $lineIdx => &$line) {
1403 $isInclusive = (bool) Arr::get($line, 'line_meta.tax_config.inclusive', false);
1404 if (!$isInclusive) {
1405 continue;
1406 }
1407
1408 $rateAmounts = Arr::get($line, 'line_meta.tax_config.rates', []);
1409 $adjustment = (int) array_sum(array_column($rateAmounts, 'tax_amount'));
1410
1411 if ($adjustment > 0) {
1412 $adjustment = max(0, min($adjustment, (int) $line['subtotal']));
1413 $adjustment = (int) apply_filters('fluent_cart/tax/reverse_charge_line_adjustment', $adjustment, [
1414 'line' => $line,
1415 'rc_mode' => $rcMode,
1416 ]);
1417 // Re-clamp after filter — prevents a buggy filter from producing negative unit_price
1418 $adjustment = max(0, min($adjustment, (int) $line['subtotal']));
1419
1420 $quantity = max(1, (int) $line['quantity']);
1421 $adjustmentPerUnit = (int) round($adjustment / $quantity, 0, PHP_ROUND_HALF_UP);
1422
1423 if ($adjustmentPerUnit > 0) {
1424 $newUnitPrice = $line['unit_price'] - $adjustmentPerUnit;
1425 $line['line_meta']['original_unit_price'] = $line['unit_price'];
1426 $line['unit_price'] = $newUnitPrice;
1427 // Use exact subtraction instead of newUnitPrice * quantity.
1428 // When $adjustment doesn't divide evenly by $quantity,
1429 // round($adjustment/$quantity) * $quantity can exceed $adjustment
1430 // by up to ($quantity - 1) cents, making the stored subtotal
1431 // 1 cent short. E.g. 3 × $10, $5 tax → round(500/3)=167,
1432 // 167×3=501 ≠ 500, subtotal becomes $24.99 instead of $25.00.
1433 $line['subtotal'] = (int) $line['subtotal'] - $adjustment;
1434 $line['line_total'] = max(0, $line['subtotal'] - (int) Arr::get($line, 'discount_total', 0));
1435 $line['line_meta']['reverse_charge_adjustment'] = $adjustment;
1436 }
1437 }
1438
1439 // Signup fee adjustment — use pre-captured value (RC block zeroes other_info.signup_fee_tax)
1440 $signupFee = (int) Arr::get($line, 'other_info.signup_fee', 0);
1441 $signupFeeTax = isset($signupFeeTaxesByIndex[$lineIdx]) ? $signupFeeTaxesByIndex[$lineIdx] : 0;
1442 if ($signupFee > 0 && $signupFeeTax > 0) {
1443 $line['other_info']['original_signup_fee'] = $signupFee;
1444 $line['other_info']['signup_fee'] = max(0, $signupFee - $signupFeeTax);
1445 }
1446
1447 }
1448 unset($line);
1449
1450 // --- Inclusive fee items: compute net amounts ---
1451 $rcAdjustedFees = [];
1452 foreach ($allTaxedLines as $taxedLine) {
1453 if (empty($taxedLine['is_fee'])) {
1454 continue;
1455 }
1456 $feeTaxConfig = Arr::get($taxedLine, 'line_meta.tax_config', []);
1457 $isFeeInclusive = (bool) Arr::get($feeTaxConfig, 'inclusive', false);
1458 if (!$isFeeInclusive) {
1459 continue;
1460 }
1461 $feeKey = Arr::get($taxedLine, 'other_info.fee_key', '');
1462 $feeSource = Arr::get($taxedLine, 'other_info.source', 'custom');
1463 $feeItemTax = (int) array_sum(array_column(Arr::get($feeTaxConfig, 'rates', []), 'tax_amount'));
1464 $feeAmount = (int) $taxedLine['unit_price'];
1465 if ($feeKey && $feeItemTax > 0) {
1466 $rcAdjustedFees[$feeSource . ':' . $feeKey] = max(0, $feeAmount - $feeItemTax);
1467 }
1468 }
1469 $checkoutData['tax_data']['rc_adjusted_fees'] = $rcAdjustedFees;
1470
1471 // Disarm the estimated_total filter — total is already net via unit_price
1472 $inclusiveTaxAdjustment = 0;
1473
1474 do_action('fluent_cart/tax/reverse_charge_applied', [
1475 'checkout_data' => $checkoutData,
1476 'product_lines' => $productLines,
1477 ]);
1478
1479 } else {
1480
1481 // Pre-restore already returned product lines to gross prices and cleared RC meta.
1482 // Clear fee adjustments — priority-20 filter becomes no-op on next getFees() call.
1483 unset($checkoutData['tax_data']['rc_adjusted_fees']);
1484
1485 if (!$shouldApplyReverseCharge) {
1486 do_action('fluent_cart/tax/reverse_charge_removed', [
1487 'checkout_data' => $checkoutData,
1488 'product_lines' => $productLines,
1489 ]);
1490 }
1491 }
1492
1493 $checkoutData['tax_data']['tax_total'] = $taxTotal;
1494 $checkoutData['tax_data']['exclusive_tax_total'] = $exclusiveTaxTotal;
1495 $checkoutData['tax_data']['reverse_charge_inclusive_adjustment'] = $inclusiveTaxAdjustment;
1496 $checkoutData['tax_data']['reverse_charge_tax_total'] = $reversedTaxTotal;
1497 $checkoutData['tax_data']['tax_behavior'] = $taxTotal === 0 && $shippingTax === 0 && $shouldApplyReverseCharge
1498 ? 0
1499 : $taxCalculator->getTaxBehaviorValue();
1500
1501 // NEW: always expose store-level inclusive mode separately
1502 $checkoutData['tax_data']['store_tax_behavior'] = $taxCalculator->getStoreTaxBehaviorValue();
1503
1504 $checkoutData['tax_data']['tax_country'] = $taxCountry;
1505 $checkoutData['tax_data']['shipping_tax'] = $shippingTax;
1506 $checkoutData['tax_data']['shipping_tax_lines'] = $shippingTaxLines;
1507 $checkoutData['tax_data']['reverse_charge_shipping_tax'] = $reversedShippingTax;
1508 $checkoutData['tax_data']['reverse_charge_price_mode'] = $shouldApplyReverseCharge ? $this->getEffectiveRcMode() : 'fixed';
1509 $checkoutData['tax_data']['fee_tax'] = $feeTax;
1510 $checkoutData['tax_data']['fee_tax_lines'] = $feeTaxLines;
1511 $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal;
1512 $checkoutData['tax_data']['tax_lines'] = $taxLines;
1513 $fillData['checkout_data'] = $checkoutData;
1514
1515 // Only product lines go back into cart_data
1516 $fillData['cart_data'] = $productLines;
1517
1518 if (isset($fillData['hook_changes'])) {
1519 Arr::set($fillData, 'hook_changes.tax', true);
1520 }
1521
1522 return $fillData;
1523 }
1524
1525 protected function shouldApplyReverseCharge($checkoutData, $taxApplicableCountry, $lineItems = [])
1526 {
1527 if (!Arr::get($checkoutData, 'tax_data.valid', false)) {
1528 return false;
1529 }
1530
1531 $validatedCountry = Arr::get($checkoutData, 'tax_data.country', '');
1532 if (!$validatedCountry || $validatedCountry !== $taxApplicableCountry) {
1533 return false;
1534 }
1535
1536 if (!$this->canApplyVatValidation($taxApplicableCountry)) {
1537 return false;
1538 }
1539
1540 if (Arr::get($this->taxSettings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes') {
1541 $excludedCategories = Arr::get($this->taxSettings, 'eu_vat_settings.vat_reverse_excluded_categories', []);
1542 if (!empty($excludedCategories)) {
1543 $productIds = array_column((array)$lineItems, 'post_id');
1544 if (!empty($productIds)) {
1545 $productTerms = $this->getTermsByProductIds($productIds);
1546 foreach ($productTerms as $terms) {
1547 if (array_intersect($terms, $excludedCategories)) {
1548 return false;
1549 }
1550 }
1551 }
1552 }
1553 }
1554
1555 return true;
1556 }
1557
1558 protected function isReverseChargeCheckout($checkoutData)
1559 {
1560 return Arr::get($checkoutData, 'tax_data.valid', false)
1561 && (int) Arr::get($checkoutData, 'tax_data.tax_behavior', 2) === 0;
1562 }
1563
1564 protected function hasReverseChargeTaxData(array $taxData): bool
1565 {
1566 return (int) Arr::get($taxData, 'reverse_charge_tax_total', 0) > 0
1567 || (int) Arr::get($taxData, 'reverse_charge_shipping_tax', 0) > 0
1568 || (int) Arr::get($taxData, 'reverse_charge_inclusive_adjustment', 0) > 0
1569 || (int) Arr::get($taxData, 'tax_behavior', 2) === 0;
1570 }
1571
1572 public function maybeRerenderEuVatField($fragments, $args)
1573 {
1574 $vatNumberEnabled = CheckoutFieldsSchema::isVatNumberEnabled();
1575
1576 if (!$vatNumberEnabled) {
1577 return $fragments;
1578 }
1579
1580 $changes = Arr::get($args, 'changes', []);
1581 $countryTriggers = ['billing_country', 'shipping_country', 'ship_to_different', 'is_business'];
1582 if (empty(array_intersect(array_keys($changes), $countryTriggers))) {
1583 return $fragments;
1584 }
1585
1586 $cart = Arr::get($args, 'cart');
1587 $taxApplicableCountry = $this->getTaxApplicableCountry(Arr::get($this->taxSettings, 'tax_calculation_basis'), $cart->checkout_data['form_data']);
1588
1589 ob_start();
1590 (new VatFieldRenderer($taxApplicableCountry))->renderInner($cart->checkout_data);
1591 $euVatView = ob_get_clean();
1592
1593 $fragments[] = [
1594 'selector' => '[data-fluent-cart-checkout-page-tax-wrapper]',
1595 'content' => $euVatView,
1596 'type' => 'replace'
1597 ];
1598
1599 return $fragments;
1600 }
1601
1602 public function prepareOtherData($data)
1603 {
1604 $cart = Arr::get($data, 'cart');
1605 $order = Arr::get($data, 'order');
1606
1607 if (empty($cart->checkout_data['tax_data']) || !$order->id || !$cart) {
1608 return;
1609 }
1610
1611 $checkoutData = $cart->checkout_data;
1612
1613 // Order-placement safety: if the cart carries a stale RC state (admin turned off
1614 // local_reverse_charge after the customer validated their VAT but before they
1615 // placed the order), recalculate at full rate and patch the order totals before
1616 // any tax records are written.
1617 if ($this->isReverseChargeCheckout($checkoutData)) {
1618 $rcTaxCountry = Arr::get($checkoutData, 'tax_data.tax_country', '');
1619 if ($rcTaxCountry && !$this->canApplyVatValidation($rcTaxCountry)) {
1620 $refreshed = $this->calculateCartTax([
1621 'cart_data' => $cart->cart_data,
1622 'checkout_data' => $checkoutData,
1623 ]);
1624 $checkoutData = $refreshed['checkout_data'];
1625
1626 $newTaxTotal = (int) Arr::get($checkoutData, 'tax_data.tax_total', 0);
1627 $newShippingTax = (int) Arr::get($checkoutData, 'tax_data.shipping_tax', 0);
1628 $newTaxBehavior = (int) Arr::get($checkoutData, 'tax_data.tax_behavior', 0);
1629 $storeTaxBehavior = (int) Arr::get($checkoutData, 'tax_data.store_tax_behavior', $newTaxBehavior);
1630 $exclusiveTaxTotal = (int) Arr::get($checkoutData, 'tax_data.exclusive_tax_total', 0);
1631 $feeTax = (int) Arr::get($checkoutData, 'tax_data.fee_tax', 0);
1632
1633 // total_amount was computed with RC-zeroed tax, and the draft's fee_total never
1634 // absorbed fee_tax (behavior was 0 then) — so the full additive portion goes on top.
1635 $addedTax = 0;
1636 if ($newTaxBehavior === 1) {
1637 $addedTax = $newTaxTotal + $newShippingTax;
1638 } elseif ($newTaxBehavior === 3) {
1639 $addedTax = $exclusiveTaxTotal;
1640 if ($storeTaxBehavior === 1) {
1641 $addedTax += $feeTax + $newShippingTax;
1642 }
1643 }
1644
1645 $order->tax_total = $newTaxTotal;
1646 $order->shipping_tax = $newShippingTax;
1647 $order->tax_behavior = $newTaxBehavior;
1648 $order->total_amount = $order->total_amount + $addedTax;
1649 $order->save();
1650
1651 // CheckoutProcessor::persistTaxMeta() already ran with the RC-zeroed values.
1652 $order->updateMeta('exclusive_tax_total', $exclusiveTaxTotal);
1653 $order->updateMeta('store_tax_behavior', $storeTaxBehavior);
1654 $order->updateMeta('fee_tax', $feeTax);
1655 $feeTaxLines = (array) Arr::get($checkoutData, 'tax_data.fee_tax_lines', []);
1656 if (!empty($feeTaxLines)) {
1657 $order->updateMeta('fee_tax_lines', $feeTaxLines);
1658 }
1659
1660 if ($addedTax > 0) {
1661 // The pending charge transaction was created from the pre-patch total —
1662 // sync it so the gateway charges the tax-adjusted amount.
1663 $pendingTransaction = \FluentCart\App\Models\OrderTransaction::query()
1664 ->where('order_id', $order->id)
1665 ->where('transaction_type', \FluentCart\App\Helpers\Status::TRANSACTION_TYPE_CHARGE)
1666 ->where('status', \FluentCart\App\Helpers\Status::PAYMENT_PENDING)
1667 ->first();
1668 if ($pendingTransaction) {
1669 $pendingTransaction->total = $order->total_amount;
1670 $pendingTransaction->save();
1671 }
1672 }
1673 }
1674 }
1675
1676 $taxCountry = Arr::get($checkoutData, 'tax_data.tax_country', '');
1677 // add store vat number into tax data
1678 $taxSettings = $this->getSettings();
1679 $isEuCountry = LocalizationManager::getInstance()->isEuTaxCountry($taxCountry ?? '');
1680
1681 $storeVatNumber = '';
1682 if ($isEuCountry) {
1683 $euVatMethod = Arr::get($taxSettings, 'eu_vat_settings.method');
1684 if ($euVatMethod === 'home') {
1685 $storeVatNumber = Arr::get($taxSettings, 'eu_vat_settings.home_vat', '');
1686 } elseif ($euVatMethod === 'oss') {
1687 $storeVatNumber = Arr::get($taxSettings, 'eu_vat_settings.oss_vat', '');
1688 }
1689 }
1690 if (empty($storeVatNumber)) {
1691 $key = 'fluent_cart_tax_id_' . $taxCountry;
1692 $taxCountryData = \FluentCart\App\Models\Meta::query()->where('meta_key', $key)->where('object_type', 'tax')->first();
1693 if ($taxCountryData) {
1694 $storeVatNumber = Arr::get($taxCountryData->meta_value, 'tax_id', '');
1695 }
1696 }
1697
1698 $customerVatData = [];
1699 $isReverseChargeApplied = $this->isReverseChargeCheckout($checkoutData);
1700 if (Arr::get($cart->checkout_data, 'tax_data.valid', false)) {
1701 $customerVatData = Arr::get($checkoutData, 'tax_data');
1702 $order = $data['order'];
1703 $order->customer->updateMeta('customer_tax_info',
1704 Arr::only($customerVatData, ['vat_number', 'country', 'valid', 'name', 'address'])
1705 );
1706 }
1707
1708 $taxMeta = [
1709 'tax_country' => $taxCountry,
1710 'store_vat_number' => $storeVatNumber,
1711 'reverse_charge_applied' => $isReverseChargeApplied,
1712 'shipping_inclusive' => (int) Arr::get($checkoutData, 'tax_data.store_tax_behavior', 2) === 2,
1713 ];
1714
1715 if ($isReverseChargeApplied && !empty($customerVatData)) {
1716 $taxMeta['vat_reverse'] = $customerVatData;
1717 }
1718
1719 if ($isReverseChargeApplied) {
1720 $taxMeta['reverse_charge_original_tax_total'] = (int) Arr::get(
1721 $checkoutData, 'tax_data.reverse_charge_tax_total', 0
1722 );
1723 $taxMeta['reverse_charge_price_mode'] = $this->getEffectiveRcMode();
1724 $taxMeta['reverse_charge_original_shipping_tax'] = (int) Arr::get(
1725 $checkoutData, 'tax_data.reverse_charge_shipping_tax', 0
1726 );
1727 // Flag: shipping_total was stored at net (VAT already stripped) for this order.
1728 // Display code uses this to skip the rcShippingAdjustment on post-order surfaces.
1729 $rcModeForMeta = (string) Arr::get($checkoutData, 'tax_data.reverse_charge_price_mode', 'fixed');
1730 $storeBehaviorForMeta = (int) Arr::get($checkoutData, 'tax_data.store_tax_behavior', 2);
1731 $rcShippingTaxForMeta = (int) Arr::get($checkoutData, 'tax_data.reverse_charge_shipping_tax', 0);
1732 if ($rcModeForMeta === 'dynamic' && $storeBehaviorForMeta === 2 && $rcShippingTaxForMeta > 0) {
1733 $taxMeta['shipping_net_stored'] = true;
1734 }
1735 }
1736
1737 static::persistTaxRates(
1738 $order->id,
1739 Arr::get($checkoutData, 'tax_data.tax_lines', []),
1740 $taxMeta,
1741 (int) Arr::get($checkoutData, 'tax_data.shipping_tax', 0),
1742 Arr::get($checkoutData, 'tax_data.shipping_tax_lines', [])
1743 );
1744 }
1745
1746 public function storeBusinessInfoOnOrder($data)
1747 {
1748 $cart = Arr::get($data, 'cart');
1749 $order = Arr::get($data, 'order');
1750 $requestData = Arr::get($data, 'request_data', []);
1751
1752 if (!$cart || !$order || !$order->id) {
1753 return;
1754 }
1755
1756 // Snapshot store's business identity at order placement so post-order surfaces
1757 // (receipts, PDFs, emails) show the values that were valid when the order was placed,
1758 // even if the admin later changes them in store settings.
1759 $storeSettings = new StoreSettings();
1760 $order->updateMeta('store_business_info', [
1761 'company_name' => (string) $storeSettings->get('company_name', ''),
1762 'legal_registration_id' => (string) $storeSettings->get('legal_registration_id', ''),
1763 'seller_vat_id' => (string) $storeSettings->get('seller_vat_id', ''),
1764 'seller_tax_id' => (string) $storeSettings->get('seller_tax_id', ''),
1765 ]);
1766
1767 $checkoutData = $cart->checkout_data;
1768
1769 // Prefer the live request value; fall back to cart only when the key was never submitted
1770 $isBusinessCheckout = apply_filters(
1771 'fluent_cart/checkout/is_business',
1772 array_key_exists('is_business', $requestData)
1773 ? Arr::get($requestData, 'is_business', 'no') === 'yes'
1774 : Arr::get($checkoutData, 'form_data.is_business', 'no') === 'yes',
1775 ['checkout_data' => $checkoutData, 'order' => $order]
1776 );
1777
1778 if (!$isBusinessCheckout) {
1779 return;
1780 }
1781
1782 // Use the submitted POST values as the primary source; fall back to the cart's
1783 // persisted checkout_data for any field the DataWatcher may not have saved yet
1784 $taxNumber = sanitize_text_field(
1785 Arr::get($requestData, 'fct_billing_tax_id', '')
1786 ?: Arr::get($checkoutData, 'tax_data.vat_number', '')
1787 );
1788 $companyName = sanitize_text_field(
1789 Arr::get($requestData, 'billing_company_name', '')
1790 ?: Arr::get($checkoutData, 'form_data.billing_company_name', '')
1791 );
1792 $legalRegId = sanitize_text_field(
1793 Arr::get($requestData, 'billing_legal_registration_id', '')
1794 ?: Arr::get($checkoutData, 'form_data.billing_legal_registration_id', '')
1795 );
1796 $declarationNote = sanitize_text_field(Arr::get($checkoutData, 'tax_data.declaration_note', ''));
1797
1798 if ($declarationNote === '') {
1799 $declarationNote = sanitize_text_field(App::request()->get('fct_vat_declaration_note', ''));
1800 }
1801
1802 if (!$taxNumber && !$companyName && !$legalRegId) {
1803 return;
1804 }
1805
1806 $businessInfo = [];
1807
1808 if ($companyName) {
1809 $businessInfo['company_name'] = $companyName;
1810 }
1811
1812 if ($legalRegId) {
1813 $businessInfo['legal_registration_id'] = $legalRegId;
1814 }
1815
1816 if ($taxNumber) {
1817 $businessInfo['tax_number'] = $taxNumber;
1818 $businessInfo['tax_number_validated'] = false;
1819 $businessInfo['tax_number_country'] = '';
1820
1821 if (Arr::get($checkoutData, 'tax_data.valid', false)) {
1822 $businessInfo['tax_number_validated'] = true;
1823 $businessInfo['tax_number_country'] = sanitize_text_field(Arr::get($checkoutData, 'tax_data.country', ''));
1824 $businessInfo['tax_number_name'] = sanitize_text_field(Arr::get($checkoutData, 'tax_data.name', ''));
1825 if ($declarationNote !== '') {
1826 $businessInfo['reverse_charge_declaration'] = $declarationNote;
1827 }
1828 }
1829 }
1830
1831 $order->updateMeta('business_info', $businessInfo);
1832 }
1833
1834
1835 public function initCheckoutActions()
1836 {
1837 add_action('fluent_cart/checkout/b2b_extra_fields', [$this, 'renderTaxField'], 10, 1);
1838 }
1839
1840 public function registerAjaxHandlers()
1841 {
1842 add_action('wp_ajax_fluent_cart_validate_vat', [$this, 'handleVatValidation']);
1843 add_action('wp_ajax_nopriv_fluent_cart_validate_vat', [$this, 'handleVatValidation']);
1844
1845 add_action('wp_ajax_fluent_cart_remove_vat', [$this, 'removeVat']);
1846 add_action('wp_ajax_nopriv_fluent_cart_remove_vat', [$this, 'removeVat']);
1847 }
1848
1849 public function renderTaxField($data)
1850 {
1851 $cart = Arr::get($data, 'cart');
1852
1853 $taxApplicableCountry = $this->getTaxApplicableCountry(
1854 Arr::get($this->taxSettings, 'tax_calculation_basis'),
1855 Arr::get($cart->checkout_data, 'form_data')
1856 );
1857
1858 // On checkout page load, if the cart has a stale RC state (admin turned
1859 // off local_reverse_charge after the customer validated), recalculate now
1860 // so the correct tax totals and Apply/Remove state render immediately.
1861 if (
1862 Arr::get($cart->checkout_data, 'tax_data.valid', false)
1863 && $this->hasReverseChargeTaxData(Arr::get($cart->checkout_data, 'tax_data', []))
1864 && !$this->canApplyVatValidation($taxApplicableCountry)
1865 ) {
1866 $this->recalculateTax($data);
1867 }
1868
1869 (new VatFieldRenderer($taxApplicableCountry))->render($cart);
1870 }
1871
1872 public function getTaxApplicableCountry($calculationBasis, $formData)
1873 {
1874 $country = '';
1875
1876 $shipToDifferent = Arr::get($formData, 'ship_to_different', 'no') === 'yes';
1877
1878 if ($calculationBasis === 'store') {
1879 $country = (new StoreSettings())->get('store_country') ?? '';
1880 } else if ($calculationBasis === 'billing' || ($calculationBasis === 'shipping' && !$shipToDifferent)) {
1881 $country = Arr::get($formData, 'billing_country') ?? '';
1882 } else {
1883 $country = Arr::get($formData, 'shipping_country') ?? '';
1884 }
1885 return $country;
1886
1887 }
1888
1889 public function canApplyVatValidation($countryCode)
1890 {
1891 if (!$countryCode) {
1892 return false;
1893 }
1894
1895 $settings = $this->getSettings();
1896
1897 if (Arr::get($settings, 'enable_tax', 'no') !== 'yes') {
1898 return false;
1899 }
1900
1901 $storeCountry = (new StoreSettings())->get('store_country');
1902 return Arr::get($settings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes'
1903 || $countryCode !== $storeCountry;
1904 }
1905
1906 public function handleVatValidation()
1907 {
1908 nocache_headers();
1909
1910 if (!isset($_REQUEST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])), 'fluentcart')) {
1911 wp_send_json(['message' => __('Security check failed', 'fluent-cart')], 403);
1912 }
1913
1914 if (!$this->isEnabled()) {
1915 wp_send_json(['message' => __('Tax is not enabled.', 'fluent-cart')], 422);
1916 }
1917
1918 if (!CheckoutFieldsSchema::isVatNumberEnabled()) {
1919 wp_send_json(['message' => __('VAT number collection is not enabled.', 'fluent-cart')], 422);
1920 }
1921
1922 $cart = CartHelper::getCart();
1923
1924 if (empty($cart->checkout_data) || empty($cart->checkout_data['form_data'])) {
1925 wp_send_json(['message' => __('Invalid checkout session.', 'fluent-cart')], 422);
1926 }
1927
1928 $taxCalculationBasis = Arr::get($this->taxSettings, 'tax_calculation_basis');
1929 $formData = Arr::get($cart->checkout_data, 'form_data', []);
1930 $shipToDifferent = Arr::get($formData, 'ship_to_different', '');
1931
1932 if ($taxCalculationBasis === 'billing' || ($taxCalculationBasis === 'shipping' && $shipToDifferent !== 'yes')) {
1933 $countryCode = Arr::get($formData, 'billing_country', '');
1934 } elseif ($taxCalculationBasis === 'shipping') {
1935 $countryCode = Arr::get($formData, 'shipping_country', '');
1936 }
1937
1938 $storeCountry = (new StoreSettings())->get('store_country');
1939 if ($taxCalculationBasis === 'store') {
1940 $countryCode = $storeCountry;
1941 }
1942
1943 $euCountryCodes = Arr::get(LocalizationManager::getInstance()->taxContinents('EU'), 'countries', []);
1944
1945 if (!$countryCode || !in_array($countryCode, $euCountryCodes)) {
1946 wp_send_json(['message' => __('VAT validation is only available for EU countries.', 'fluent-cart')], 422);
1947 }
1948
1949 $vatNumber = isset($_REQUEST['vat_number']) ? sanitize_text_field(wp_unslash($_REQUEST['vat_number'])) : '';
1950
1951 if (!$vatNumber) {
1952 wp_send_json(['message' => __('Missing required data', 'fluent-cart')], 422);
1953 }
1954
1955 // Strip EU country code prefix from VAT number if present
1956 foreach ($euCountryCodes as $code) {
1957 if (strpos($vatNumber, $code) === 0) {
1958 $vatNumber = substr($vatNumber, strlen($code));
1959 break;
1960 }
1961 }
1962
1963 $taxData = $this->validateEuVatNumber($countryCode, $vatNumber);
1964
1965 if (is_wp_error($taxData)) {
1966 wp_send_json(['message' => $taxData->get_error_message()], 422);
1967 }
1968
1969 if (!Arr::get($taxData, 'valid')) {
1970 wp_send_json(['message' => __('VAT number is not valid!', 'fluent-cart')], 422);
1971 }
1972
1973 $localRc = Arr::get($this->taxSettings, 'eu_vat_settings.local_reverse_charge', 'no');
1974 $isExcluded = false;
1975 if ($localRc === 'yes') {
1976 // if there is any excluded category in the cart, then don't apply VAT reverse charge
1977 $excludedCategories = Arr::get($this->taxSettings, 'eu_vat_settings.vat_reverse_excluded_categories', []);
1978 $productIds = array_column($cart->cart_data, 'post_id');
1979 $productTerms = $this->getTermsByProductIds($productIds);
1980 foreach ($productTerms as $productId => $terms) {
1981 if (array_intersect($terms, $excludedCategories)) {
1982 $isExcluded = true;
1983 break;
1984 }
1985 }
1986 }
1987
1988 $appliesReverseCharge = !$isExcluded && ($localRc === 'yes' || $countryCode !== $storeCountry);
1989
1990 if ($appliesReverseCharge) {
1991 $cartTaxData = Arr::get($cart->checkout_data, 'tax_data', []);
1992 $inclusiveAdj = (int) Arr::get($cartTaxData, 'reverse_charge_inclusive_adjustment', 0);
1993
1994 $existingRcTotal = (int) Arr::get($cartTaxData, 'reverse_charge_tax_total', 0);
1995 if ($existingRcTotal > 0) {
1996 $taxData['reverse_charge_tax_total'] = $existingRcTotal;
1997 $taxData['reverse_charge_shipping_tax'] = (int) Arr::get($cartTaxData, 'reverse_charge_shipping_tax', 0);
1998 } else {
1999 $rcExclusiveTax = (int) Arr::get($cartTaxData, 'exclusive_tax_total', 0);
2000 $rcFeeTax = (int) Arr::get($cartTaxData, 'fee_tax', 0);
2001 $rcShippingTax = (int) Arr::get($cartTaxData, 'shipping_tax', 0);
2002 $rcTaxTotal = (int) Arr::get($cartTaxData, 'tax_total', 0);
2003 $rcMode = $this->getEffectiveRcMode();
2004 $rcInclPortion = max(0, $rcTaxTotal - $rcExclusiveTax - $rcFeeTax - $rcShippingTax);
2005 $rcReversedIncl = ($rcMode === 'dynamic') ? $rcInclPortion : 0;
2006 $taxData['reverse_charge_tax_total'] = $rcExclusiveTax + $rcFeeTax + $rcShippingTax + $rcReversedIncl;
2007 $taxData['reverse_charge_shipping_tax'] = $rcShippingTax;
2008 }
2009 $taxData['reverse_charge_inclusive_adjustment'] = $inclusiveAdj;
2010 $taxData['tax_total'] = 0;
2011 $taxData['exclusive_tax_total'] = 0;
2012 $taxData['shipping_tax'] = 0;
2013 $taxData['tax_behavior'] = 0;
2014 $taxData['fee_tax'] = 0;
2015 $taxData['signup_fee_tax'] = 0;
2016 $taxData['shipping_tax_lines'] = [];
2017 $existingTaxLines = isset($cartTaxData['tax_lines']) && is_array($cartTaxData['tax_lines'])
2018 ? $cartTaxData['tax_lines']
2019 : [];
2020 $taxData['tax_lines'] = array_map(function ($line) {
2021 $line['tax_amount'] = 0;
2022 return $line;
2023 }, $existingTaxLines);
2024 }
2025
2026 $checkoutData = $cart->checkout_data;
2027 if (!isset($checkoutData['tax_data']) || !is_array($checkoutData['tax_data'])) {
2028 $checkoutData['tax_data'] = [];
2029 }
2030 $checkoutData['tax_data'] = array_merge($checkoutData['tax_data'], $taxData);
2031 $cart->checkout_data = $checkoutData;
2032 $fillData = $this->calculateCartTax([
2033 'cart_data' => $cart->cart_data,
2034 'checkout_data' => $cart->checkout_data,
2035 ]);
2036 $cart->fill($fillData);
2037 $cart->save();
2038
2039 ob_start();
2040 (new CartSummaryRender($cart))->render(false);
2041 $cartSummaryInner = ob_get_clean();
2042
2043 ob_start();
2044 (new VatFieldRenderer(Arr::get($taxData, 'country', $countryCode)))->renderInner($checkoutData);
2045 $euVatView = ob_get_clean();
2046
2047 wp_send_json([
2048 'success' => true,
2049 'message' => $appliesReverseCharge
2050 ? __('VAT has been applied successfully', 'fluent-cart')
2051 : __('VAT number has been validated successfully', 'fluent-cart'),
2052 'tax_data' => $taxData,
2053 'fragments' => [
2054 [
2055 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
2056 'content' => $cartSummaryInner,
2057 'type' => 'replace'
2058 ],
2059 [
2060 'selector' => '[data-fluent-cart-checkout-page-tax-wrapper]',
2061 'content' => $euVatView,
2062 'type' => 'replace'
2063 ]
2064 ],
2065 ], 200);
2066 }
2067
2068 public function removeVat()
2069 {
2070 nocache_headers();
2071
2072 if (!isset($_REQUEST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])), 'fluentcart')) {
2073 wp_send_json(['message' => __('Security check failed', 'fluent-cart')], 403);
2074 }
2075
2076 if (isset($_REQUEST['fct_cart_hash'])) {
2077 $cart = CartResource::get(['hash' => sanitize_text_field(wp_unslash($_REQUEST['fct_cart_hash']))]);
2078 } else {
2079 $cart = CartResource::get();
2080 }
2081
2082 // recalculate tax amount
2083 do_action('fluent_cart/checkout/cart_amount_updated', [
2084 'cart' => $cart
2085 ]);
2086
2087 $checkoutData = $cart->checkout_data;
2088
2089 // Reset VAT-related fields
2090 if (isset($checkoutData)) {
2091 unset($checkoutData['tax_data']['valid']);
2092 unset($checkoutData['tax_data']['name']);
2093 unset($checkoutData['tax_data']['address']);
2094 unset($checkoutData['tax_data']['vat_number']);
2095 unset($checkoutData['tax_data']['country']);
2096 unset($checkoutData['tax_data']['declaration_note']);
2097 unset($checkoutData['tax_data']['reverse_charge_tax_total']);
2098 unset($checkoutData['tax_data']['reverse_charge_shipping_tax']);
2099 unset($checkoutData['tax_data']['reverse_charge_inclusive_adjustment']);
2100 }
2101
2102 $cart->checkout_data = $checkoutData;
2103 $fillData = $this->calculateCartTax([
2104 'cart_data' => $cart->cart_data,
2105 'checkout_data' => $cart->checkout_data,
2106 ]);
2107 $cart->fill($fillData);
2108 $cart->save();
2109
2110 ob_start();
2111 (new CartSummaryRender($cart))->render(false);
2112 $cartSummaryInner = ob_get_clean();
2113
2114 wp_send_json([
2115 'success' => true,
2116 'message' => __('VAT has been removed successfully', 'fluent-cart'),
2117 'checkout_data' => [],
2118 'fragments' => [
2119 [
2120 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
2121 'content' => $cartSummaryInner,
2122 'type' => 'replace'
2123 ]
2124 ]
2125 ]);
2126 }
2127
2128 protected function getTermsByProductIds($products)
2129 {
2130 $formattedTerms = null;
2131
2132 if ($formattedTerms === null) {
2133 $terms = App::make('db')->table('term_relationships')
2134 ->whereIn('object_id', $products)
2135 ->get();
2136
2137 $formattedTerms = [];
2138
2139 foreach ($terms as $term) {
2140 if (!isset($formattedTerms[$term->object_id])) {
2141 $formattedTerms[$term->object_id] = [];
2142 }
2143 $formattedTerms[$term->object_id][] = $term->term_taxonomy_id;
2144 }
2145 }
2146
2147 return $formattedTerms;
2148 }
2149
2150 protected function validateEuVatNumber($countryCode, $vatNumber)
2151 {
2152 /*
2153 * Allow third parties to validate a VAT number without using the VIES SOAP service.
2154 *
2155 * Return null → FluentCart performs its default SOAP validation.
2156 * Return array → treated as a successful validation result (same shape as SOAP response:
2157 * 'country', 'vat_number', 'valid' => true, 'name', 'address').
2158 * Return WP_Error → treated as a validation error; its message is forwarded to the customer.
2159 */
2160
2161 $thirdPartyResult = apply_filters('fluent_cart/tax/validate_eu_vat_number', null, [
2162 'country_code' => $countryCode,
2163 'vat_number' => $vatNumber,
2164 ]);
2165
2166 if (is_wp_error($thirdPartyResult) || is_array($thirdPartyResult)) {
2167 return $thirdPartyResult;
2168 }
2169
2170 try {
2171 $wsdl = "https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl";
2172
2173 if (!class_exists('\\SoapClient')) {
2174 return new \WP_Error('service_unavailable', __('SOAP is not available on the server.', 'fluent-cart'));
2175 }
2176
2177 $client = new \SoapClient($wsdl, [
2178 'exceptions' => true,
2179 'trace' => true,
2180 'connection_timeout' => 10,
2181 ]);
2182
2183 $params = [
2184 'countryCode' => $countryCode,
2185 'vatNumber' => preg_replace('/[^A-Za-z0-9]/', '', $vatNumber)
2186 ];
2187
2188 $result = $client->checkVat($params);
2189
2190 if (empty($result->valid)) {
2191 $countryName = LocalizationManager::getInstance()->getCountryNameByCode($countryCode);
2192 return new \WP_Error(
2193 'invalid',
2194 sprintf(
2195 /* translators: %1$s: country name */
2196 __('Invalid VAT number for %1$s!', 'fluent-cart'),
2197 $countryName
2198 )
2199 );
2200 }
2201
2202 $taxData = [
2203 'country' => $result->countryCode,
2204 'vat_number' => $result->vatNumber,
2205 'valid' => (bool)$result->valid,
2206 'name' => $result->name,
2207 'address' => $result->address,
2208 ];
2209
2210 return $taxData;
2211
2212 } catch (\SoapFault $e) {
2213 $faultString = strtoupper($e->getMessage());
2214
2215 if (strpos($faultString, 'MAX_CONCURRENT_REQ') !== false) {
2216 return new \WP_Error(
2217 'rate_limit',
2218 __('VAT validation rate limit reached. Please try again in a few moments.', 'fluent-cart')
2219 );
2220 }
2221
2222 if (strpos($faultString, 'INVALID_INPUT') !== false || strpos($faultString, 'INVALID_REQUESTER_INFO') !== false) {
2223 $countryName = LocalizationManager::getInstance()->getCountryNameByCode($countryCode);
2224 return new \WP_Error(
2225 'invalid',
2226 sprintf(
2227 /* translators: %1$s: country name */
2228 __('Invalid VAT number for country %1$s!', 'fluent-cart'),
2229 $countryName
2230 )
2231 );
2232 }
2233
2234 return new \WP_Error('service_unavailable', __('The VAT validation service is temporarily unavailable. Please try again later.', 'fluent-cart'));
2235 } catch (\Exception $e) {
2236 return new \WP_Error('invalid', $e->getMessage());
2237 }
2238 }
2239
2240 public function validateVatForAdmin($countryCode, $vatNumber)
2241 {
2242 return $this->validateEuVatNumber($countryCode, $vatNumber);
2243 }
2244
2245 /**
2246 * Persist tax-rate rows for an order.
2247 *
2248 * Accepts the output of AdminOrderTaxService::calculate() (or the equivalent
2249 * data built inside prepareOtherData) and writes / updates fct_order_tax_rate rows.
2250 *
2251 * @param int $orderId The order ID.
2252 * @param array $taxLines Array of ['rate_id', 'label', 'tax_amount'] — one entry per rate.
2253 * @param array $taxMeta Arbitrary meta merged into every row (country, vat numbers, etc.).
2254 * @param int $shippingTax Total shipping tax for the order (distributed proportionally).
2255 */
2256 public static function persistTaxRates($orderId, $taxLines, $taxMeta, $shippingTax = 0, $shippingTaxLines = [])
2257 {
2258 if (empty($taxLines)) {
2259 OrderTaxRate::query()
2260 ->where('order_id', $orderId)
2261 ->where('tax_rate_id', '!=', 0)
2262 ->delete();
2263
2264 // Zero-tax sentinel row — keeps fct_order_tax_rate populated for every order.
2265 $existing = OrderTaxRate::query()
2266 ->where('order_id', $orderId)
2267 ->where('tax_rate_id', 0)
2268 ->first();
2269
2270 if (!$existing) {
2271 OrderTaxRate::create([
2272 'order_id' => $orderId,
2273 'tax_rate_id' => 0,
2274 'shipping_tax' => (int) $shippingTax,
2275 'order_tax' => 0,
2276 'total_tax' => (int) $shippingTax,
2277 'meta' => $taxMeta,
2278 ]);
2279 } else {
2280 $existing->update([
2281 'shipping_tax' => (int) $shippingTax,
2282 'order_tax' => 0,
2283 'total_tax' => (int) $shippingTax,
2284 'meta' => $taxMeta,
2285 ]);
2286 }
2287 return;
2288 }
2289
2290 // Re-index to guarantee 0-based iteration; callers may pass associative arrays.
2291 $taxLines = array_values($taxLines);
2292 $activeRateIds = array_values(array_unique(array_map(function ($taxLine) {
2293 return (int) Arr::get($taxLine, 'rate_id', 0);
2294 }, $taxLines)));
2295
2296 // Remove stale persisted rows from prior tax compositions, including the zero-tax sentinel.
2297 OrderTaxRate::query()
2298 ->where('order_id', $orderId)
2299 ->where(function ($query) use ($activeRateIds) {
2300 $query->where('tax_rate_id', 0)
2301 ->orWhereNotIn('tax_rate_id', $activeRateIds);
2302 })
2303 ->delete();
2304
2305 // Build exact per-rate shipping tax map from checkout-time calculation when available.
2306 // Falls back to proportional split for admin order recalculation and legacy callers.
2307 $shippingTaxByRateId = [];
2308 foreach ($shippingTaxLines as $stl) {
2309 $stlRateId = (int) Arr::get($stl, 'rate_id', 0);
2310 if ($stlRateId > 0) {
2311 $shippingTaxByRateId[$stlRateId] = (int) Arr::get($stl, 'shipping_tax', 0);
2312 }
2313 }
2314
2315 $lineShippingTaxes = [];
2316 if (!empty($shippingTaxByRateId)) {
2317 // Exact amounts from checkout calculator — no proportional approximation needed.
2318 foreach ($taxLines as $taxLine) {
2319 $rateId = (int) Arr::get($taxLine, 'rate_id', 0);
2320 $lineShippingTaxes[] = isset($shippingTaxByRateId[$rateId]) ? $shippingTaxByRateId[$rateId] : 0;
2321 }
2322 // Reconcile rounding differences so persisted rows always sum to the order-level total.
2323 $exactSum = array_sum($lineShippingTaxes);
2324 $lastIdx = count($lineShippingTaxes) - 1;
2325 if ($lastIdx >= 0 && $exactSum !== (int) $shippingTax) {
2326 $lineShippingTaxes[$lastIdx] += ((int) $shippingTax - $exactSum);
2327 }
2328 } else {
2329 // Proportional split (admin order recalculation and legacy callers without exact breakdown).
2330 // Assign rounding remainder to the last rate so the sum always equals shipping_tax exactly.
2331 $totalOrderTax = array_reduce($taxLines, function ($carry, $line) {
2332 return $carry + (int) Arr::get($line, 'tax_amount', 0);
2333 }, 0);
2334 $distributedTotal = 0;
2335 foreach ($taxLines as $taxLine) {
2336 $orderTax = (int) Arr::get($taxLine, 'tax_amount', 0);
2337 $share = ($shippingTax > 0 && $totalOrderTax > 0)
2338 ? (int) round($shippingTax * ($orderTax / $totalOrderTax))
2339 : 0;
2340 $lineShippingTaxes[] = $share;
2341 $distributedTotal += $share;
2342 }
2343 $lastIdx = count($lineShippingTaxes) - 1;
2344 if ($lastIdx >= 0) {
2345 $lineShippingTaxes[$lastIdx] += ($shippingTax - $distributedTotal);
2346 }
2347 }
2348
2349 foreach ($taxLines as $index => $taxLine) {
2350 $rateId = (int) Arr::get($taxLine, 'rate_id', 0);
2351 $orderTax = (int) Arr::get($taxLine, 'tax_amount', 0);
2352 $lineShippingTax = $lineShippingTaxes[$index];
2353 $lineMeta = array_merge($taxMeta, [
2354 'label' => sanitize_text_field((string) Arr::get($taxLine, 'label', '')),
2355 'rate_percent' => (float) Arr::get($taxLine, 'rate_percent', 0),
2356 'is_compound' => (bool) Arr::get($taxLine, 'is_compound', false),
2357 'taxable_amount' => (int) Arr::get($taxLine, 'taxable_amount', 0),
2358 'inclusive' => Arr::get($taxLine, 'inclusive', null),
2359 'is_mixed_inclusive' => (bool) Arr::get($taxLine, 'is_mixed_inclusive', false),
2360 ]);
2361
2362 $existing = OrderTaxRate::query()
2363 ->where('order_id', $orderId)
2364 ->where('tax_rate_id', $rateId)
2365 ->first();
2366
2367 if (!$existing) {
2368 OrderTaxRate::create([
2369 'order_id' => $orderId,
2370 'tax_rate_id' => $rateId,
2371 'shipping_tax' => $lineShippingTax,
2372 'order_tax' => $orderTax,
2373 'total_tax' => $lineShippingTax + $orderTax,
2374 'meta' => $lineMeta,
2375 ]);
2376 } else {
2377 $existing->update([
2378 'shipping_tax' => $lineShippingTax,
2379 'order_tax' => $orderTax,
2380 'total_tax' => $lineShippingTax + $orderTax,
2381 'meta' => $lineMeta,
2382 ]);
2383 }
2384 }
2385 }
2386
2387 public static function isTaxEnabled()
2388 {
2389 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
2390 return Arr::get($taxSettings, 'enable_tax', 'no') === 'yes';
2391 }
2392
2393 /**
2394 * Whether reverse charge can be applied for a given customer country.
2395 * Same logic as canApplyVatValidation() but accessible statically for renderers.
2396 * Returns false when local_reverse_charge is off AND the country equals the store country.
2397 */
2398 public static function canApplyReverseCharge($countryCode)
2399 {
2400 if (!$countryCode || !static::isTaxEnabled()) {
2401 return false;
2402 }
2403 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
2404 $storeCountry = (new StoreSettings())->get('store_country');
2405 return Arr::get($taxSettings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes'
2406 || $countryCode !== $storeCountry;
2407 }
2408
2409 public static function euVatCountyOptions()
2410 {
2411 $continents = require dirname(__DIR__, 2) . '/Services/Localization/i18n/eu_tax_countries.php';
2412 $euCountries = Arr::get($continents, 'EU.countries', []);
2413 $countries = LocalizationManager::getCountries();
2414 $taxPresets = require dirname(__DIR__, 2) . '/Services/Tax/tax.php';
2415 $countryOptions = [];
2416
2417 foreach ($euCountries as $countryCode) {
2418 $preset = isset($taxPresets[$countryCode]) ? $taxPresets[$countryCode] : [];
2419 $taxEntries = isset($preset['tax']) ? (array) $preset['tax'] : [];
2420 $defaultRates = [];
2421 $defaultRate = 0;
2422
2423 foreach ($taxEntries as $entry) {
2424 $slug = isset($entry['type']) ? $entry['type'] : 'standard';
2425 $rate = isset($entry['rate']) ? (float) $entry['rate'] : 0;
2426 $defaultRates[$slug] = $rate;
2427 if ($slug === 'standard') {
2428 $defaultRate = $rate;
2429 }
2430 }
2431
2432 $countryOptions[] = [
2433 'label' => Arr::get($countries, $countryCode, $countryCode),
2434 'value' => $countryCode,
2435 'default_rate' => $defaultRate,
2436 'default_rates' => $defaultRates,
2437 ];
2438 }
2439
2440 return $countryOptions;
2441 }
2442
2443 public static function taxTitleLists(): array
2444 {
2445 return apply_filters('fluent_cart/tax/country_tax_titles', [
2446 'AU' => __('ABN', 'fluent-cart'), // Australia
2447 'NZ' => __('GST', 'fluent-cart'), // New Zealand
2448 'IN' => __('GST', 'fluent-cart'), // India
2449 'SG' => __('GST', 'fluent-cart'), // Singapore
2450 'MY' => __('SST', 'fluent-cart'), // Malaysia
2451 'CA' => __('GST / HST / PST / QST', 'fluent-cart'), // Canada
2452 'GB' => __('VAT', 'fluent-cart'), // United Kingdom
2453 'EU' => __('VAT', 'fluent-cart'), // European Union
2454 'FR' => __('VAT', 'fluent-cart'), // France
2455 'DE' => __('VAT', 'fluent-cart'), // Germany
2456 'NL' => __('VAT', 'fluent-cart'), // Netherlands
2457 'ES' => __('VAT', 'fluent-cart'), // Spain
2458 'IT' => __('VAT', 'fluent-cart'), // Italy
2459 'IE' => __('VAT', 'fluent-cart'), // Ireland
2460 'US' => __('EIN / Sales Tax', 'fluent-cart'), // United States
2461 'ZA' => __('VAT', 'fluent-cart'), // South Africa
2462 'NG' => __('TIN / VAT', 'fluent-cart'), // Nigeria
2463 'AE' => __('TRN / VAT', 'fluent-cart'), // United Arab Emirates
2464 'SA' => __('VAT', 'fluent-cart'), // Saudi Arabia
2465 'QA' => __('VAT', 'fluent-cart'), // Qatar
2466 'JP' => __('Consumption Tax (CTN)', 'fluent-cart'), // Japan
2467 'CN' => __('VAT', 'fluent-cart'), // China
2468 'HK' => __('BRN', 'fluent-cart'), // Hong Kong
2469 'PH' => __('TIN / VAT', 'fluent-cart'), // Philippines
2470 'ID' => __('NPWP / PPN', 'fluent-cart'), // Indonesia
2471 'TH' => __('VAT', 'fluent-cart'), // Thailand
2472 'VN' => __('MST / VAT', 'fluent-cart'), // Vietnam
2473 'BD' => __('BIN / VAT', 'fluent-cart'), // Bangladesh
2474 'PK' => __('NTN / STRN', 'fluent-cart'), // Pakistan
2475 'LK' => __('VAT', 'fluent-cart'), // Sri Lanka
2476 'NP' => __('PAN / VAT', 'fluent-cart'), // Nepal
2477 'BR' => __('CNPJ / CPF', 'fluent-cart'), // Brazil
2478 'AR' => __('CUIT', 'fluent-cart'), // Argentina
2479 'MX' => __('RFC / IVA', 'fluent-cart'), // Mexico
2480 'CL' => __('RUT / IVA', 'fluent-cart'), // Chile
2481 'PE' => __('RUC / IGV', 'fluent-cart'), // Peru
2482 'RU' => __('INN / VAT', 'fluent-cart'), // Russia
2483 'TR' => __('VKN / VAT', 'fluent-cart'), // Turkey
2484 'CH' => __('MWST / TVA / IVA', 'fluent-cart'), // Switzerland
2485 'NO' => __('VAT', 'fluent-cart'), // Norway
2486 'IS' => __('VSK', 'fluent-cart'), // Iceland
2487 'IL' => __('VAT', 'fluent-cart'), // Israel
2488 'SE' => __('VAT', 'fluent-cart'), // Sweden
2489 ]);
2490
2491 }
2492
2493 public static function getCountryTaxTitle($countryCode = '')
2494 {
2495 $countryTaxTitles = self::taxTitleLists();
2496 if (isset($countryTaxTitles[$countryCode])) {
2497 return $countryTaxTitles[$countryCode];
2498 }
2499 return __('VAT', 'fluent-cart');
2500 }
2501
2502 public function applyRcFeeAdjustments($fees, $context)
2503 {
2504 $cart = Arr::get($context, 'cart');
2505 if (!$cart) {
2506 return $fees;
2507 }
2508 $rcAdjustedFees = Arr::get($cart->checkout_data, 'tax_data.rc_adjusted_fees', []);
2509 if (empty($rcAdjustedFees)) {
2510 return $fees;
2511 }
2512 foreach ($fees as &$fee) {
2513 $key = Arr::get($fee, 'key', '');
2514 $source = Arr::get($fee, 'source', 'custom');
2515 $compositeKey = $source . ':' . $key;
2516 if ($key && isset($rcAdjustedFees[$compositeKey])) {
2517 $fee['amount'] = (int) $rcAdjustedFees[$compositeKey];
2518 }
2519 }
2520 unset($fee);
2521 return $fees;
2522 }
2523
2524 }
2525