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

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

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