PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 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 All 50 releases
← All changes | app/Http/Requests/ProductUpdateRequest.php +153 -25 1.3.27 → 1.7.0 View file →
@@ -1,10 +1,14 @@
1 1 <?php
2 2
3 3 namespace FluentCart\App\Http\Requests;
4 4
5 +use FluentCart\App\Helpers\Helper;
6 +use FluentCart\App\Models\ProductVariation;
5 7 use FluentCart\App\Models\ShippingClass;
8 +use FluentCart\App\Modules\FluentPlayer\ProductVideoSettings;
6 9 use FluentCart\App\Services\DateTime\DateTime;
10 +use FluentCart\App\Http\Rules\RequiredWhenRule;
7 11 use FluentCart\Framework\Foundation\RequestGuard;
8 12 use FluentCart\Framework\Support\Arr;
9 13
10 14 class ProductUpdateRequest extends RequestGuard
@@ -9,22 +13,22 @@
9 13
10 14 class ProductUpdateRequest extends RequestGuard
11 15 {
12 16 /**
13 - * Prepare and normalize the incoming data before validation.
17 + * Drop the subscription fields from any variant saved as one-time, so a
18 + * product switched back to a single payment does not keep billing settings
19 + * the merchant can no longer see.
14 20 *
15 - * This method ensures that each variant in the request payload has the necessary structure
16 - * expected for processing, particularly focusing on the `other_info` attribute.
21 + * This does NOT fill missing keys. The block below it — which rebuilt
22 + * `other_info` and `fulfillment_type` from defaults, as ProductRequest still
23 + * does on create — stays commented out deliberately: on an update the payload
24 + * is a partial row, so rebuilding the column from defaults would overwrite a
25 + * stored subscription's interval and setup fee on an unrelated edit. Callers
26 + * therefore have to send a complete variant row; the editor builds one in
27 + * Models/Product/productUpdatePayload.js by merging each change record with
28 + * the variation it came from.
17 29 *
18 - * If `other_info` is missing from a variant (common during data migration scenarios),
19 - * this method sets default values to prevent validation or processing errors.
20 - *
21 - * It also ensures that:
22 - * - `fulfillment_type` is consistently applied across all variants, defaulting to 'physical'.
23 - * - `payment_type` is set (defaulting to 'onetime') and injected into `other_info`.
24 - * - `other_info` is populated with a consistent structure containing default billing and setup fee options.
25 - *
26 - * @return array The normalized request data ready for validation.
30 + * @return array The request data, with dead subscription fields removed.
27 31 */
28 32 public function beforeValidation()
29 33 {
30 34 $data = $this->all();
@@ -133,8 +137,30 @@
133 137 return null;
134 138
135 139 }
136 140
141 + function validateTaxClassSlug($attribute, $value): ?string
142 + {
143 + static $checked = [];
144 +
145 + if (empty($value)) {
146 + return null;
147 + }
148 +
149 + $value = sanitize_text_field($value);
150 +
151 + if (isset($checked[$value])) {
152 + return $checked[$value];
153 + }
154 +
155 + if (empty(\FluentCart\App\Models\TaxClass::query()->where('slug', $value)->first())) {
156 + $checked[$value] = __("Invalid Tax Class.", 'fluent-cart');
157 + return $checked[$value];
158 + }
159 +
160 + return null;
161 + }
162 +
137 163 public function validatePostDate($attribute, $value): ?string
138 164 {
139 165 if ($this->get('post_status') !== 'future') {
140 166 return null;
@@ -161,10 +187,37 @@
161 187 * @return array
162 188 */
163 189 public function rules(): array
164 190 {
191 + $data = $this->all();
192 + $hasDetail = isset($data['detail']) && is_array($data['detail']);
193 + $hasVariants = isset($data['variants']) && is_array($data['variants']);
165 194
166 - $variationType = Arr::get($this->all(), 'detail.variation_type', 'simple');
195 + if (!$hasDetail && !$hasVariants) {
196 + $rules = [];
197 + if (isset($data['post_title'])) {
198 + $rules['post_title'] = 'sanitizeText|maxLength:200';
199 + }
200 + if (isset($data['post_excerpt'])) {
201 + $rules['post_excerpt'] = ['nullable', 'string'];
202 + }
203 + if (isset($data['post_status'])) {
204 + $rules['post_status'] = ['string', function ($attribute, $value) {
205 + if (!in_array($value, ['publish', 'draft', 'future', 'private'], true)) {
206 + return __('Invalid post status provided.', 'fluent-cart');
207 + }
208 + return null;
209 + }];
210 + }
211 + if (isset($data['post_status']) && $data['post_status'] === 'future') {
212 + $rules['post_date'] = function ($attribute, $value) {
213 + return $this->validatePostDate($attribute, $value);
214 + };
215 + }
216 + return $rules;
217 + }
218 +
219 + $variationType = Arr::get($data, 'detail.variation_type', 'simple');
167 220 $rules = [
168 221 'post_title' => 'required|sanitizeText|maxLength:200',
169 222 'post_excerpt' => [
170 223 'nullable',
@@ -203,9 +256,12 @@
203 256 }],
204 257 'detail.other_info.tax_class' => ['nullable', function ($attribute, $value) {
205 258 return $this->validateTaxClassId($attribute, $value);
206 259 }],
260 + 'detail.other_info.tax_exempt' => 'nullable|sanitizeText|in:yes,no',
207 261 'detail.other_info.active_editor' => 'nullable|sanitizeText',
262 + 'detail.other_info.reviews_enabled' => 'nullable|sanitizeText|in:yes,no',
263 + 'detail.other_info.fluent_player_video' => 'nullable|array',
208 264 'product_terms' => 'nullable|array',
209 265 'product_terms.*' => 'nullable|array',
210 266 'product_terms.*.*' => 'nullable|numeric',
211 267
@@ -222,20 +278,44 @@
222 278 'variants.*.compare_price' => [
223 279 'nullable',
224 280 'numeric',
225 281 function ($attribute, $value) {
282 + // Zero or empty means no compare-at price, which the storefront
283 + // also treats as "none" — nothing to compare.
284 + if ($value === null || $value === '' || (float) $value <= 0) {
285 + return null;
286 + }
287 +
226 288 $index = explode('.', $attribute)[1];
227 289 $itemPrice = $this->get("variants.$index.item_price");
228 - if (empty($itemPrice)) {
229 - $itemPrice = 0;
290 +
291 + // The editor posts only what changed, so a compare-price-only edit
292 + // carries no item_price. Falling back to 0 made the comparison
293 + // vacuous and the rule passed for any value; read the stored price
294 + // for that variation instead, which is what the row is really
295 + // being compared against.
296 + if ($itemPrice === null || $itemPrice === '') {
297 + $variantId = $this->get("variants.$index.id");
298 +
299 + $itemPrice = $variantId
300 + ? ProductVariation::query()->where('id', $variantId)->value('item_price')
301 + : 0;
230 302 }
231 - if ($value !== null && $value < $itemPrice) {
303 +
304 + if ((float) $value < (float) $itemPrice) {
232 305 return sprintf(__("Compare price must be greater than or equal to item price.", 'fluent-cart'));
233 306 }
307 +
234 308 return null;
235 309 },
236 310 ],
237 311 'variants.*.manage_cost' => 'nullable|sanitizeText|maxLength:10',
312 + // Variant tax classes are stored as slugs, not numeric IDs like the
313 + // older product-detail tax field.
314 + 'variants.*.other_info.tax_class' => ['nullable', function ($attribute, $value) {
315 + return $this->validateTaxClassSlug($attribute, $value);
316 + }],
317 + 'variants.*.other_info.tax_exempt' => 'nullable|sanitizeText|in:yes,no',
238 318 // 'variants.*.shipping_class' => ['nullable', 'numeric', function ($attribute, $value) {
239 319 // return $this->validateShippingClassId($attribute, $value);
240 320 // }],
241 321 // 'variants.*.item_cost' => 'required_if:variants.*.manage_cost,true',
@@ -240,8 +320,17 @@
240 320 // }],
241 321 // 'variants.*.item_cost' => 'required_if:variants.*.manage_cost,true',
242 322 'variants.*.serial_index' => 'nullable|numeric',
243 323 // 'variants.*.downloadable' => 'nullable|sanitizeText|maxLength:10',
324 + // Outside the variation_type gate below: the other_info rules there only
325 + // register for 'simple', but a simple_variations save posts variants too.
326 + 'variants.*.other_info.times' => [
327 + function ($attribute, $value) {
328 + $index = explode('.', $attribute)[1];
329 +
330 + return Helper::installmentTimesError($this->get("variants.$index.other_info"));
331 + },
332 + ],
244 333 ];
245 334
246 335 if ($variationType === 'simple') {
247 336 $variantsOtherInfoRules = [
@@ -257,9 +346,9 @@
257 346 }
258 347 if (!empty($value) && !is_numeric($value)) {
259 348 return __('Times must be a number.', 'fluent-cart');
260 349 }
261 - return null;
350 + return Helper::installmentTimesError($this->get("variants.$index.other_info"));
262 351 },
263 352 ],
264 353 'variants.*.other_info.trial_days' => [
265 354 function ($attribute, $value) {
@@ -275,13 +364,43 @@
275 364 }
276 365 return null;
277 366 },
278 367 ],
279 - 'variants.*.other_info.repeat_interval' => 'required_if:variants.*.other_info.payment_type,subscription|sanitizeText|maxLength:100',
368 + 'variants.*.other_info.repeat_interval' => [
369 + RequiredWhenRule::make(
370 + 'variants.*.other_info.payment_type',
371 + 'subscription',
372 + esc_html__('Interval is required.', 'fluent-cart')
373 + ),
374 + 'sanitizeText',
375 + 'maxLength:100',
376 + ],
280 377 'variants.*.other_info.billing_summary' => 'nullable|sanitizeTextArea|maxLength:255',
281 - 'variants.*.other_info.manage_setup_fee' => 'required_if:variants.*.other_info.payment_type,subscription|sanitizeText|maxLength:100',
282 - 'variants.*.other_info.signup_fee' => 'required_if:variants.*.other_info.manage_setup_fee,yes',
283 - 'variants.*.other_info.signup_fee_name' => 'required_if:variants.*.other_info.manage_setup_fee,yes|sanitizeText|maxLength:100',
378 + 'variants.*.other_info.manage_setup_fee' => [
379 + RequiredWhenRule::make(
380 + 'variants.*.other_info.payment_type',
381 + 'subscription',
382 + esc_html__('Setup Fee option is required.', 'fluent-cart')
383 + ),
384 + 'sanitizeText',
385 + 'maxLength:100',
386 + ],
387 + 'variants.*.other_info.signup_fee' => [
388 + RequiredWhenRule::make(
389 + 'variants.*.other_info.manage_setup_fee',
390 + 'yes',
391 + esc_html__('Setup Fee Amount is required.', 'fluent-cart')
392 + ),
393 + ],
394 + 'variants.*.other_info.signup_fee_name' => [
395 + RequiredWhenRule::make(
396 + 'variants.*.other_info.manage_setup_fee',
397 + 'yes',
398 + esc_html__('Setup Fee Name is required.', 'fluent-cart')
399 + ),
400 + 'sanitizeText',
401 + 'maxLength:100',
402 + ],
284 403 ];
285 404 $rules = array_merge($rules, $variantsOtherInfoRules);
286 405
287 406 }
@@ -340,11 +459,8 @@
340 459 'variants.*.item_cost.required_if' => esc_html__('Item cost is required.', 'fluent-cart'),
341 460 'variants.*.other_info.description.max' => esc_html__('Description may not be greater than 255 characters.', 'fluent-cart'),
342 461 'variants.*.other_info.payment_type.required' => esc_html__('Payment Type is required.', 'fluent-cart'),
343 462 'variants.*.other_info.times.required_if' => esc_html__('Times is required.', 'fluent-cart'),
344 - 'variants.*.other_info.repeat_interval.required_if' => esc_html__('Interval is required.', 'fluent-cart'),
345 - 'variants.*.other_info.signup_fee.required_if' => esc_html__('Setup Fee Amount is required.', 'fluent-cart'),
346 - 'variants.*.other_info.signup_fee_name.required_if' => esc_html__('Setup Fee Name is required.', 'fluent-cart'),
347 463 ];
348 464
349 465 $messages = array_merge($messages, $otherInfoMessages);
350 466 }
@@ -405,9 +521,14 @@
405 521 'detail.other_info.sold_individually' => 'sanitize_text_field',
406 522 'detail.other_info.use_pricing_table' => 'sanitize_text_field',
407 523 'detail.other_info.shipping_class' => 'intval',
408 524 'detail.other_info.tax_class' => 'intval',
525 + 'detail.other_info.tax_exempt' => 'sanitize_text_field',
409 526 'detail.other_info.active_editor' => 'sanitize_text_field',
527 + 'detail.other_info.reviews_enabled' => 'sanitize_text_field',
528 + 'detail.other_info.fluent_player_video' => function ($value) {
529 + return ProductVideoSettings::sanitize($value);
530 + },
410 531 ];
411 532
412 533 foreach ($detailFieldMap as $field => $sanitizer) {
413 534 if (Arr::has($data, $field)) {
@@ -455,9 +576,14 @@
455 576 "variants.$index.serial_index" => 'intval',
456 577 "variants.$index.downloadable" => 'sanitize_text_field',
457 578 "variants.$index.fulfillment_type" => 'sanitize_text_field',
458 579 "variants.$index.sku" => function ($value) {
459 - return empty($value) ? null : sanitize_text_field($value);
580 + // empty() treats the string "0" as empty too, which would silently
581 + // convert a legitimate sku of "0" to NULL — sku is a textual
582 + // identifier (VARCHAR), not a numeric flag. Match the explicit
583 + // '' / null check ProductResource::update() already uses for the
584 + // same reason when it clears a sku.
585 + return ($value === '' || $value === null) ? null : sanitize_text_field($value);
460 586 },
461 587 ];
462 588
463 589 foreach ($variantFieldMap as $field => $sanitizer) {
@@ -491,8 +617,10 @@
491 617 "variants.$index.other_info.manage_setup_fee" => 'sanitize_text_field',
492 618 "variants.$index.other_info.signup_fee" => 'floatval',
493 619 "variants.$index.other_info.signup_fee_name" => 'sanitize_text_field',
494 620 "variants.$index.other_info.installment" => 'sanitize_text_field',
621 + "variants.$index.other_info.tax_class" => 'sanitize_text_field',
622 + "variants.$index.other_info.tax_exempt" => 'sanitize_text_field',
495 623 ];
496 624
497 625 foreach ($otherInfoFieldMap as $field => $sanitizer) {
498 626 if (Arr::has($data, $field)) {