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.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 1.3.22 All 52 releases
← All changes | app/Modules/Tax/TaxModule.php +1333 -314 1.3.25 → 1.8.0 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,16 +271,27 @@
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
201 - ]);
293 + ], $cart);
202 294
203 295 $cart->fill($fillData);
204 296 $cart->save();
205 297 }
@@ -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
227 - return $this->calculateCartTax($fillData);
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);
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';
@@ -254,12 +455,110 @@
254 455 $settings = $this->getSettings();
255 456 return Arr::get($settings, 'enable_tax', 'no') === 'yes';
256 457 }
257 458
258 - public function calculateCartTax($fillData)
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)
259 466 {
260 467 $lineItems = Arr::get($fillData, 'cart_data', []);
261 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 +
262 561 $country = '';
263 562 $state = '';
264 563 $postCode = '';
265 564
@@ -289,9 +588,8 @@
289 588 $city = $storeSettings->get('store_city');
290 589 $postCode = $storeSettings->get('store_postcode');
291 590 }
292 591
293 - // Add taxable fee items to the tax calculation pipeline
294 592 $fees = (array)Arr::get($checkoutData, 'fees', []);
295 593 $feeItems = [];
296 594 foreach ($fees as $fee) {
297 595 if (!empty($fee['taxable']) && !empty($fee['amount'])) {
@@ -301,13 +599,14 @@
301 599
302 600 $allItems = array_merge($lineItems, $feeItems);
303 601
304 602 $taxCalculator = new TaxCalculator($allItems, [
305 - 'inclusive' => false,
306 - 'country' => $country,
307 - 'state' => $state,
308 - 'city' => $city,
309 - 'postcode' => $postCode,
603 + 'inclusive' => false,
604 + 'country' => $country,
605 + 'state' => $state,
606 + 'city' => $city,
607 + 'postcode' => $postCode,
608 + 'tax_rounding' => Arr::get($taxSettings, 'tax_rounding', 'item'),
310 609 ]);
311 610
312 611 if (empty($checkoutData['tax_data'])) {
313 612 $checkoutData['tax_data'] = [];
@@ -313,32 +612,208 @@
313 612 $checkoutData['tax_data'] = [];
314 613 }
315 614
316 615 $taxTotal = $taxCalculator->getTotalTax();
616 + $exclusiveTaxTotal = $taxCalculator->getExclusiveTaxTotal();
317 617 $shippingTax = $taxCalculator->getShippingTax();
618 + $shippingTaxLines = $taxCalculator->getShippingTaxByRates();
318 619 $taxCountry = $taxCalculator->getTaxCountry();
319 620 $taxLines = $taxCalculator->getTaxLinesByRates();
320 621
622 +
321 623 // Separate product and fee items from taxed lines
322 624 $allTaxedLines = $taxCalculator->getTaxedLines();
625 +
323 626 $productLines = [];
324 627 $feeTax = 0;
628 + $feeTaxLines = [];
325 629 foreach ($allTaxedLines as $taxedLine) {
326 630 if (!empty($taxedLine['is_fee'])) {
327 - $feeTax += (int)Arr::get($taxedLine, 'tax_amount', 0);
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 + ];
328 638 } else {
329 639 $productLines[] = $taxedLine;
330 640 }
331 641 }
332 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 +
333 789 $checkoutData['tax_data']['tax_total'] = $taxTotal;
334 - $checkoutData['tax_data']['tax_behavior'] = $taxCalculator->getTaxBahaviorValue();
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 +
335 800 $checkoutData['tax_data']['tax_country'] = $taxCountry;
336 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';
337 806 $checkoutData['tax_data']['fee_tax'] = $feeTax;
807 + $checkoutData['tax_data']['fee_tax_lines'] = $feeTaxLines;
808 + $checkoutData['tax_data']['signup_fee_tax'] = $signupFeeTaxTotal;
338 809 $checkoutData['tax_data']['tax_lines'] = $taxLines;
339 810 $fillData['checkout_data'] = $checkoutData;
340 811
812 + if ($cart instanceof Cart) {
813 + $productLines = $this->restoreUnpricedValues($productLines, $unpricedLineItems);
814 + }
815 +
341 816 // Only product lines go back into cart_data
342 817 $fillData['cart_data'] = $productLines;
343 818
344 819 if (isset($fillData['hook_changes'])) {
@@ -347,31 +822,183 @@
347 822
348 823 return $fillData;
349 824 }
350 825
351 - public function maybeRerenderEuVatField($fragments, $args)
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
352 837 {
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';
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;
356 842
357 - $cart = Arr::get($args, 'cart');
843 + $priced = apply_filters('fluent_cart/cart/item_dynamic_discount', $lineItems, [
844 + 'cart' => $pricedCart,
845 + ]);
846 + $lineItems = is_array($priced) ? $priced : $lineItems;
358 847
359 - // all changes are relevant for tax , so not checking changes for now
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 + }
360 853
854 + if ($shipping <= 0) {
855 + return $lineItems;
856 + }
361 857
362 - if (!$taxEnabled || !$euVatEnabled) {
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) {
363 987 return $fragments;
364 988 }
365 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');
366 997 $taxApplicableCountry = $this->getTaxApplicableCountry(Arr::get($this->taxSettings, 'tax_calculation_basis'), $cart->checkout_data['form_data']);
367 998
368 - $euCountryCodes = LocalizationManager::getInstance()->taxContinents('EU');
369 - $euCountryCodes = Arr::get($euCountryCodes, 'countries');
370 - $isEuCountry = in_array($taxApplicableCountry, $euCountryCodes);
371 -
372 999 ob_start();
373 - (new EUVatRenderer($isEuCountry, $taxApplicableCountry))->render($cart);
1000 + (new VatFieldRenderer($taxApplicableCountry))->renderInner($cart->checkout_data);
374 1001 $euVatView = ob_get_clean();
375 1002
376 1003 $fragments[] = [
377 1004 'selector' => '[data-fluent-cart-checkout-page-tax-wrapper]',
@@ -391,13 +1018,76 @@
391 1018 return;
392 1019 }
393 1020
394 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 +
395 1086 $taxCountry = Arr::get($checkoutData, 'tax_data.tax_country', '');
396 -
397 1087 // add store vat number into tax data
398 1088 $taxSettings = $this->getSettings();
399 - $isEuCountry = LocalizationManager::getInstance()->continentFromCountry($taxCountry ?? '') === 'EU';
1089 + $isEuCountry = LocalizationManager::getInstance()->isEuTaxCountry($taxCountry ?? '');
400 1090
401 1091 $storeVatNumber = '';
402 1092 if ($isEuCountry) {
403 1093 $euVatMethod = Arr::get($taxSettings, 'eu_vat_settings.method');
@@ -415,8 +1105,9 @@
415 1105 }
416 1106 }
417 1107
418 1108 $customerVatData = [];
1109 + $isReverseChargeApplied = $this->isReverseChargeCheckout($checkoutData);
419 1110 if (Arr::get($cart->checkout_data, 'tax_data.valid', false)) {
420 1111 $customerVatData = Arr::get($checkoutData, 'tax_data');
421 1112 $order = $data['order'];
422 1113 $order->customer->updateMeta('customer_tax_info',
@@ -423,56 +1114,129 @@
423 1114 Arr::only($customerVatData, ['vat_number', 'country', 'valid', 'name', 'address'])
424 1115 );
425 1116 }
426 1117
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');
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 + ];
430 1124
431 - if (!$rateId) {
432 - $rateId = 0; // assume this rate is from JSON tax rate
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;
433 1144 }
1145 + }
434 1146
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;
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 + }
440 1155
441 - if (!empty($customerVatData)) {
442 - $taxMeta['vat_reverse'] = $customerVatData;
443 - $taxAmount = 0;
444 - $shippingTax = 0;
445 - }
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', []);
446 1161
447 - $orderTaxRate = OrderTaxRate::query()->where('order_id', $order->id)
448 - ->where('tax_rate_id', $rateId)
449 - ->first();
1162 + if (!$cart || !$order || !$order->id) {
1163 + return;
1164 + }
450 1165
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 - ]);
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', ''));
467 1229 }
468 1230 }
1231 +
1232 + $order->updateMeta('business_info', $businessInfo);
469 1233 }
470 1234
471 1235
472 1236 public function initCheckoutActions()
473 1237 {
474 - add_action('fluent_cart/before_payment_methods', [$this, 'renderTaxField'], 10, 1);
1238 + add_action('fluent_cart/checkout/b2b_extra_fields', [$this, 'renderTaxField'], 10, 1);
475 1239 }
476 1240
477 1241 public function registerAjaxHandlers()
478 1242 {
@@ -486,59 +1250,25 @@
486 1250 public function renderTaxField($data)
487 1251 {
488 1252 $cart = Arr::get($data, 'cart');
489 1253
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 1254 $taxApplicableCountry = $this->getTaxApplicableCountry(
495 1255 Arr::get($this->taxSettings, 'tax_calculation_basis'),
496 - Arr::get($checkoutData, 'form_data')
1256 + Arr::get($cart->checkout_data, 'form_data')
497 1257 );
498 1258
499 - $isEuCountry = LocalizationManager::getInstance()->continentFromCountry($taxApplicableCountry) === 'EU';
500 -
501 - $isEuVatRcAvailable = false;
502 - if ($isEuCountry && $euVatEnabled === 'yes') {
503 - $isEuVatRcAvailable = true;
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);
504 1268 }
505 1269
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
1270 + (new VatFieldRenderer($taxApplicableCountry))->render($cart);
541 1271 }
542 1272
543 1273 public function getTaxApplicableCountry($calculationBasis, $formData)
544 1274 {
@@ -556,35 +1286,23 @@
556 1286 return $country;
557 1287
558 1288 }
559 1289
560 - public function renderValidNote($checkoutData)
1290 + public function canApplyVatValidation($countryCode)
561 1291 {
562 - $isValid = Arr::get($checkoutData, 'tax_data.valid', false);
563 - ?>
1292 + if (!$countryCode) {
1293 + return false;
1294 + }
564 1295
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>
1296 + $settings = $this->getSettings();
574 1297
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; ?>
1298 + if (Arr::get($settings, 'enable_tax', 'no') !== 'yes') {
1299 + return false;
1300 + }
580 1301
581 - <button data-fluent-cart-tax-remove-btn>
582 - <?php echo esc_html__('Remove', 'fluent-cart'); ?>
583 - </button>
584 - </div>
585 -
586 - <?php
1302 + $storeCountry = (new StoreSettings())->get('store_country');
1303 + return Arr::get($settings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes'
1304 + || $countryCode !== $storeCountry;
587 1305 }
588 1306
589 1307 public function handleVatValidation()
590 1308 {
@@ -593,14 +1311,22 @@
593 1311 if (!isset($_REQUEST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])), 'fluentcart')) {
594 1312 wp_send_json(['message' => __('Security check failed', 'fluent-cart')], 403);
595 1313 }
596 1314
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);
1315 + if (!$this->isEnabled()) {
1316 + wp_send_json(['message' => __('Tax is not enabled.', 'fluent-cart')], 422);
599 1317 }
600 1318
1319 + if (!CheckoutFieldsSchema::isVatNumberEnabled()) {
1320 + wp_send_json(['message' => __('VAT number collection is not enabled.', 'fluent-cart')], 422);
1321 + }
1322 +
601 1323 $cart = CartHelper::getCart();
602 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 +
603 1329 $taxCalculationBasis = Arr::get($this->taxSettings, 'tax_calculation_basis');
604 1330 $formData = Arr::get($cart->checkout_data, 'form_data', []);
605 1331 $shipToDifferent = Arr::get($formData, 'ship_to_different', '');
606 1332
@@ -609,26 +1335,29 @@
609 1335 } elseif ($taxCalculationBasis === 'shipping') {
610 1336 $countryCode = Arr::get($formData, 'shipping_country', '');
611 1337 }
612 1338
613 - $vatNumber = isset($_REQUEST['vat_number']) ? sanitize_text_field(wp_unslash($_REQUEST['vat_number'])) : '';
614 -
615 1339 $storeCountry = (new StoreSettings())->get('store_country');
616 1340 if ($taxCalculationBasis === 'store') {
617 1341 $countryCode = $storeCountry;
618 1342 }
619 1343
620 - if (!$countryCode || !$vatNumber) {
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) {
621 1353 wp_send_json(['message' => __('Missing required data', 'fluent-cart')], 422);
622 1354 }
623 1355
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
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));
631 1360 break;
632 1361 }
633 1362 }
634 1363
@@ -641,29 +1370,59 @@
641 1370 if (!Arr::get($taxData, 'valid')) {
642 1371 wp_send_json(['message' => __('VAT number is not valid!', 'fluent-cart')], 422);
643 1372 }
644 1373
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 -
1374 + $localRc = Arr::get($this->taxSettings, 'eu_vat_settings.local_reverse_charge', 'no');
650 1375 $isExcluded = false;
651 - foreach ($productTerms as $productId => $terms) {
652 - if (array_intersect($terms, $excludedCategories)) {
653 - $isExcluded = true;
654 - break;
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 + }
655 1386 }
656 1387 }
657 1388
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;
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;
665 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);
666 1425 }
667 1426
668 1427 $checkoutData = $cart->checkout_data;
669 1428 if (!isset($checkoutData['tax_data']) || !is_array($checkoutData['tax_data'])) {
@@ -670,9 +1429,14 @@
670 1429 $checkoutData['tax_data'] = [];
671 1430 }
672 1431 $checkoutData['tax_data'] = array_merge($checkoutData['tax_data'], $taxData);
673 1432 $cart->checkout_data = $checkoutData;
674 - $cart->update();
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();
675 1439
676 1440 ob_start();
677 1441 (new CartSummaryRender($cart))->render(false);
678 1442 $cartSummaryInner = ob_get_clean();
@@ -677,14 +1441,16 @@
677 1441 (new CartSummaryRender($cart))->render(false);
678 1442 $cartSummaryInner = ob_get_clean();
679 1443
680 1444 ob_start();
681 - (new EUVatRenderer(true, Arr::get($taxData, 'tax_country')))->render($cart);
1445 + (new VatFieldRenderer(Arr::get($taxData, 'country', $countryCode)))->renderInner($checkoutData);
682 1446 $euVatView = ob_get_clean();
683 1447
684 1448 wp_send_json([
685 1449 'success' => true,
686 - 'message' => __('VAT has been applied successfully', 'fluent-cart'),
1450 + 'message' => $appliesReverseCharge
1451 + ? __('VAT has been applied successfully', 'fluent-cart')
1452 + : __('VAT number has been validated successfully', 'fluent-cart'),
687 1453 'tax_data' => $taxData,
688 1454 'fragments' => [
689 1455 [
690 1456 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
@@ -727,11 +1493,21 @@
727 1493 unset($checkoutData['tax_data']['name']);
728 1494 unset($checkoutData['tax_data']['address']);
729 1495 unset($checkoutData['tax_data']['vat_number']);
730 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']);
731 1502 }
732 1503
733 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);
734 1510 $cart->save();
735 1511
736 1512 ob_start();
737 1513 (new CartSummaryRender($cart))->render(false);
@@ -774,18 +1550,37 @@
774 1550 }
775 1551
776 1552 protected function validateEuVatNumber($countryCode, $vatNumber)
777 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 +
778 1572 try {
779 1573 $wsdl = "https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl";
780 1574
781 1575 if (!class_exists('\\SoapClient')) {
782 - throw new \Exception(__('SOAP is not available on the server.', 'fluent-cart'));
1576 + return new \WP_Error('service_unavailable', __('SOAP is not available on the server.', 'fluent-cart'));
783 1577 }
784 1578
785 1579 $client = new \SoapClient($wsdl, [
786 - 'exceptions' => true,
787 - 'trace' => true,
1580 + 'exceptions' => true,
1581 + 'trace' => true,
1582 + 'connection_timeout' => 10,
788 1583 ]);
789 1584
790 1585 $params = [
791 1586 'countryCode' => $countryCode,
@@ -794,13 +1589,15 @@
794 1589
795 1590 $result = $client->checkVat($params);
796 1591
797 1592 if (empty($result->valid)) {
798 - throw new \Exception(
1593 + $countryName = LocalizationManager::getInstance()->getCountryNameByCode($countryCode);
1594 + return new \WP_Error(
1595 + 'invalid',
799 1596 sprintf(
800 - /* translators: %s is the country code */
801 - __('Invalid VAT number for country %s!', 'fluent-cart'),
802 - $countryCode
1597 + /* translators: %1$s: country name */
1598 + __('Invalid VAT number for %1$s!', 'fluent-cart'),
1599 + $countryName
803 1600 )
804 1601 );
805 1602 }
806 1603
@@ -814,14 +1611,182 @@
814 1611
815 1612 return $taxData;
816 1613
817 1614 } catch (\SoapFault $e) {
818 - return new \WP_Error('soap_fault', $e->getMessage());
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'));
819 1637 } catch (\Exception $e) {
820 1638 return new \WP_Error('invalid', $e->getMessage());
821 1639 }
822 1640 }
823 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 +
824 1789 public static function isTaxEnabled()
825 1790 {
826 1791 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
827 1792 return Arr::get($taxSettings, 'enable_tax', 'no') === 'yes';
@@ -826,40 +1791,72 @@
826 1791 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
827 1792 return Arr::get($taxSettings, 'enable_tax', 'no') === 'yes';
828 1793 }
829 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 +
830 1827 public static function euVatCountyOptions()
831 1828 {
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;
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;
862 1859 }
863 1860
864 1861 public static function taxTitleLists(): array
865 1862 {
@@ -917,7 +1914,29 @@
917 1914 if (isset($countryTaxTitles[$countryCode])) {
918 1915 return $countryTaxTitles[$countryCode];
919 1916 }
920 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;
921 1940 }
922 1941
923 1942 }