| @@ -3,8 +3,9 @@ | ||
| 3 | 3 | namespace FluentCart\App\Services; |
| 4 | 4 | |
| 5 | 5 | use FluentCart\Api\Resource\ProductResource; |
| 6 | 6 | use FluentCart\Api\Resource\ProductVariationResource; |
| 7 | +use FluentCart\App\Helpers\Helper; | |
| 7 | 8 | use FluentCart\App\Models\Product; |
| 8 | 9 | use FluentCart\App\Models\ProductDetail; |
| 9 | 10 | use FluentCart\App\Models\ProductVariation; |
| 10 | 11 | use FluentCart\App\Services\Filter\ProductFilter; |
| @@ -9,8 +10,10 @@ | ||
| 9 | 10 | use FluentCart\App\Models\ProductVariation; |
| 10 | 11 | use FluentCart\App\Services\Filter\ProductFilter; |
| 11 | 12 | use FluentCart\Framework\Http\Request\Request; |
| 12 | 13 | use FluentCart\Framework\Support\Arr; |
| 14 | +use FluentCart\App\Http\Rules\RequiredWhenRule; | |
| 15 | +use FluentCart\App\Http\Rules\WhenFilledRule; | |
| 13 | 16 | use FluentCart\Framework\Validator\Validator; |
| 14 | 17 | |
| 15 | 18 | class BulkProductUpdateService |
| 16 | 19 | { |
| @@ -15,9 +18,11 @@ | ||
| 15 | 18 | class BulkProductUpdateService |
| 16 | 19 | { |
| 17 | 20 | /** |
| 18 | 21 | * Fetch products formatted for bulk editing. |
| 19 | - * 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. | |
| 20 | 25 | */ |
| 21 | 26 | public function fetchForBulkEdit(Request $request): array |
| 22 | 27 | { |
| 23 | 28 | $products = ProductFilter::fromRequest($request)->paginate(); |
| @@ -35,9 +40,9 @@ | ||
| 35 | 40 | } |
| 36 | 41 | |
| 37 | 42 | /** |
| 38 | 43 | * Format a single product for the bulk edit spreadsheet. |
| 39 | - * Converts prices from cents to decimal and attaches categories. | |
| 44 | + * Money stays in cents; PriceInput renders it as dollars in the grid. | |
| 40 | 45 | */ |
| 41 | 46 | protected function formatProductForEdit(Product $product): array |
| 42 | 47 | { |
| 43 | 48 | $product->load([ |
| @@ -69,9 +74,9 @@ | ||
| 69 | 74 | 'manage_stock' => (int) $product->detail->manage_stock, |
| 70 | 75 | ]; |
| 71 | 76 | } |
| 72 | 77 | |
| 73 | - // Variants — convert prices from cents to decimal | |
| 78 | + // Variants — money stays in cents | |
| 74 | 79 | $data['variants'] = []; |
| 75 | 80 | if ($product->variants) { |
| 76 | 81 | foreach ($product->variants as $variant) { |
| 77 | 82 | $variantMedia = []; |
| @@ -83,10 +88,12 @@ | ||
| 83 | 88 | 'id' => $variant->id, |
| 84 | 89 | 'post_id' => $variant->post_id, |
| 85 | 90 | 'variation_title' => $variant->variation_title, |
| 86 | 91 | 'sku' => $variant->sku, |
| 87 | - 'item_price' => $variant->item_price / 100, | |
| 88 | - '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, | |
| 89 | 96 | 'payment_type' => $variant->payment_type, |
| 90 | 97 | 'manage_stock' => (int) $variant->manage_stock, |
| 91 | 98 | 'total_stock' => (int) $variant->total_stock, |
| 92 | 99 | 'available' => (int) $variant->available, |
| @@ -193,13 +200,60 @@ | ||
| 193 | 200 | }, |
| 194 | 201 | ], |
| 195 | 202 | 'variants.*.other_info' => 'required|array', |
| 196 | 203 | 'variants.*.other_info.payment_type' => 'required|sanitizeText|in:onetime,subscription', |
| 197 | - 'variants.*.other_info.repeat_interval' => 'nullable|required_if:variants.*.other_info.payment_type,subscription|sanitizeText|in:yearly,half_yearly,quarterly,monthly,weekly,daily', | |
| 204 | + 'variants.*.other_info.times' => [ | |
| 205 | + function ($attribute, $value, $rules, $allData) { | |
| 206 | + $index = explode('.', $attribute)[1]; | |
| 207 | + | |
| 208 | + return Helper::installmentTimesError(Arr::get($allData, "variants.$index.other_info")); | |
| 209 | + }, | |
| 210 | + ], | |
| 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 | + ], | |
| 198 | 231 | 'variants.*.other_info.trial_days' => 'nullable|numeric|min:0|max:365', |
| 199 | - 'variants.*.other_info.manage_setup_fee' => 'nullable|required_if:variants.*.other_info.payment_type,subscription|sanitizeText|in:no,yes', | |
| 200 | - 'variants.*.other_info.signup_fee' => 'nullable|required_if:variants.*.other_info.manage_setup_fee,yes|numeric|min:0', | |
| 201 | - '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 | + ], | |
| 202 | 256 | ]; |
| 203 | 257 | |
| 204 | 258 | $messages = [ |
| 205 | 259 | 'post_title.required' => __('Title is required.', 'fluent-cart'), |
| @@ -212,17 +266,12 @@ | ||
| 212 | 266 | 'variants.*.item_price.numeric' => __('Price must be a number.', 'fluent-cart'), |
| 213 | 267 | 'variants.*.item_price.min' => __('Price must be a positive number.', 'fluent-cart'), |
| 214 | 268 | 'variants.*.other_info.payment_type.required' => __('Payment Type is required.', 'fluent-cart'), |
| 215 | 269 | 'variants.*.other_info.payment_type.in' => __('Payment Type must be onetime or subscription.', 'fluent-cart'), |
| 216 | - 'variants.*.other_info.repeat_interval.required_if' => __('Interval is required for subscriptions.', 'fluent-cart'), | |
| 217 | - 'variants.*.other_info.repeat_interval.in' => __('Interval must be a valid frequency.', 'fluent-cart'), | |
| 218 | 270 | 'variants.*.other_info.trial_days.numeric' => __('Trial days must be a number.', 'fluent-cart'), |
| 219 | 271 | 'variants.*.other_info.trial_days.min' => __('Trial days must be 0 or more.', 'fluent-cart'), |
| 220 | 272 | 'variants.*.other_info.trial_days.max' => __('Trial days may not be greater than 365.', 'fluent-cart'), |
| 221 | 273 | 'variants.*.other_info.manage_setup_fee.in' => __('Setup fee option must be yes or no.', 'fluent-cart'), |
| 222 | - 'variants.*.other_info.signup_fee.required_if' => __('Setup Fee Amount is required.', 'fluent-cart'), | |
| 223 | - 'variants.*.other_info.signup_fee.numeric' => __('Setup Fee must be a number.', 'fluent-cart'), | |
| 224 | - 'variants.*.other_info.signup_fee_name.required_if' => __('Setup Fee Name is required.', 'fluent-cart'), | |
| 225 | 274 | ]; |
| 226 | 275 | |
| 227 | 276 | $validator = Validator::make($data, $rules, $messages); |
| 228 | 277 | |
| @@ -335,9 +384,9 @@ | ||
| 335 | 384 | if (!$product) { |
| 336 | 385 | throw new \RuntimeException(__('Product not found', 'fluent-cart')); |
| 337 | 386 | } |
| 338 | 387 | |
| 339 | - // Use ProductResource::update for variants/detail (handles price * 100) | |
| 388 | + // Use ProductResource::update for variants/detail (amounts are cents) | |
| 340 | 389 | $updatePayload = []; |
| 341 | 390 | |
| 342 | 391 | // Detail |
| 343 | 392 | if (Arr::has($productData, 'detail')) { |
| @@ -387,9 +436,9 @@ | ||
| 387 | 436 | if (Arr::get($updatePayload, 'post_status') === 'published') { |
| 388 | 437 | $updatePayload['post_status'] = 'publish'; |
| 389 | 438 | } |
| 390 | 439 | |
| 391 | - // Use ProductResource::update which handles price conversion and variant updates | |
| 440 | + // Use ProductResource::update which handles the variant and detail writes | |
| 392 | 441 | if (!empty($updatePayload['variants']) || !empty($updatePayload['detail'])) { |
| 393 | 442 | ProductResource::update($updatePayload, $postId); |
| 394 | 443 | } |
| 395 | 444 | |
| @@ -475,12 +524,13 @@ | ||
| 475 | 524 | * Create a new variant for an existing product (used when duplicating a variant in bulk edit). |
| 476 | 525 | */ |
| 477 | 526 | protected function createVariantForProduct(int $postId, Product $product, array $variantData): void |
| 478 | 527 | { |
| 528 | + // Amounts arrive in cents; normalize float artifacts without scaling. | |
| 479 | 529 | $priceColumns = ['item_price', 'compare_price', 'item_cost']; |
| 480 | 530 | foreach ($priceColumns as $column) { |
| 481 | 531 | if (Arr::has($variantData, $column)) { |
| 482 | - $variantData[$column] = floatval(Arr::get($variantData, $column, 0)) * 100; | |
| 532 | + $variantData[$column] = Helper::roundCent(Arr::get($variantData, $column, 0)); | |
| 483 | 533 | } |
| 484 | 534 | } |
| 485 | 535 | |
| 486 | 536 | $otherInfo = Arr::get($variantData, 'other_info', []); |
| @@ -552,14 +602,18 @@ | ||
| 552 | 602 | wp_set_post_terms($postId, $termIds, 'product-categories'); |
| 553 | 603 | } |
| 554 | 604 | |
| 555 | 605 | /** |
| 556 | - * 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. | |
| 557 | 611 | */ |
| 558 | 612 | protected function formatOtherInfoForEdit(array $otherInfo): array |
| 559 | 613 | { |
| 560 | 614 | if (!empty($otherInfo['signup_fee']) && is_numeric($otherInfo['signup_fee'])) { |
| 561 | - $otherInfo['signup_fee'] = (float) $otherInfo['signup_fee'] / 100; | |
| 615 | + $otherInfo['signup_fee'] = (int) $otherInfo['signup_fee']; | |
| 562 | 616 | } |
| 563 | 617 | |
| 564 | 618 | return $otherInfo; |
| 565 | 619 | } |