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/Http/Controllers/TaxEUController.php +324 -128 1.3.26 → 1.6.5 View file →
@@ -1,11 +1,13 @@
1 1 <?php
2 2
3 3 namespace FluentCart\App\Http\Controllers;
4 4
5 +use FluentCart\App\Models\Meta;
5 6 use FluentCart\App\Models\TaxRate;
6 7 use FluentCart\App\Models\TaxClass;
7 8 use FluentCart\Framework\Support\Arr;
9 +use FluentCart\Framework\Support\Collection;
8 10 use FluentCart\App\Modules\Tax\TaxModule;
9 11 use FluentCart\App\Services\Tax\TaxManager;
10 12 use FluentCart\Framework\Http\Request\Request;
11 13
@@ -12,217 +14,393 @@
12 14 class TaxEUController extends Controller
13 15 {
14 16 public function saveEuVatSettings(Request $request)
15 17 {
16 - if ($request->getSafe('action', 'sanitize_text_field') === 'euCrossBorderSettings') {
18 + $action = $request->getSafe('action', 'sanitize_text_field');
19 +
20 + if ($action === 'euCrossBorderSettings') {
17 21 return $this->euCrossBorderSettings($request);
22 + } else if ($action === 'saveCountryRegistration') {
23 + return $this->saveCountryRegistration($request);
24 + } else if ($action === 'deleteCountryRegistration') {
25 + return $this->deleteCountryRegistration($request);
18 26 } else {
19 27 return $this->sendError([
20 28 'message' => __('Invalid method', 'fluent-cart')
21 - ], 423);
29 + ], 422);
22 30 }
23 31 }
24 32
25 - public function getEuTaxRates(Request $request)
33 + public function saveCountryRegistration(Request $request)
26 34 {
27 - $taxRates = TaxRate::query()->where('group', 'EU')->select('group', 'country', 'name', 'rate', 'class_id','for_shipping')
28 - ->orderBy('group')
29 - ->orderBy('country')
30 - ->orderBy('class_id')
31 - ->get();
35 + $countryCode = $this->sanitizeCountryCode($request->get('country', ''));
36 + $vat = $request->getSafe('vat', 'sanitize_text_field');
37 + $rawRates = $request->get('rates', []);
38 + $rateErrors = [];
32 39
33 - $taxManager = TaxManager::getInstance();
34 - $groupedTaxRates = $taxManager->groupTaxRatesByGroup($taxRates);
40 + // Normalise rates: each entry is either {rate, label} or a legacy plain float
41 + $rates = [];
42 + if (is_array($rawRates)) {
43 + foreach ($rawRates as $slug => $rateData) {
44 + $cleanSlug = sanitize_key($slug);
45 + if (!$cleanSlug) {
46 + continue;
47 + }
48 + if (is_array($rateData)) {
49 + $rates[$cleanSlug] = [
50 + 'rate' => floatval($rateData['rate'] ?? 0),
51 + 'label' => sanitize_text_field($rateData['label'] ?? ''),
52 + ];
53 + } else {
54 + // Legacy plain-number format
55 + $rates[$cleanSlug] = ['rate' => floatval($rateData), 'label' => ''];
56 + }
57 + }
58 + }
35 59
36 - return $this->sendSuccess([
37 - 'tax_rates' => $groupedTaxRates
38 - ]);
39 - }
60 + // Legacy single-rate fallback (old clients send rate= not rates=)
61 + if (empty($rates)) {
62 + $legacyRate = floatval($request->get('rate', 0));
63 + if ($legacyRate > 0) {
64 + $rates['standard'] = ['rate' => $legacyRate, 'label' => sanitize_text_field($request->get('tax_label', ''))];
65 + }
66 + }
40 67
41 - public function saveOssTaxOverride(Request $request)
42 - {
43 - $newOssTaxOverride = $request->get('overrides', []);
44 - $countryCode = $request->get('country_code');
68 + if (!$countryCode) {
69 + return $this->sendError([
70 + 'message' => __('Select a registration country', 'fluent-cart'),
71 + ], 422);
72 + }
45 73
46 - $errors = [];
47 - if (!$countryCode) {
48 - $errors['country_code'] = __('Select country of OSS registration', 'fluent-cart');
74 + if (!$this->isEuVatCountry($countryCode)) {
75 + return $this->sendError([
76 + 'message' => __('Select a valid EU VAT registration country', 'fluent-cart'),
77 + 'errors' => [
78 + 'country' => __('Select a valid EU VAT registration country', 'fluent-cart')
79 + ]
80 + ], 422);
49 81 }
50 82
51 - if ($errors) {
83 + if (strlen($vat) > 50) {
52 84 return $this->sendError([
53 - 'message' => __('Validation failed for OSS tax override', 'fluent-cart'),
54 - 'errors' => $errors
55 - ], 423);
85 + 'message' => __('VAT number is too long', 'fluent-cart'),
86 + ], 422);
56 87 }
57 88
58 - $taxClassesFromType = TaxClass::query()->whereIn('slug', ['standard', 'reduced', 'zero'])->get()->mapWithKeys(function ($item) {
59 - return [$item->slug => $item->id];
60 - })->toArray();
61 -
89 + $hasNonZeroRate = false;
90 + foreach ($rates as $rateData) {
91 + if (floatval($rateData['rate'] ?? 0) > 0) {
92 + $hasNonZeroRate = true;
93 + break;
94 + }
95 + }
62 96
63 - foreach ($newOssTaxOverride as $override) {
64 - if (!isset($override['type']) || !isset($taxClassesFromType[$override['type']])) {
65 - continue;
97 + if (!$hasNonZeroRate) {
98 + return $this->sendError([
99 + 'message' => __('At least one tax rate must be greater than 0%', 'fluent-cart'),
100 + ], 422);
101 + }
102 +
103 + $requestedClassSlugs = array_keys($rates);
104 + $taxClasses = TaxClass::query()
105 + ->whereIn('slug', $requestedClassSlugs)
106 + ->get()
107 + ->keyBy('slug');
108 +
109 + foreach ($requestedClassSlugs as $classSlug) {
110 + if (!$taxClasses->has($classSlug)) {
111 + /* translators: %1$s: tax class slug (e.g. standard, reduced) */
112 + $rateErrors['rates.' . $classSlug] = sprintf(
113 + __('Tax class "%1$s" could not be found. Create the class first and try again.', 'fluent-cart'),
114 + $classSlug
115 + );
66 116 }
117 + }
67 118
68 - $taxClassId = $taxClassesFromType[$override['type']];
119 + if ($rateErrors) {
120 + return $this->sendError([
121 + 'message' => __('Validation failed for VAT registration rates', 'fluent-cart'),
122 + 'errors' => $rateErrors,
123 + ], 422);
124 + }
69 125
70 - $existingTaxRate = TaxRate::query()->where('country', $countryCode)->where('group', 'EU')->
71 - where('class_id', intval($taxClassId))->first();
126 + $standardRate = floatval($rates['standard']['rate'] ?? 0);
127 + $standardLabel = sanitize_text_field($rates['standard']['label'] ?? '');
72 128
129 + TaxManager::getInstance()->saveEuVatRegistration($countryCode, [
130 + 'country' => $countryCode,
131 + 'vat' => $vat,
132 + 'rate' => $standardRate,
133 + 'rates' => $rates,
134 + 'tax_label' => $standardLabel,
135 + ]);
73 136
74 - $overrideRate = Arr::get($override, 'rate');
75 - if ($existingTaxRate) {
76 - $existingTaxRate->rate = $overrideRate;
77 - $existingTaxRate->save();
78 - } else {
79 - TaxRate::query()->create([
80 - 'country' => $countryCode,
81 - 'name' => $override['type'],
82 - 'rate' => $overrideRate,
83 - 'group' => 'EU',
84 - 'class_id' => intval($taxClassId),
85 - 'tax_rate' => $overrideRate
86 - ]);
87 - }
137 + $currentSettings = (new TaxModule())->getSettings();
138 + $euVatSettings = Arr::get($currentSettings, 'eu_vat_settings', []);
139 + if (Arr::get($euVatSettings, 'method') === 'home' && Arr::get($euVatSettings, 'home_country') === $countryCode) {
140 + $euVatSettings['home_vat'] = $vat;
141 + $currentSettings['eu_vat_settings'] = $euVatSettings;
142 + update_option('fluent_cart_tax_configuration_settings', $currentSettings, true);
88 143 }
89 144
90 145 return $this->sendSuccess([
91 - 'message' => __('OSS tax override saved successfully', 'fluent-cart')
146 + 'message' => __('Country VAT registration saved successfully', 'fluent-cart')
92 147 ]);
93 148 }
94 149
95 - public function saveOssShippingOverride(Request $request)
150 + public function deleteCountryRegistration(Request $request)
96 151 {
97 - $newOssTaxOverride = $request->get('overrides', []);
98 - $countryCode = $request->get('country_code');
152 + $countryCode = $this->sanitizeCountryCode($request->get('country', ''));
99 153
100 - $errors = [];
101 154 if (!$countryCode) {
102 - $errors['country_code'] = __('Select country of OSS registration', 'fluent-cart');
155 + return $this->sendError([
156 + 'message' => __('Country code is required', 'fluent-cart'),
157 + ], 422);
103 158 }
104 159
105 - if ($errors) {
160 + if (!$this->isEuVatCountry($countryCode)) {
106 161 return $this->sendError([
107 - 'message' => __('Validation failed for OSS tax override', 'fluent-cart'),
108 - 'errors' => $errors
109 - ], 423);
162 + 'message' => __('Select a valid EU VAT registration country', 'fluent-cart'),
163 + ], 422);
110 164 }
111 165
112 - $taxClassesFromType = TaxClass::query()->whereIn('slug', ['standard', 'reduced', 'zero'])->get()->mapWithKeys(function ($item) {
113 - return [$item->slug => $item->id];
114 - })->toArray();
115 -
166 + TaxManager::getInstance()->deleteEuVatRegistration($countryCode);
116 167
117 - foreach ($newOssTaxOverride as $override) {
118 - if (!isset($override['type']) || !isset($taxClassesFromType[$override['type']])) {
119 - continue;
120 - }
168 + return $this->sendSuccess([
169 + 'message' => __('Country registration removed successfully', 'fluent-cart')
170 + ]);
171 + }
121 172
122 - $taxClassId = $taxClassesFromType[$override['type']];
173 + public function getOssCountryRates()
174 + {
175 + $euCountries = TaxModule::euVatCountyOptions();
123 176
124 - $existingTaxRate = TaxRate::query()->where('country', $countryCode)->where('group', 'EU')->
125 - where('class_id', intval($taxClassId))->first();
177 + // Get all tax classes
178 + $taxClasses = TaxClass::query()->orderBy('id', 'ASC')->get();
179 + $classMap = $taxClasses->keyBy('slug');
126 180
181 + // Load all EU rates grouped by country then class_id
182 + $allDbRates = TaxRate::query()
183 + ->where('group', 'EU')
184 + ->where(function ($q) {
185 + $q->whereNull('state')->orWhere('state', '');
186 + })
187 + ->get()
188 + ->groupBy('country');
127 189
128 - if ($existingTaxRate) {
129 - $existingTaxRate->rate = $override['rate'];
130 - $existingTaxRate->save();
131 - } else {
132 - TaxRate::query()->create([
133 - 'country' => $countryCode,
134 - 'name' => $override['type'],
135 - 'rate' => $override['rate'],
136 - 'for_shipping' => $override['for_shipping'] ?? 0,
137 - 'group' => 'EU',
138 - 'class_id' => intval($taxClassId),
139 - 'tax_rate' => $override['rate']
140 - ]);
190 + $rates = [];
191 + foreach ($euCountries as $country) {
192 + $code = $country['value'];
193 + $countryDbRates = $allDbRates->get($code, new Collection())->keyBy('class_id');
194 + $defaultRates = $country['default_rates'] ?? [];
195 +
196 + $classRates = [];
197 + foreach ($taxClasses as $tc) {
198 + $dbRate = $countryDbRates->get($tc->id);
199 + $defaultRate = $defaultRates[$tc->slug] ?? 0;
200 + // Strip auto-generated fallback names like "AT Reduced Tax" → show as empty so UI shows placeholder
201 + $dbLabel = '';
202 + if ($dbRate && $dbRate->name && !preg_match('/^[A-Z]{2} \w+ Tax$/', $dbRate->name)) {
203 + $dbLabel = $dbRate->name;
204 + }
205 + $classRates[$tc->slug] = [
206 + 'rate' => $dbRate ? (float) $dbRate->rate : ($defaultRate ?? 0),
207 + 'default_rate' => $defaultRate ?? 0,
208 + 'has_custom' => (bool) $dbRate,
209 + 'label' => $dbLabel,
210 + ];
141 211 }
212 +
213 + // Tax label for top-level (from standard class, for backward compat)
214 + $standardClassId = $classMap->has('standard') ? $classMap->get('standard')->id : 1;
215 + $standardDbRate = $countryDbRates->get($standardClassId);
216 + $taxLabel = $classRates['standard']['label'] ?? 'VAT';
217 + if (!$taxLabel) $taxLabel = 'VAT';
218 +
219 + $rates[] = [
220 + 'country' => $code,
221 + 'label' => $country['label'],
222 + 'rate' => $classRates['standard']['rate'] ?? ($country['default_rate'] ?? 0),
223 + 'tax_label' => $taxLabel,
224 + 'default_rate' => $country['default_rate'] ?? 0,
225 + 'has_custom' => $classRates['standard']['has_custom'] ?? false,
226 + 'class_rates' => $classRates,
227 + ];
142 228 }
143 229
230 + $classesInfo = $taxClasses->map(function ($tc) {
231 + return ['slug' => $tc->slug, 'title' => $tc->title, 'id' => $tc->id];
232 + })->values();
233 +
144 234 return $this->sendSuccess([
145 - 'message' => __('OSS tax override saved successfully', 'fluent-cart')
235 + 'rates' => $rates,
236 + 'classes' => $classesInfo,
146 237 ]);
147 238 }
148 239
149 - public function deleteOssTaxOverride(Request $request)
240 + public function saveOssCountryRates(Request $request)
150 241 {
151 - $countryCode = $request->getSafe('country', 'sanitize_text_field');
152 - $state = $request->getSafe('state', 'sanitize_text_field');
242 + $rates = $request->get('rates', []);
243 + $taxClasses = TaxClass::query()->get()->keyBy('slug');
244 + $errors = [];
153 245
154 - if (!$countryCode) {
155 - return $this->sendError([
156 - 'message' => __('Country code is required', 'fluent-cart')
157 - ], 423);
158 - }
246 + foreach ($rates as $index => $item) {
247 + $country = $this->sanitizeCountryCode($item['country'] ?? '');
248 + $taxLabel = sanitize_text_field($item['tax_label'] ?? '');
249 + if (!$country) continue;
159 250
160 - // select on base of countryCode, state if given and group eu
161 - $query = TaxRate::query()->where('country', $countryCode)->where('group', 'EU');
162 - if ($state) {
163 - $query->where('state', $state);
251 + if (!$this->isEuVatCountry($country)) {
252 + $errors['rates.' . $index . '.country'] = __('Select a valid EU VAT country', 'fluent-cart');
253 + continue;
254 + }
255 +
256 + $classRates = $item['class_rates'] ?? [];
257 +
258 + // If no class_rates provided, fall back to single 'rate' field (backward compat)
259 + if (empty($classRates)) {
260 + $standardClass = $taxClasses->get('standard');
261 + $classId = $standardClass ? $standardClass->id : 1;
262 + $rate = floatval($item['rate'] ?? 0);
263 + $rateName = $taxLabel ?: ($country . ' Standard Tax');
264 +
265 + $this->upsertOssRate($country, $classId, $rate, $rateName);
266 + continue;
267 + }
268 +
269 + // Save rate for each class — use per-class label, fall back to shared tax_label
270 + foreach ($classRates as $classSlug => $classData) {
271 + $classSlug = sanitize_key($classSlug);
272 + $taxClass = $taxClasses->get($classSlug);
273 + if (!$taxClass) continue;
274 +
275 + $rate = floatval($classData['rate'] ?? 0);
276 + $classLabel = sanitize_text_field($classData['label'] ?? '');
277 + $rateName = $classLabel ?: ($taxLabel ?: ($country . ' ' . ucfirst($classSlug) . ' Tax'));
278 +
279 + $this->upsertOssRate($country, $taxClass->id, $rate, $rateName);
280 + }
164 281 }
165 282
166 - $deleted = $query->delete();
167 -
168 - if (!$deleted) {
283 + if ($errors) {
169 284 return $this->sendError([
170 - 'message' => __('No matching OSS tax override found to delete', 'fluent-cart')
171 - ], 423);
285 + 'message' => __('Validation failed for OSS country rates', 'fluent-cart'),
286 + 'errors' => $errors
287 + ], 422);
172 288 }
173 289
174 290 return $this->sendSuccess([
175 - 'message' => __('OSS tax override deleted successfully', 'fluent-cart')
291 + 'message' => __('OSS country rates saved successfully', 'fluent-cart')
176 292 ]);
177 293 }
178 294
179 - public function deleteOssShippingOverride(Request $request)
295 + private function sanitizeNestedArray(array $data)
180 296 {
181 - $countryCode = $request->getSafe('country', 'sanitize_text_field');
182 - $state = $request->getSafe('state', 'sanitize_text_field');
297 + $sanitized = [];
298 + foreach ($data as $key => $value) {
299 + if (is_array($value)) {
300 + $sanitized[$key] = $this->sanitizeNestedArray($value);
301 + } elseif (is_numeric($value)) {
302 + $sanitized[$key] = $value + 0;
303 + } else {
304 + $sanitized[$key] = sanitize_text_field($value);
305 + }
306 + }
307 + return $sanitized;
308 + }
183 309
184 - if (!$countryCode) {
185 - return $this->sendError([
186 - 'message' => __('Country code is required', 'fluent-cart')
187 - ], 423);
310 + private function upsertOssRate($country, $classId, $rate, $rateName)
311 + {
312 + TaxRate::query()->updateOrCreate(
313 + [
314 + 'country' => $country,
315 + 'group' => 'EU',
316 + 'class_id' => $classId,
317 + 'state' => '',
318 + ],
319 + [
320 + 'name' => $rateName,
321 + 'rate' => $rate,
322 + 'city' => '',
323 + 'postcode' => '',
324 + ]
325 + );
326 + }
327 +
328 + public function getEuProductOverrides()
329 + {
330 + $euCountryCodes = array_column(TaxModule::euVatCountyOptions(), 'value');
331 +
332 + $taxClasses = TaxClass::query()->get()->keyBy('id');
333 +
334 + $overrides = Meta::query()
335 + ->productCategoryTaxOverrides()
336 + ->forTaxOverrideCountries($euCountryCodes)
337 + ->get();
338 +
339 + foreach ($overrides as $override) {
340 + $classId = (int) Arr::get($override->meta_value, 'class_id', 0);
341 + $taxClass = $classId ? $taxClasses->get($classId) : null;
342 + $override->setAttribute('class_id', $classId);
343 + $override->setAttribute('class_label', $taxClass ? $taxClass->title : '');
188 344 }
189 345
190 - // select on base of countryCode, state if given and group eu
191 - $query = TaxRate::query()->where('country', $countryCode)->where('group', 'EU');
192 - if ($state) {
193 - $query->where('state', $state);
194 - }
346 + $shippingOverrides = TaxRate::query()
347 + ->where('group', 'EU')
348 + ->whereNotNull('for_shipping')
349 + ->where(function ($q) {
350 + $q->whereNull('state')->orWhere('state', '');
351 + })
352 + ->orderBy('country', 'asc')
353 + ->orderBy('class_id', 'asc')
354 + ->orderBy('id', 'asc')
355 + ->get()
356 + ->map(function ($rate) use ($taxClasses) {
357 + $taxClass = $taxClasses->get($rate->class_id);
358 + $rate->setAttribute('class_label', $taxClass ? $taxClass->title : '');
359 + return $rate;
360 + })
361 + ->values();
195 362
196 - $deleted = $query->delete();
363 + return $this->sendSuccess([
364 + 'overrides' => $overrides,
365 + 'shipping_overrides' => $shippingOverrides,
366 + ]);
367 + }
197 368
198 - if (!$deleted) {
199 - return $this->sendError([
200 - 'message' => __('No matching OSS shipping override found to delete', 'fluent-cart')
201 - ], 423);
202 - }
369 + public function resetEuRatesToDefaults()
370 + {
371 + $taxManager = TaxManager::getInstance();
372 + $taxManager->resetEuRates();
203 373
204 374 return $this->sendSuccess([
205 - 'message' => __('OSS shipping override deleted successfully', 'fluent-cart')
375 + 'message' => __('EU tax rates have been reset to defaults', 'fluent-cart')
206 376 ]);
207 377 }
208 -
378 +
209 379 public function euCrossBorderSettings(Request $request)
210 380 {
211 - $newEuVatSettings = $request->get('eu_vat_settings', []);
212 - $sanitizedEuVatSettings = array_map('sanitize_text_field', $newEuVatSettings);
381 + $newEuVatSettings = (array) $request->get('eu_vat_settings', []);
382 + $sanitizedEuVatSettings = $this->sanitizeNestedArray($newEuVatSettings);
213 383 $method = Arr::get($sanitizedEuVatSettings, 'method');
384 + $ossCountry = $this->sanitizeCountryCode(Arr::get($sanitizedEuVatSettings, 'oss_country'));
385 + $homeCountry = $this->sanitizeCountryCode(Arr::get($sanitizedEuVatSettings, 'home_country'));
386 + Arr::set($sanitizedEuVatSettings, 'oss_country', $ossCountry);
387 + Arr::set($sanitizedEuVatSettings, 'home_country', $homeCountry);
214 388 $errors = [];
215 389
216 390 if (!in_array($method, ['oss', 'home', 'specific'], true)) {
217 391 $errors['method'] = __('Select a cross-border registration type', 'fluent-cart');
218 392 } else if ($method === 'oss') {
219 - if (!Arr::get($sanitizedEuVatSettings, 'oss_country')) {
393 + if (!$ossCountry) {
220 394 $errors['oss_country'] = __('Select country of OSS registration', 'fluent-cart');
395 + } else if (!$this->isEuVatCountry($ossCountry)) {
396 + $errors['oss_country'] = __('Select a valid EU VAT country', 'fluent-cart');
221 397 }
222 398 } else if ($method === 'home') {
223 - if (!Arr::get($sanitizedEuVatSettings, 'home_country')) {
399 + if (!$homeCountry) {
224 400 $errors['home_country'] = __('Select home country of registration', 'fluent-cart');
401 + } else if (!$this->isEuVatCountry($homeCountry)) {
402 + $errors['home_country'] = __('Select a valid EU VAT country', 'fluent-cart');
225 403 }
226 404 }
227 405
228 406 if ($errors) {
@@ -228,9 +406,9 @@
228 406 if ($errors) {
229 407 return $this->sendError([
230 408 'message' => __('Validation failed for EU VAT settings', 'fluent-cart'),
231 409 'errors' => $errors
232 - ], 423);
410 + ], 422);
233 411 }
234 412 $currentSettings = (new TaxModule())->getSettings();
235 413
236 414 $currentSettings['eu_vat_settings'] = array_merge(
@@ -248,5 +426,23 @@
248 426 'message' => __('EU VAT settings saved successfully', 'fluent-cart')
249 427 ]);
250 428
251 429 }
252 -}
430 +
431 + private function sanitizeCountryCode($countryCode)
432 + {
433 + return strtoupper(sanitize_text_field($countryCode));
434 + }
435 +
436 + private function isEuVatCountry($countryCode)
437 + {
438 + static $euCountryCodes = null;
439 +
440 + if ($euCountryCodes === null) {
441 + $euCountryCodes = array_map(function ($country) {
442 + return strtoupper($country['value']);
443 + }, TaxModule::euVatCountyOptions());
444 + }
445 +
446 + return in_array($countryCode, $euCountryCodes, true);
447 + }
448 +}