| @@ -129,12 +129,12 @@ | ||
| 129 | 129 | * 'variants' => (array) Required. Variants of the product. |
| 130 | 130 | * 'id' => (int) Required. The variant ID. |
| 131 | 131 | * 'post_id' => (int) Required. The product ID. |
| 132 | 132 | * 'variant_title' => (string) Required. The variant title. |
| 133 | - * 'item_price' => (float) Required. The item price. | |
| 134 | - * '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. | |
| 135 | 135 | * 'manage_cost' => (string) Optional. Whether to manage costs. |
| 136 | - * '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. | |
| 137 | 137 | * 'manage_stock' => (string) Required. Whether to manage stock. |
| 138 | 138 | * 'stock_status' => (string) Required. The stock status. |
| 139 | 139 | * 'stock' => (int) Required. The stock quantity. |
| 140 | 140 | * 'media' => (array) Optional. Info of media files for each variant. |
| @@ -187,11 +187,12 @@ | ||
| 187 | 187 | 'compare_price', |
| 188 | 188 | 'item_cost', |
| 189 | 189 | ]; |
| 190 | 190 | |
| 191 | + // Amounts arrive in CENTS; normalize float artifacts without scaling. | |
| 191 | 192 | foreach ($priceColumns as $column) { |
| 192 | 193 | if (Arr::has($variant, $column)) { |
| 193 | - $variant[$column] = Arr::get($variant, $column) * 100; | |
| 194 | + $variant[$column] = Helper::roundCent(Arr::get($variant, $column)); | |
| 194 | 195 | } |
| 195 | 196 | } |
| 196 | 197 | |
| 197 | 198 | unset($variant['rowId']); |
| @@ -209,12 +210,15 @@ | ||
| 209 | 210 | } |
| 210 | 211 | |
| 211 | 212 | $variantData = $variant; |
| 212 | 213 | |
| 213 | - // Remove empty sku and shipping_class to prevent unique constraint violation | |
| 214 | - if (array_key_exists('sku', $variantData) && empty($variantData['sku'])) { | |
| 215 | - 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; | |
| 216 | 219 | } |
| 220 | + // Remove empty shipping_class to prevent unique constraint violation | |
| 217 | 221 | if (array_key_exists('shipping_class', $variantData) && empty($variantData['shipping_class'])) { |
| 218 | 222 | unset($variantData['shipping_class']); |
| 219 | 223 | } |
| 220 | 224 | |
| @@ -221,9 +225,9 @@ | ||
| 221 | 225 | // Handle other_info |
| 222 | 226 | if (!empty($otherInfo)) { |
| 223 | 227 | if (Arr::get($otherInfo, 'payment_type') == 'subscription') { |
| 224 | 228 | if (Arr::get($otherInfo, 'manage_setup_fee') == 'yes') { |
| 225 | - $signupFee = Helper::toCent(floatval(Arr::get($otherInfo, 'signup_fee', 0))); | |
| 229 | + $signupFee = Helper::roundCent(Arr::get($otherInfo, 'signup_fee', 0)); | |
| 226 | 230 | Arr::set($otherInfo, 'signup_fee', $signupFee); |
| 227 | 231 | } |
| 228 | 232 | $variantData['payment_type'] = 'subscription'; |
| 229 | 233 | } else { |
| @@ -249,16 +253,35 @@ | ||
| 249 | 253 | 'compare_price', |
| 250 | 254 | 'item_cost', |
| 251 | 255 | ]; |
| 252 | 256 | |
| 257 | + // Amounts arrive in CENTS; normalize without scaling. | |
| 253 | 258 | foreach ($priceColumns as $column) { |
| 254 | 259 | if (Arr::has($variant, $column)) { |
| 255 | - $variant[$column] = Arr::get($variant, $column) * 100; | |
| 260 | + $variant[$column] = Helper::roundCent(Arr::get($variant, $column)); | |
| 256 | 261 | } |
| 257 | 262 | } |
| 258 | 263 | unset($variant['rowId']); |
| 259 | - $variant['serial_index'] = $index + 1; | |
| 260 | 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 | + | |
| 261 | 284 | // Recalculate stock_status from available and manage_stock |
| 262 | 285 | if (isset($variant['manage_stock'])) { |
| 263 | 286 | if ($variant['manage_stock']) { |
| 264 | 287 | $avail = intval(Arr::get($variant, 'available', 0)); |
| @@ -271,9 +294,9 @@ | ||
| 271 | 294 | |
| 272 | 295 | if (!empty($otherInfo)) { |
| 273 | 296 | if (Arr::get($otherInfo, 'payment_type') == 'subscription') { |
| 274 | 297 | if (Arr::get($otherInfo, 'manage_setup_fee') == 'yes') { |
| 275 | - $signupFee = Helper::toCent(floatval(Arr::get($otherInfo, 'signup_fee', 0))); | |
| 298 | + $signupFee = Helper::roundCent(Arr::get($otherInfo, 'signup_fee', 0)); | |
| 276 | 299 | Arr::set($otherInfo, 'signup_fee', $signupFee); |
| 277 | 300 | } |
| 278 | 301 | } |
| 279 | 302 | $variant['other_info'] = $otherInfo; |
| @@ -305,10 +328,13 @@ | ||
| 305 | 328 | |
| 306 | 329 | |
| 307 | 330 | } |
| 308 | 331 | |
| 309 | - $defaultVariationId = Arr::get($detail, 'default_variation_id'); | |
| 310 | - $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. | |
| 311 | 337 | |
| 312 | 338 | // Recalculate min_price / max_price from current variant prices |
| 313 | 339 | $variantPriceRange = ProductVariation::query() |
| 314 | 340 | ->where('post_id', $postId) |
| @@ -351,8 +377,64 @@ | ||
| 351 | 377 | __('Product has been updated', 'fluent-cart') |
| 352 | 378 | ); |
| 353 | 379 | } |
| 354 | 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 | + | |
| 355 | 437 | public static function updateWpPost($postId, $params = []) |
| 356 | 438 | { |
| 357 | 439 | |
| 358 | 440 | $postStatus = Arr::get($params, 'post_status'); |
| @@ -360,17 +442,9 @@ | ||
| 360 | 442 | $postContent = Arr::get($params, 'post_content'); |
| 361 | 443 | $postExcerpt = Arr::get($params, 'post_excerpt'); |
| 362 | 444 | $commentStatus = Arr::get($params, 'comment_status'); |
| 363 | 445 | $postName = Arr::get($params, 'post_name'); |
| 364 | - $postDate = Arr::get($params, 'post_date'); | |
| 365 | - if (empty($postDate) || $postStatus !== 'future') { | |
| 366 | - $postDate = DateTime::gmtNow()->format('Y-m-d H:i:s'); | |
| 367 | - } | |
| 368 | 446 | |
| 369 | - if ($postStatus === 'future') { | |
| 370 | - $postDate = DateTime::anyTimeToGmt($postDate)->format('Y-m-d H:i:s'); | |
| 371 | - } | |
| 372 | - | |
| 373 | 447 | $data = [ |
| 374 | 448 | 'ID' => $postId, |
| 375 | 449 | 'post_title' => $postTitle, |
| 376 | 450 | 'post_status' => $postStatus, |
| @@ -388,25 +462,41 @@ | ||
| 388 | 462 | } |
| 389 | 463 | if (isset($postContent)) { |
| 390 | 464 | $data['post_content'] = $postContent; |
| 391 | 465 | } |
| 392 | - if (!empty($postDate)) { | |
| 393 | - $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; | |
| 394 | 480 | $data['post_date_gmt'] = $postDate; |
| 395 | - $data['post_modified'] = $postDate; | |
| 396 | - $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; | |
| 397 | 488 | } |
| 398 | 489 | |
| 399 | 490 | $updated = wp_update_post($data); |
| 400 | 491 | |
| 401 | 492 | if ($updated) { |
| 402 | - Product::query()->where('ID', $postId)->update([ | |
| 403 | - 'post_status' => $postStatus, | |
| 404 | - 'post_date' => $postDate, | |
| 405 | - 'post_date_gmt' => $postDate, | |
| 406 | - 'post_modified' => $postDate, | |
| 407 | - 'post_modified_gmt' => $postDate, | |
| 408 | - ]); | |
| 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); | |
| 409 | 499 | } |
| 410 | 500 | |
| 411 | 501 | return $updated; |
| 412 | 502 | } |