PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / app / Http / Controllers / TaxEUController.php

TaxEUController.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Http/Controllers/TaxEUController.php

449 lines 16.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Http\Controllers;
4
5 use FluentCart\App\Models\Meta;
6 use FluentCart\App\Models\TaxRate;
7 use FluentCart\App\Models\TaxClass;
8 use FluentCart\Framework\Support\Arr;
9 use FluentCart\Framework\Support\Collection;
10 use FluentCart\App\Modules\Tax\TaxModule;
11 use FluentCart\App\Services\Tax\TaxManager;
12 use FluentCart\Framework\Http\Request\Request;
13
14 class TaxEUController extends Controller
15 {
16 public function saveEuVatSettings(Request $request)
17 {
18 $action = $request->getSafe('action', 'sanitize_text_field');
19
20 if ($action === 'euCrossBorderSettings') {
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);
26 } else {
27 return $this->sendError([
28 'message' => __('Invalid method', 'fluent-cart')
29 ], 422);
30 }
31 }
32
33 public function saveCountryRegistration(Request $request)
34 {
35 $countryCode = $this->sanitizeCountryCode($request->get('country', ''));
36 $vat = $request->getSafe('vat', 'sanitize_text_field');
37 $rawRates = $request->get('rates', []);
38 $rateErrors = [];
39
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 }
59
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 }
67
68 if (!$countryCode) {
69 return $this->sendError([
70 'message' => __('Select a registration country', 'fluent-cart'),
71 ], 422);
72 }
73
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);
81 }
82
83 if (strlen($vat) > 50) {
84 return $this->sendError([
85 'message' => __('VAT number is too long', 'fluent-cart'),
86 ], 422);
87 }
88
89 $hasNonZeroRate = false;
90 foreach ($rates as $rateData) {
91 if (floatval($rateData['rate'] ?? 0) > 0) {
92 $hasNonZeroRate = true;
93 break;
94 }
95 }
96
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 );
116 }
117 }
118
119 if ($rateErrors) {
120 return $this->sendError([
121 'message' => __('Validation failed for VAT registration rates', 'fluent-cart'),
122 'errors' => $rateErrors,
123 ], 422);
124 }
125
126 $standardRate = floatval($rates['standard']['rate'] ?? 0);
127 $standardLabel = sanitize_text_field($rates['standard']['label'] ?? '');
128
129 TaxManager::getInstance()->saveEuVatRegistration($countryCode, [
130 'country' => $countryCode,
131 'vat' => $vat,
132 'rate' => $standardRate,
133 'rates' => $rates,
134 'tax_label' => $standardLabel,
135 ]);
136
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);
143 }
144
145 return $this->sendSuccess([
146 'message' => __('Country VAT registration saved successfully', 'fluent-cart')
147 ]);
148 }
149
150 public function deleteCountryRegistration(Request $request)
151 {
152 $countryCode = $this->sanitizeCountryCode($request->get('country', ''));
153
154 if (!$countryCode) {
155 return $this->sendError([
156 'message' => __('Country code is required', 'fluent-cart'),
157 ], 422);
158 }
159
160 if (!$this->isEuVatCountry($countryCode)) {
161 return $this->sendError([
162 'message' => __('Select a valid EU VAT registration country', 'fluent-cart'),
163 ], 422);
164 }
165
166 TaxManager::getInstance()->deleteEuVatRegistration($countryCode);
167
168 return $this->sendSuccess([
169 'message' => __('Country registration removed successfully', 'fluent-cart')
170 ]);
171 }
172
173 public function getOssCountryRates()
174 {
175 $euCountries = TaxModule::euVatCountyOptions();
176
177 // Get all tax classes
178 $taxClasses = TaxClass::query()->orderBy('id', 'ASC')->get();
179 $classMap = $taxClasses->keyBy('slug');
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');
189
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 ];
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 ];
228 }
229
230 $classesInfo = $taxClasses->map(function ($tc) {
231 return ['slug' => $tc->slug, 'title' => $tc->title, 'id' => $tc->id];
232 })->values();
233
234 return $this->sendSuccess([
235 'rates' => $rates,
236 'classes' => $classesInfo,
237 ]);
238 }
239
240 public function saveOssCountryRates(Request $request)
241 {
242 $rates = $request->get('rates', []);
243 $taxClasses = TaxClass::query()->get()->keyBy('slug');
244 $errors = [];
245
246 foreach ($rates as $index => $item) {
247 $country = $this->sanitizeCountryCode($item['country'] ?? '');
248 $taxLabel = sanitize_text_field($item['tax_label'] ?? '');
249 if (!$country) continue;
250
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 }
281 }
282
283 if ($errors) {
284 return $this->sendError([
285 'message' => __('Validation failed for OSS country rates', 'fluent-cart'),
286 'errors' => $errors
287 ], 422);
288 }
289
290 return $this->sendSuccess([
291 'message' => __('OSS country rates saved successfully', 'fluent-cart')
292 ]);
293 }
294
295 private function sanitizeNestedArray(array $data)
296 {
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 }
309
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 : '');
344 }
345
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();
362
363 return $this->sendSuccess([
364 'overrides' => $overrides,
365 'shipping_overrides' => $shippingOverrides,
366 ]);
367 }
368
369 public function resetEuRatesToDefaults()
370 {
371 $taxManager = TaxManager::getInstance();
372 $taxManager->resetEuRates();
373
374 return $this->sendSuccess([
375 'message' => __('EU tax rates have been reset to defaults', 'fluent-cart')
376 ]);
377 }
378
379 public function euCrossBorderSettings(Request $request)
380 {
381 $newEuVatSettings = (array) $request->get('eu_vat_settings', []);
382 $sanitizedEuVatSettings = $this->sanitizeNestedArray($newEuVatSettings);
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);
388 $errors = [];
389
390 if (!in_array($method, ['oss', 'home', 'specific'], true)) {
391 $errors['method'] = __('Select a cross-border registration type', 'fluent-cart');
392 } else if ($method === 'oss') {
393 if (!$ossCountry) {
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');
397 }
398 } else if ($method === 'home') {
399 if (!$homeCountry) {
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');
403 }
404 }
405
406 if ($errors) {
407 return $this->sendError([
408 'message' => __('Validation failed for EU VAT settings', 'fluent-cart'),
409 'errors' => $errors
410 ], 422);
411 }
412 $currentSettings = (new TaxModule())->getSettings();
413
414 $currentSettings['eu_vat_settings'] = array_merge(
415 Arr::get($currentSettings, 'eu_vat_settings', []),
416 $sanitizedEuVatSettings
417 );
418
419 if ($request->getSafe('reset_registration', 'sanitize_text_field') === 'yes') {
420 Arr::set($currentSettings['eu_vat_settings'], 'method', '');
421 }
422
423 update_option('fluent_cart_tax_configuration_settings', $currentSettings, true);
424
425 return $this->sendSuccess([
426 'message' => __('EU VAT settings saved successfully', 'fluent-cart')
427 ]);
428
429 }
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 }
449