PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.9.0 1.8.0 1.7.1 1.7.0 1.6.6 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 All 53 releases
fluent-cart / app / Modules / Tax / TaxModule.php

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

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