PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.4
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 trunk All 48 releases
fluent-cart / app / Modules / Tax / TaxModule.php

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

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