| @@ -10,8 +10,10 @@ | ||
| 10 | 10 | use FluentCart\App\Models\ProductVariation; |
| 11 | 11 | use FluentCart\App\Services\Filter\ProductFilter; |
| 12 | 12 | use FluentCart\Framework\Http\Request\Request; |
| 13 | 13 | use FluentCart\Framework\Support\Arr; |
| 14 | +use FluentCart\App\Http\Rules\RequiredWhenRule; | |
| 15 | +use FluentCart\App\Http\Rules\WhenFilledRule; | |
| 14 | 16 | use FluentCart\Framework\Validator\Validator; |
| 15 | 17 | |
| 16 | 18 | class BulkProductUpdateService |
| 17 | 19 | { |
| @@ -16,9 +18,11 @@ | ||
| 16 | 18 | class BulkProductUpdateService |
| 17 | 19 | { |
| 18 | 20 | /** |
| 19 | 21 | * Fetch products formatted for bulk editing. |
| 20 | - * Returns products with decimal prices and category terms. | |
| 22 | + * Returns money in CENTS and category terms. It said "decimal prices" back | |
| 23 | + * when a temporary adapter divided here for a dollars-based grid; the grid | |
| 24 | + * renders cents through PriceInput now, so nothing is scaled on the way out. | |
| 21 | 25 | */ |
| 22 | 26 | public function fetchForBulkEdit(Request $request): array |
| 23 | 27 | { |
| 24 | 28 | $products = ProductFilter::fromRequest($request)->paginate(); |
| @@ -36,9 +40,9 @@ | ||
| 36 | 40 | } |
| 37 | 41 | |
| 38 | 42 | /** |
| 39 | 43 | * Format a single product for the bulk edit spreadsheet. |
| 40 | - * Converts prices from cents to decimal and attaches categories. | |
| 44 | + * Money stays in cents; PriceInput renders it as dollars in the grid. | |
| 41 | 45 | */ |
| 42 | 46 | protected function formatProductForEdit(Product $product): array |
| 43 | 47 | { |
| 44 | 48 | $product->load([ |
| @@ -70,9 +74,9 @@ | ||
| 70 | 74 | 'manage_stock' => (int) $product->detail->manage_stock, |
| 71 | 75 | ]; |
| 72 | 76 | } |
| 73 | 77 | |
| 74 | - // Variants — convert prices from cents to decimal | |
| 78 | + // Variants — money stays in cents | |
| 75 | 79 | $data['variants'] = []; |
| 76 | 80 | if ($product->variants) { |
| 77 | 81 | foreach ($product->variants as $variant) { |
| 78 | 82 | $variantMedia = []; |
| @@ -84,10 +88,12 @@ | ||
| 84 | 88 | 'id' => $variant->id, |
| 85 | 89 | 'post_id' => $variant->post_id, |
| 86 | 90 | 'variation_title' => $variant->variation_title, |
| 87 | 91 | 'sku' => $variant->sku, |
| 88 | - 'item_price' => $variant->item_price / 100, | |
| 89 | - 'compare_price' => $variant->compare_price / 100, | |
| 92 | + // Cents, as stored and as the write endpoints now expect. | |
| 93 | + // PriceInput renders these as dollars for the merchant. | |
| 94 | + 'item_price' => (int) $variant->item_price, | |
| 95 | + 'compare_price' => (int) $variant->compare_price, | |
| 90 | 96 | 'payment_type' => $variant->payment_type, |
| 91 | 97 | 'manage_stock' => (int) $variant->manage_stock, |
| 92 | 98 | 'total_stock' => (int) $variant->total_stock, |
| 93 | 99 | 'available' => (int) $variant->available, |
| @@ -201,13 +207,53 @@ | ||
| 201 | 207 | |
| 202 | 208 | return Helper::installmentTimesError(Arr::get($allData, "variants.$index.other_info")); |
| 203 | 209 | }, |
| 204 | 210 | ], |
| 205 | - 'variants.*.other_info.repeat_interval' => 'nullable|required_if:variants.*.other_info.payment_type,subscription|sanitizeText|in:yearly,half_yearly,quarterly,monthly,weekly,daily', | |
| 211 | + // Conditional requirements here are closures, not `required_if`, and | |
| 212 | + // the attributes carrying one have no `nullable`: filterExcludeables() | |
| 213 | + // drops EVERY rule — closures included — when `nullable` meets a falsy | |
| 214 | + // value, which is what left these requirements dead. WhenFilledRule | |
| 215 | + // therefore carries the value checks that `nullable` used to guard. | |
| 216 | + // | |
| 217 | + // manage_setup_fee keeps `nullable` on purpose: it is an optional flag | |
| 218 | + // that both services already default to 'no', so demanding it would | |
| 219 | + // reject payloads that simply omit it. | |
| 220 | + 'variants.*.other_info.repeat_interval' => [ | |
| 221 | + RequiredWhenRule::make( | |
| 222 | + 'variants.*.other_info.payment_type', | |
| 223 | + 'subscription', | |
| 224 | + __('Interval is required for subscriptions.', 'fluent-cart') | |
| 225 | + ), | |
| 226 | + WhenFilledRule::in( | |
| 227 | + ['yearly', 'half_yearly', 'quarterly', 'monthly', 'weekly', 'daily'], | |
| 228 | + __('Interval must be a valid frequency.', 'fluent-cart') | |
| 229 | + ), | |
| 230 | + ], | |
| 206 | 231 | 'variants.*.other_info.trial_days' => 'nullable|numeric|min:0|max:365', |
| 207 | - 'variants.*.other_info.manage_setup_fee' => 'nullable|required_if:variants.*.other_info.payment_type,subscription|sanitizeText|in:no,yes', | |
| 208 | - 'variants.*.other_info.signup_fee' => 'nullable|required_if:variants.*.other_info.manage_setup_fee,yes|numeric|min:0', | |
| 209 | - 'variants.*.other_info.signup_fee_name' => 'nullable|required_if:variants.*.other_info.manage_setup_fee,yes|sanitizeText|maxLength:100', | |
| 232 | + 'variants.*.other_info.manage_setup_fee' => 'nullable|sanitizeText|in:no,yes', | |
| 233 | + 'variants.*.other_info.signup_fee' => [ | |
| 234 | + RequiredWhenRule::make( | |
| 235 | + 'variants.*.other_info.manage_setup_fee', | |
| 236 | + 'yes', | |
| 237 | + __('Setup Fee Amount is required.', 'fluent-cart') | |
| 238 | + ), | |
| 239 | + WhenFilledRule::numericAtLeast( | |
| 240 | + 0, | |
| 241 | + __('Setup Fee must be a number.', 'fluent-cart'), | |
| 242 | + __('Setup Fee must be 0 or more.', 'fluent-cart') | |
| 243 | + ), | |
| 244 | + ], | |
| 245 | + 'variants.*.other_info.signup_fee_name' => [ | |
| 246 | + RequiredWhenRule::make( | |
| 247 | + 'variants.*.other_info.manage_setup_fee', | |
| 248 | + 'yes', | |
| 249 | + __('Setup Fee Name is required.', 'fluent-cart') | |
| 250 | + ), | |
| 251 | + WhenFilledRule::text( | |
| 252 | + 100, | |
| 253 | + __('Setup Fee Name must be plain text of 100 characters or fewer.', 'fluent-cart') | |
| 254 | + ), | |
| 255 | + ], | |
| 210 | 256 | ]; |
| 211 | 257 | |
| 212 | 258 | $messages = [ |
| 213 | 259 | 'post_title.required' => __('Title is required.', 'fluent-cart'), |
| @@ -220,17 +266,12 @@ | ||
| 220 | 266 | 'variants.*.item_price.numeric' => __('Price must be a number.', 'fluent-cart'), |
| 221 | 267 | 'variants.*.item_price.min' => __('Price must be a positive number.', 'fluent-cart'), |
| 222 | 268 | 'variants.*.other_info.payment_type.required' => __('Payment Type is required.', 'fluent-cart'), |
| 223 | 269 | 'variants.*.other_info.payment_type.in' => __('Payment Type must be onetime or subscription.', 'fluent-cart'), |
| 224 | - 'variants.*.other_info.repeat_interval.required_if' => __('Interval is required for subscriptions.', 'fluent-cart'), | |
| 225 | - 'variants.*.other_info.repeat_interval.in' => __('Interval must be a valid frequency.', 'fluent-cart'), | |
| 226 | 270 | 'variants.*.other_info.trial_days.numeric' => __('Trial days must be a number.', 'fluent-cart'), |
| 227 | 271 | 'variants.*.other_info.trial_days.min' => __('Trial days must be 0 or more.', 'fluent-cart'), |
| 228 | 272 | 'variants.*.other_info.trial_days.max' => __('Trial days may not be greater than 365.', 'fluent-cart'), |
| 229 | 273 | 'variants.*.other_info.manage_setup_fee.in' => __('Setup fee option must be yes or no.', 'fluent-cart'), |
| 230 | - 'variants.*.other_info.signup_fee.required_if' => __('Setup Fee Amount is required.', 'fluent-cart'), | |
| 231 | - 'variants.*.other_info.signup_fee.numeric' => __('Setup Fee must be a number.', 'fluent-cart'), | |
| 232 | - 'variants.*.other_info.signup_fee_name.required_if' => __('Setup Fee Name is required.', 'fluent-cart'), | |
| 233 | 274 | ]; |
| 234 | 275 | |
| 235 | 276 | $validator = Validator::make($data, $rules, $messages); |
| 236 | 277 | |
| @@ -343,9 +384,9 @@ | ||
| 343 | 384 | if (!$product) { |
| 344 | 385 | throw new \RuntimeException(__('Product not found', 'fluent-cart')); |
| 345 | 386 | } |
| 346 | 387 | |
| 347 | - // Use ProductResource::update for variants/detail (handles price * 100) | |
| 388 | + // Use ProductResource::update for variants/detail (amounts are cents) | |
| 348 | 389 | $updatePayload = []; |
| 349 | 390 | |
| 350 | 391 | // Detail |
| 351 | 392 | if (Arr::has($productData, 'detail')) { |
| @@ -395,9 +436,9 @@ | ||
| 395 | 436 | if (Arr::get($updatePayload, 'post_status') === 'published') { |
| 396 | 437 | $updatePayload['post_status'] = 'publish'; |
| 397 | 438 | } |
| 398 | 439 | |
| 399 | - // Use ProductResource::update which handles price conversion and variant updates | |
| 440 | + // Use ProductResource::update which handles the variant and detail writes | |
| 400 | 441 | if (!empty($updatePayload['variants']) || !empty($updatePayload['detail'])) { |
| 401 | 442 | ProductResource::update($updatePayload, $postId); |
| 402 | 443 | } |
| 403 | 444 | |
| @@ -483,12 +524,13 @@ | ||
| 483 | 524 | * Create a new variant for an existing product (used when duplicating a variant in bulk edit). |
| 484 | 525 | */ |
| 485 | 526 | protected function createVariantForProduct(int $postId, Product $product, array $variantData): void |
| 486 | 527 | { |
| 528 | + // Amounts arrive in cents; normalize float artifacts without scaling. | |
| 487 | 529 | $priceColumns = ['item_price', 'compare_price', 'item_cost']; |
| 488 | 530 | foreach ($priceColumns as $column) { |
| 489 | 531 | if (Arr::has($variantData, $column)) { |
| 490 | - $variantData[$column] = floatval(Arr::get($variantData, $column, 0)) * 100; | |
| 532 | + $variantData[$column] = Helper::roundCent(Arr::get($variantData, $column, 0)); | |
| 491 | 533 | } |
| 492 | 534 | } |
| 493 | 535 | |
| 494 | 536 | $otherInfo = Arr::get($variantData, 'other_info', []); |
| @@ -560,14 +602,18 @@ | ||
| 560 | 602 | wp_set_post_terms($postId, $termIds, 'product-categories'); |
| 561 | 603 | } |
| 562 | 604 | |
| 563 | 605 | /** |
| 564 | - * Convert cents-based fields in other_info to dollars for frontend display. | |
| 606 | + * Normalize the money fields in other_info for the bulk edit grid. | |
| 607 | + * | |
| 608 | + * Despite what this used to say, nothing is converted to dollars: signup_fee | |
| 609 | + * stays in CENTS and is only int-cast, because PriceInput does the rendering. | |
| 610 | + * Reintroducing a division here would halve-by-100 every setup fee in the grid. | |
| 565 | 611 | */ |
| 566 | 612 | protected function formatOtherInfoForEdit(array $otherInfo): array |
| 567 | 613 | { |
| 568 | 614 | if (!empty($otherInfo['signup_fee']) && is_numeric($otherInfo['signup_fee'])) { |
| 569 | - $otherInfo['signup_fee'] = (float) $otherInfo['signup_fee'] / 100; | |
| 615 | + $otherInfo['signup_fee'] = (int) $otherInfo['signup_fee']; | |
| 570 | 616 | } |
| 571 | 617 | |
| 572 | 618 | return $otherInfo; |
| 573 | 619 | } |