PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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 | api/Resource/ProductResource.php +164 -54 1.3.26 → 1.6.6 View file →
@@ -2,16 +2,17 @@
2 2
3 3 namespace FluentCart\Api\Resource;
4 4
5 5 use FluentCart\Api\Taxonomy;
6 +use FluentCart\App\App;
6 7 use FluentCart\App\CPT\FluentProducts;
7 8 use FluentCart\App\Events\StockChanged;
8 9 use FluentCart\App\Helpers\Helper;
9 -use FluentCart\App\Helpers\ProductAdminHelper;
10 10 use FluentCart\App\Helpers\Status;
11 11 use FluentCart\App\Models\Product;
12 12 use FluentCart\App\Models\ProductDetail;
13 13 use FluentCart\App\Models\ProductMeta;
14 +use FluentCart\App\Models\AttributeRelation;
14 15 use FluentCart\App\Models\ProductVariation;
15 16 use FluentCart\App\Services\DateTime\DateTime;
16 17 use FluentCart\Framework\Database\Orm\Builder;
17 18 use FluentCart\Framework\Support\Arr;
@@ -128,12 +129,12 @@
128 129 * 'variants' => (array) Required. Variants of the product.
129 130 * 'id' => (int) Required. The variant ID.
130 131 * 'post_id' => (int) Required. The product ID.
131 132 * 'variant_title' => (string) Required. The variant title.
132 - * 'item_price' => (float) Required. The item price.
133 - * 'compare_price' => (float) Required. The compare price.
133 + * 'item_price' => (int) Required. The item price in CENTS (129900 = $1,299.00).
134 + * 'compare_price' => (int) Required. The compare price, in cents.
134 135 * 'manage_cost' => (string) Optional. Whether to manage costs.
135 - * 'item_cost' => (float) Required if manage cost is yes. The item cost.
136 + * 'item_cost' => (int) Required if manage cost is yes. The item cost, in cents.
136 137 * 'manage_stock' => (string) Required. Whether to manage stock.
137 138 * 'stock_status' => (string) Required. The stock status.
138 139 * 'stock' => (int) Required. The stock quantity.
139 140 * 'media' => (array) Optional. Info of media files for each variant.
@@ -175,8 +176,9 @@
175 176
176 177 if (count($variants) > 0) {
177 178
178 179 $variationType = Arr::get($detail, 'variation_type', 'simple');
180 +
179 181 if ($variationType === 'simple') {
180 182 $variant = $variants[0];
181 183 $otherInfo = Arr::get($variant, 'other_info', []);
182 184
@@ -185,11 +187,12 @@
185 187 'compare_price',
186 188 'item_cost',
187 189 ];
188 190
191 + // Amounts arrive in CENTS; normalize float artifacts without scaling.
189 192 foreach ($priceColumns as $column) {
190 193 if (Arr::has($variant, $column)) {
191 - $variant[$column] = Arr::get($variant, $column) * 100;
194 + $variant[$column] = Helper::roundCent(Arr::get($variant, $column));
192 195 }
193 196 }
194 197
195 198 unset($variant['rowId']);
@@ -207,12 +210,15 @@
207 210 }
208 211
209 212 $variantData = $variant;
210 213
211 - // Remove empty sku and shipping_class to prevent unique constraint violation
212 - if (array_key_exists('sku', $variantData) && empty($variantData['sku'])) {
213 - unset($variantData['sku']);
214 + // An explicitly cleared sku ('' or null) must persist as NULL so the
215 + // stored value is actually cleared, while still avoiding the sku_unique
216 + // constraint (MySQL treats multiple NULLs as distinct, unlike '').
217 + if (array_key_exists('sku', $variantData) && ($variantData['sku'] === '' || $variantData['sku'] === null)) {
218 + $variantData['sku'] = null;
214 219 }
220 + // Remove empty shipping_class to prevent unique constraint violation
215 221 if (array_key_exists('shipping_class', $variantData) && empty($variantData['shipping_class'])) {
216 222 unset($variantData['shipping_class']);
217 223 }
218 224
@@ -219,9 +225,9 @@
219 225 // Handle other_info
220 226 if (!empty($otherInfo)) {
221 227 if (Arr::get($otherInfo, 'payment_type') == 'subscription') {
222 228 if (Arr::get($otherInfo, 'manage_setup_fee') == 'yes') {
223 - $signupFee = Helper::toCent(floatval(Arr::get($otherInfo, 'signup_fee', 0)));
229 + $signupFee = Helper::roundCent(Arr::get($otherInfo, 'signup_fee', 0));
224 230 Arr::set($otherInfo, 'signup_fee', $signupFee);
225 231 }
226 232 $variantData['payment_type'] = 'subscription';
227 233 } else {
@@ -247,16 +253,35 @@
247 253 'compare_price',
248 254 'item_cost',
249 255 ];
250 256
257 + // Amounts arrive in CENTS; normalize without scaling.
251 258 foreach ($priceColumns as $column) {
252 259 if (Arr::has($variant, $column)) {
253 - $variant[$column] = Arr::get($variant, $column) * 100;
260 + $variant[$column] = Helper::roundCent(Arr::get($variant, $column));
254 261 }
255 262 }
256 263 unset($variant['rowId']);
257 - $variant['serial_index'] = $index + 1;
258 264
265 + // serial_index is display ordering the caller owns, not a column derived
266 + // from this loop. An advanced-variation save carries only the rows the
267 + // merchant actually touched, so deriving it from the payload index
268 + // renumbered an edited row to the front and left two variations sharing a
269 + // position. The reorder path sends an explicit serial_index for every row
270 + // and bulk edit round-trips the stored one, so an absent value means
271 + // "unchanged": drop the column and let batchUpdate's `ELSE serial_index`
272 + // keep what is stored.
273 + if (!isset($variant['serial_index']) || $variant['serial_index'] === '') {
274 + unset($variant['serial_index']);
275 + }
276 +
277 + // An explicitly cleared sku ('' or null) must persist as NULL so the
278 + // stored value is actually cleared, while still avoiding the sku_unique
279 + // constraint (MySQL treats multiple NULLs as distinct, unlike '').
280 + if (array_key_exists('sku', $variant) && ($variant['sku'] === '' || $variant['sku'] === null)) {
281 + $variant['sku'] = null;
282 + }
283 +
259 284 // Recalculate stock_status from available and manage_stock
260 285 if (isset($variant['manage_stock'])) {
261 286 if ($variant['manage_stock']) {
262 287 $avail = intval(Arr::get($variant, 'available', 0));
@@ -269,31 +294,47 @@
269 294
270 295 if (!empty($otherInfo)) {
271 296 if (Arr::get($otherInfo, 'payment_type') == 'subscription') {
272 297 if (Arr::get($otherInfo, 'manage_setup_fee') == 'yes') {
273 - $signupFee = Helper::toCent(floatval(Arr::get($otherInfo, 'signup_fee', 0)));
298 + $signupFee = Helper::roundCent(Arr::get($otherInfo, 'signup_fee', 0));
274 299 Arr::set($otherInfo, 'signup_fee', $signupFee);
275 300 }
276 301 }
277 302 $variant['other_info'] = $otherInfo;
278 303 }
279 - $variantData[] = $variant;
280 -
304 + $variantData[] = apply_filters('fluent_cart/product/variant_save_data', $variant, $postId);
281 305 }
282 306
283 - // Only batch update if there's data
307 + // Only batch update if there's data. Wrap the write in a transaction so
308 + // a mid-batch failure (UNIQUE constraint, deadlock, etc.) rolls back the
309 + // entire variant update instead of leaving the product in a partially-saved
310 + // state. The variants_updated action fires only after a successful commit
311 + // so listeners never observe a rolled-back state.
284 312 if (!empty($variantData)) {
285 - ProductVariation::query()->batchUpdate($variantData);
313 + $db = App::db();
314 + $db->beginTransaction();
315 + try {
316 + ProductVariation::query()->batchUpdate($variantData);
317 + $db->commit();
318 + } catch (\Exception $e) {
319 + $db->rollBack();
320 + throw $e;
321 + }
322 + do_action('fluent_cart/product/variants_updated', [
323 + 'post_id' => $postId,
324 + 'variants' => $variantData,
325 + ]);
286 326 }
287 327 }
288 328
289 329
290 -// $variationDetails = $detail;
291 -// $variants = ProductAdminHelper::syncProduct($variationDetails, $variants);
292 330 }
293 331
294 - $defaultVariationId = Arr::get($detail, 'default_variation_id');
295 - $detail['default_variation_id'] = $defaultVariationId;
332 + // Deliberately NOT defaulted here. $detail is a partial row — the editor
333 + // stages only what the merchant touched — so materialising this key as null
334 + // told ProductDetailResource::update() to clear the stored Default Variant
335 + // on every unrelated save (an inline price edit was enough). Absent now
336 + // means "unchanged"; an explicit empty value still clears it.
296 337
297 338 // Recalculate min_price / max_price from current variant prices
298 339 $variantPriceRange = ProductVariation::query()
299 340 ->where('post_id', $postId)
@@ -336,8 +377,64 @@
336 377 __('Product has been updated', 'fluent-cart')
337 378 );
338 379 }
339 380
381 + public static function partialUpdate(array $data, $postId)
382 + {
383 + $product = get_post($postId);
384 +
385 + if (!$product || $product->post_type !== 'fluent-products') {
386 + return new \WP_Error('not_found', __('Product not found', 'fluent-cart'));
387 + }
388 +
389 + $postData = ['ID' => (int) $postId];
390 +
391 + $allowedFields = ['post_title', 'post_content', 'post_excerpt', 'post_status', 'post_date'];
392 +
393 + foreach ($allowedFields as $field) {
394 + if (\array_key_exists($field, $data)) {
395 + $postData[$field] = $data[$field];
396 + }
397 + }
398 +
399 + if (\count($postData) === 1) {
400 + return new \WP_Error('no_fields', __('No valid fields provided for update', 'fluent-cart'));
401 + }
402 +
403 + $newStatus = $postData['post_status'] ?? null;
404 + $syncOrmDates = false;
405 +
406 + if ($newStatus === 'future') {
407 + $postDate = DateTime::anyTimeToGmt($postData['post_date'])->format('Y-m-d H:i:s');
408 + $postData['post_date'] = $postDate;
409 + $postData['post_date_gmt'] = $postDate;
410 + $syncOrmDates = true;
411 + } elseif ($newStatus === 'publish' && $product->post_status === 'future') {
412 + $now = DateTime::gmtNow()->format('Y-m-d H:i:s');
413 + $postData['post_date'] = $now;
414 + $postData['post_date_gmt'] = $now;
415 + $syncOrmDates = true;
416 + }
417 +
418 + $updated = wp_update_post($postData, true);
419 +
420 + if (is_wp_error($updated)) {
421 + return $updated;
422 + }
423 +
424 + if ($syncOrmDates) {
425 + Product::query()->where('ID', $postId)->update([
426 + 'post_status' => $newStatus,
427 + 'post_date' => $postData['post_date'],
428 + 'post_date_gmt' => $postData['post_date_gmt'],
429 + ]);
430 + }
431 +
432 + $product = static::getQuery()->with('variants')->addAppends(['viewUrl'])->find($postId);
433 +
434 + return static::makeSuccessResponse($product, __('Product has been updated', 'fluent-cart'));
435 + }
436 +
340 437 public static function updateWpPost($postId, $params = [])
341 438 {
342 439
343 440 $postStatus = Arr::get($params, 'post_status');
@@ -345,17 +442,9 @@
345 442 $postContent = Arr::get($params, 'post_content');
346 443 $postExcerpt = Arr::get($params, 'post_excerpt');
347 444 $commentStatus = Arr::get($params, 'comment_status');
348 445 $postName = Arr::get($params, 'post_name');
349 - $postDate = Arr::get($params, 'post_date');
350 - if (empty($postDate) || $postStatus !== 'future') {
351 - $postDate = DateTime::gmtNow()->format('Y-m-d H:i:s');
352 - }
353 446
354 - if ($postStatus === 'future') {
355 - $postDate = DateTime::anyTimeToGmt($postDate)->format('Y-m-d H:i:s');
356 - }
357 -
358 447 $data = [
359 448 'ID' => $postId,
360 449 'post_title' => $postTitle,
361 450 'post_status' => $postStatus,
@@ -373,25 +462,41 @@
373 462 }
374 463 if (isset($postContent)) {
375 464 $data['post_content'] = $postContent;
376 465 }
377 - if (!empty($postDate)) {
378 - $data['post_date'] = $postDate;
466 +
467 + // Only write post_date when the product is being scheduled (status
468 + // "future") — the admin editor exposes the date picker in that case
469 + // only. For ordinary edits we must NOT rewrite post_date/post_date_gmt,
470 + // otherwise the creation date changes on every save and "sort by newest"
471 + // breaks. WordPress updates post_modified on its own.
472 + $postDate = null;
473 + if ($postStatus === 'future') {
474 + $scheduledDate = Arr::get($params, 'post_date');
475 + if (empty($scheduledDate)) {
476 + $scheduledDate = DateTime::gmtNow()->format('Y-m-d H:i:s');
477 + }
478 + $postDate = DateTime::anyTimeToGmt($scheduledDate)->format('Y-m-d H:i:s');
479 + $data['post_date'] = $postDate;
379 480 $data['post_date_gmt'] = $postDate;
380 - $data['post_modified'] = $postDate;
381 - $data['post_modified_gmt'] = $postDate;
481 + } elseif ($postStatus === 'publish' && get_post_field('post_status', $postId) === 'future') {
482 + // Publishing a scheduled product early: stamp the creation date to
483 + // now so it doesn't go live with a future date (which would sort as
484 + // "newest"). Mirrors partialUpdate().
485 + $postDate = DateTime::gmtNow()->format('Y-m-d H:i:s');
486 + $data['post_date'] = $postDate;
487 + $data['post_date_gmt'] = $postDate;
382 488 }
383 489
384 490 $updated = wp_update_post($data);
385 491
386 492 if ($updated) {
387 - Product::query()->where('ID', $postId)->update([
388 - 'post_status' => $postStatus,
389 - 'post_date' => $postDate,
390 - 'post_date_gmt' => $postDate,
391 - 'post_modified' => $postDate,
392 - 'post_modified_gmt' => $postDate,
393 - ]);
493 + $ormData = ['post_status' => $postStatus];
494 + if ($postDate !== null) {
495 + $ormData['post_date'] = $postDate;
496 + $ormData['post_date_gmt'] = $postDate;
497 + }
498 + Product::query()->where('ID', $postId)->update($ormData);
394 499 }
395 500
396 501 return $updated;
397 502 }
@@ -422,11 +527,15 @@
422 527 return static::makeErrorResponse([
423 528 ['code' => 400, 'message' => __('This product cannot be deleted at the moment. There are pending orders associated with it. Deleting the product will disrupt the order processing and might cause inconvenience to our customers.', 'fluent-cart')]
424 529 ]);
425 530 }
531 + $variantIds = $product->variants->pluck('id')->toArray();
426 532 foreach ($product->variants as $variant) {
427 533 $variant->media()->delete();
428 534 }
535 + if ($variantIds) {
536 + AttributeRelation::query()->whereIn('object_id', $variantIds)->delete();
537 + }
429 538 $product->detail()->delete();
430 539 $product->variants()->delete();
431 540 $product->licensesMeta()->delete();
432 541 $product->downloadable_files()->delete();
@@ -482,25 +591,26 @@
482 591 * @return mixed
483 592 */
484 593 public static function syncVariantOption($productId, $data = [])
485 594 {
486 - $srcPricing = ProductDetail::where('post_id', $productId)->first();
487 - $settings = Arr::get($data, 'options');
488 - $variationType = Arr::get($data, 'variation_type');
595 + $preprocessed = apply_filters('fluent_cart/product/variant_option_payload', [
596 + 'product_id' => (int) $productId,
597 + 'data' => $data,
598 + ]);
599 + if (!is_array($preprocessed) || !isset($preprocessed['data'])) {
600 + $preprocessed = ['product_id' => (int) $productId, 'data' => $data];
601 + }
602 + $data = $preprocessed['data'];
489 603
490 - if (!empty($variationType) && $variationType === Helper::PRODUCT_TYPE_ADVANCE_VARIATION) {
604 + $result = apply_filters('fluent_cart/product/variant_option_sync', [
605 + 'product_id' => (int) $productId,
606 + 'data' => $data,
607 + 'handled' => false,
608 + 'response' => null,
609 + ]);
491 610
492 - $variants = ProductAdminHelper::syncProduct($srcPricing, $settings);
493 -
494 - $srcPricing->fill([
495 - 'other_info' => $settings,
496 - 'variation_type' => Helper::PRODUCT_TYPE_ADVANCE_VARIATION,
497 - ])->save();
498 -
499 - return static::makeSuccessResponse(
500 - $variants,
501 - __('Variation combination updated!', 'fluent-cart')
502 - );
611 + if (!empty($result['handled'])) {
612 + return $result['response'];
503 613 }
504 614
505 615 return static::makeErrorResponse([
506 616 ['code' => 400, 'message' => __('Illegal data provided.', 'fluent-cart')]