| @@ -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')] |