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 / TaxRateController.php

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

1,052 lines 38.7 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\App;
6 use FluentCart\App\Http\Requests\TaxCountryStatusRequest;
7 use FluentCart\App\Http\Requests\TaxRateRequest;
8 use FluentCart\App\Models\Meta;
9 use FluentCart\App\Models\ProductDetail;
10 use FluentCart\App\Models\ProductVariation;
11 use FluentCart\App\Models\TaxClass;
12 use FluentCart\App\Models\TaxRate;
13 use FluentCart\App\Services\Tax\TaxManager;
14 use FluentCart\Framework\Http\Request\Request;
15 use FluentCart\Framework\Support\Arr;
16
17 class TaxRateController extends Controller
18 {
19 private static $maxTaxClasses = 6;
20
21 private static $builtInClasses = [
22 ['slug' => 'reduced', 'title' => 'Reduced'],
23 ['slug' => 'zero', 'title' => 'Zero'],
24 ];
25
26 public function getClasses(Request $request)
27 {
28 $classes = TaxClass::query()->orderBy('id', 'ASC')->get();
29
30 return $this->sendSuccess([
31 'classes' => $classes,
32 'max_classes' => self::$maxTaxClasses,
33 'next_builtin' => $this->getNextBuiltinClass(),
34 ]);
35 }
36
37 public function createClass(Request $request)
38 {
39 $classCount = TaxClass::query()->count();
40 if ($classCount >= self::$maxTaxClasses) {
41 return $this->sendError([
42 'message' => __('Maximum of 6 tax classes allowed', 'fluent-cart')
43 ], 423);
44 }
45
46 $slug = sanitize_text_field($request->get('slug', ''));
47 $title = sanitize_text_field($request->get('title', ''));
48
49 // Check if this is a built-in class being created
50 $builtIn = null;
51 foreach (self::$builtInClasses as $bi) {
52 if ($bi['slug'] === $slug) {
53 $builtIn = $bi;
54 break;
55 }
56 }
57
58 if ($builtIn) {
59 $existing = TaxClass::query()->where('slug', $builtIn['slug'])->first();
60 if ($existing) {
61 return $this->sendError([
62 'message' => __('This tax class already exists', 'fluent-cart')
63 ], 423);
64 }
65 $taxClass = TaxClass::query()->create([
66 'title' => $builtIn['title'],
67 'slug' => $builtIn['slug'],
68 ]);
69 } else {
70 if (!$title) {
71 return $this->sendError([
72 'message' => __('Tax class name is required', 'fluent-cart')
73 ], 423);
74 }
75 if (strlen($title) > 30) {
76 return $this->sendError([
77 'message' => __('Tax class name must be 30 characters or fewer', 'fluent-cart')
78 ], 422);
79 }
80 // Prevent duplicate titles/slugs
81 $newSlug = \FluentCart\Framework\Support\Str::slug($title) ?: 'tax-class';
82 $existingBySlug = TaxClass::query()->where('slug', $newSlug)->first();
83 if ($existingBySlug) {
84 return $this->sendError([
85 'message' => __('A tax class with this name already exists', 'fluent-cart')
86 ], 423);
87 }
88 $taxClass = TaxClass::query()->create([
89 'title' => $title,
90 ]);
91 }
92
93 return $this->sendSuccess([
94 'class' => $taxClass,
95 'message' => __('Tax class created successfully', 'fluent-cart')
96 ]);
97 }
98
99 public function deleteClass(Request $request, $id)
100 {
101 $taxClass = TaxClass::query()->findOrFail($id);
102
103 if ($taxClass->slug === 'standard') {
104 return $this->sendError([
105 'message' => __('Cannot delete the Standard tax class', 'fluent-cart')
106 ], 423);
107 }
108
109 $standardClass = TaxClass::query()->where('slug', 'standard')->first();
110
111 if (!$standardClass) {
112 return $this->sendError([
113 'message' => __('Standard tax class could not be found', 'fluent-cart')
114 ], 423);
115 }
116
117 $db = App::db();
118
119 try {
120 $db->beginTransaction();
121
122 // The admin explicitly promises a fallback to Standard when a class
123 // is deleted, so rewrite every persisted reference before removing
124 // the class row and its rates.
125 $this->migrateProductTaxClassReferences($taxClass->id, $standardClass->id);
126 $this->migrateVariationTaxClassReferences($taxClass->slug, $standardClass->slug);
127 $this->migrateEuRegistrationTaxClassReferences($taxClass->slug, $standardClass->slug);
128 $this->migrateProductOverridesToStandard($taxClass->id, $standardClass->id);
129
130 $this->migrateTaxRatesToStandard($taxClass->id, $standardClass->id);
131 TaxRate::query()->where('class_id', $taxClass->id)->delete();
132 $taxClass->delete();
133
134 $db->commit();
135 } catch (\Exception $exception) {
136 $db->rollBack();
137
138 return $this->sendError([
139 'message' => __('Failed to delete tax class', 'fluent-cart')
140 ], 400);
141 }
142
143 return $this->sendSuccess([
144 'message' => __('Tax class deleted successfully', 'fluent-cart')
145 ]);
146 }
147
148 private function migrateProductTaxClassReferences($deletedClassId, $standardClassId)
149 {
150 ProductDetail::query()
151 ->whereNotNull('other_info')
152 ->get()
153 ->each(function ($productDetail) use ($deletedClassId, $standardClassId) {
154 $otherInfo = $productDetail->other_info ?: [];
155
156 if ((int) Arr::get($otherInfo, 'tax_class') !== (int) $deletedClassId) {
157 return;
158 }
159
160 // Product detail stores tax classes as numeric IDs, so keep the
161 // existing storage contract and swap the deleted ID to Standard.
162 $otherInfo['tax_class'] = (int) $standardClassId;
163 $productDetail->update([
164 'other_info' => $otherInfo
165 ]);
166 });
167 }
168
169 private function migrateVariationTaxClassReferences($deletedClassSlug, $standardClassSlug)
170 {
171 ProductVariation::query()
172 ->whereNotNull('other_info')
173 ->get()
174 ->each(function ($variation) use ($deletedClassSlug, $standardClassSlug) {
175 $otherInfo = $variation->other_info ?: [];
176
177 if (Arr::get($otherInfo, 'tax_class') !== $deletedClassSlug) {
178 return;
179 }
180
181 // Variations persist the tax class as a slug, so their fallback
182 // needs to stay in slug form instead of using the class ID.
183 $otherInfo['tax_class'] = $standardClassSlug;
184 $variation->update([
185 'other_info' => $otherInfo
186 ]);
187 });
188 }
189
190 private function migrateEuRegistrationTaxClassReferences($deletedClassSlug, $standardClassSlug)
191 {
192 $taxManager = TaxManager::getInstance();
193 $registrations = $taxManager->getEuVatRegistrations();
194
195 foreach ($registrations as $registration) {
196 $rates = Arr::get($registration, 'rates', []);
197 if (!is_array($rates) || !array_key_exists($deletedClassSlug, $rates)) {
198 continue;
199 }
200
201 if (!array_key_exists($standardClassSlug, $rates)) {
202 $rates[$standardClassSlug] = $rates[$deletedClassSlug];
203 }
204
205 unset($rates[$deletedClassSlug]);
206 $registration['rates'] = $rates;
207
208 $taxManager->saveEuVatRegistration($registration['country'], $registration);
209 }
210 }
211
212 private function migrateProductOverridesToStandard($deletedClassId, $standardClassId)
213 {
214 $overrides = Meta::query()->productCategoryTaxOverrides()->get();
215
216 // Pre-index existing Standard-class overrides by category + location so
217 // the conflict check below needs no per-row query.
218 $standardKeys = [];
219 foreach ($overrides as $override) {
220 $metaValue = $override->meta_value ?: [];
221 if ((int) Arr::get($metaValue, 'class_id', 0) === (int) $standardClassId) {
222 $standardKeys[$this->taxOverrideLocationKey($override, $metaValue)] = true;
223 }
224 }
225
226 foreach ($overrides as $override) {
227 $metaValue = $override->meta_value ?: [];
228
229 if ((int) Arr::get($metaValue, 'class_id', 0) !== (int) $deletedClassId) {
230 continue;
231 }
232
233 $locationKey = $this->taxOverrideLocationKey($override, $metaValue);
234
235 // The deleted class's overrides must fall back to Standard. If a
236 // Standard override already covers the same category and location,
237 // migrating would duplicate it, so drop the now-redundant row.
238 if (isset($standardKeys[$locationKey])) {
239 $override->delete();
240 continue;
241 }
242
243 $metaValue['class_id'] = (int) $standardClassId;
244 $override->meta_value = $metaValue;
245 $override->save();
246 $standardKeys[$locationKey] = true;
247 }
248 }
249
250 private function taxOverrideLocationKey($override, array $metaValue)
251 {
252 // Legacy rows can have object_id null/0 with the real category stored
253 // in meta_value.category_id — use whichever is non-zero so that two
254 // overrides for different categories never collapse to the same key.
255 $categoryId = (int) $override->object_id ?: (int) Arr::get($metaValue, 'category_id', 0);
256
257 return implode('|', [
258 $categoryId,
259 (string) Arr::get($metaValue, 'country', ''),
260 (string) Arr::get($metaValue, 'state', ''),
261 (string) Arr::get($metaValue, 'city', ''),
262 (string) Arr::get($metaValue, 'postcode', ''),
263 ]);
264 }
265
266 private function migrateTaxRatesToStandard($deletedClassId, $standardClassId)
267 {
268 $deletedRates = TaxRate::query()->where('class_id', $deletedClassId)->get();
269
270 foreach ($deletedRates as $rate) {
271 $exists = TaxRate::query()
272 ->where('class_id', $standardClassId)
273 ->where('country', $rate->country)
274 ->where('state', $rate->state ?: '')
275 ->where('city', $rate->city ?: '')
276 ->where('postcode', $rate->postcode ?: '')
277 ->exists();
278
279 if (!$exists) {
280 $newRateData = [
281 'class_id' => $standardClassId,
282 'country' => $rate->country,
283 'state' => $rate->state ?: '',
284 'postcode' => $rate->postcode ?: '',
285 'city' => $rate->city ?: '',
286 'rate' => $rate->rate,
287 'name' => $rate->name,
288 'group' => $rate->group,
289 'priority' => $rate->priority,
290 'is_compound' => $rate->is_compound,
291 ];
292 if ($rate->for_shipping !== null) {
293 $newRateData['for_shipping'] = $rate->for_shipping;
294 }
295 TaxRate::query()->create($newRateData);
296 }
297 }
298 }
299
300 public function index(Request $request)
301 {
302 $taxManager = TaxManager::getInstance();
303 $rates = $taxManager->getTaxRates();
304 $countryCodes = [];
305
306 foreach ($rates as &$group) {
307 foreach (($group['countries'] ?? []) as &$country) {
308 $countryCodes[] = $country['country_code'];
309 }
310 unset($country);
311 }
312 unset($group);
313
314 $countryEnabledMap = $taxManager->getCountryTaxEnabledMap(array_merge($countryCodes, ['EU']));
315
316 foreach ($rates as &$group) {
317 foreach (($group['countries'] ?? []) as &$country) {
318 $country['enabled'] = $countryEnabledMap[$country['country_code']] ?? true;
319 }
320 unset($country);
321 }
322 unset($group);
323
324 return $this->sendSuccess([
325 'tax_rates' => $rates,
326 'country_enabled_map' => $countryEnabledMap
327 ]);
328 }
329
330 public function show(Request $request)
331 {
332 $countryCode = sanitize_text_field($request->get('country_code'));
333 $classId = intval($request->get('class_id', 0));
334
335 $query = TaxRate::query()
336 ->where('country', $countryCode)
337 ->orderBy('priority', 'ASC')
338 ->orderBy('id', 'ASC');
339
340 if ($classId) {
341 $query->where('class_id', $classId);
342 }
343
344 $taxRates = $query->get();
345
346 $taxManager = TaxManager::getInstance();
347 $settings = $taxManager->getCountryConfiguration($countryCode);
348
349 return $this->sendSuccess([
350 'tax_rates' => $taxRates,
351 'settings' => $settings,
352 'tax_enabled' => $taxManager->isTaxEnabledForCountry($countryCode)
353 ]);
354 }
355
356 public function update(TaxRateRequest $request, $id)
357 {
358 $data = $request->getSafe($request->sanitize());
359
360 // wpdb->prepare() converts PHP null to '' which MySQL coerces to 0 on DECIMAL
361 // columns. Exclude for_shipping when absent from the request so the existing
362 // DB value is preserved rather than silently overwritten with 0.
363 if (array_key_exists('for_shipping', $data) && $data['for_shipping'] === null) {
364 unset($data['for_shipping']);
365 }
366
367 $taxRate = TaxRate::query()->findOrFail($id);
368 $isUpdated = $taxRate->update($data);
369
370 if (!$isUpdated) {
371 return $this->sendError([
372 'message' => __('Failed to update tax rate', 'fluent-cart')
373 ]);
374 }
375
376 return $this->sendSuccess([
377 'tax_rate' => $taxRate,
378 'message' => __('Tax rate has been updated successfully', 'fluent-cart')
379 ]);
380 }
381
382 public function store(TaxRateRequest $request)
383 {
384 $data = $request->getSafe($request->sanitize());
385
386 // wpdb->prepare() converts PHP null to '' which MySQL coerces to 0 on DECIMAL
387 // columns. Exclude for_shipping when absent from the request so the INSERT uses
388 // the column DEFAULT (NULL) rather than silently storing 0.
389 if (array_key_exists('for_shipping', $data) && $data['for_shipping'] === null) {
390 unset($data['for_shipping']);
391 }
392
393 $classId = intval($request->get('class_id', 0));
394 if ($classId) {
395 $taxClass = TaxClass::query()->find($classId);
396 if (!$taxClass) {
397 return $this->sendError([
398 'message' => __('Invalid tax class', 'fluent-cart')
399 ], 422);
400 }
401 $data['class_id'] = $taxClass->id;
402 } else {
403 $standardClass = TaxClass::query()->where('slug', 'standard')->first();
404 $data['class_id'] = $standardClass ? $standardClass->id : 1;
405 }
406
407 $matchCriteria = [
408 'class_id' => $data['class_id'],
409 'country' => $data['country'] ?? '',
410 'state' => $data['state'] ?? '',
411 'city' => $data['city'] ?? '',
412 'postcode' => $data['postcode'] ?? '',
413 ];
414
415 $db = App::db();
416 $db->beginTransaction();
417 try {
418 // The schema has no unique key for {class_id, country, state, city,
419 // postcode}, so serialize concurrent upserts on the tax class row,
420 // which always exists — otherwise two requests can both miss the
421 // target row and insert duplicate rates for the same location.
422 TaxClass::query()->where('id', $data['class_id'])->lockForUpdate()->first();
423
424 $taxRate = TaxRate::query()->where($matchCriteria)->lockForUpdate()->first();
425 if ($taxRate) {
426 $taxRate->update($data);
427 } else {
428 $taxRate = TaxRate::create(array_merge($matchCriteria, $data));
429 }
430 $db->commit();
431 } catch (\Throwable $exception) {
432 $db->rollBack();
433 throw $exception;
434 }
435
436 return $this->sendSuccess([
437 'tax_rate' => $taxRate,
438 'message' => __('Tax rate has been saved successfully', 'fluent-cart')
439 ]);
440
441 }
442
443 public function delete(Request $request, $id)
444 {
445 $taxRate = TaxRate::query()->findOrFail($id);
446 $isDeleted = $taxRate->delete();
447
448 if (!$isDeleted) {
449 return $this->sendError([
450 'message' => __('Failed to delete tax rate', 'fluent-cart')
451 ]);
452 }
453
454 return $this->sendSuccess([
455 'message' => __('Tax rate has been deleted successfully', 'fluent-cart')
456 ]);
457 }
458
459 public function updateCountryStatus(TaxCountryStatusRequest $request, $country_code)
460 {
461 $countryCode = strtoupper(sanitize_text_field($country_code));
462 $enabledValue = intval($request->getSafe('enabled'));
463
464 if (!array_key_exists($countryCode, App::localization()->countryIsoList()) && $countryCode !== 'EU') {
465 return $this->sendError([
466 'message' => __('Invalid country code', 'fluent-cart')
467 ], 422);
468 }
469
470 $isEnabled = $enabledValue === 1;
471 TaxManager::getInstance()->setTaxEnabledForCountry($countryCode, $isEnabled);
472
473 return $this->sendSuccess([
474 'enabled' => $isEnabled,
475 'message' => $isEnabled
476 ? __('Tax has been enabled successfully', 'fluent-cart')
477 : __('Tax has been disabled successfully', 'fluent-cart')
478 ]);
479 }
480
481 public function getCountryTaxId(Request $request, $country_code)
482 {
483 $countryCode = sanitize_text_field($country_code);
484 $taxData = Meta::query()
485 ->where('meta_key', 'fluent_cart_tax_id_' . $countryCode)
486 ->where('object_type', 'tax')
487 ->value('meta_value');
488
489 if (!$taxData) {
490 return $this->sendSuccess([
491 'tax_data' => [
492 'tax_id' => ''
493 ]
494 ]);
495 }
496
497 return $this->sendSuccess([
498 'tax_data' => $taxData
499 ]);
500 }
501
502 public function saveCountryTaxId(Request $request, $country_code)
503 {
504 $countryCode = sanitize_text_field($country_code);
505 $taxId = sanitize_text_field($request->get('tax_id'));
506
507 $data = [
508 'tax_id' => $taxId
509 ];
510
511 // save taxId to fct_meta
512 $meta = Meta::query()
513 ->where('meta_key', 'fluent_cart_tax_id_' . $countryCode)
514 ->where('object_type', 'tax')
515 ->first();
516
517 if ($meta) {
518 $meta->meta_value = $data;
519 $meta->save();
520 } else {
521 Meta::query()->create([
522 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
523 'meta_key' => 'fluent_cart_tax_id_' . $countryCode,
524 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
525 'meta_value' => [
526 'tax_id' => $taxId
527 ],
528 'object_type' => 'tax'
529 ]);
530 }
531
532 return $this->sendSuccess([
533 'message' => __('Tax ID has been saved successfully', 'fluent-cart')
534 ]);
535
536 }
537
538 public function deleteShippingOverride(Request $request, $id)
539 {
540 TaxManager::clearShippingOverrideById($id);
541
542 return $this->sendSuccess([
543 'message' => __('Shipping override has been deleted successfully', 'fluent-cart')
544 ]);
545 }
546
547
548 public function saveShippingOverride(Request $request)
549 {
550 $id = intval($request->getSafe('id', 'intval'));
551 $classId = intval($request->getSafe('class_id', 'intval'));
552 $previousId = intval($request->getSafe('previous_id', 'intval'));
553 $sourceType = sanitize_text_field($request->get('source_type', ''));
554 $sourceId = intval($request->getSafe('source_id', 'intval'));
555 $isProductToShippingConversion = $sourceType === 'products' && $sourceId;
556 $city = substr(sanitize_text_field($request->get('city', '')), 0, 45);
557 $postcode = sanitize_text_field($request->get('postcode', ''));
558
559 // Validate all request input before any row is touched, so a request
560 // that fails validation cannot leave behind an orphan fallback rate row.
561 $overrideTaxRate = $request->get('override_tax_rate');
562
563 if ($overrideTaxRate === null || $overrideTaxRate === '' || !is_numeric($overrideTaxRate)) {
564 return $this->sendError([
565 'message' => __('Tax rate must be a valid number', 'fluent-cart')
566 ], 422);
567 }
568
569 $overrideTaxRate = floatval($overrideTaxRate);
570
571 if ($overrideTaxRate < 0) {
572 return $this->sendError([
573 'message' => __('Tax rate must be 0 or greater', 'fluent-cart')
574 ], 422);
575 }
576
577 $productOverride = $isProductToShippingConversion ? TaxManager::getProductOverrideById($sourceId) : null;
578
579 if ($isProductToShippingConversion && !$productOverride) {
580 return $this->sendError([
581 'message' => __('Override not found', 'fluent-cart')
582 ], 404);
583 }
584
585 $taxRate = $id ? TaxRate::query()->find($id) : null;
586
587 if (!$taxRate) {
588 $country = strtoupper(sanitize_text_field($request->get('country', '')));
589 $stateVal = sanitize_text_field($request->get('state', ''));
590
591 if (!$country || !$classId) {
592 return $this->sendError([
593 'message' => __('Tax rate not found', 'fluent-cart')
594 ], 422);
595 }
596
597 if (!array_key_exists($country, App::localization()->countryIsoList())) {
598 return $this->sendError([
599 'message' => __('Invalid country code', 'fluent-cart')
600 ], 422);
601 }
602
603 if (!TaxClass::query()->where('id', $classId)->exists()) {
604 return $this->sendError([
605 'message' => __('Invalid tax class', 'fluent-cart')
606 ], 422);
607 }
608
609 $findDb = App::db();
610 $findDb->beginTransaction();
611 try {
612 // Serialize concurrent fallback creations on the tax class row
613 // (always present) so two requests cannot both miss the base
614 // rate row and insert duplicates for the same location.
615 TaxClass::query()->where('id', $classId)->lockForUpdate()->first();
616
617 $taxRate = TaxRate::query()
618 ->where('country', $country)
619 ->where('class_id', $classId)
620 ->where(function ($q) use ($stateVal) {
621 if ($stateVal === '') {
622 $q->whereNull('state')->orWhere('state', '');
623 } else {
624 $q->where('state', $stateVal);
625 }
626 })
627 ->where(function ($q) { $q->whereNull('city')->orWhere('city', ''); })
628 ->where(function ($q) { $q->whereNull('postcode')->orWhere('postcode', ''); })
629 ->lockForUpdate()
630 ->first();
631
632 if (!$taxRate) {
633 $existingForGroup = TaxRate::query()->where('country', $country)->orderBy('id', 'asc')->first();
634 $taxRate = TaxRate::create([
635 'country' => $country,
636 'state' => $stateVal,
637 'class_id' => $classId,
638 'rate' => 0,
639 'name' => 'Tax',
640 'group' => $existingForGroup ? $existingForGroup->group : null,
641 'city' => '',
642 'postcode' => '',
643 'priority' => 1,
644 'is_compound' => 0,
645 'for_order' => 0,
646 'for_shipping' => null,
647 ]);
648 }
649 $findDb->commit();
650 } catch (\Throwable $exception) {
651 $findDb->rollBack();
652 throw $exception;
653 }
654 }
655
656 if ($classId && intval($taxRate->class_id) !== $classId) {
657 return $this->sendError([
658 'message' => __('Selected tax class does not match the target tax rate', 'fluent-cart')
659 ], 422);
660 }
661
662 $db = App::db();
663 $db->beginTransaction();
664
665 try {
666 if ($city || $postcode) {
667 // Serialize concurrent city/postcode-specific inserts on the
668 // tax class row so two requests cannot both miss the specific
669 // rate and create duplicates for the same location.
670 TaxClass::query()->where('id', $taxRate->class_id)->lockForUpdate()->first();
671
672 $existingSpecificRate = TaxRate::query()
673 ->where('country', $taxRate->country)
674 ->where('state', $taxRate->state)
675 ->where('city', $city)
676 ->where('postcode', $postcode)
677 ->where('class_id', $taxRate->class_id)
678 ->lockForUpdate()
679 ->first();
680
681 if ($existingSpecificRate) {
682 $taxRate = $existingSpecificRate;
683 } else {
684 $taxRate = TaxRate::create([
685 'country' => $taxRate->country,
686 'state' => $taxRate->state,
687 'city' => $city,
688 'postcode' => $postcode,
689 'class_id' => $taxRate->class_id,
690 'rate' => $taxRate->rate,
691 'name' => $taxRate->name,
692 'group' => $taxRate->group,
693 'priority' => $taxRate->priority,
694 'is_compound' => $taxRate->is_compound,
695 'for_order' => $taxRate->for_order,
696 'for_shipping' => null,
697 ]);
698 }
699 }
700
701 if ((($previousId && $previousId !== intval($taxRate->id)) || $isProductToShippingConversion) && $taxRate->for_shipping !== null) {
702 $db->rollBack();
703 return $this->sendError([
704 'message' => __('A shipping override already exists for the selected location', 'fluent-cart')
705 ], 422);
706 }
707
708 if ($previousId && $previousId !== intval($taxRate->id)) {
709 TaxManager::clearShippingOverrideById($previousId);
710 }
711
712 $taxRate->for_shipping = $overrideTaxRate;
713 $taxRate->save();
714
715 if ($isProductToShippingConversion) {
716 $productOverride->delete();
717 }
718
719 $db->commit();
720 } catch (\Throwable $exception) {
721 $db->rollBack();
722 throw $exception;
723 }
724
725 return $this->sendSuccess([
726 'message' => __('Tax override has been saved successfully', 'fluent-cart')
727 ]);
728 }
729
730 public function getProductOverrides(Request $request, $country_code)
731 {
732 $countryCode = sanitize_text_field($country_code);
733
734 $overrides = Meta::query()
735 ->productCategoryTaxOverrides()
736 ->forTaxOverrideCountry($countryCode)
737 ->get();
738
739 $taxClasses = TaxClass::query()->get()->keyBy('id');
740
741 foreach ($overrides as $override) {
742 $classId = (int) Arr::get($override->meta_value, 'class_id', 0);
743 $taxClass = $classId ? $taxClasses->get($classId) : null;
744 $override->setAttribute('class_id', $classId);
745 $override->setAttribute('class_label', $taxClass ? $taxClass->title : '');
746 }
747
748 return $this->sendSuccess([
749 'overrides' => $overrides
750 ]);
751 }
752
753 public function saveProductOverride(Request $request)
754 {
755 $overrideId = intval($request->get('id'));
756 $sourceType = sanitize_text_field($request->get('source_type', ''));
757 $sourceId = intval($request->getSafe('source_id', 'intval'));
758 $isShippingToProductConversion = $sourceType === 'shipping' && $sourceId;
759 $country = sanitize_text_field($request->get('country'));
760 $state = sanitize_text_field($request->get('state', ''));
761 $city = substr(sanitize_text_field($request->get('city', '')), 0, 45);
762 $postcode = sanitize_text_field($request->get('postcode', ''));
763 $categoryId = intval($request->get('category_id'));
764 $taxLabel = sanitize_text_field($request->get('tax_label', ''));
765 $overrideStateTax = in_array($request->get('override_state_tax'), ['yes', 'no'], true)
766 ? $request->get('override_state_tax') : 'no';
767 $rate = max(0, floatval($request->get('rate')));
768 $classId = intval($request->get('class_id', 0));
769
770 if (!$country || !$categoryId) {
771 return $this->sendError([
772 'message' => __('Country and category are required', 'fluent-cart')
773 ]);
774 }
775
776 if (!array_key_exists($country, App::localization()->countryIsoList())) {
777 return $this->sendError([
778 'message' => __('Invalid country code', 'fluent-cart')
779 ], 422);
780 }
781
782 if ($classId !== 0 && !TaxClass::query()->where('id', $classId)->exists()) {
783 return $this->sendError([
784 'message' => __('Invalid tax class', 'fluent-cart')
785 ], 422);
786 }
787
788 $categoryTerm = get_term($categoryId, 'product-categories');
789
790 if (!$categoryTerm || is_wp_error($categoryTerm)) {
791 return $this->sendError([
792 'message' => __('Invalid product category', 'fluent-cart')
793 ], 422);
794 }
795
796 $categoryName = sanitize_text_field($categoryTerm->name);
797
798 if ($isShippingToProductConversion) {
799 TaxRate::query()->findOrFail($sourceId);
800 }
801
802 $metaValue = [
803 'country' => $country,
804 'state' => $state,
805 'city' => $city,
806 'postcode' => $postcode,
807 'category_id' => $categoryId,
808 'category_name' => $categoryName,
809 'tax_label' => $taxLabel,
810 'override_state_tax' => $overrideStateTax,
811 'rate' => $rate,
812 'class_id' => $classId,
813 ];
814
815 $db = App::db();
816 $db->beginTransaction();
817
818 try {
819 $existingOverride = null;
820 $upsertTarget = null;
821
822 if ($overrideId) {
823 $existingOverride = Meta::query()
824 ->where('id', $overrideId)
825 ->where('object_type', 'tax_override')
826 ->where('meta_key', 'product_category_override')
827 ->lockForUpdate()
828 ->first();
829
830 if (!$existingOverride) {
831 $db->rollBack();
832 return $this->sendError([
833 'message' => __('Override not found', 'fluent-cart')
834 ], 404);
835 }
836
837 $conflictingOverride = Meta::query()
838 ->productCategoryTaxOverrides()
839 ->where('id', '!=', $overrideId)
840 ->where('object_id', $categoryId)
841 ->forTaxOverrideCountry($country)
842 ->forTaxOverrideState($state)
843 ->forTaxOverrideCity($city)
844 ->forTaxOverridePostcode($postcode)
845 ->forTaxOverrideClassId($classId)
846 ->lockForUpdate()
847 ->first();
848
849 if (!$conflictingOverride) {
850 $conflictingOverride = Meta::query()
851 ->productCategoryTaxOverrides()
852 ->where('id', '!=', $overrideId)
853 ->legacyTaxOverrideObjectId()
854 ->forTaxOverrideCategoryId($categoryId)
855 ->forTaxOverrideCountry($country)
856 ->forTaxOverrideState($state)
857 ->forTaxOverrideCity($city)
858 ->forTaxOverridePostcode($postcode)
859 ->forTaxOverrideClassId($classId)
860 ->lockForUpdate()
861 ->first();
862 }
863
864 if ($conflictingOverride) {
865 $db->rollBack();
866 return $this->sendError([
867 'message' => __('An override already exists for the selected category, location, and tax class', 'fluent-cart')
868 ], 422);
869 }
870
871 $existingOverride->object_id = $categoryId;
872 $existingOverride->meta_value = $metaValue;
873 $existingOverride->save();
874
875 if ($isShippingToProductConversion) {
876 TaxManager::clearShippingOverrideById($sourceId);
877 }
878
879 $db->commit();
880
881 return $this->sendSuccess([
882 'override' => $existingOverride,
883 'message' => __('Product category tax override updated', 'fluent-cart')
884 ]);
885 }
886
887 // Serialize concurrent create-or-update requests on the tax class
888 // row so two requests cannot both miss $upsertTarget and insert
889 // duplicate meta rows for the same category/location/class.
890 $lockClassId = $classId
891 ?: TaxClass::query()->where('slug', 'standard')->value('id');
892 if ($lockClassId) {
893 TaxClass::query()->where('id', $lockClassId)->lockForUpdate()->first();
894 }
895
896 $upsertTarget = Meta::query()
897 ->productCategoryTaxOverrides()
898 ->where('object_id', $categoryId)
899 ->forTaxOverrideCountry($country)
900 ->forTaxOverrideState($state)
901 ->forTaxOverrideCity($city)
902 ->forTaxOverridePostcode($postcode)
903 ->forTaxOverrideClassId($classId)
904 ->lockForUpdate()
905 ->first();
906
907 if (!$upsertTarget) {
908 $upsertTarget = Meta::query()
909 ->productCategoryTaxOverrides()
910 ->legacyTaxOverrideObjectId()
911 ->forTaxOverrideCategoryId($categoryId)
912 ->forTaxOverrideCountry($country)
913 ->forTaxOverrideState($state)
914 ->forTaxOverrideCity($city)
915 ->forTaxOverridePostcode($postcode)
916 ->forTaxOverrideClassId($classId)
917 ->lockForUpdate()
918 ->first();
919 }
920
921 if ($upsertTarget && $isShippingToProductConversion) {
922 $db->rollBack();
923 return $this->sendError([
924 'message' => __('An override already exists for the selected category and location', 'fluent-cart')
925 ], 422);
926 }
927
928 if ($upsertTarget) {
929 $upsertTarget->object_id = $categoryId;
930 $upsertTarget->meta_value = $metaValue;
931 $upsertTarget->save();
932
933 $db->commit();
934
935 return $this->sendSuccess([
936 'override' => $upsertTarget,
937 'message' => __('Product category tax override updated', 'fluent-cart')
938 ]);
939 }
940
941 $created = Meta::query()->create([
942 'object_type' => 'tax_override',
943 'object_id' => $categoryId,
944 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
945 'meta_key' => 'product_category_override',
946 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
947 'meta_value' => $metaValue,
948 ]);
949
950 if ($isShippingToProductConversion) {
951 TaxManager::clearShippingOverrideById($sourceId);
952 }
953
954 $db->commit();
955
956 return $this->sendSuccess([
957 'override' => $created,
958 'message' => __('Product category tax override saved', 'fluent-cart')
959 ]);
960
961 } catch (\Throwable $exception) {
962 $db->rollBack();
963 throw $exception;
964 }
965 }
966
967 public function deleteProductOverride(Request $request, $id)
968 {
969 $override = Meta::query()
970 ->where('id', $id)
971 ->where('object_type', 'tax_override')
972 ->where('meta_key', 'product_category_override')
973 ->first();
974
975 if (!$override) {
976 return $this->sendError([
977 'message' => __('Override not found', 'fluent-cart')
978 ]);
979 }
980
981 $override->delete();
982
983 return $this->sendSuccess([
984 'message' => __('Product category tax override deleted', 'fluent-cart')
985 ]);
986 }
987
988 public function addCountry(Request $request)
989 {
990 $countryCode = sanitize_text_field($request->get('country'));
991 if (!$countryCode) {
992 return $this->sendError([
993 'message' => __('Country code is required', 'fluent-cart')
994 ]);
995 }
996
997 if ($countryCode !== 'EU' && !array_key_exists($countryCode, App::localization()->countryIsoList())) {
998 return $this->sendError([
999 'message' => __('Invalid country code', 'fluent-cart')
1000 ], 422);
1001 }
1002
1003 $classId = intval($request->get('class_id', 0));
1004 if ($classId) {
1005 if (!TaxClass::query()->where('id', $classId)->exists()) {
1006 return $this->sendError([
1007 'message' => __('Invalid tax class', 'fluent-cart')
1008 ], 422);
1009 }
1010 } else {
1011 $standardClass = TaxClass::query()->where('slug', 'standard')->first();
1012 if (!$standardClass) {
1013 return $this->sendError([
1014 'message' => __('Standard tax class could not be found', 'fluent-cart')
1015 ], 422);
1016 }
1017 $classId = $standardClass->id;
1018 }
1019
1020 $localization = App::localization();
1021 $continent = $localization->continentFromCountry($countryCode);
1022
1023 $taxRate = TaxRate::query()->create([
1024 'country' => $countryCode,
1025 'group' => $continent,
1026 'class_id' => $classId,
1027 ]);
1028
1029 if (!$taxRate) {
1030 return $this->sendError([
1031 'message' => __('Failed to add country', 'fluent-cart')
1032 ]);
1033 }
1034
1035 return $this->sendSuccess([
1036 'message' => __('Country has been added successfully', 'fluent-cart')
1037 ]);
1038 }
1039
1040 private function getNextBuiltinClass()
1041 {
1042 foreach (self::$builtInClasses as $builtIn) {
1043 $exists = TaxClass::query()->where('slug', $builtIn['slug'])->exists();
1044 if (!$exists) {
1045 return $builtIn;
1046 }
1047 }
1048 return null; // all built-ins exist, next + click is custom
1049 }
1050
1051 }
1052