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

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