PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.3
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.3, at app/Modules/Tax/TaxModule.php

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