PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Modules/Tax/TaxModule.php +1206 -311 1.3.27 → 1.6.5 View file →
@@ -8,12 +8,16 @@
8 8 use FluentCart\Framework\Support\Arr;
9 9 use FluentCart\App\Helpers\CartHelper;
10 10 use FluentCart\App\Models\Cart;
11 11 use FluentCart\App\Models\OrderTaxRate;
12 -use FluentCart\App\Services\Renderer\EUVatRenderer;
12 +use FluentCart\App\Services\Renderer\TaxRenderer;
13 +use FluentCart\App\Services\Renderer\VatFieldRenderer;
13 14 use FluentCart\App\Services\Renderer\CartSummaryRender;
15 +use FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper;
16 +use FluentCart\App\Services\Renderer\CheckoutFieldsSchema;
14 17 use FluentCart\Api\Resource\FrontendResource\CartResource;
15 18 use FluentCart\App\Services\Localization\LocalizationManager;
19 +use FluentCart\App\Services\Tax\TaxManager;
16 20
17 21 class TaxModule
18 22 {
19 23
@@ -18,26 +22,112 @@
18 22 {
19 23
20 24 protected $taxSettings = [];
21 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 +
22 36 public function register()
23 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 +
24 55 if (!$this->isEnabled()) {
25 56 return;
26 57 }
27 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 +
28 66 add_filter('fluent_cart/cart/estimated_total', function ($total, $data) {
29 67 $cart = $data['cart'];
30 - if (Arr::get($cart->checkout_data, 'tax_data.tax_behavior', 0) == 1) {
31 - $taxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
32 - $total += (int)$taxTotal;
33 - // adds shipping tax as well
34 - $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
35 - $total += (int)$shippingTax;
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 + }
36 98 }
37 99 return $total;
38 100 }, 10, 2);
39 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 +
40 130 //new hook to get changes
41 131 add_filter('fluent_cart/checkout/before_patch_checkout_data', [$this, 'maybeRecalculateTaxAmount'], 10, 2);
42 132
43 133 add_filter('fluent_cart/cart/tax_behavior', function ($behavior, $data) {
@@ -51,141 +141,132 @@
51 141 if (empty($cart->checkout_data['tax_data'])) {
52 142 return;
53 143 }
54 144
55 - $taxAmount = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
56 - $taxLines = Arr::get($cart->checkout_data, 'tax_data.tax_lines', []);
57 - $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
58 - $isInclusive = Arr::get($cart->checkout_data, 'tax_data.tax_behavior', 2) === 2;
59 - $isReverseCharge = Arr::get($cart->checkout_data, 'tax_data.valid', false);
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);
60 148
61 - // if (!$taxAmount && !$isReverseCharge && !$shippingTax) {
62 - // return;
63 - // }
64 -
65 -
66 - // Show breakdown by tax rate if available
67 - if (!empty($taxLines) && !$isReverseCharge) {
68 - $this->renderCompoundTaxRow($cart);
69 - } else {
70 - // Fallback to showing total tax if no breakdown available
71 - $this->renderTaxRow($cart);
72 - ?>
73 - <?php
149 + if (!$taxAmount && !$isReverseCharge && !$shippingTax) {
150 + return;
74 151 }
75 152
76 - $this->renderShippingTaxRow($cart);
153 + $this->renderTaxSummaryBox($cart);
77 154 });
78 155
79 156 add_action('fluent_cart/checkout/prepare_other_data', [$this, 'prepareOtherData'], 10, 1);
80 157
81 - add_action('fluent_cart/product/after_price', function () {
82 - $priceSuffix = Arr::get($this->taxSettings, 'price_suffix', '');
83 - if ($priceSuffix) {
84 - echo '<span class="fct_price_suffix">' . wp_kses_post($priceSuffix) . '</span>';
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;
85 170 }
86 - });
87 171
88 - add_filter('fluent_cart/product/price_suffix_atts', function ($suffix) {
89 - $priceSuffix = Arr::get($this->taxSettings, 'price_suffix', '');
90 - if ($priceSuffix) {
91 - return $priceSuffix;
92 - }
93 - return $suffix;
94 - });
172 + $priceSuffix = $this->resolvePriceSuffix($variant);
173 + $variantInclusion = $variant ? Arr::get($variant->other_info ?: [], 'tax_inclusion', '') : '';
174 + $taxInclusion = $variantInclusion ?: Arr::get($this->taxSettings, 'tax_inclusion', '');
95 175
96 - add_filter('fluent_cart/checkout/after_patch_checkout_data_fragments', [$this, 'maybeRerenderEuVatField'], 10, 2);
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);
97 183
98 - $this->initCheckoutActions();
99 - $this->registerAjaxHandlers();
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);
100 191
192 + add_filter('fluent_cart/cart/fees', [$this, 'applyRcFeeAdjustments'], 20, 2);
193 +
101 194 add_action('fluent_cart/cart/cart_data_items_updated', [$this, 'recalculateTax']);
102 195 }
103 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 +
104 225 public function renderTaxRow($cart, $atts = '')
105 226 {
106 - $taxAmount = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
107 - $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
108 - $isInclusive = Arr::get($cart->checkout_data, 'tax_data.tax_behavior', 2) === 2;
109 - $isReverseCharge = Arr::get($cart->checkout_data, 'tax_data.valid', false);
110 - ?>
111 - <li <?php echo $atts; ?>>
112 - <span class="fct_summary_label">
113 - <?php if ($isReverseCharge && $taxAmount == 0): ?>
114 - <?php echo esc_html__('Reverse Charge (No Tax)', 'fluent-cart'); ?>
115 - <?php elseif ($isInclusive) : ?>
116 - <?php echo esc_html__('Tax Estimate (Included)', 'fluent-cart'); ?>
117 - <?php else : ?>
118 - <?php echo esc_html__('Tax Estimate (Excluded)', 'fluent-cart'); ?>
119 - <?php endif; ?>
120 - </span>
121 - <span class="fct_summary_value"><?php echo esc_html(Helper::toDecimal($taxAmount)); ?></span>
122 - </li>
123 - <?php
227 + $this->renderer()->renderTaxRow($cart, $atts);
124 228 }
125 229
126 230 public function renderShippingTaxRow($cart, $atts = '')
127 231 {
232 + return $this->renderer()->renderShippingTaxRow($cart, $atts);
233 + }
128 234
129 - $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
130 - $isInclusive = Arr::get($cart->checkout_data, 'tax_data.tax_behavior', 2) === 2;
235 + public function renderTaxSummaryBox($cart)
236 + {
237 + $this->renderer()->renderTaxSummaryBox($cart);
238 + }
131 239
132 - if ($shippingTax == 0) {
133 - return '';
240 + public function maybeRestoreRcAdjustedPrices($data)
241 + {
242 + if ($this->isEnabled()) {
243 + return; // full recalculation handled by recalculateTax registered below
134 244 }
135 - ?>
136 - <li <?php echo $atts; ?>>
137 - <!-- shipping tax -->
138 - <span class="fct_summary_label">
139 - <?php if ($isInclusive) : ?>
140 - <?php echo esc_html__('Shipping Tax (Included)', 'fluent-cart'); ?>
141 - <?php else : ?>
142 - <?php echo esc_html__('Shipping Tax (Excluded)', 'fluent-cart'); ?>
143 - <?php endif; ?>
144 - </span>
145 - <span class="fct_summary_value"><?php echo esc_html(Helper::toDecimal($shippingTax)); ?></span>
146 - </li>
147 - <?php
148 - }
149 245
150 - public function renderCompoundTaxRow($cart, $atts = '')
151 - {
152 - $taxAmount = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
153 - $taxLines = Arr::get($cart->checkout_data, 'tax_data.tax_lines', []);
154 - $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
155 - $isInclusive = Arr::get($cart->checkout_data, 'tax_data.tax_behavior', 2) === 2;
156 - $isReverseCharge = Arr::get($cart->checkout_data, 'tax_data.valid', false);
246 + $cart = Arr::get($data, 'cart');
247 + $cartLines = (array) $cart->cart_data;
248 + $checkoutData = $cart->checkout_data;
157 249
158 - foreach ($taxLines as $taxLine) {
159 - $rateLabel = Arr::get($taxLine, 'label', 'Tax');
160 - $rateTaxAmount = (int)Arr::get($taxLine, 'tax_amount', 0);
161 - $isCompound = Arr::get($taxLine, 'is_compound', false);
162 - $taxableBase = (int)Arr::get($taxLine, 'taxable_amount', 0);
163 - $ratePercent = Arr::get($taxLine, 'rate_percent', 0);
250 + $hasRcMeta = !empty(Arr::get($checkoutData, 'tax_data.rc_adjusted_fees'));
164 251
165 - // Build the label with compound indicator if needed
166 - $displayLabel = $rateLabel;
167 - if ($isCompound) {
168 - $displayLabel .= ' (' . __('Compound', 'fluent-cart') . ')';
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 + }
169 261 }
262 + }
170 263
171 - // Add calculation details if available
172 - if ($taxableBase && $ratePercent) {
173 - $displayLabel .= ' [' . Helper::toDecimal($taxableBase) . ' × ' . number_format($ratePercent, 2) . '%]';
174 - }
175 - ?>
176 - <li>
177 - <span class="fct_summary_label">
178 - <?php if ($isInclusive) : ?>
179 - <?php echo esc_html($displayLabel . ' (' . __('Included', 'fluent-cart') . ')'); ?>
180 - <?php else : ?>
181 - <?php echo esc_html($displayLabel . ' (' . __('Excluded', 'fluent-cart') . ')'); ?>
182 - <?php endif; ?>
183 - </span>
184 - <span class="fct_summary_value"><?php echo esc_html(Helper::toDecimal($rateTaxAmount)); ?></span>
185 - </li>
186 - <?php
264 + if (!$hasRcMeta) {
265 + return;
187 266 }
267 +
268 + $this->recalculateTax($data);
188 269 }
189 270
190 271 public function recalculateTax($data)
191 272 {
@@ -190,11 +271,22 @@
190 271 public function recalculateTax($data)
191 272 {
192 273 $cart = Arr::get($data, 'cart');
193 274
194 - // Persist dynamic fees so tax pipeline can see them
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.
195 280 $checkoutData = $cart->checkout_data;
196 - $checkoutData['fees'] = $cart->getFees();
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 + }
197 289
198 290 $fillData = $this->calculateCartTax([
199 291 'cart_data' => $cart->cart_data,
200 292 'checkout_data' => $checkoutData
@@ -208,9 +300,9 @@
208 300 {
209 301 $changes = Arr::get($data, 'changes', []);
210 302
211 303 $watchings = array_filter($changes, function ($value, $key) {
212 - return preg_match('/^(billing_|shipping_|ship_to_|fct_billing_tax)/i', $key);
304 + return preg_match('/^(billing_|shipping_|ship_to_|fct_billing_tax|is_business)/i', $key);
213 305 }, ARRAY_FILTER_USE_BOTH);
214 306
215 307 if (empty($watchings)) {
216 308 return $fillData;
@@ -215,19 +307,78 @@
215 307 if (empty($watchings)) {
216 308 return $fillData;
217 309 }
218 310
219 - // Persist dynamic fees so tax pipeline can see them
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.
220 324 $cart = Arr::get($data, 'cart');
221 325 if ($cart) {
222 326 $checkoutData = Arr::get($fillData, 'checkout_data', []);
223 - $checkoutData['fees'] = $cart->getFees();
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 + }
224 335 $fillData['checkout_data'] = $checkoutData;
225 336 }
226 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 +
227 347 return $this->calculateCartTax($fillData);
228 348 }
229 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 +
230 381 public function getSettings()
231 382 {
232 383 if (!empty($this->taxSettings)) {
233 384 return $this->taxSettings;
@@ -236,20 +387,70 @@
236 387 $defaultSettings = [
237 388 'tax_inclusion' => 'included',
238 389 'tax_calculation_basis' => 'shipping',
239 390 'tax_rounding' => 'item',
391 + 'checkout_tax_breakdown_display' => 'itemized',
392 + 'tax_display_label' => 'Tax',
240 393 'enable_tax' => 'no',
241 394 'eu_vat_settings' => [
242 395 'require_vat_number' => 'no',
243 - 'local_reverse_charge' => 'yes',
396 + 'local_reverse_charge' => 'no',
397 + 'reverse_charge_price_mode' => 'fixed',
244 398 'vat_reverse_excluded_categories' => []
245 399 ]
246 400 ];
247 401
248 402 $savedSettings = get_option('fluent_cart_tax_configuration_settings', []);
249 - return $this->taxSettings = wp_parse_args($savedSettings, $defaultSettings);
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;
250 410 }
251 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 +
252 453 public function isEnabled()
253 454 {
254 455 $settings = $this->getSettings();
255 456 return Arr::get($settings, 'enable_tax', 'no') === 'yes';
@@ -258,8 +459,95 @@
258 459 public function calculateCartTax($fillData)
259 460 {
260 461 $lineItems = Arr::get($fillData, 'cart_data', []);
261 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 +
262 550 $country = '';
263 551 $state = '';
264 552 $postCode = '';
265 553
@@ -289,9 +577,8 @@
289 577 $city = $storeSettings->get('store_city');
290 578 $postCode = $storeSettings->get('store_postcode');
291 579 }
292 580
293 - // Add taxable fee items to the tax calculation pipeline
294 581 $fees = (array)Arr::get($checkoutData, 'fees', []);
295 582 $feeItems = [];
296 583 foreach ($fees as $fee) {
297 584 if (!empty($fee['taxable']) && !empty($fee['amount'])) {
@@ -301,13 +588,14 @@
301 588
302 589 $allItems = array_merge($lineItems, $feeItems);
303 590
304 591 $taxCalculator = new TaxCalculator($allItems, [
305 - 'inclusive' => false,
306 - 'country' => $country,
307 - 'state' => $state,
308 - 'city' => $city,
309 - 'postcode' => $postCode,
592 + 'inclusive' => false,
593 + 'country' => $country,
594 + 'state' => $state,
595 + 'city' => $city,
596 + 'postcode' => $postCode,
597 + 'tax_rounding' => Arr::get($taxSettings, 'tax_rounding', 'item'),
310 598 ]);
311 599
312 600 if (empty($checkoutData['tax_data'])) {
313 601 $checkoutData['tax_data'] = [];
@@ -313,29 +601,201 @@
313 601 $checkoutData['tax_data'] = [];
314 602 }
315 603
316 604 $taxTotal = $taxCalculator->getTotalTax();
605 + $exclusiveTaxTotal = $taxCalculator->getExclusiveTaxTotal();
317 606 $shippingTax = $taxCalculator->getShippingTax();
607 + $shippingTaxLines = $taxCalculator->getShippingTaxByRates();
318 608 $taxCountry = $taxCalculator->getTaxCountry();
319 609 $taxLines = $taxCalculator->getTaxLinesByRates();
320 610
611 +
321 612 // Separate product and fee items from taxed lines
322 613 $allTaxedLines = $taxCalculator->getTaxedLines();
614 +
323 615 $productLines = [];
324 616 $feeTax = 0;
617 + $feeTaxLines = [];
325 618 foreach ($allTaxedLines as $taxedLine) {
326 619 if (!empty($taxedLine['is_fee'])) {
327 - $feeTax += (int)Arr::get($taxedLine, 'tax_amount', 0);
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 + ];
328 627 } else {
329 628 $productLines[] = $taxedLine;
330 629 }
331 630 }
332 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 +
333 778 $checkoutData['tax_data']['tax_total'] = $taxTotal;
334 - $checkoutData['tax_data']['tax_behavior'] = $taxCalculator->getTaxBahaviorValue();
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 +
335 789 $checkoutData['tax_data']['tax_country'] = $taxCountry;
336 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';
337 795 $checkoutData['tax_data']['fee_tax'] = $feeTax;
796 + $checkoutData['tax_data']['fee_tax_lines'] = $feeTaxLines;
797 + $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal;
338 798 $checkoutData['tax_data']['tax_lines'] = $taxLines;
339 799 $fillData['checkout_data'] = $checkoutData;
340 800
341 801 // Only product lines go back into cart_data
@@ -347,31 +807,74 @@
347 807
348 808 return $fillData;
349 809 }
350 810
351 - public function maybeRerenderEuVatField($fragments, $args)
811 + protected function shouldApplyReverseCharge($checkoutData, $taxApplicableCountry, $lineItems = [])
352 812 {
353 - $taxSettings = $this->getSettings();
354 - $taxEnabled = Arr::get($taxSettings, 'enable_tax', 'no') === 'yes';
355 - $euVatEnabled = Arr::get($taxSettings, 'eu_vat_settings.require_vat_number', 'no') === 'yes';
813 + if (!Arr::get($checkoutData, 'tax_data.valid', false)) {
814 + return false;
815 + }
356 816
357 - $cart = Arr::get($args, 'cart');
817 + $validatedCountry = Arr::get($checkoutData, 'tax_data.country', '');
818 + if (!$validatedCountry || $validatedCountry !== $taxApplicableCountry) {
819 + return false;
820 + }
358 821
359 - // all changes are relevant for tax , so not checking changes for now
822 + if (!$this->canApplyVatValidation($taxApplicableCountry)) {
823 + return false;
824 + }
360 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 + }
361 840
362 - if (!$taxEnabled || !$euVatEnabled) {
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) {
363 863 return $fragments;
364 864 }
365 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');
366 873 $taxApplicableCountry = $this->getTaxApplicableCountry(Arr::get($this->taxSettings, 'tax_calculation_basis'), $cart->checkout_data['form_data']);
367 874
368 - $euCountryCodes = LocalizationManager::getInstance()->taxContinents('EU');
369 - $euCountryCodes = Arr::get($euCountryCodes, 'countries');
370 - $isEuCountry = in_array($taxApplicableCountry, $euCountryCodes);
371 -
372 875 ob_start();
373 - (new EUVatRenderer($isEuCountry, $taxApplicableCountry))->render($cart);
876 + (new VatFieldRenderer($taxApplicableCountry))->renderInner($cart->checkout_data);
374 877 $euVatView = ob_get_clean();
375 878
376 879 $fragments[] = [
377 880 'selector' => '[data-fluent-cart-checkout-page-tax-wrapper]',
@@ -391,13 +894,76 @@
391 894 return;
392 895 }
393 896
394 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 +
395 962 $taxCountry = Arr::get($checkoutData, 'tax_data.tax_country', '');
396 -
397 963 // add store vat number into tax data
398 964 $taxSettings = $this->getSettings();
399 - $isEuCountry = LocalizationManager::getInstance()->continentFromCountry($taxCountry ?? '') === 'EU';
965 + $isEuCountry = LocalizationManager::getInstance()->isEuTaxCountry($taxCountry ?? '');
400 966
401 967 $storeVatNumber = '';
402 968 if ($isEuCountry) {
403 969 $euVatMethod = Arr::get($taxSettings, 'eu_vat_settings.method');
@@ -415,8 +981,9 @@
415 981 }
416 982 }
417 983
418 984 $customerVatData = [];
985 + $isReverseChargeApplied = $this->isReverseChargeCheckout($checkoutData);
419 986 if (Arr::get($cart->checkout_data, 'tax_data.valid', false)) {
420 987 $customerVatData = Arr::get($checkoutData, 'tax_data');
421 988 $order = $data['order'];
422 989 $order->customer->updateMeta('customer_tax_info',
@@ -423,56 +990,129 @@
423 990 Arr::only($customerVatData, ['vat_number', 'country', 'valid', 'name', 'address'])
424 991 );
425 992 }
426 993
427 - foreach ($cart->cart_data as $item) {
428 - $taxMeta = Arr::get($item, 'line_meta.tax_config.rates.0', []);
429 - $rateId = Arr::get($taxMeta, 'rate_id');
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 + ];
430 1000
431 - if (!$rateId) {
432 - $rateId = 0; // assume this rate is from JSON tax rate
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;
433 1020 }
1021 + }
434 1022
435 - $shippingTax = (int)Arr::get($taxMeta, 'shipping_tax', 0);
436 - $taxAmount = (int)Arr::get($taxMeta, 'tax_amount', 0);
437 - $taxMeta = Arr::get($item, 'line_meta.tax_config', []);
438 - $taxMeta['tax_country'] = $taxCountry;
439 - $taxMeta['store_vat_number'] = $storeVatNumber;
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 + }
440 1031
441 - if (!empty($customerVatData)) {
442 - $taxMeta['vat_reverse'] = $customerVatData;
443 - $taxAmount = 0;
444 - $shippingTax = 0;
445 - }
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', []);
446 1037
447 - $orderTaxRate = OrderTaxRate::query()->where('order_id', $order->id)
448 - ->where('tax_rate_id', $rateId)
449 - ->first();
1038 + if (!$cart || !$order || !$order->id) {
1039 + return;
1040 + }
450 1041
451 - if (!$orderTaxRate) {
452 - $orderTaxRate = OrderTaxRate::create([
453 - 'order_id' => $order->id,
454 - 'tax_rate_id' => $rateId,
455 - 'shipping_tax' => $shippingTax,
456 - 'order_tax' => $taxAmount,
457 - 'total_tax' => $shippingTax + $taxAmount,
458 - 'meta' => $taxMeta
459 - ]);
460 - } else {
461 - $orderTaxRate->update([
462 - 'shipping_tax' => $shippingTax,
463 - 'order_tax' => $taxAmount,
464 - 'total_tax' => $shippingTax + $taxAmount,
465 - 'meta' => $taxMeta
466 - ]);
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', ''));
467 1105 }
468 1106 }
1107 +
1108 + $order->updateMeta('business_info', $businessInfo);
469 1109 }
470 1110
471 1111
472 1112 public function initCheckoutActions()
473 1113 {
474 - add_action('fluent_cart/before_payment_methods', [$this, 'renderTaxField'], 10, 1);
1114 + add_action('fluent_cart/checkout/b2b_extra_fields', [$this, 'renderTaxField'], 10, 1);
475 1115 }
476 1116
477 1117 public function registerAjaxHandlers()
478 1118 {
@@ -486,59 +1126,25 @@
486 1126 public function renderTaxField($data)
487 1127 {
488 1128 $cart = Arr::get($data, 'cart');
489 1129
490 - $checkoutData = $cart->checkout_data;
491 - $vatNumber = Arr::get($checkoutData, 'tax_data.vat_number', '');
492 -
493 - $euVatEnabled = Arr::get($this->taxSettings, 'eu_vat_settings.require_vat_number', 'no');
494 1130 $taxApplicableCountry = $this->getTaxApplicableCountry(
495 1131 Arr::get($this->taxSettings, 'tax_calculation_basis'),
496 - Arr::get($checkoutData, 'form_data')
1132 + Arr::get($cart->checkout_data, 'form_data')
497 1133 );
498 1134
499 - $isEuCountry = LocalizationManager::getInstance()->continentFromCountry($taxApplicableCountry) === 'EU';
500 -
501 - $isEuVatRcAvailable = false;
502 - if ($isEuCountry && $euVatEnabled === 'yes') {
503 - $isEuVatRcAvailable = true;
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);
504 1144 }
505 1145
506 - ?>
507 - <div class="fct_checkout_tax_wrapper" data-fluent-cart-checkout-page-tax-wrapper>
508 - <?php if ($isEuVatRcAvailable): ?>
509 - <div class="fct_checkout_form_section">
510 - <div class="fct_form_section_header">
511 - <h4 class="fct_form_section_header_label"><?php echo esc_html__('EU VAT', 'fluent-cart'); ?></h4>
512 - </div>
513 -
514 - <div class="fct_form_section_body">
515 - <div class="fct_tax_field">
516 - <div data-fluent-cart-checkout-page-form-input-wrapper class="fct_tax_input_wrapper"
517 - id="fct_billing_tax_id_wrapper">
518 - <input
519 - data-fluent-cart-checkout-page-tax-id
520 - type="text"
521 - name="fct_billing_tax_id"
522 - autocomplete="tax-id"
523 - placeholder="<?php echo esc_html__('Enter Tax ID', 'fluent-cart'); ?>"
524 - id="fct_billing_tax_id"
525 - value="<?php echo esc_attr($vatNumber) ?? ''; ?>"
526 - />
527 -
528 - <button data-fluent-cart-checkout-page-tax-apply-btn>
529 - <?php echo esc_html__('Apply', 'fluent-cart'); ?>
530 - </button>
531 - </div>
532 - <span data-fluent-cart-checkout-page-tax-loading class="fct_tax_loading"></span>
533 - <span data-fluent-cart-checkout-page-form-error class="fct_form_error"></span>
534 - <?php $this->renderValidNote($checkoutData); ?>
535 - </div>
536 - </div>
537 - </div>
538 - <?php endif; ?>
539 - </div>
540 - <?php
1146 + (new VatFieldRenderer($taxApplicableCountry))->render($cart);
541 1147 }
542 1148
543 1149 public function getTaxApplicableCountry($calculationBasis, $formData)
544 1150 {
@@ -556,35 +1162,23 @@
556 1162 return $country;
557 1163
558 1164 }
559 1165
560 - public function renderValidNote($checkoutData)
1166 + public function canApplyVatValidation($countryCode)
561 1167 {
562 - $isValid = Arr::get($checkoutData, 'tax_data.valid', false);
563 - ?>
1168 + if (!$countryCode) {
1169 + return false;
1170 + }
564 1171
565 - <div class="fct_vat_valid_note <?php echo !$isValid ? 'is-hidden' : ''; ?>"
566 - data-fluent-cart-tax-valid-note-wrapper>
567 - <span data-fluent-cart-tax-valid-note>
568 - <?php echo esc_html__('Valid VAT number', 'fluent-cart'); ?>
569 - <?php $name = Arr::get($checkoutData, 'tax_data.name', '');
570 - if ($name): ?>
571 - — <?php echo esc_html($name); ?>
572 - <?php endif; ?>
573 - </span>
1172 + $settings = $this->getSettings();
574 1173
575 - <?php if (Arr::get($checkoutData, 'tax_data.tax_total') != 0): ?>
576 - <span class="ml-2" style="color: #ffa500;">
577 - <?php echo esc_html__('(Reverse Charge not Applied)', 'fluent-cart'); ?>
578 - </span>
579 - <?php endif; ?>
1174 + if (Arr::get($settings, 'enable_tax', 'no') !== 'yes') {
1175 + return false;
1176 + }
580 1177
581 - <button data-fluent-cart-tax-remove-btn>
582 - <?php echo esc_html__('Remove', 'fluent-cart'); ?>
583 - </button>
584 - </div>
585 -
586 - <?php
1178 + $storeCountry = (new StoreSettings())->get('store_country');
1179 + return Arr::get($settings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes'
1180 + || $countryCode !== $storeCountry;
587 1181 }
588 1182
589 1183 public function handleVatValidation()
590 1184 {
@@ -593,14 +1187,22 @@
593 1187 if (!isset($_REQUEST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])), 'fluentcart')) {
594 1188 wp_send_json(['message' => __('Security check failed', 'fluent-cart')], 403);
595 1189 }
596 1190
597 - if (Arr::get($this->taxSettings, 'eu_vat_settings.require_vat_number', 'yes') !== 'yes') {
598 - wp_send_json(['message' => __('EU VAT reverse charge is not enabled!', 'fluent-cart')], 422);
1191 + if (!$this->isEnabled()) {
1192 + wp_send_json(['message' => __('Tax is not enabled.', 'fluent-cart')], 422);
599 1193 }
600 1194
1195 + if (!CheckoutFieldsSchema::isVatNumberEnabled()) {
1196 + wp_send_json(['message' => __('VAT number collection is not enabled.', 'fluent-cart')], 422);
1197 + }
1198 +
601 1199 $cart = CartHelper::getCart();
602 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 +
603 1205 $taxCalculationBasis = Arr::get($this->taxSettings, 'tax_calculation_basis');
604 1206 $formData = Arr::get($cart->checkout_data, 'form_data', []);
605 1207 $shipToDifferent = Arr::get($formData, 'ship_to_different', '');
606 1208
@@ -609,26 +1211,29 @@
609 1211 } elseif ($taxCalculationBasis === 'shipping') {
610 1212 $countryCode = Arr::get($formData, 'shipping_country', '');
611 1213 }
612 1214
613 - $vatNumber = isset($_REQUEST['vat_number']) ? sanitize_text_field(wp_unslash($_REQUEST['vat_number'])) : '';
614 -
615 1215 $storeCountry = (new StoreSettings())->get('store_country');
616 1216 if ($taxCalculationBasis === 'store') {
617 1217 $countryCode = $storeCountry;
618 1218 }
619 1219
620 - if (!$countryCode || !$vatNumber) {
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) {
621 1229 wp_send_json(['message' => __('Missing required data', 'fluent-cart')], 422);
622 1230 }
623 1231
624 - // check if the $vatNumber contains any EU country code prefix remove it and get only vat number
625 - $euCountry = LocalizationManager::getInstance()->taxContinents('EU');
626 - $euCountryCodes = Arr::get($euCountry, 'countries', []);
627 -
628 - foreach ($euCountryCodes as $i => $code) {
629 - if (strpos($vatNumber, $code) === 0) { // Check if $code is at the beginning of $vatNumber
630 - $vatNumber = substr($vatNumber, strlen($code)); // Remove the prefix
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));
631 1236 break;
632 1237 }
633 1238 }
634 1239
@@ -641,29 +1246,59 @@
641 1246 if (!Arr::get($taxData, 'valid')) {
642 1247 wp_send_json(['message' => __('VAT number is not valid!', 'fluent-cart')], 422);
643 1248 }
644 1249
645 - // if there is any excluded category in the cart, then don't apply VAT reverse charge
646 - $excludedCategories = Arr::get($this->taxSettings, 'eu_vat_settings.vat_reverse_excluded_categories', []);
647 - $productIds = array_column($cart->cart_data, 'post_id');
648 - $productTerms = $this->getTermsByProductIds($productIds);
649 -
1250 + $localRc = Arr::get($this->taxSettings, 'eu_vat_settings.local_reverse_charge', 'no');
650 1251 $isExcluded = false;
651 - foreach ($productTerms as $productId => $terms) {
652 - if (array_intersect($terms, $excludedCategories)) {
653 - $isExcluded = true;
654 - break;
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 + }
655 1262 }
656 1263 }
657 1264
658 - //make existing tax zero if local reverse charge is enabled
659 - if (!$isExcluded) {
660 - if (Arr::get($this->taxSettings, 'eu_vat_settings.local_reverse_charge', 'yes') === 'yes'
661 - || $countryCode !== $storeCountry) {
662 - $taxData['tax_total'] = 0;
663 - $taxData['shipping_tax'] = 0;
664 - $taxData['tax_behavior'] = 0;
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;
665 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);
666 1301 }
667 1302
668 1303 $checkoutData = $cart->checkout_data;
669 1304 if (!isset($checkoutData['tax_data']) || !is_array($checkoutData['tax_data'])) {
@@ -670,9 +1305,14 @@
670 1305 $checkoutData['tax_data'] = [];
671 1306 }
672 1307 $checkoutData['tax_data'] = array_merge($checkoutData['tax_data'], $taxData);
673 1308 $cart->checkout_data = $checkoutData;
674 - $cart->update();
1309 + $fillData = $this->calculateCartTax([
1310 + 'cart_data' => $cart->cart_data,
1311 + 'checkout_data' => $cart->checkout_data,
1312 + ]);
1313 + $cart->fill($fillData);
1314 + $cart->save();
675 1315
676 1316 ob_start();
677 1317 (new CartSummaryRender($cart))->render(false);
678 1318 $cartSummaryInner = ob_get_clean();
@@ -677,14 +1317,16 @@
677 1317 (new CartSummaryRender($cart))->render(false);
678 1318 $cartSummaryInner = ob_get_clean();
679 1319
680 1320 ob_start();
681 - (new EUVatRenderer(true, Arr::get($taxData, 'tax_country')))->render($cart);
1321 + (new VatFieldRenderer(Arr::get($taxData, 'country', $countryCode)))->renderInner($checkoutData);
682 1322 $euVatView = ob_get_clean();
683 1323
684 1324 wp_send_json([
685 1325 'success' => true,
686 - 'message' => __('VAT has been applied successfully', 'fluent-cart'),
1326 + 'message' => $appliesReverseCharge
1327 + ? __('VAT has been applied successfully', 'fluent-cart')
1328 + : __('VAT number has been validated successfully', 'fluent-cart'),
687 1329 'tax_data' => $taxData,
688 1330 'fragments' => [
689 1331 [
690 1332 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
@@ -727,11 +1369,21 @@
727 1369 unset($checkoutData['tax_data']['name']);
728 1370 unset($checkoutData['tax_data']['address']);
729 1371 unset($checkoutData['tax_data']['vat_number']);
730 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']);
731 1378 }
732 1379
733 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);
734 1386 $cart->save();
735 1387
736 1388 ob_start();
737 1389 (new CartSummaryRender($cart))->render(false);
@@ -774,18 +1426,37 @@
774 1426 }
775 1427
776 1428 protected function validateEuVatNumber($countryCode, $vatNumber)
777 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 +
778 1448 try {
779 1449 $wsdl = "https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl";
780 1450
781 1451 if (!class_exists('\\SoapClient')) {
782 - throw new \Exception(__('SOAP is not available on the server.', 'fluent-cart'));
1452 + return new \WP_Error('service_unavailable', __('SOAP is not available on the server.', 'fluent-cart'));
783 1453 }
784 1454
785 1455 $client = new \SoapClient($wsdl, [
786 - 'exceptions' => true,
787 - 'trace' => true,
1456 + 'exceptions' => true,
1457 + 'trace' => true,
1458 + 'connection_timeout' => 10,
788 1459 ]);
789 1460
790 1461 $params = [
791 1462 'countryCode' => $countryCode,
@@ -794,13 +1465,15 @@
794 1465
795 1466 $result = $client->checkVat($params);
796 1467
797 1468 if (empty($result->valid)) {
798 - throw new \Exception(
1469 + $countryName = LocalizationManager::getInstance()->getCountryNameByCode($countryCode);
1470 + return new \WP_Error(
1471 + 'invalid',
799 1472 sprintf(
800 - /* translators: %s is the country code */
801 - __('Invalid VAT number for country %s!', 'fluent-cart'),
802 - $countryCode
1473 + /* translators: %1$s: country name */
1474 + __('Invalid VAT number for %1$s!', 'fluent-cart'),
1475 + $countryName
803 1476 )
804 1477 );
805 1478 }
806 1479
@@ -814,14 +1487,182 @@
814 1487
815 1488 return $taxData;
816 1489
817 1490 } catch (\SoapFault $e) {
818 - return new \WP_Error('soap_fault', $e->getMessage());
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'));
819 1513 } catch (\Exception $e) {
820 1514 return new \WP_Error('invalid', $e->getMessage());
821 1515 }
822 1516 }
823 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 +
824 1665 public static function isTaxEnabled()
825 1666 {
826 1667 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
827 1668 return Arr::get($taxSettings, 'enable_tax', 'no') === 'yes';
@@ -826,40 +1667,72 @@
826 1667 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
827 1668 return Arr::get($taxSettings, 'enable_tax', 'no') === 'yes';
828 1669 }
829 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 +
830 1703 public static function euVatCountyOptions()
831 1704 {
832 - $eu_vat_countries = [
833 - ['label' => 'Austria', 'value' => 'AT'],
834 - ['label' => 'Belgium', 'value' => 'BE'],
835 - ['label' => 'Bulgaria', 'value' => 'BG'],
836 - ['label' => 'Croatia', 'value' => 'HR'],
837 - ['label' => 'Cyprus', 'value' => 'CY'],
838 - ['label' => 'Czech Republic', 'value' => 'CZ'],
839 - ['label' => 'Denmark', 'value' => 'DK'],
840 - ['label' => 'Estonia', 'value' => 'EE'],
841 - ['label' => 'Finland', 'value' => 'FI'],
842 - ['label' => 'France', 'value' => 'FR'],
843 - ['label' => 'Germany', 'value' => 'DE'],
844 - ['label' => 'Greece', 'value' => 'GR'],
845 - ['label' => 'Hungary', 'value' => 'HU'],
846 - ['label' => 'Ireland', 'value' => 'IE'],
847 - ['label' => 'Italy', 'value' => 'IT'],
848 - ['label' => 'Latvia', 'value' => 'LV'],
849 - ['label' => 'Lithuania', 'value' => 'LT'],
850 - ['label' => 'Luxembourg', 'value' => 'LU'],
851 - ['label' => 'Malta', 'value' => 'MT'],
852 - ['label' => 'Netherlands', 'value' => 'NL'],
853 - ['label' => 'Poland', 'value' => 'PL'],
854 - ['label' => 'Portugal', 'value' => 'PT'],
855 - ['label' => 'Romania', 'value' => 'RO'],
856 - ['label' => 'Slovakia', 'value' => 'SK'],
857 - ['label' => 'Slovenia', 'value' => 'SI'],
858 - ['label' => 'Spain', 'value' => 'ES'],
859 - ['label' => 'Sweden', 'value' => 'SE'],
860 - ];
861 - return $eu_vat_countries;
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;
862 1735 }
863 1736
864 1737 public static function taxTitleLists(): array
865 1738 {
@@ -917,7 +1790,29 @@
917 1790 if (isset($countryTaxTitles[$countryCode])) {
918 1791 return $countryTaxTitles[$countryCode];
919 1792 }
920 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;
921 1816 }
922 1817
923 1818 }