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 +188 -27 1.3.22 → 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,28 +13,36 @@
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 {
34 + $data = $this->all();
30 35
36 + $subscriptionFields = ['trial_days', 'times', 'repeat_interval', 'billing_summary', 'manage_setup_fee', 'signup_fee', 'signup_fee_name', 'setup_fee_per_item'];
37 + foreach (Arr::get($data, 'variants', []) as $index => $variant) {
38 + if (Arr::get($variant, 'other_info.payment_type') === 'onetime') {
39 + foreach ($subscriptionFields as $field) {
40 + unset($data['variants'][$index]['other_info'][$field]);
41 + }
42 + }
43 + }
31 44
32 - $data = $this->all();
33 45 return $data;
34 46 //
35 47 // $fulfilmentType = Arr::get(
36 48 // $data,
@@ -125,8 +137,30 @@
125 137 return null;
126 138
127 139 }
128 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 +
129 163 public function validatePostDate($attribute, $value): ?string
130 164 {
131 165 if ($this->get('post_status') !== 'future') {
132 166 return null;
@@ -153,10 +187,37 @@
153 187 * @return array
154 188 */
155 189 public function rules(): array
156 190 {
191 + $data = $this->all();
192 + $hasDetail = isset($data['detail']) && is_array($data['detail']);
193 + $hasVariants = isset($data['variants']) && is_array($data['variants']);
157 194
158 - $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');
159 220 $rules = [
160 221 'post_title' => 'required|sanitizeText|maxLength:200',
161 222 'post_excerpt' => [
162 223 'nullable',
@@ -195,9 +256,12 @@
195 256 }],
196 257 'detail.other_info.tax_class' => ['nullable', function ($attribute, $value) {
197 258 return $this->validateTaxClassId($attribute, $value);
198 259 }],
260 + 'detail.other_info.tax_exempt' => 'nullable|sanitizeText|in:yes,no',
199 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',
200 264 'product_terms' => 'nullable|array',
201 265 'product_terms.*' => 'nullable|array',
202 266 'product_terms.*.*' => 'nullable|numeric',
203 267
@@ -214,20 +278,44 @@
214 278 'variants.*.compare_price' => [
215 279 'nullable',
216 280 'numeric',
217 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 +
218 288 $index = explode('.', $attribute)[1];
219 289 $itemPrice = $this->get("variants.$index.item_price");
220 - if (empty($itemPrice)) {
221 - $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;
222 302 }
223 - if ($value !== null && $value < $itemPrice) {
303 +
304 + if ((float) $value < (float) $itemPrice) {
224 305 return sprintf(__("Compare price must be greater than or equal to item price.", 'fluent-cart'));
225 306 }
307 +
226 308 return null;
227 309 },
228 310 ],
229 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',
230 318 // 'variants.*.shipping_class' => ['nullable', 'numeric', function ($attribute, $value) {
231 319 // return $this->validateShippingClassId($attribute, $value);
232 320 // }],
233 321 // 'variants.*.item_cost' => 'required_if:variants.*.manage_cost,true',
@@ -232,8 +320,17 @@
232 320 // }],
233 321 // 'variants.*.item_cost' => 'required_if:variants.*.manage_cost,true',
234 322 'variants.*.serial_index' => 'nullable|numeric',
235 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 + ],
236 333 ];
237 334
238 335 if ($variationType === 'simple') {
239 336 $variantsOtherInfoRules = [
@@ -240,15 +337,70 @@
240 337
241 338 'variants.*.other_info' => 'required|array',
242 339 'variants.*.other_info.description' => 'nullable|sanitizeTextArea|maxLength:255',
243 340 'variants.*.other_info.payment_type' => 'required|sanitizeText|in:onetime,subscription',
244 - 'variants.*.other_info.times' => 'nullable|sanitizeText|maxLength:50',
245 - 'variants.*.other_info.trial_days' => 'nullable|sanitizeText|maxLength:365',
246 - 'variants.*.other_info.repeat_interval' => 'required_if:variants.*.other_info.payment_type,subscription|sanitizeText|maxLength:100',
341 + 'variants.*.other_info.times' => [
342 + function ($attribute, $value) {
343 + $index = explode('.', $attribute)[1];
344 + if ($this->get("variants.$index.other_info.payment_type") !== 'subscription') {
345 + return null;
346 + }
347 + if (!empty($value) && !is_numeric($value)) {
348 + return __('Times must be a number.', 'fluent-cart');
349 + }
350 + return Helper::installmentTimesError($this->get("variants.$index.other_info"));
351 + },
352 + ],
353 + 'variants.*.other_info.trial_days' => [
354 + function ($attribute, $value) {
355 + $index = explode('.', $attribute)[1];
356 + if ($this->get("variants.$index.other_info.payment_type") !== 'subscription') {
357 + return null;
358 + }
359 + if (!empty($value) && !is_numeric($value)) {
360 + return __('Trial days must be a number.', 'fluent-cart');
361 + }
362 + if (!empty($value) && $value > 365) {
363 + return __('Trial period cannot exceed 365 days.', 'fluent-cart');
364 + }
365 + return null;
366 + },
367 + ],
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 + ],
247 377 'variants.*.other_info.billing_summary' => 'nullable|sanitizeTextArea|maxLength:255',
248 - 'variants.*.other_info.manage_setup_fee' => 'required_if:variants.*.other_info.payment_type,subscription|sanitizeText|maxLength:100',
249 - 'variants.*.other_info.signup_fee' => 'required_if:variants.*.other_info.manage_setup_fee,yes',
250 - '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 + ],
251 403 ];
252 404 $rules = array_merge($rules, $variantsOtherInfoRules);
253 405
254 406 }
@@ -307,11 +459,8 @@
307 459 'variants.*.item_cost.required_if' => esc_html__('Item cost is required.', 'fluent-cart'),
308 460 'variants.*.other_info.description.max' => esc_html__('Description may not be greater than 255 characters.', 'fluent-cart'),
309 461 'variants.*.other_info.payment_type.required' => esc_html__('Payment Type is required.', 'fluent-cart'),
310 462 'variants.*.other_info.times.required_if' => esc_html__('Times is required.', 'fluent-cart'),
311 - 'variants.*.other_info.repeat_interval.required_if' => esc_html__('Interval is required.', 'fluent-cart'),
312 - 'variants.*.other_info.signup_fee.required_if' => esc_html__('Setup Fee Amount is required.', 'fluent-cart'),
313 - 'variants.*.other_info.signup_fee_name.required_if' => esc_html__('Setup Fee Name is required.', 'fluent-cart'),
314 463 ];
315 464
316 465 $messages = array_merge($messages, $otherInfoMessages);
317 466 }
@@ -372,9 +521,14 @@
372 521 'detail.other_info.sold_individually' => 'sanitize_text_field',
373 522 'detail.other_info.use_pricing_table' => 'sanitize_text_field',
374 523 'detail.other_info.shipping_class' => 'intval',
375 524 'detail.other_info.tax_class' => 'intval',
525 + 'detail.other_info.tax_exempt' => 'sanitize_text_field',
376 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 + },
377 531 ];
378 532
379 533 foreach ($detailFieldMap as $field => $sanitizer) {
380 534 if (Arr::has($data, $field)) {
@@ -422,9 +576,14 @@
422 576 "variants.$index.serial_index" => 'intval',
423 577 "variants.$index.downloadable" => 'sanitize_text_field',
424 578 "variants.$index.fulfillment_type" => 'sanitize_text_field',
425 579 "variants.$index.sku" => function ($value) {
426 - 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);
427 586 },
428 587 ];
429 588
430 589 foreach ($variantFieldMap as $field => $sanitizer) {
@@ -458,8 +617,10 @@
458 617 "variants.$index.other_info.manage_setup_fee" => 'sanitize_text_field',
459 618 "variants.$index.other_info.signup_fee" => 'floatval',
460 619 "variants.$index.other_info.signup_fee_name" => 'sanitize_text_field',
461 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',
462 623 ];
463 624
464 625 foreach ($otherInfoFieldMap as $field => $sanitizer) {
465 626 if (Arr::has($data, $field)) {