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

1,810 lines 78.7 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\TaxRenderer;
13 use FluentCart\App\Services\Renderer\VatFieldRenderer;
14 use FluentCart\App\Services\Renderer\CartSummaryRender;
15 use FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper;
16 use FluentCart\App\Services\Renderer\CheckoutFieldsSchema;
17 use FluentCart\Api\Resource\FrontendResource\CartResource;
18 use FluentCart\App\Services\Localization\LocalizationManager;
19 use FluentCart\App\Services\Tax\TaxManager;
20
21 class TaxModule
22 {
23
24 protected $taxSettings = [];
25
26 protected $renderer = null;
27
28 private function renderer(): TaxRenderer
29 {
30 if (!$this->renderer) {
31 $this->renderer = new TaxRenderer($this);
32 }
33 return $this->renderer;
34 }
35
36 public function register()
37 {
38 $this->getSettings();
39
40 add_action('fluent_cart/checkout/prepare_other_data', [$this, 'storeBusinessInfoOnOrder'], 9, 1);
41
42 add_filter('fluent_cart/checkout/after_patch_checkout_data_fragments', [$this, 'maybeRerenderEuVatField'], 10, 2);
43 $this->initCheckoutActions();
44 $this->registerAjaxHandlers();
45
46 // Registered unconditionally so that stale RC price adjustments are restored
47 // when tax is disabled after RC was active. No-op when tax is enabled
48 // (recalculateTax registered below runs the full recalculation instead).
49 add_action('fluent_cart/cart/cart_data_items_updated', [$this, 'maybeRestoreRcAdjustedPrices']);
50 // Registered unconditionally: Case 2 catches non-tax rounding from any module;
51 // Case 1 (RC) fast-exits when tax is off (no rcAdjustment stored).
52 add_action('fluent_cart/cart/line_item/price_note', [$this, 'renderUnitPriceRoundingTooltip'], 20, 1);
53 add_action('fluent_cart/cart/line_item/unit_price_hint', [$this, 'renderUnitPriceRoundingTooltip'], 10, 1);
54
55 if (!$this->isEnabled()) {
56 return;
57 }
58
59 add_action('fluent_cart/cart/line_item/footer_start', [$this, 'renderCheckoutLineItemTaxLabel'], 10, 1);
60 add_action('fluent_cart/cart/line_item/after_setup_fee_info', [$this, 'renderCheckoutSetupFeeTaxLabel'], 10, 1);
61 add_action('fluent_cart/cart/line_item/setup_fee_price_info', [$this, 'renderCheckoutSetupFeeTaxTooltip'], 10, 1);
62 add_action('fluent_cart/cart/line_item/setup_fee_price_note', [$this, 'renderCheckoutSetupFeeTaxInfo'], 10, 1);
63 add_action('fluent_cart/cart/line_item/after_total', [$this, 'renderCheckoutLineItemTaxTooltip'], 10, 1);
64 add_action('fluent_cart/cart/line_item/price_note', [$this, 'renderCheckoutLineItemTaxInfo'], 10, 1);
65
66 add_filter('fluent_cart/cart/estimated_total', function ($total, $data) {
67 $cart = $data['cart'];
68 $taxData = Arr::get($cart->checkout_data, 'tax_data', []);
69 if (is_array($taxData) && array_key_exists('exclusive_tax_total', $taxData)) {
70 $total += (int) $taxData['exclusive_tax_total'];
71
72 // Shipping and fees always use the store-level inclusive flag.
73 // Fall back to tax_behavior if store_tax_behavior absent (old cart data).
74 $storeBehavior = (int) Arr::get($taxData, 'store_tax_behavior',
75 Arr::get($taxData, 'tax_behavior', 0));
76
77 if ($storeBehavior === 1) {
78 $total += (int) Arr::get($taxData, 'shipping_tax', 0);
79 // fee_tax is also exclusive when store is exclusive
80 $total += (int) Arr::get($taxData, 'fee_tax', 0);
81 }
82
83 $rcMode = $this->getEffectiveRcMode();
84 if ($rcMode === 'dynamic' && $this->isReverseChargeCheckout($cart->checkout_data)) {
85 $inclusiveAdj = (int) Arr::get($taxData, 'reverse_charge_inclusive_adjustment', 0);
86 if ($inclusiveAdj > 0) {
87 $total -= $inclusiveAdj;
88 }
89 // Inclusive shipping is now reduced at source via fluent_cart/cart/shipping_total,
90 // so getShippingTotal() already returns the net amount — no further adjustment here.
91 }
92 } else {
93 // Backward compat: old tax_data without exclusive_tax_total.
94 if (Arr::get($taxData, 'tax_behavior', 0) == 1) {
95 $total += (int) Arr::get($taxData, 'tax_total', 0);
96 $total += (int) Arr::get($taxData, 'shipping_tax', 0);
97 }
98 }
99 return $total;
100 }, 10, 2);
101
102 // Reduce the raw shipping_charge by the inclusive shipping VAT for dynamic RC orders.
103 // This makes getShippingTotal() return the net amount, so the actual gateway charge
104 // and the stored shipping_total on the order are correct (not overcharged).
105 add_filter('fluent_cart/cart/shipping_total', function ($shippingTotal, $data) {
106 $cart = Arr::get($data, 'cart');
107 if (!$cart || $shippingTotal <= 0) {
108 return $shippingTotal;
109 }
110 $taxData = Arr::get($cart->checkout_data, 'tax_data', []);
111 if (!is_array($taxData)) {
112 return $shippingTotal;
113 }
114 $rcMode = $this->getEffectiveRcMode();
115 if ($rcMode !== 'dynamic' || !$this->isReverseChargeCheckout($cart->checkout_data)) {
116 return $shippingTotal;
117 }
118 $storeBehavior = (int) Arr::get($taxData, 'store_tax_behavior',
119 Arr::get($taxData, 'tax_behavior', 2));
120 if ($storeBehavior !== 2) {
121 return $shippingTotal;
122 }
123 $rcShippingTax = (int) Arr::get($taxData, 'reverse_charge_shipping_tax', 0);
124 if ($rcShippingTax > 0) {
125 $shippingTotal = max(0, $shippingTotal - $rcShippingTax);
126 }
127 return $shippingTotal;
128 }, 10, 2);
129
130 //new hook to get changes
131 add_filter('fluent_cart/checkout/before_patch_checkout_data', [$this, 'maybeRecalculateTaxAmount'], 10, 2);
132
133 add_filter('fluent_cart/cart/tax_behavior', function ($behavior, $data) {
134 $cart = $data['cart'];
135 return Arr::get($cart->checkout_data, 'tax_data.tax_behavior', $behavior);
136 }, 10, 2);
137
138 add_action('fluent_cart/checkout/before_summary_total', function ($data) {
139 $cart = $data['cart'];
140
141 if (empty($cart->checkout_data['tax_data'])) {
142 return;
143 }
144
145 $taxAmount = (int) Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
146 $shippingTax = (int) Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
147 $isReverseCharge = $this->isReverseChargeCheckout($cart->checkout_data);
148
149 if (!$taxAmount && !$isReverseCharge && !$shippingTax) {
150 return;
151 }
152
153 $this->renderTaxSummaryBox($cart);
154 });
155
156 add_action('fluent_cart/checkout/prepare_other_data', [$this, 'prepareOtherData'], 10, 1);
157
158 add_action('fluent_cart/product/after_price', function ($data) {
159 if (Arr::get($data, 'scope') === 'price_range') {
160 return;
161 }
162 $variant = Arr::get($data, 'variant', null);
163 $priceSuffix = $this->resolvePriceSuffix($variant);
164 $variantInclusion = $variant ? Arr::get($variant->other_info ?: [], 'tax_inclusion', '') : '';
165 $taxInclusion = $variantInclusion ?: Arr::get($this->taxSettings, 'tax_inclusion', '');
166
167 $ctxCb = null;
168 $ctxCb = function ($ctx) use ($taxInclusion, &$ctxCb) {
169 remove_filter('fluent_cart/product/price_suffix_context', $ctxCb, 1);
170 $ctx['tax_inclusion'] = $taxInclusion;
171 return $ctx;
172 };
173 add_filter('fluent_cart/product/price_suffix_context', $ctxCb, 1);
174
175 $sfxCb = null;
176 $sfxCb = function ($suffix) use ($priceSuffix, &$sfxCb) {
177 remove_filter('fluent_cart/product/price_suffix_atts', $sfxCb, 1);
178 return $priceSuffix ?: $suffix;
179 };
180 add_filter('fluent_cart/product/price_suffix_atts', $sfxCb, 1, 1);
181 }, 10, 1);
182
183 add_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20, 2);
184
185 add_action('fluent_cart/cart/cart_data_items_updated', [$this, 'recalculateTax']);
186 }
187
188 private function resolvePriceSuffix($variant)
189 {
190 $includedSuffix = Arr::get($this->taxSettings, 'price_suffix_included', '');
191 $excludedSuffix = Arr::get($this->taxSettings, 'price_suffix_excluded', '');
192
193 if ($variant !== null) {
194 $taxInclusion = Arr::get($variant->other_info ?: [], 'tax_inclusion', '');
195 } else {
196 $taxInclusion = '';
197 }
198
199 if ($taxInclusion === 'included') {
200 $isInclusive = true;
201 } elseif ($taxInclusion === 'excluded') {
202 $isInclusive = false;
203 } else {
204 $isInclusive = Arr::get($this->taxSettings, 'tax_inclusion') === 'included';
205 }
206
207 $suffix = $isInclusive ? $includedSuffix : $excludedSuffix;
208
209 if (!$suffix) {
210 $suffix = Arr::get($this->taxSettings, 'price_suffix', '');
211 }
212
213 return $suffix;
214 }
215
216 public function renderTaxRow($cart, $atts = '')
217 {
218 $this->renderer()->renderTaxRow($cart, $atts);
219 }
220
221 public function renderShippingTaxRow($cart, $atts = '')
222 {
223 return $this->renderer()->renderShippingTaxRow($cart, $atts);
224 }
225
226 public function renderTaxSummaryBox($cart)
227 {
228 $this->renderer()->renderTaxSummaryBox($cart);
229 }
230
231 public function maybeRestoreRcAdjustedPrices($data)
232 {
233 if ($this->isEnabled()) {
234 return; // full recalculation handled by recalculateTax registered below
235 }
236
237 $cart = Arr::get($data, 'cart');
238 $cartLines = (array) $cart->cart_data;
239 $checkoutData = $cart->checkout_data;
240
241 $hasRcMeta = !empty(Arr::get($checkoutData, 'tax_data.rc_adjusted_fees'));
242
243 if (!$hasRcMeta) {
244 foreach ($cartLines as $line) {
245 if (
246 Arr::get($line, 'line_meta.original_unit_price') !== null ||
247 Arr::get($line, 'other_info.original_signup_fee') !== null
248 ) {
249 $hasRcMeta = true;
250 break;
251 }
252 }
253 }
254
255 if (!$hasRcMeta) {
256 return;
257 }
258
259 $this->recalculateTax($data);
260 }
261
262 public function recalculateTax($data)
263 {
264 $cart = Arr::get($data, 'cart');
265
266 // Get all fees (stored + dynamic) at gross (pre-RC) amounts so dynamic
267 // fees added via fluent_cart/cart/fees are included in the tax pipeline.
268 // Temporarily remove applyRcFeeAdjustments so it cannot override dynamic
269 // fee amounts with previously-stored RC-net values; calculateCartTax()
270 // recomputes the RC adjustment from scratch if RC is still active.
271 $checkoutData = $cart->checkout_data;
272 remove_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20);
273 $cart->clearFeeCache();
274 try {
275 $checkoutData['fees'] = $cart->getFees();
276 } finally {
277 add_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20, 2);
278 $cart->clearFeeCache();
279 }
280
281 $fillData = $this->calculateCartTax([
282 'cart_data' => $cart->cart_data,
283 'checkout_data' => $checkoutData
284 ]);
285
286 $cart->fill($fillData);
287 $cart->save();
288 }
289
290 public function maybeRecalculateTaxAmount($fillData, $data)
291 {
292 $changes = Arr::get($data, 'changes', []);
293
294 $watchings = array_filter($changes, function ($value, $key) {
295 return preg_match('/^(billing_|shipping_|ship_to_|fct_billing_tax|is_business)/i', $key);
296 }, ARRAY_FILTER_USE_BOTH);
297
298 if (empty($watchings)) {
299 return $fillData;
300 }
301
302 if (isset($changes['is_business']) && $changes['is_business'] === 'no') {
303 $checkoutData = Arr::get($fillData, 'checkout_data', []);
304 if (Arr::get($checkoutData, 'tax_data.valid', false)) {
305 $checkoutData['tax_data']['valid'] = false;
306 unset($checkoutData['tax_data']['name']);
307 unset($checkoutData['tax_data']['address']);
308 unset($checkoutData['tax_data']['country']);
309 $fillData['checkout_data'] = $checkoutData;
310 }
311 }
312
313 // Persist dynamic fees so tax pipeline can see them. Use gross (pre-RC)
314 // amounts to prevent compounding when the address changes while RC is active.
315 $cart = Arr::get($data, 'cart');
316 if ($cart) {
317 $checkoutData = Arr::get($fillData, 'checkout_data', []);
318 remove_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20);
319 $cart->clearFeeCache();
320 try {
321 $checkoutData['fees'] = $cart->getFees();
322 } finally {
323 add_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20, 2);
324 $cart->clearFeeCache();
325 }
326 $fillData['checkout_data'] = $checkoutData;
327 }
328
329 $fillData['checkout_data'] = $this->maybeInvalidateVatValidationForCountryChange(
330 Arr::get($fillData, 'checkout_data', []),
331 [
332 'ship_to_different' => Arr::get($data, 'prev_data.ship_to_different', Arr::get($fillData, 'checkout_data.form_data.ship_to_different', 'no')),
333 'billing_country' => Arr::get($data, 'prev_data.billing_country', Arr::get($fillData, 'checkout_data.form_data.billing_country', '')),
334 'shipping_country' => Arr::get($data, 'prev_data.shipping_country', Arr::get($fillData, 'checkout_data.form_data.shipping_country', '')),
335 ]
336 );
337
338 return $this->calculateCartTax($fillData);
339 }
340
341 public function maybeInvalidateVatValidationForCountryChange($checkoutData, $previousFormData = [])
342 {
343 if (!Arr::get($checkoutData, 'tax_data.valid', false)) {
344 return $checkoutData;
345 }
346
347 $taxCalculationBasis = Arr::get($this->taxSettings, 'tax_calculation_basis', 'shipping');
348
349 $previousApplicableCountry = $this->getTaxApplicableCountry($taxCalculationBasis, [
350 'ship_to_different' => Arr::get($previousFormData, 'ship_to_different', Arr::get($checkoutData, 'form_data.ship_to_different', 'no')),
351 'billing_country' => Arr::get($previousFormData, 'billing_country', Arr::get($checkoutData, 'form_data.billing_country', '')),
352 'shipping_country' => Arr::get($previousFormData, 'shipping_country', Arr::get($checkoutData, 'form_data.shipping_country', '')),
353 ]);
354
355 $currentApplicableCountry = $this->getTaxApplicableCountry(
356 $taxCalculationBasis,
357 Arr::get($checkoutData, 'form_data', [])
358 );
359
360 if ($previousApplicableCountry === $currentApplicableCountry) {
361 return $checkoutData;
362 }
363
364 unset($checkoutData['tax_data']['valid']);
365 unset($checkoutData['tax_data']['name']);
366 unset($checkoutData['tax_data']['address']);
367 unset($checkoutData['tax_data']['country']);
368
369 return $checkoutData;
370 }
371
372 public function getSettings()
373 {
374 if (!empty($this->taxSettings)) {
375 return $this->taxSettings;
376 }
377
378 $defaultSettings = [
379 'tax_inclusion' => 'included',
380 'tax_calculation_basis' => 'shipping',
381 'tax_rounding' => 'item',
382 'checkout_tax_breakdown_display' => 'itemized',
383 'tax_display_label' => 'Tax',
384 'enable_tax' => 'no',
385 'eu_vat_settings' => [
386 'require_vat_number' => 'no',
387 'local_reverse_charge' => 'no',
388 'reverse_charge_price_mode' => 'fixed',
389 'vat_reverse_excluded_categories' => []
390 ]
391 ];
392
393 $savedSettings = get_option('fluent_cart_tax_configuration_settings', []);
394 $settings = wp_parse_args($savedSettings, $defaultSettings);
395
396 // country_registrations live in fct_meta — inject so all consumers see one shape.
397 $registrations = TaxManager::getInstance()->getEuVatRegistrations();
398 $settings['eu_vat_settings']['country_registrations'] = $registrations;
399
400 return $this->taxSettings = $settings;
401 }
402
403 public function getEffectiveRcMode()
404 {
405 $settings = $this->getSettings();
406 return Arr::get($settings, 'eu_vat_settings.reverse_charge_price_mode', 'fixed');
407 }
408
409 public function renderCheckoutLineItemTaxLabel($data)
410 {
411 $this->renderer()->renderCheckoutLineItemTaxLabel($data);
412 }
413
414 public function renderCheckoutSetupFeeTaxLabel($data)
415 {
416 $this->renderer()->renderCheckoutSetupFeeTaxLabel($data);
417 }
418
419 public function renderCheckoutSetupFeeTaxTooltip($data)
420 {
421 $this->renderer()->renderCheckoutSetupFeeTaxTooltip($data);
422 }
423
424 public function renderCheckoutSetupFeeTaxInfo($data)
425 {
426 $this->renderer()->renderCheckoutSetupFeeTaxInfo($data);
427 }
428
429 public function renderCheckoutLineItemTaxTooltip($data)
430 {
431 $this->renderer()->renderCheckoutLineItemTaxTooltip($data);
432 }
433
434 public function renderCheckoutLineItemTaxInfo($data)
435 {
436 $this->renderer()->renderCheckoutLineItemTaxInfo($data);
437 }
438
439 public function renderUnitPriceRoundingTooltip($data)
440 {
441 $this->renderer()->renderUnitPriceRoundingTooltip($data);
442 }
443
444 public function isEnabled()
445 {
446 $settings = $this->getSettings();
447 return Arr::get($settings, 'enable_tax', 'no') === 'yes';
448 }
449
450 public function calculateCartTax($fillData)
451 {
452 $lineItems = Arr::get($fillData, 'cart_data', []);
453 $checkoutData = Arr::get($fillData, 'checkout_data', []);
454
455 // Pre-restore: undo any previous dynamic RC unit_price adjustment before TaxCalculator
456 // runs, so it always sees original gross prices. Fixes: rounding residual in subtotal,
457 // incorrect tax badge amounts after RC removal, and signup_fee_tax being zeroed too early.
458 foreach ($lineItems as &$lineItem) {
459 if (isset($lineItem['line_meta']['original_unit_price'])) {
460 $qty = max(1, (int) Arr::get($lineItem, 'quantity', 1));
461 $lineItem['unit_price'] = (int) $lineItem['line_meta']['original_unit_price'];
462 $lineItem['subtotal'] = $lineItem['unit_price'] * $qty;
463 $lineItem['line_total'] = max(0, $lineItem['subtotal'] - (int) Arr::get($lineItem, 'discount_total', 0));
464 unset($lineItem['line_meta']['original_unit_price'], $lineItem['line_meta']['reverse_charge_adjustment']);
465 }
466 if (isset($lineItem['other_info']['original_signup_fee'])) {
467 $lineItem['other_info']['signup_fee'] = (int) $lineItem['other_info']['original_signup_fee'];
468 unset($lineItem['other_info']['original_signup_fee']);
469 }
470 }
471 unset($lineItem);
472
473 // Tax is disabled — return the pre-restored line items untouched.
474 // Pre-restore above already reverted any dynamic RC unit_price adjustments,
475 // so this also handles the case where RC was applied before tax was disabled.
476 if (!$this->isEnabled()) {
477 if (!empty($fillData['checkout_data']['tax_data']['valid'])) {
478 $fillData['checkout_data']['tax_data']['valid'] = false;
479 }
480 $fillData['cart_data'] = $lineItems;
481 return $fillData;
482 }
483
484 // Shipping tax is derived from each item's itemwise_shipping_charge, but the
485 // authoritative selected shipping charge lives in checkout_data.shipping_data.
486 // These desync when a recalc entry point (VAT apply/remove, order placement,
487 // address patch) runs after the display path computed shipping without persisting
488 // the per-item distribution. Left unfixed the tax calculator sees zero shipping and
489 // drops the shipping tax (and, under reverse charge, its reversed portion). Repair
490 // the distribution here — the single point all recalc paths share.
491 $shipMethodId = Arr::get($checkoutData, 'shipping_data.shipping_method_id');
492 $shipCharge = (int) Arr::get($checkoutData, 'shipping_data.shipping_charge', 0);
493 $distributedShipping = 0;
494 foreach ($lineItems as $cartLine) {
495 $distributedShipping += (int) Arr::get($cartLine, 'itemwise_shipping_charge', 0);
496 $distributedShipping += (int) Arr::get($cartLine, 'shipping_charge', 0);
497 }
498 if ($shipMethodId && $shipCharge > 0 && $distributedShipping === 0) {
499 // A method is selected with a charge but no per-item share — redistribute it.
500 $shipMethod = \FluentCart\App\Models\ShippingMethod::query()->find($shipMethodId);
501 if ($shipMethod) {
502 $redistributed = \FluentCart\App\Helpers\CartHelper::calculateShippingMethodCharge(
503 $shipMethod, $lineItems, 'items'
504 );
505 $lineItems = Arr::get($redistributed, 'items', $lineItems);
506 }
507 } elseif (!$shipMethodId && $distributedShipping === 0) {
508 // Nothing persisted in the stored cart, yet the checkout page auto-selects the
509 // single available method for display. Logged-in customers whose default address
510 // is pre-filled never fire the change event that would persist that selection, so
511 // shipping_data stays empty and the tax calculator drops shipping entirely. Mirror
512 // the renderer's single-method auto-select so VAT apply / placement tax the
513 // shipping the customer already sees selected.
514 $requiresShipping = false;
515 foreach ($lineItems as $cartLine) {
516 if (Arr::get($cartLine, 'fulfillment_type') === 'physical') {
517 $requiresShipping = true;
518 break;
519 }
520 }
521 if ($requiresShipping) {
522 $autoCountry = Arr::get($checkoutData, 'form_data.shipping_country')
523 ?: Arr::get($checkoutData, 'form_data.billing_country');
524 $autoState = Arr::get($checkoutData, 'form_data.shipping_state')
525 ?: Arr::get($checkoutData, 'form_data.billing_state');
526 if ($autoCountry) {
527 $autoMethods = \FluentCart\App\Helpers\AddressHelper::getShippingMethods($autoCountry, $autoState);
528 if ($autoMethods && !is_wp_error($autoMethods) && count($autoMethods) === 1) {
529 $autoMethod = Arr::first($autoMethods);
530 $autoCalc = \FluentCart\App\Helpers\CartHelper::calculateShippingMethodCharge(
531 $autoMethod, $lineItems, 'items'
532 );
533 $lineItems = Arr::get($autoCalc, 'items', $lineItems);
534 Arr::set($checkoutData, 'shipping_data.shipping_method_id', $autoMethod->id);
535 Arr::set($checkoutData, 'shipping_data.shipping_charge', (int) Arr::get($autoCalc, 'shipping_amount', 0));
536 }
537 }
538 }
539 }
540
541 $country = '';
542 $state = '';
543 $postCode = '';
544
545 $taxSettings = $this->getSettings();
546
547 $taxCalculationBasis = Arr::get($taxSettings, 'tax_calculation_basis', 'shipping');
548
549 //$checkoutData = $cart->checkout_data;
550 if ($taxCalculationBasis === 'shipping' && Arr::get($checkoutData, 'form_data.ship_to_different', '') !== 'yes') {
551 $taxCalculationBasis = 'billing';
552 }
553
554 if ($taxCalculationBasis === 'shipping') {
555 $country = Arr::get($checkoutData, 'form_data.shipping_country', '');
556 $state = Arr::get($checkoutData, 'form_data.shipping_state', '');
557 $city = Arr::get($checkoutData, 'form_data.shipping_city', '');
558 $postCode = Arr::get($checkoutData, 'form_data.shipping_postcode', '');
559 } elseif ($taxCalculationBasis === 'billing') {
560 $country = Arr::get($checkoutData, 'form_data.billing_country', '');
561 $state = Arr::get($checkoutData, 'form_data.billing_state', '');
562 $city = Arr::get($checkoutData, 'form_data.billing_city', '');
563 $postCode = Arr::get($checkoutData, 'form_data.billing_postcode', '');
564 } elseif ($taxCalculationBasis === 'store') {
565 $storeSettings = new StoreSettings();
566 $country = $storeSettings->get('store_country');
567 $state = $storeSettings->get('store_state');
568 $city = $storeSettings->get('store_city');
569 $postCode = $storeSettings->get('store_postcode');
570 }
571
572 $fees = (array)Arr::get($checkoutData, 'fees', []);
573 $feeItems = [];
574 foreach ($fees as $fee) {
575 if (!empty($fee['taxable']) && !empty($fee['amount'])) {
576 $feeItems[] = Cart::buildFeeCartItem($fee);
577 }
578 }
579
580 $allItems = array_merge($lineItems, $feeItems);
581
582 $taxCalculator = new TaxCalculator($allItems, [
583 'inclusive' => false,
584 'country' => $country,
585 'state' => $state,
586 'city' => $city,
587 'postcode' => $postCode,
588 'tax_rounding' => Arr::get($taxSettings, 'tax_rounding', 'item'),
589 ]);
590
591 if (empty($checkoutData['tax_data'])) {
592 $checkoutData['tax_data'] = [];
593 }
594
595 $taxTotal = $taxCalculator->getTotalTax();
596 $exclusiveTaxTotal = $taxCalculator->getExclusiveTaxTotal();
597 $shippingTax = $taxCalculator->getShippingTax();
598 $shippingTaxLines = $taxCalculator->getShippingTaxByRates();
599 $taxCountry = $taxCalculator->getTaxCountry();
600 $taxLines = $taxCalculator->getTaxLinesByRates();
601
602
603 // Separate product and fee items from taxed lines
604 $allTaxedLines = $taxCalculator->getTaxedLines();
605
606 $productLines = [];
607 $feeTax = 0;
608 $feeTaxLines = [];
609 foreach ($allTaxedLines as $taxedLine) {
610 if (!empty($taxedLine['is_fee'])) {
611 $taxAmount = (int) Arr::get($taxedLine, 'tax_amount', 0);
612 $feeTax += $taxAmount;
613 $feeTaxLines[] = [
614 'label' => Arr::get($taxedLine, 'title', ''),
615 'tax_amount' => $taxAmount,
616 'inclusive' => (bool) Arr::get($taxedLine, 'line_meta.tax_config.inclusive', false),
617 ];
618 } else {
619 $productLines[] = $taxedLine;
620 }
621 }
622
623 $signupFeeTaxTotal = 0;
624 foreach ($productLines as $taxedLine) {
625 if (Arr::get($taxedLine, 'other_info.payment_type') === 'subscription') {
626 $signupFeeTaxTotal += (int) Arr::get($taxedLine, 'other_info.signup_fee_tax', 0);
627 }
628 }
629
630 $shouldApplyReverseCharge = $this->shouldApplyReverseCharge($checkoutData, $country, $lineItems);
631 $rcMode = $this->getEffectiveRcMode();
632
633 // Capture signup_fee_tax BEFORE the RC zeroing block sets it to 0.
634 $signupFeeTaxesByIndex = [];
635 if ($shouldApplyReverseCharge && $rcMode === 'dynamic') {
636 foreach ($productLines as $idx => $pLine) {
637 $signupFeeTaxesByIndex[$idx] = (int) Arr::get($pLine, 'other_info.signup_fee_tax', 0);
638 }
639 }
640
641 $inclusiveTaxAdjustment = 0;
642 $reversedTaxTotal = 0;
643 $reversedShippingTax = 0;
644 $reversedShippingTaxLines = [];
645 if ($shouldApplyReverseCharge) {
646 $inclusivePortion = $taxTotal - $exclusiveTaxTotal - $feeTax;
647 $reversedInclusive = ($rcMode === 'dynamic') ? $inclusivePortion : 0;
648 $reversedTaxTotal = $exclusiveTaxTotal + $feeTax + $shippingTax + $reversedInclusive;
649 $inclusiveTaxAdjustment = $inclusivePortion;
650 $reversedShippingTax = $shippingTax;
651 $reversedShippingTaxLines = $shippingTaxLines;
652 $taxTotal = 0;
653 $exclusiveTaxTotal = 0;
654 $shippingTax = 0;
655 $shippingTaxLines = [];
656 $feeTax = 0;
657 $feeTaxLines = [];
658 $signupFeeTaxTotal = 0;
659 $taxLines = array_map(function ($taxLine) {
660 $taxLine['tax_amount'] = 0;
661 return $taxLine;
662 }, $taxLines);
663 // Also zero out tax_amount in product lines to prevent order items from having non-zero tax
664 $productLines = array_map(function ($productLine) {
665 $productLine['tax_amount'] = 0;
666 if (Arr::get($productLine, 'other_info.payment_type') === 'subscription') {
667 $productLine['other_info']['signup_fee_tax'] = 0;
668 $productLine['other_info']['first_iteration_tax'] = 0;
669 }
670 return $productLine;
671 }, $productLines);
672 }
673
674 if ($shouldApplyReverseCharge && $rcMode === 'dynamic') {
675
676 // --- Product lines: adjust unit_price to net for inclusive-tax lines ---
677 // Pre-restore guarantees gross prices and cleared meta on every pass — no idempotency guard needed.
678 foreach ($productLines as $lineIdx => &$line) {
679 $isInclusive = (bool) Arr::get($line, 'line_meta.tax_config.inclusive', false);
680 if (!$isInclusive) {
681 continue;
682 }
683
684 $rateAmounts = Arr::get($line, 'line_meta.tax_config.rates', []);
685 $adjustment = (int) array_sum(array_column($rateAmounts, 'tax_amount'));
686
687 if ($adjustment > 0) {
688 $adjustment = max(0, min($adjustment, (int) $line['subtotal']));
689 $adjustment = (int) apply_filters('fluent_cart/tax/reverse_charge_line_adjustment', $adjustment, [
690 'line' => $line,
691 'rc_mode' => $rcMode,
692 ]);
693 // Re-clamp after filter — prevents a buggy filter from producing negative unit_price
694 $adjustment = max(0, min($adjustment, (int) $line['subtotal']));
695
696 $quantity = max(1, (int) $line['quantity']);
697 $adjustmentPerUnit = (int) round($adjustment / $quantity, 0, PHP_ROUND_HALF_UP);
698
699 if ($adjustmentPerUnit > 0) {
700 $newUnitPrice = $line['unit_price'] - $adjustmentPerUnit;
701 $line['line_meta']['original_unit_price'] = $line['unit_price'];
702 $line['unit_price'] = $newUnitPrice;
703 // Use exact subtraction instead of newUnitPrice * quantity.
704 // When $adjustment doesn't divide evenly by $quantity,
705 // round($adjustment/$quantity) * $quantity can exceed $adjustment
706 // by up to ($quantity - 1) cents, making the stored subtotal
707 // 1 cent short. E.g. 3 × $10, $5 tax → round(500/3)=167,
708 // 167×3=501 ≠ 500, subtotal becomes $24.99 instead of $25.00.
709 $line['subtotal'] = (int) $line['subtotal'] - $adjustment;
710 $line['line_total'] = max(0, $line['subtotal'] - (int) Arr::get($line, 'discount_total', 0));
711 $line['line_meta']['reverse_charge_adjustment'] = $adjustment;
712 }
713 }
714
715 // Signup fee adjustment — use pre-captured value (RC block zeroes other_info.signup_fee_tax)
716 $signupFee = (int) Arr::get($line, 'other_info.signup_fee', 0);
717 $signupFeeTax = isset($signupFeeTaxesByIndex[$lineIdx]) ? $signupFeeTaxesByIndex[$lineIdx] : 0;
718 if ($signupFee > 0 && $signupFeeTax > 0) {
719 $line['other_info']['original_signup_fee'] = $signupFee;
720 $line['other_info']['signup_fee'] = max(0, $signupFee - $signupFeeTax);
721 }
722
723 }
724 unset($line);
725
726 // --- Inclusive fee items: compute net amounts ---
727 $rcAdjustedFees = [];
728 foreach ($allTaxedLines as $taxedLine) {
729 if (empty($taxedLine['is_fee'])) {
730 continue;
731 }
732 $feeTaxConfig = Arr::get($taxedLine, 'line_meta.tax_config', []);
733 $isFeeInclusive = (bool) Arr::get($feeTaxConfig, 'inclusive', false);
734 if (!$isFeeInclusive) {
735 continue;
736 }
737 $feeKey = Arr::get($taxedLine, 'other_info.fee_key', '');
738 $feeSource = Arr::get($taxedLine, 'other_info.source', 'custom');
739 $feeItemTax = (int) array_sum(array_column(Arr::get($feeTaxConfig, 'rates', []), 'tax_amount'));
740 $feeAmount = (int) $taxedLine['unit_price'];
741 if ($feeKey && $feeItemTax > 0) {
742 $rcAdjustedFees[$feeSource . ':' . $feeKey] = max(0, $feeAmount - $feeItemTax);
743 }
744 }
745 $checkoutData['tax_data']['rc_adjusted_fees'] = $rcAdjustedFees;
746
747 // Disarm the estimated_total filter — total is already net via unit_price
748 $inclusiveTaxAdjustment = 0;
749
750 do_action('fluent_cart/tax/reverse_charge_applied', [
751 'checkout_data' => $checkoutData,
752 'product_lines' => $productLines,
753 ]);
754
755 } else {
756
757 // Pre-restore already returned product lines to gross prices and cleared RC meta.
758 // Clear fee adjustments — priority-20 filter becomes no-op on next getFees() call.
759 unset($checkoutData['tax_data']['rc_adjusted_fees']);
760
761 if (!$shouldApplyReverseCharge) {
762 do_action('fluent_cart/tax/reverse_charge_removed', [
763 'checkout_data' => $checkoutData,
764 'product_lines' => $productLines,
765 ]);
766 }
767 }
768
769 $checkoutData['tax_data']['tax_total'] = $taxTotal;
770 $checkoutData['tax_data']['exclusive_tax_total'] = $exclusiveTaxTotal;
771 $checkoutData['tax_data']['reverse_charge_inclusive_adjustment'] = $inclusiveTaxAdjustment;
772 $checkoutData['tax_data']['reverse_charge_tax_total'] = $reversedTaxTotal;
773 $checkoutData['tax_data']['tax_behavior'] = $taxTotal === 0 && $shippingTax === 0 && $shouldApplyReverseCharge
774 ? 0
775 : $taxCalculator->getTaxBehaviorValue();
776
777 // NEW: always expose store-level inclusive mode separately
778 $checkoutData['tax_data']['store_tax_behavior'] = $taxCalculator->getStoreTaxBehaviorValue();
779
780 $checkoutData['tax_data']['tax_country'] = $taxCountry;
781 $checkoutData['tax_data']['shipping_tax'] = $shippingTax;
782 $checkoutData['tax_data']['shipping_tax_lines'] = $shippingTaxLines;
783 $checkoutData['tax_data']['reverse_charge_shipping_tax'] = $reversedShippingTax;
784 $checkoutData['tax_data']['reverse_charge_shipping_tax_lines'] = $reversedShippingTaxLines;
785 $checkoutData['tax_data']['reverse_charge_price_mode'] = $shouldApplyReverseCharge ? $this->getEffectiveRcMode() : 'fixed';
786 $checkoutData['tax_data']['fee_tax'] = $feeTax;
787 $checkoutData['tax_data']['fee_tax_lines'] = $feeTaxLines;
788 $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal;
789 $checkoutData['tax_data']['tax_lines'] = $taxLines;
790 $fillData['checkout_data'] = $checkoutData;
791
792 // Only product lines go back into cart_data
793 $fillData['cart_data'] = $productLines;
794
795 if (isset($fillData['hook_changes'])) {
796 Arr::set($fillData, 'hook_changes.tax', true);
797 }
798
799 return $fillData;
800 }
801
802 protected function shouldApplyReverseCharge($checkoutData, $taxApplicableCountry, $lineItems = [])
803 {
804 if (!Arr::get($checkoutData, 'tax_data.valid', false)) {
805 return false;
806 }
807
808 $validatedCountry = Arr::get($checkoutData, 'tax_data.country', '');
809 if (!$validatedCountry || $validatedCountry !== $taxApplicableCountry) {
810 return false;
811 }
812
813 if (!$this->canApplyVatValidation($taxApplicableCountry)) {
814 return false;
815 }
816
817 if (Arr::get($this->taxSettings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes') {
818 $excludedCategories = Arr::get($this->taxSettings, 'eu_vat_settings.vat_reverse_excluded_categories', []);
819 if (!empty($excludedCategories)) {
820 $productIds = array_column((array)$lineItems, 'post_id');
821 if (!empty($productIds)) {
822 $productTerms = $this->getTermsByProductIds($productIds);
823 foreach ($productTerms as $terms) {
824 if (array_intersect($terms, $excludedCategories)) {
825 return false;
826 }
827 }
828 }
829 }
830 }
831
832 return true;
833 }
834
835 public function isReverseChargeCheckout($checkoutData)
836 {
837 return Arr::get($checkoutData, 'tax_data.valid', false)
838 && (int) Arr::get($checkoutData, 'tax_data.tax_behavior', 2) === 0;
839 }
840
841 protected function hasReverseChargeTaxData(array $taxData): bool
842 {
843 return (int) Arr::get($taxData, 'reverse_charge_tax_total', 0) > 0
844 || (int) Arr::get($taxData, 'reverse_charge_shipping_tax', 0) > 0
845 || (int) Arr::get($taxData, 'reverse_charge_inclusive_adjustment', 0) > 0
846 || (int) Arr::get($taxData, 'tax_behavior', 2) === 0;
847 }
848
849 public function maybeRerenderEuVatField($fragments, $args)
850 {
851 $vatNumberEnabled = CheckoutFieldsSchema::isVatNumberEnabled();
852
853 if (!$vatNumberEnabled) {
854 return $fragments;
855 }
856
857 $changes = Arr::get($args, 'changes', []);
858 $countryTriggers = ['billing_country', 'shipping_country', 'ship_to_different', 'is_business'];
859 if (empty(array_intersect(array_keys($changes), $countryTriggers))) {
860 return $fragments;
861 }
862
863 $cart = Arr::get($args, 'cart');
864 $taxApplicableCountry = $this->getTaxApplicableCountry(Arr::get($this->taxSettings, 'tax_calculation_basis'), $cart->checkout_data['form_data']);
865
866 ob_start();
867 (new VatFieldRenderer($taxApplicableCountry))->renderInner($cart->checkout_data);
868 $euVatView = ob_get_clean();
869
870 $fragments[] = [
871 'selector' => '[data-fluent-cart-checkout-page-tax-wrapper]',
872 'content' => $euVatView,
873 'type' => 'replace'
874 ];
875
876 return $fragments;
877 }
878
879 public function prepareOtherData($data)
880 {
881 $cart = Arr::get($data, 'cart');
882 $order = Arr::get($data, 'order');
883
884 if (empty($cart->checkout_data['tax_data']) || !$order->id || !$cart) {
885 return;
886 }
887
888 $checkoutData = $cart->checkout_data;
889
890 // Order-placement safety: if the cart carries a stale RC state (admin turned off
891 // local_reverse_charge after the customer validated their VAT but before they
892 // placed the order), recalculate at full rate and patch the order totals before
893 // any tax records are written.
894 if ($this->isReverseChargeCheckout($checkoutData)) {
895 $rcTaxCountry = Arr::get($checkoutData, 'tax_data.tax_country', '');
896 if ($rcTaxCountry && !$this->canApplyVatValidation($rcTaxCountry)) {
897 $refreshed = $this->calculateCartTax([
898 'cart_data' => $cart->cart_data,
899 'checkout_data' => $checkoutData,
900 ]);
901 $checkoutData = $refreshed['checkout_data'];
902
903 $newTaxTotal = (int) Arr::get($checkoutData, 'tax_data.tax_total', 0);
904 $newShippingTax = (int) Arr::get($checkoutData, 'tax_data.shipping_tax', 0);
905 $newTaxBehavior = (int) Arr::get($checkoutData, 'tax_data.tax_behavior', 0);
906 $storeTaxBehavior = (int) Arr::get($checkoutData, 'tax_data.store_tax_behavior', $newTaxBehavior);
907 $exclusiveTaxTotal = (int) Arr::get($checkoutData, 'tax_data.exclusive_tax_total', 0);
908 $feeTax = (int) Arr::get($checkoutData, 'tax_data.fee_tax', 0);
909
910 // total_amount was computed with RC-zeroed tax, and the draft's fee_total never
911 // absorbed fee_tax (behavior was 0 then) — so the full additive portion goes on top.
912 $addedTax = 0;
913 if ($newTaxBehavior === 1) {
914 $addedTax = $newTaxTotal + $newShippingTax;
915 } elseif ($newTaxBehavior === 3) {
916 $addedTax = $exclusiveTaxTotal;
917 if ($storeTaxBehavior === 1) {
918 $addedTax += $feeTax + $newShippingTax;
919 }
920 }
921
922 $order->tax_total = $newTaxTotal;
923 $order->shipping_tax = $newShippingTax;
924 $order->tax_behavior = $newTaxBehavior;
925 $order->total_amount = $order->total_amount + $addedTax;
926 $order->save();
927
928 // CheckoutProcessor::persistTaxMeta() already ran with the RC-zeroed values.
929 $order->updateMeta('exclusive_tax_total', $exclusiveTaxTotal);
930 $order->updateMeta('store_tax_behavior', $storeTaxBehavior);
931 $order->updateMeta('fee_tax', $feeTax);
932 $feeTaxLines = (array) Arr::get($checkoutData, 'tax_data.fee_tax_lines', []);
933 if (!empty($feeTaxLines)) {
934 $order->updateMeta('fee_tax_lines', $feeTaxLines);
935 }
936
937 if ($addedTax > 0) {
938 // The pending charge transaction was created from the pre-patch total —
939 // sync it so the gateway charges the tax-adjusted amount.
940 $pendingTransaction = \FluentCart\App\Models\OrderTransaction::query()
941 ->where('order_id', $order->id)
942 ->where('transaction_type', \FluentCart\App\Helpers\Status::TRANSACTION_TYPE_CHARGE)
943 ->where('status', \FluentCart\App\Helpers\Status::PAYMENT_PENDING)
944 ->first();
945 if ($pendingTransaction) {
946 $pendingTransaction->total = $order->total_amount;
947 $pendingTransaction->save();
948 }
949 }
950 }
951 }
952
953 $taxCountry = Arr::get($checkoutData, 'tax_data.tax_country', '');
954 // add store vat number into tax data
955 $taxSettings = $this->getSettings();
956 $isEuCountry = LocalizationManager::getInstance()->isEuTaxCountry($taxCountry ?? '');
957
958 $storeVatNumber = '';
959 if ($isEuCountry) {
960 $euVatMethod = Arr::get($taxSettings, 'eu_vat_settings.method');
961 if ($euVatMethod === 'home') {
962 $storeVatNumber = Arr::get($taxSettings, 'eu_vat_settings.home_vat', '');
963 } elseif ($euVatMethod === 'oss') {
964 $storeVatNumber = Arr::get($taxSettings, 'eu_vat_settings.oss_vat', '');
965 }
966 }
967 if (empty($storeVatNumber)) {
968 $key = 'fluent_cart_tax_id_' . $taxCountry;
969 $taxCountryData = \FluentCart\App\Models\Meta::query()->where('meta_key', $key)->where('object_type', 'tax')->first();
970 if ($taxCountryData) {
971 $storeVatNumber = Arr::get($taxCountryData->meta_value, 'tax_id', '');
972 }
973 }
974
975 $customerVatData = [];
976 $isReverseChargeApplied = $this->isReverseChargeCheckout($checkoutData);
977 if (Arr::get($cart->checkout_data, 'tax_data.valid', false)) {
978 $customerVatData = Arr::get($checkoutData, 'tax_data');
979 $order = $data['order'];
980 $order->customer->updateMeta('customer_tax_info',
981 Arr::only($customerVatData, ['vat_number', 'country', 'valid', 'name', 'address'])
982 );
983 }
984
985 $taxMeta = [
986 'tax_country' => $taxCountry,
987 'store_vat_number' => $storeVatNumber,
988 'reverse_charge_applied' => $isReverseChargeApplied,
989 'shipping_inclusive' => (int) Arr::get($checkoutData, 'tax_data.store_tax_behavior', 2) === 2,
990 ];
991
992 if ($isReverseChargeApplied && !empty($customerVatData)) {
993 $taxMeta['vat_reverse'] = $customerVatData;
994 }
995
996 if ($isReverseChargeApplied) {
997 $taxMeta['reverse_charge_original_tax_total'] = (int) Arr::get(
998 $checkoutData, 'tax_data.reverse_charge_tax_total', 0
999 );
1000 $taxMeta['reverse_charge_price_mode'] = $this->getEffectiveRcMode();
1001 $taxMeta['reverse_charge_original_shipping_tax'] = (int) Arr::get(
1002 $checkoutData, 'tax_data.reverse_charge_shipping_tax', 0
1003 );
1004 // Flag: shipping_total was stored at net (VAT already stripped) for this order.
1005 // Display code uses this to skip the rcShippingAdjustment on post-order surfaces.
1006 $rcModeForMeta = (string) Arr::get($checkoutData, 'tax_data.reverse_charge_price_mode', 'fixed');
1007 $storeBehaviorForMeta = (int) Arr::get($checkoutData, 'tax_data.store_tax_behavior', 2);
1008 $rcShippingTaxForMeta = (int) Arr::get($checkoutData, 'tax_data.reverse_charge_shipping_tax', 0);
1009 if ($rcModeForMeta === 'dynamic' && $storeBehaviorForMeta === 2 && $rcShippingTaxForMeta > 0) {
1010 $taxMeta['shipping_net_stored'] = true;
1011 }
1012 }
1013
1014 static::persistTaxRates(
1015 $order->id,
1016 Arr::get($checkoutData, 'tax_data.tax_lines', []),
1017 $taxMeta,
1018 (int) Arr::get($checkoutData, 'tax_data.shipping_tax', 0),
1019 Arr::get($checkoutData, 'tax_data.shipping_tax_lines', [])
1020 );
1021 }
1022
1023 public function storeBusinessInfoOnOrder($data)
1024 {
1025 $cart = Arr::get($data, 'cart');
1026 $order = Arr::get($data, 'order');
1027 $requestData = Arr::get($data, 'request_data', []);
1028
1029 if (!$cart || !$order || !$order->id) {
1030 return;
1031 }
1032
1033 // Snapshot store's business identity at order placement so post-order surfaces
1034 // (receipts, PDFs, emails) show the values that were valid when the order was placed,
1035 // even if the admin later changes them in store settings.
1036 $storeSettings = new StoreSettings();
1037 $order->updateMeta('store_business_info', [
1038 'company_name' => (string) $storeSettings->get('company_name', ''),
1039 'legal_registration_id' => (string) $storeSettings->get('legal_registration_id', ''),
1040 'seller_vat_id' => (string) $storeSettings->get('seller_vat_id', ''),
1041 'seller_tax_id' => (string) $storeSettings->get('seller_tax_id', ''),
1042 ]);
1043
1044 $checkoutData = $cart->checkout_data;
1045
1046 // Prefer the live request value; fall back to cart only when the key was never submitted
1047 $isBusinessCheckout = apply_filters(
1048 'fluent_cart/checkout/is_business',
1049 array_key_exists('is_business', $requestData)
1050 ? Arr::get($requestData, 'is_business', 'no') === 'yes'
1051 : Arr::get($checkoutData, 'form_data.is_business', 'no') === 'yes',
1052 ['checkout_data' => $checkoutData, 'order' => $order]
1053 );
1054
1055 if (!$isBusinessCheckout) {
1056 return;
1057 }
1058
1059 // Use the submitted POST values as the primary source; fall back to the cart's
1060 // persisted checkout_data for any field the DataWatcher may not have saved yet
1061 $taxNumber = sanitize_text_field(
1062 Arr::get($requestData, 'fct_billing_tax_id', '')
1063 ?: Arr::get($checkoutData, 'tax_data.vat_number', '')
1064 );
1065 $companyName = sanitize_text_field(
1066 Arr::get($requestData, 'billing_company_name', '')
1067 ?: Arr::get($checkoutData, 'form_data.billing_company_name', '')
1068 );
1069 $legalRegId = sanitize_text_field(
1070 Arr::get($requestData, 'billing_legal_registration_id', '')
1071 ?: Arr::get($checkoutData, 'form_data.billing_legal_registration_id', '')
1072 );
1073 if (!$taxNumber && !$companyName && !$legalRegId) {
1074 return;
1075 }
1076
1077 $businessInfo = [];
1078
1079 if ($companyName) {
1080 $businessInfo['company_name'] = $companyName;
1081 }
1082
1083 if ($legalRegId) {
1084 $businessInfo['legal_registration_id'] = $legalRegId;
1085 }
1086
1087 if ($taxNumber) {
1088 $businessInfo['tax_number'] = $taxNumber;
1089 $businessInfo['tax_number_validated'] = false;
1090 $businessInfo['tax_number_country'] = '';
1091
1092 if (Arr::get($checkoutData, 'tax_data.valid', false)) {
1093 $businessInfo['tax_number_validated'] = true;
1094 $businessInfo['tax_number_country'] = sanitize_text_field(Arr::get($checkoutData, 'tax_data.country', ''));
1095 $businessInfo['tax_number_name'] = sanitize_text_field(Arr::get($checkoutData, 'tax_data.name', ''));
1096 }
1097 }
1098
1099 $order->updateMeta('business_info', $businessInfo);
1100 }
1101
1102
1103 public function initCheckoutActions()
1104 {
1105 add_action('fluent_cart/checkout/b2b_extra_fields', [$this, 'renderTaxField'], 10, 1);
1106 }
1107
1108 public function registerAjaxHandlers()
1109 {
1110 add_action('wp_ajax_fluent_cart_validate_vat', [$this, 'handleVatValidation']);
1111 add_action('wp_ajax_nopriv_fluent_cart_validate_vat', [$this, 'handleVatValidation']);
1112
1113 add_action('wp_ajax_fluent_cart_remove_vat', [$this, 'removeVat']);
1114 add_action('wp_ajax_nopriv_fluent_cart_remove_vat', [$this, 'removeVat']);
1115 }
1116
1117 public function renderTaxField($data)
1118 {
1119 $cart = Arr::get($data, 'cart');
1120
1121 $taxApplicableCountry = $this->getTaxApplicableCountry(
1122 Arr::get($this->taxSettings, 'tax_calculation_basis'),
1123 Arr::get($cart->checkout_data, 'form_data')
1124 );
1125
1126 // On checkout page load, if the cart has a stale RC state (admin turned
1127 // off local_reverse_charge after the customer validated), recalculate now
1128 // so the correct tax totals and Apply/Remove state render immediately.
1129 if (
1130 Arr::get($cart->checkout_data, 'tax_data.valid', false)
1131 && $this->hasReverseChargeTaxData(Arr::get($cart->checkout_data, 'tax_data', []))
1132 && !$this->canApplyVatValidation($taxApplicableCountry)
1133 ) {
1134 $this->recalculateTax($data);
1135 }
1136
1137 (new VatFieldRenderer($taxApplicableCountry))->render($cart);
1138 }
1139
1140 public function getTaxApplicableCountry($calculationBasis, $formData)
1141 {
1142 $country = '';
1143
1144 $shipToDifferent = Arr::get($formData, 'ship_to_different', 'no') === 'yes';
1145
1146 if ($calculationBasis === 'store') {
1147 $country = (new StoreSettings())->get('store_country') ?? '';
1148 } else if ($calculationBasis === 'billing' || ($calculationBasis === 'shipping' && !$shipToDifferent)) {
1149 $country = Arr::get($formData, 'billing_country') ?? '';
1150 } else {
1151 $country = Arr::get($formData, 'shipping_country') ?? '';
1152 }
1153 return $country;
1154
1155 }
1156
1157 public function canApplyVatValidation($countryCode)
1158 {
1159 if (!$countryCode) {
1160 return false;
1161 }
1162
1163 $settings = $this->getSettings();
1164
1165 if (Arr::get($settings, 'enable_tax', 'no') !== 'yes') {
1166 return false;
1167 }
1168
1169 $storeCountry = (new StoreSettings())->get('store_country');
1170 return Arr::get($settings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes'
1171 || $countryCode !== $storeCountry;
1172 }
1173
1174 public function handleVatValidation()
1175 {
1176 nocache_headers();
1177
1178 if (!isset($_REQUEST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])), 'fluentcart')) {
1179 wp_send_json(['message' => __('Security check failed', 'fluent-cart')], 403);
1180 }
1181
1182 if (!$this->isEnabled()) {
1183 wp_send_json(['message' => __('Tax is not enabled.', 'fluent-cart')], 422);
1184 }
1185
1186 if (!CheckoutFieldsSchema::isVatNumberEnabled()) {
1187 wp_send_json(['message' => __('VAT number collection is not enabled.', 'fluent-cart')], 422);
1188 }
1189
1190 $cart = CartHelper::getCart();
1191
1192 if (empty($cart->checkout_data) || empty($cart->checkout_data['form_data'])) {
1193 wp_send_json(['message' => __('Invalid checkout session.', 'fluent-cart')], 422);
1194 }
1195
1196 $taxCalculationBasis = Arr::get($this->taxSettings, 'tax_calculation_basis');
1197 $formData = Arr::get($cart->checkout_data, 'form_data', []);
1198 $shipToDifferent = Arr::get($formData, 'ship_to_different', '');
1199
1200 if ($taxCalculationBasis === 'billing' || ($taxCalculationBasis === 'shipping' && $shipToDifferent !== 'yes')) {
1201 $countryCode = Arr::get($formData, 'billing_country', '');
1202 } elseif ($taxCalculationBasis === 'shipping') {
1203 $countryCode = Arr::get($formData, 'shipping_country', '');
1204 }
1205
1206 $storeCountry = (new StoreSettings())->get('store_country');
1207 if ($taxCalculationBasis === 'store') {
1208 $countryCode = $storeCountry;
1209 }
1210
1211 $euCountryCodes = Arr::get(LocalizationManager::getInstance()->taxContinents('EU'), 'countries', []);
1212
1213 if (!$countryCode || !in_array($countryCode, $euCountryCodes)) {
1214 wp_send_json(['message' => __('VAT validation is only available for EU countries.', 'fluent-cart')], 422);
1215 }
1216
1217 $vatNumber = isset($_REQUEST['vat_number']) ? sanitize_text_field(wp_unslash($_REQUEST['vat_number'])) : '';
1218
1219 if (!$vatNumber) {
1220 wp_send_json(['message' => __('Missing required data', 'fluent-cart')], 422);
1221 }
1222
1223 // Strip EU country code prefix from VAT number if present
1224 foreach ($euCountryCodes as $code) {
1225 if (strpos($vatNumber, $code) === 0) {
1226 $vatNumber = substr($vatNumber, strlen($code));
1227 break;
1228 }
1229 }
1230
1231 $taxData = $this->validateEuVatNumber($countryCode, $vatNumber);
1232
1233 if (is_wp_error($taxData)) {
1234 wp_send_json(['message' => $taxData->get_error_message()], 422);
1235 }
1236
1237 if (!Arr::get($taxData, 'valid')) {
1238 wp_send_json(['message' => __('VAT number is not valid!', 'fluent-cart')], 422);
1239 }
1240
1241 $localRc = Arr::get($this->taxSettings, 'eu_vat_settings.local_reverse_charge', 'no');
1242 $isExcluded = false;
1243 if ($localRc === 'yes') {
1244 // if there is any excluded category in the cart, then don't apply VAT reverse charge
1245 $excludedCategories = Arr::get($this->taxSettings, 'eu_vat_settings.vat_reverse_excluded_categories', []);
1246 $productIds = array_column($cart->cart_data, 'post_id');
1247 $productTerms = $this->getTermsByProductIds($productIds);
1248 foreach ($productTerms as $productId => $terms) {
1249 if (array_intersect($terms, $excludedCategories)) {
1250 $isExcluded = true;
1251 break;
1252 }
1253 }
1254 }
1255
1256 $appliesReverseCharge = !$isExcluded && ($localRc === 'yes' || $countryCode !== $storeCountry);
1257
1258 if ($appliesReverseCharge) {
1259 $cartTaxData = Arr::get($cart->checkout_data, 'tax_data', []);
1260 $inclusiveAdj = (int) Arr::get($cartTaxData, 'reverse_charge_inclusive_adjustment', 0);
1261
1262 $existingRcTotal = (int) Arr::get($cartTaxData, 'reverse_charge_tax_total', 0);
1263 if ($existingRcTotal > 0) {
1264 $taxData['reverse_charge_tax_total'] = $existingRcTotal;
1265 $taxData['reverse_charge_shipping_tax'] = (int) Arr::get($cartTaxData, 'reverse_charge_shipping_tax', 0);
1266 } else {
1267 $rcExclusiveTax = (int) Arr::get($cartTaxData, 'exclusive_tax_total', 0);
1268 $rcFeeTax = (int) Arr::get($cartTaxData, 'fee_tax', 0);
1269 $rcShippingTax = (int) Arr::get($cartTaxData, 'shipping_tax', 0);
1270 $rcTaxTotal = (int) Arr::get($cartTaxData, 'tax_total', 0);
1271 $rcMode = $this->getEffectiveRcMode();
1272 $rcInclPortion = max(0, $rcTaxTotal - $rcExclusiveTax - $rcFeeTax - $rcShippingTax);
1273 $rcReversedIncl = ($rcMode === 'dynamic') ? $rcInclPortion : 0;
1274 $taxData['reverse_charge_tax_total'] = $rcExclusiveTax + $rcFeeTax + $rcShippingTax + $rcReversedIncl;
1275 $taxData['reverse_charge_shipping_tax'] = $rcShippingTax;
1276 }
1277 $taxData['reverse_charge_inclusive_adjustment'] = $inclusiveAdj;
1278 $taxData['tax_total'] = 0;
1279 $taxData['exclusive_tax_total'] = 0;
1280 $taxData['shipping_tax'] = 0;
1281 $taxData['tax_behavior'] = 0;
1282 $taxData['fee_tax'] = 0;
1283 $taxData['signup_fee_tax'] = 0;
1284 $taxData['shipping_tax_lines'] = [];
1285 $existingTaxLines = isset($cartTaxData['tax_lines']) && is_array($cartTaxData['tax_lines'])
1286 ? $cartTaxData['tax_lines']
1287 : [];
1288 $taxData['tax_lines'] = array_map(function ($line) {
1289 $line['tax_amount'] = 0;
1290 return $line;
1291 }, $existingTaxLines);
1292 }
1293
1294 $checkoutData = $cart->checkout_data;
1295 if (!isset($checkoutData['tax_data']) || !is_array($checkoutData['tax_data'])) {
1296 $checkoutData['tax_data'] = [];
1297 }
1298 $checkoutData['tax_data'] = array_merge($checkoutData['tax_data'], $taxData);
1299 $cart->checkout_data = $checkoutData;
1300 $fillData = $this->calculateCartTax([
1301 'cart_data' => $cart->cart_data,
1302 'checkout_data' => $cart->checkout_data,
1303 ]);
1304 $cart->fill($fillData);
1305 $cart->save();
1306
1307 ob_start();
1308 (new CartSummaryRender($cart))->render(false);
1309 $cartSummaryInner = ob_get_clean();
1310
1311 ob_start();
1312 (new VatFieldRenderer(Arr::get($taxData, 'country', $countryCode)))->renderInner($checkoutData);
1313 $euVatView = ob_get_clean();
1314
1315 wp_send_json([
1316 'success' => true,
1317 'message' => $appliesReverseCharge
1318 ? __('VAT has been applied successfully', 'fluent-cart')
1319 : __('VAT number has been validated successfully', 'fluent-cart'),
1320 'tax_data' => $taxData,
1321 'fragments' => [
1322 [
1323 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
1324 'content' => $cartSummaryInner,
1325 'type' => 'replace'
1326 ],
1327 [
1328 'selector' => '[data-fluent-cart-checkout-page-tax-wrapper]',
1329 'content' => $euVatView,
1330 'type' => 'replace'
1331 ]
1332 ],
1333 ], 200);
1334 }
1335
1336 public function removeVat()
1337 {
1338 nocache_headers();
1339
1340 if (!isset($_REQUEST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])), 'fluentcart')) {
1341 wp_send_json(['message' => __('Security check failed', 'fluent-cart')], 403);
1342 }
1343
1344 if (isset($_REQUEST['fct_cart_hash'])) {
1345 $cart = CartResource::get(['hash' => sanitize_text_field(wp_unslash($_REQUEST['fct_cart_hash']))]);
1346 } else {
1347 $cart = CartResource::get();
1348 }
1349
1350 // recalculate tax amount
1351 do_action('fluent_cart/checkout/cart_amount_updated', [
1352 'cart' => $cart
1353 ]);
1354
1355 $checkoutData = $cart->checkout_data;
1356
1357 // Reset VAT-related fields
1358 if (isset($checkoutData)) {
1359 unset($checkoutData['tax_data']['valid']);
1360 unset($checkoutData['tax_data']['name']);
1361 unset($checkoutData['tax_data']['address']);
1362 unset($checkoutData['tax_data']['vat_number']);
1363 unset($checkoutData['tax_data']['country']);
1364 unset($checkoutData['tax_data']['declaration_note']);
1365 unset($checkoutData['tax_data']['reverse_charge_tax_total']);
1366 unset($checkoutData['tax_data']['reverse_charge_shipping_tax']);
1367 unset($checkoutData['tax_data']['reverse_charge_shipping_tax_lines']);
1368 unset($checkoutData['tax_data']['reverse_charge_inclusive_adjustment']);
1369 }
1370
1371 $cart->checkout_data = $checkoutData;
1372 $fillData = $this->calculateCartTax([
1373 'cart_data' => $cart->cart_data,
1374 'checkout_data' => $cart->checkout_data,
1375 ]);
1376 $cart->fill($fillData);
1377 $cart->save();
1378
1379 ob_start();
1380 (new CartSummaryRender($cart))->render(false);
1381 $cartSummaryInner = ob_get_clean();
1382
1383 wp_send_json([
1384 'success' => true,
1385 'message' => __('VAT has been removed successfully', 'fluent-cart'),
1386 'checkout_data' => [],
1387 'fragments' => [
1388 [
1389 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
1390 'content' => $cartSummaryInner,
1391 'type' => 'replace'
1392 ]
1393 ]
1394 ]);
1395 }
1396
1397 protected function getTermsByProductIds($products)
1398 {
1399 $formattedTerms = null;
1400
1401 if ($formattedTerms === null) {
1402 $terms = App::make('db')->table('term_relationships')
1403 ->whereIn('object_id', $products)
1404 ->get();
1405
1406 $formattedTerms = [];
1407
1408 foreach ($terms as $term) {
1409 if (!isset($formattedTerms[$term->object_id])) {
1410 $formattedTerms[$term->object_id] = [];
1411 }
1412 $formattedTerms[$term->object_id][] = $term->term_taxonomy_id;
1413 }
1414 }
1415
1416 return $formattedTerms;
1417 }
1418
1419 protected function validateEuVatNumber($countryCode, $vatNumber)
1420 {
1421 /*
1422 * Allow third parties to validate a VAT number without using the VIES SOAP service.
1423 *
1424 * Return null → FluentCart performs its default SOAP validation.
1425 * Return array → treated as a successful validation result (same shape as SOAP response:
1426 * 'country', 'vat_number', 'valid' => true, 'name', 'address').
1427 * Return WP_Error → treated as a validation error; its message is forwarded to the customer.
1428 */
1429
1430 $thirdPartyResult = apply_filters('fluent_cart/tax/validate_eu_vat_number', null, [
1431 'country_code' => $countryCode,
1432 'vat_number' => $vatNumber,
1433 ]);
1434
1435 if (is_wp_error($thirdPartyResult) || is_array($thirdPartyResult)) {
1436 return $thirdPartyResult;
1437 }
1438
1439 try {
1440 $wsdl = "https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl";
1441
1442 if (!class_exists('\\SoapClient')) {
1443 return new \WP_Error('service_unavailable', __('SOAP is not available on the server.', 'fluent-cart'));
1444 }
1445
1446 $client = new \SoapClient($wsdl, [
1447 'exceptions' => true,
1448 'trace' => true,
1449 'connection_timeout' => 10,
1450 ]);
1451
1452 $params = [
1453 'countryCode' => $countryCode,
1454 'vatNumber' => preg_replace('/[^A-Za-z0-9]/', '', $vatNumber)
1455 ];
1456
1457 $result = $client->checkVat($params);
1458
1459 if (empty($result->valid)) {
1460 $countryName = LocalizationManager::getInstance()->getCountryNameByCode($countryCode);
1461 return new \WP_Error(
1462 'invalid',
1463 sprintf(
1464 /* translators: %1$s: country name */
1465 __('Invalid VAT number for %1$s!', 'fluent-cart'),
1466 $countryName
1467 )
1468 );
1469 }
1470
1471 $taxData = [
1472 'country' => $result->countryCode,
1473 'vat_number' => $result->vatNumber,
1474 'valid' => (bool)$result->valid,
1475 'name' => $result->name,
1476 'address' => $result->address,
1477 ];
1478
1479 return $taxData;
1480
1481 } catch (\SoapFault $e) {
1482 $faultString = strtoupper($e->getMessage());
1483
1484 if (strpos($faultString, 'MAX_CONCURRENT_REQ') !== false) {
1485 return new \WP_Error(
1486 'rate_limit',
1487 __('VAT validation rate limit reached. Please try again in a few moments.', 'fluent-cart')
1488 );
1489 }
1490
1491 if (strpos($faultString, 'INVALID_INPUT') !== false || strpos($faultString, 'INVALID_REQUESTER_INFO') !== false) {
1492 $countryName = LocalizationManager::getInstance()->getCountryNameByCode($countryCode);
1493 return new \WP_Error(
1494 'invalid',
1495 sprintf(
1496 /* translators: %1$s: country name */
1497 __('Invalid VAT number for country %1$s!', 'fluent-cart'),
1498 $countryName
1499 )
1500 );
1501 }
1502
1503 return new \WP_Error('service_unavailable', __('The VAT validation service is temporarily unavailable. Please try again later.', 'fluent-cart'));
1504 } catch (\Exception $e) {
1505 return new \WP_Error('invalid', $e->getMessage());
1506 }
1507 }
1508
1509 public function validateVatForAdmin($countryCode, $vatNumber)
1510 {
1511 return $this->validateEuVatNumber($countryCode, $vatNumber);
1512 }
1513
1514 /**
1515 * Persist tax-rate rows for an order.
1516 *
1517 * Accepts the output of AdminOrderTaxService::calculate() (or the equivalent
1518 * data built inside prepareOtherData) and writes / updates fct_order_tax_rate rows.
1519 *
1520 * @param int $orderId The order ID.
1521 * @param array $taxLines Array of ['rate_id', 'label', 'tax_amount'] — one entry per rate.
1522 * @param array $taxMeta Arbitrary meta merged into every row (country, vat numbers, etc.).
1523 * @param int $shippingTax Total shipping tax for the order (distributed proportionally).
1524 */
1525 public static function persistTaxRates($orderId, $taxLines, $taxMeta, $shippingTax = 0, $shippingTaxLines = [])
1526 {
1527 if (empty($taxLines)) {
1528 OrderTaxRate::query()
1529 ->where('order_id', $orderId)
1530 ->where('tax_rate_id', '!=', 0)
1531 ->delete();
1532
1533 // Zero-tax sentinel row — keeps fct_order_tax_rate populated for every order.
1534 $existing = OrderTaxRate::query()
1535 ->where('order_id', $orderId)
1536 ->where('tax_rate_id', 0)
1537 ->first();
1538
1539 if (!$existing) {
1540 OrderTaxRate::create([
1541 'order_id' => $orderId,
1542 'tax_rate_id' => 0,
1543 'shipping_tax' => (int) $shippingTax,
1544 'order_tax' => 0,
1545 'total_tax' => (int) $shippingTax,
1546 'meta' => $taxMeta,
1547 ]);
1548 } else {
1549 $existing->update([
1550 'shipping_tax' => (int) $shippingTax,
1551 'order_tax' => 0,
1552 'total_tax' => (int) $shippingTax,
1553 'meta' => $taxMeta,
1554 ]);
1555 }
1556 return;
1557 }
1558
1559 // Re-index to guarantee 0-based iteration; callers may pass associative arrays.
1560 $taxLines = array_values($taxLines);
1561 $activeRateIds = array_values(array_unique(array_map(function ($taxLine) {
1562 return (int) Arr::get($taxLine, 'rate_id', 0);
1563 }, $taxLines)));
1564
1565 // Remove stale persisted rows from prior tax compositions, including the zero-tax sentinel.
1566 OrderTaxRate::query()
1567 ->where('order_id', $orderId)
1568 ->where(function ($query) use ($activeRateIds) {
1569 $query->where('tax_rate_id', 0)
1570 ->orWhereNotIn('tax_rate_id', $activeRateIds);
1571 })
1572 ->delete();
1573
1574 // Build exact per-rate shipping tax map from checkout-time calculation when available.
1575 // Falls back to proportional split for admin order recalculation and legacy callers.
1576 $shippingTaxByRateId = [];
1577 foreach ($shippingTaxLines as $stl) {
1578 $stlRateId = (int) Arr::get($stl, 'rate_id', 0);
1579 if ($stlRateId !== 0) {
1580 $shippingTaxByRateId[$stlRateId] = (int) Arr::get($stl, 'shipping_tax', 0);
1581 }
1582 }
1583
1584 $lineShippingTaxes = [];
1585 if (!empty($shippingTaxByRateId)) {
1586 // Exact amounts from checkout calculator — no proportional approximation needed.
1587 foreach ($taxLines as $taxLine) {
1588 $rateId = (int) Arr::get($taxLine, 'rate_id', 0);
1589 $lineShippingTaxes[] = isset($shippingTaxByRateId[$rateId]) ? $shippingTaxByRateId[$rateId] : 0;
1590 }
1591 // Reconcile rounding differences so persisted rows always sum to the order-level total.
1592 $exactSum = array_sum($lineShippingTaxes);
1593 $lastIdx = count($lineShippingTaxes) - 1;
1594 if ($lastIdx >= 0 && $exactSum !== (int) $shippingTax) {
1595 $lineShippingTaxes[$lastIdx] += ((int) $shippingTax - $exactSum);
1596 }
1597 } else {
1598 // Proportional split (admin order recalculation and legacy callers without exact breakdown).
1599 // Assign rounding remainder to the last rate so the sum always equals shipping_tax exactly.
1600 $totalOrderTax = array_reduce($taxLines, function ($carry, $line) {
1601 return $carry + (int) Arr::get($line, 'tax_amount', 0);
1602 }, 0);
1603 $distributedTotal = 0;
1604 foreach ($taxLines as $taxLine) {
1605 $orderTax = (int) Arr::get($taxLine, 'tax_amount', 0);
1606 $share = ($shippingTax > 0 && $totalOrderTax > 0)
1607 ? (int) round($shippingTax * ($orderTax / $totalOrderTax))
1608 : 0;
1609 $lineShippingTaxes[] = $share;
1610 $distributedTotal += $share;
1611 }
1612 $lastIdx = count($lineShippingTaxes) - 1;
1613 if ($lastIdx >= 0) {
1614 $lineShippingTaxes[$lastIdx] += ($shippingTax - $distributedTotal);
1615 }
1616 }
1617
1618 foreach ($taxLines as $index => $taxLine) {
1619 $rateId = (int) Arr::get($taxLine, 'rate_id', 0);
1620 $orderTax = (int) Arr::get($taxLine, 'tax_amount', 0);
1621 $lineShippingTax = $lineShippingTaxes[$index];
1622 $lineMeta = array_merge($taxMeta, [
1623 'label' => sanitize_text_field((string) Arr::get($taxLine, 'label', '')),
1624 'rate_percent' => (float) Arr::get($taxLine, 'rate_percent', 0),
1625 'is_compound' => (bool) Arr::get($taxLine, 'is_compound', false),
1626 'taxable_amount' => (int) Arr::get($taxLine, 'taxable_amount', 0),
1627 'inclusive' => Arr::get($taxLine, 'inclusive', null),
1628 'is_mixed_inclusive' => (bool) Arr::get($taxLine, 'is_mixed_inclusive', false),
1629 ]);
1630
1631 $existing = OrderTaxRate::query()
1632 ->where('order_id', $orderId)
1633 ->where('tax_rate_id', $rateId)
1634 ->first();
1635
1636 if (!$existing) {
1637 OrderTaxRate::create([
1638 'order_id' => $orderId,
1639 'tax_rate_id' => $rateId,
1640 'shipping_tax' => $lineShippingTax,
1641 'order_tax' => $orderTax,
1642 'total_tax' => $lineShippingTax + $orderTax,
1643 'meta' => $lineMeta,
1644 ]);
1645 } else {
1646 $existing->update([
1647 'shipping_tax' => $lineShippingTax,
1648 'order_tax' => $orderTax,
1649 'total_tax' => $lineShippingTax + $orderTax,
1650 'meta' => $lineMeta,
1651 ]);
1652 }
1653 }
1654 }
1655
1656 public static function isTaxEnabled()
1657 {
1658 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
1659 return Arr::get($taxSettings, 'enable_tax', 'no') === 'yes';
1660 }
1661
1662 /**
1663 * Whether reverse charge can be applied for a given customer country.
1664 * Same logic as canApplyVatValidation() but accessible statically for renderers.
1665 * Returns false when local_reverse_charge is off AND the country equals the store country.
1666 */
1667 public static function canApplyReverseCharge($countryCode)
1668 {
1669 if (!$countryCode || !static::isTaxEnabled()) {
1670 return false;
1671 }
1672 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
1673 $storeCountry = (new StoreSettings())->get('store_country');
1674 return Arr::get($taxSettings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes'
1675 || $countryCode !== $storeCountry;
1676 }
1677
1678 /**
1679 * The legal reverse-charge notice shown on checkout, invoices, receipts and emails.
1680 * EU VAT Directive 2006/112/EC Article 226(11a) requires the invoice to carry the
1681 * literal mention "Reverse charge" when the customer is liable for the VAT, so the
1682 * default string must always start with those exact words. Article 196 covers
1683 * services; stores supplying intra-EU goods (Art. 138) or needing other
1684 * jurisdiction-specific wording can override via the filter.
1685 */
1686 public static function getReverseChargeNoticeText()
1687 {
1688 return apply_filters(
1689 'fluent_cart/tax/reverse_charge_notice_text',
1690 __('Reverse charge — Article 196, Council Directive 2006/112/EC. Customer is liable for VAT.', 'fluent-cart')
1691 );
1692 }
1693
1694 public static function euVatCountyOptions()
1695 {
1696 $continents = require dirname(__DIR__, 2) . '/Services/Localization/i18n/eu_tax_countries.php';
1697 $euCountries = Arr::get($continents, 'EU.countries', []);
1698 $countries = LocalizationManager::getCountries();
1699 $taxPresets = require dirname(__DIR__, 2) . '/Services/Tax/tax.php';
1700 $countryOptions = [];
1701
1702 foreach ($euCountries as $countryCode) {
1703 $preset = isset($taxPresets[$countryCode]) ? $taxPresets[$countryCode] : [];
1704 $taxEntries = isset($preset['tax']) ? (array) $preset['tax'] : [];
1705 $defaultRates = [];
1706 $defaultRate = 0;
1707
1708 foreach ($taxEntries as $entry) {
1709 $slug = isset($entry['type']) ? $entry['type'] : 'standard';
1710 $rate = isset($entry['rate']) ? (float) $entry['rate'] : 0;
1711 $defaultRates[$slug] = $rate;
1712 if ($slug === 'standard') {
1713 $defaultRate = $rate;
1714 }
1715 }
1716
1717 $countryOptions[] = [
1718 'label' => Arr::get($countries, $countryCode, $countryCode),
1719 'value' => $countryCode,
1720 'default_rate' => $defaultRate,
1721 'default_rates' => $defaultRates,
1722 ];
1723 }
1724
1725 return $countryOptions;
1726 }
1727
1728 public static function taxTitleLists(): array
1729 {
1730 return apply_filters('fluent_cart/tax/country_tax_titles', [
1731 'AU' => __('ABN', 'fluent-cart'), // Australia
1732 'NZ' => __('GST', 'fluent-cart'), // New Zealand
1733 'IN' => __('GST', 'fluent-cart'), // India
1734 'SG' => __('GST', 'fluent-cart'), // Singapore
1735 'MY' => __('SST', 'fluent-cart'), // Malaysia
1736 'CA' => __('GST / HST / PST / QST', 'fluent-cart'), // Canada
1737 'GB' => __('VAT', 'fluent-cart'), // United Kingdom
1738 'EU' => __('VAT', 'fluent-cart'), // European Union
1739 'FR' => __('VAT', 'fluent-cart'), // France
1740 'DE' => __('VAT', 'fluent-cart'), // Germany
1741 'NL' => __('VAT', 'fluent-cart'), // Netherlands
1742 'ES' => __('VAT', 'fluent-cart'), // Spain
1743 'IT' => __('VAT', 'fluent-cart'), // Italy
1744 'IE' => __('VAT', 'fluent-cart'), // Ireland
1745 'US' => __('EIN / Sales Tax', 'fluent-cart'), // United States
1746 'ZA' => __('VAT', 'fluent-cart'), // South Africa
1747 'NG' => __('TIN / VAT', 'fluent-cart'), // Nigeria
1748 'AE' => __('TRN / VAT', 'fluent-cart'), // United Arab Emirates
1749 'SA' => __('VAT', 'fluent-cart'), // Saudi Arabia
1750 'QA' => __('VAT', 'fluent-cart'), // Qatar
1751 'JP' => __('Consumption Tax (CTN)', 'fluent-cart'), // Japan
1752 'CN' => __('VAT', 'fluent-cart'), // China
1753 'HK' => __('BRN', 'fluent-cart'), // Hong Kong
1754 'PH' => __('TIN / VAT', 'fluent-cart'), // Philippines
1755 'ID' => __('NPWP / PPN', 'fluent-cart'), // Indonesia
1756 'TH' => __('VAT', 'fluent-cart'), // Thailand
1757 'VN' => __('MST / VAT', 'fluent-cart'), // Vietnam
1758 'BD' => __('BIN / VAT', 'fluent-cart'), // Bangladesh
1759 'PK' => __('NTN / STRN', 'fluent-cart'), // Pakistan
1760 'LK' => __('VAT', 'fluent-cart'), // Sri Lanka
1761 'NP' => __('PAN / VAT', 'fluent-cart'), // Nepal
1762 'BR' => __('CNPJ / CPF', 'fluent-cart'), // Brazil
1763 'AR' => __('CUIT', 'fluent-cart'), // Argentina
1764 'MX' => __('RFC / IVA', 'fluent-cart'), // Mexico
1765 'CL' => __('RUT / IVA', 'fluent-cart'), // Chile
1766 'PE' => __('RUC / IGV', 'fluent-cart'), // Peru
1767 'RU' => __('INN / VAT', 'fluent-cart'), // Russia
1768 'TR' => __('VKN / VAT', 'fluent-cart'), // Turkey
1769 'CH' => __('MWST / TVA / IVA', 'fluent-cart'), // Switzerland
1770 'NO' => __('VAT', 'fluent-cart'), // Norway
1771 'IS' => __('VSK', 'fluent-cart'), // Iceland
1772 'IL' => __('VAT', 'fluent-cart'), // Israel
1773 'SE' => __('VAT', 'fluent-cart'), // Sweden
1774 ]);
1775
1776 }
1777
1778 public static function getCountryTaxTitle($countryCode = '')
1779 {
1780 $countryTaxTitles = self::taxTitleLists();
1781 if (isset($countryTaxTitles[$countryCode])) {
1782 return $countryTaxTitles[$countryCode];
1783 }
1784 return __('VAT', 'fluent-cart');
1785 }
1786
1787 public function applyRcFeeAdjustments($fees, $context)
1788 {
1789 $cart = Arr::get($context, 'cart');
1790 if (!$cart) {
1791 return $fees;
1792 }
1793 $rcAdjustedFees = Arr::get($cart->checkout_data, 'tax_data.rc_adjusted_fees', []);
1794 if (empty($rcAdjustedFees)) {
1795 return $fees;
1796 }
1797 foreach ($fees as &$fee) {
1798 $key = Arr::get($fee, 'key', '');
1799 $source = Arr::get($fee, 'source', 'custom');
1800 $compositeKey = $source . ':' . $key;
1801 if ($key && isset($rcAdjustedFees[$compositeKey])) {
1802 $fee['amount'] = (int) $rcAdjustedFees[$compositeKey];
1803 }
1804 }
1805 unset($fee);
1806 return $fees;
1807 }
1808
1809 }
1810