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.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 1.3.21 1.3.20 1.3.19 All 49 releases
← All changes | app/Http/Controllers/TaxRateController.php +841 -68 1.3.21 → 1.6.5 View file →
@@ -2,30 +2,329 @@
2 2
3 3 namespace FluentCart\App\Http\Controllers;
4 4
5 5 use FluentCart\App\App;
6 -use FluentCart\App\Helpers\AddressHelper;
7 -use FluentCart\App\Http\Requests\TaxClassRequest;
6 +use FluentCart\App\Http\Requests\TaxCountryStatusRequest;
8 7 use FluentCart\App\Http\Requests\TaxRateRequest;
9 8 use FluentCart\App\Models\Meta;
9 +use FluentCart\App\Models\ProductDetail;
10 +use FluentCart\App\Models\ProductVariation;
10 11 use FluentCart\App\Models\TaxClass;
11 12 use FluentCart\App\Models\TaxRate;
12 -use FluentCart\App\Services\Localization\LocalizationManager;
13 13 use FluentCart\App\Services\Tax\TaxManager;
14 14 use FluentCart\Framework\Http\Request\Request;
15 15 use FluentCart\Framework\Support\Arr;
16 -use FluentCart\Framework\Support\Str;
17 16
18 17 class TaxRateController extends Controller
19 18 {
19 + private static $maxTaxClasses = 6;
20 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 +
21 300 public function index(Request $request)
22 301 {
23 302 $taxManager = TaxManager::getInstance();
24 303 $rates = $taxManager->getTaxRates();
304 + $countryCodes = [];
25 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 +
26 324 return $this->sendSuccess([
27 - 'tax_rates' => $rates
325 + 'tax_rates' => $rates,
326 + 'country_enabled_map' => $countryEnabledMap
28 327 ]);
29 328 }
30 329
31 330 public function show(Request $request)
@@ -30,22 +329,28 @@
30 329
31 330 public function show(Request $request)
32 331 {
33 332 $countryCode = sanitize_text_field($request->get('country_code'));
333 + $classId = intval($request->get('class_id', 0));
34 334
35 - // get taxRates by $countryCode
36 - $taxRates = TaxRate::query()
335 + $query = TaxRate::query()
37 336 ->where('country', $countryCode)
38 - ->with(['tax_class:id,title'])
39 - ->orderBy('priority', 'DESC')
40 - ->get();
337 + ->orderBy('priority', 'ASC')
338 + ->orderBy('id', 'ASC');
41 339
340 + if ($classId) {
341 + $query->where('class_id', $classId);
342 + }
343 +
344 + $taxRates = $query->get();
345 +
42 346 $taxManager = TaxManager::getInstance();
43 347 $settings = $taxManager->getCountryConfiguration($countryCode);
44 348
45 349 return $this->sendSuccess([
46 - 'tax_rates' => $taxRates,
47 - 'settings' => $settings
350 + 'tax_rates' => $taxRates,
351 + 'settings' => $settings,
352 + 'tax_enabled' => $taxManager->isTaxEnabledForCountry($countryCode)
48 353 ]);
49 354 }
50 355
51 356 public function update(TaxRateRequest $request, $id)
@@ -51,8 +356,15 @@
51 356 public function update(TaxRateRequest $request, $id)
52 357 {
53 358 $data = $request->getSafe($request->sanitize());
54 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 +
55 367 $taxRate = TaxRate::query()->findOrFail($id);
56 368 $isUpdated = $taxRate->update($data);
57 369
58 370 if (!$isUpdated) {
@@ -59,16 +371,8 @@
59 371 return $this->sendError([
60 372 'message' => __('Failed to update tax rate', 'fluent-cart')
61 373 ]);
62 374 }
63 - $taxClass = TaxClass::query()->select('id', 'title')->find($taxRate->class_id);
64 - $taxRate->tax_class = [
65 - 'id' => 0,
66 - 'title' => ''
67 - ];
68 - if ($taxClass) {
69 - $taxRate->tax_class = $taxClass;
70 - }
71 375
72 376 return $this->sendSuccess([
73 377 'tax_rate' => $taxRate,
74 378 'message' => __('Tax rate has been updated successfully', 'fluent-cart')
@@ -78,37 +382,61 @@
78 382 public function store(TaxRateRequest $request)
79 383 {
80 384 $data = $request->getSafe($request->sanitize());
81 385
82 - $classId = Arr::get($data, 'class_id');
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 + }
83 392
84 - if (!$classId) {
85 - return $this->sendError([
86 - 'message' => __('Tax class is required', 'fluent-cart')
87 - ]);
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;
88 405 }
89 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 + ];
90 414
91 - $taxRate = TaxRate::query()->create($data);
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();
92 423
93 - if (!$taxRate) {
94 - return $this->sendError([
95 - 'message' => __('Failed to create tax rate', 'fluent-cart')
96 - ]);
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;
97 434 }
98 435
99 - $taxClass = TaxClass::query()->select('id', 'title')->find($taxRate->class_id);
100 - $taxRate->tax_class = [
101 - 'id' => 0,
102 - 'title' => ''
103 - ];
104 - if ($taxClass) {
105 - $taxRate->tax_class = $taxClass;
106 - }
107 -
108 436 return $this->sendSuccess([
109 437 'tax_rate' => $taxRate,
110 - 'message' => __('Tax rate has been created successfully', 'fluent-cart')
438 + 'message' => __('Tax rate has been saved successfully', 'fluent-cart')
111 439 ]);
112 440
113 441 }
114 442
@@ -127,22 +455,27 @@
127 455 'message' => __('Tax rate has been deleted successfully', 'fluent-cart')
128 456 ]);
129 457 }
130 458
131 - public function deleteCountry(Request $request, $country_code)
459 + public function updateCountryStatus(TaxCountryStatusRequest $request, $country_code)
132 460 {
133 - $countryCode = sanitize_text_field($country_code);
461 + $countryCode = strtoupper(sanitize_text_field($country_code));
462 + $enabledValue = intval($request->getSafe('enabled'));
134 463
135 - $taxRates = TaxRate::query()->where('country', $countryCode)->delete();
136 -
137 - if (!$taxRates) {
464 + if (!array_key_exists($countryCode, App::localization()->countryIsoList()) && $countryCode !== 'EU') {
138 465 return $this->sendError([
139 - 'message' => __('Failed to delete country', 'fluent-cart')
140 - ]);
466 + 'message' => __('Invalid country code', 'fluent-cart')
467 + ], 422);
141 468 }
142 469
470 + $isEnabled = $enabledValue === 1;
471 + TaxManager::getInstance()->setTaxEnabledForCountry($countryCode, $isEnabled);
472 +
143 473 return $this->sendSuccess([
144 - 'message' => __('Country has been deleted successfully', 'fluent-cart')
474 + 'enabled' => $isEnabled,
475 + 'message' => $isEnabled
476 + ? __('Tax has been enabled successfully', 'fluent-cart')
477 + : __('Tax has been disabled successfully', 'fluent-cart')
145 478 ]);
146 479 }
147 480
148 481 public function getCountryTaxId(Request $request, $country_code)
@@ -203,11 +536,9 @@
203 536 }
204 537
205 538 public function deleteShippingOverride(Request $request, $id)
206 539 {
207 - $taxRate = TaxRate::query()->findOrFail($id);
208 - $taxRate->for_shipping = null;
209 - $taxRate->save();
540 + TaxManager::clearShippingOverrideById($id);
210 541
211 542 return $this->sendSuccess([
212 543 'message' => __('Shipping override has been deleted successfully', 'fluent-cart')
213 544 ]);
@@ -215,21 +546,443 @@
215 546
216 547
217 548 public function saveShippingOverride(Request $request)
218 549 {
219 - $taxRate = TaxRate::query()->findOrFail($request->getSafe('id', 'intval'));
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', ''));
220 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 +
221 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) {
222 657 return $this->sendError([
223 - 'message' => __('Tax rate not found', 'fluent-cart')
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')
224 773 ]);
225 774 }
226 775
227 - $taxRate->for_shipping = $request->getSafe('override_tax_rate', 'intval');
228 - $taxRate->save();
776 + if (!array_key_exists($country, App::localization()->countryIsoList())) {
777 + return $this->sendError([
778 + 'message' => __('Invalid country code', 'fluent-cart')
779 + ], 422);
780 + }
229 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 +
230 983 return $this->sendSuccess([
231 - 'message' => __('Tax override has been saved successfully', 'fluent-cart')
984 + 'message' => __('Product category tax override deleted', 'fluent-cart')
232 985 ]);
233 986 }
234 987
235 988 public function addCountry(Request $request)
@@ -234,9 +987,8 @@
234 987
235 988 public function addCountry(Request $request)
236 989 {
237 990 $countryCode = sanitize_text_field($request->get('country'));
238 - $taxClassId = intval($request->get('class_id'));
239 991 if (!$countryCode) {
240 992 return $this->sendError([
241 993 'message' => __('Country code is required', 'fluent-cart')
242 994 ]);
@@ -241,19 +993,29 @@
241 993 'message' => __('Country code is required', 'fluent-cart')
242 994 ]);
243 995 }
244 996
245 - if (!$taxClassId) {
997 + if ($countryCode !== 'EU' && !array_key_exists($countryCode, App::localization()->countryIsoList())) {
246 998 return $this->sendError([
247 - 'message' => __('Tax class is required', 'fluent-cart')
248 - ]);
999 + 'message' => __('Invalid country code', 'fluent-cart')
1000 + ], 422);
249 1001 }
250 1002
251 - $taxClass = TaxClass::query()->select('id')->find($taxClassId);
252 - if (!$taxClass) {
253 - return $this->sendError([
254 - 'message' => __('Tax class not found', 'fluent-cart')
255 - ]);
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;
256 1018 }
257 1019
258 1020 $localization = App::localization();
259 1021 $continent = $localization->continentFromCountry($countryCode);
@@ -258,11 +1020,11 @@
258 1020 $localization = App::localization();
259 1021 $continent = $localization->continentFromCountry($countryCode);
260 1022
261 1023 $taxRate = TaxRate::query()->create([
262 - 'country' => $countryCode,
263 - 'group' => $continent,
264 - 'class_id' => $taxClass->id,
1024 + 'country' => $countryCode,
1025 + 'group' => $continent,
1026 + 'class_id' => $classId,
265 1027 ]);
266 1028
267 1029 if (!$taxRate) {
268 1030 return $this->sendError([
@@ -272,7 +1034,18 @@
272 1034
273 1035 return $this->sendSuccess([
274 1036 'message' => __('Country has been added successfully', 'fluent-cart')
275 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
276 1049 }
277 1050
278 1051 }