PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 1.3.19 trunk All 48 releases
← All changes | app/Http/Controllers/ProductVariationController.php +123 -23 1.5.0 → 1.6.5 View file →
@@ -16,11 +16,12 @@
16 16 class ProductVariationController extends Controller
17 17 {
18 18 public function index(Request $request): array
19 19 {
20 - //
21 -
22 - $parameters = $request->get('params');
20 + // 'params' is optional in the query string; default to an empty array so
21 + // ProductVariationResource::get() (which type-hints array) never receives
22 + // null when the endpoint is called without params.
23 + $parameters = $request->get('params') ?: [];
23 24 $variants = ProductVariationResource::get($parameters);
24 25
25 26 return [
26 27 'variants' => $variants['variants'],
@@ -288,24 +289,26 @@
288 289
289 290 $row = ['id' => $id];
290 291
291 292 if (array_key_exists('item_price', $update)) {
292 - $itemPriceDollar = floatval($update['item_price']);
293 + // Submitted in CENTS. roundCent() normalizes float artifacts
294 + // without scaling; it does not multiply by 100.
295 + $itemPriceCentsIn = floatval($update['item_price']);
293 296 // Reject negative prices outright rather than coerce to 0 —
294 297 // a caller submitting -50 has either bad client logic or
295 298 // hostile intent; either way we should not silently
296 299 // substitute a price they didn't choose.
297 - if ($itemPriceDollar >= 0) {
298 - $row['item_price'] = Helper::toCent($itemPriceDollar);
300 + if ($itemPriceCentsIn >= 0) {
301 + $row['item_price'] = Helper::roundCent($itemPriceCentsIn);
299 302 }
300 303 }
301 304
302 305 if (array_key_exists('compare_price', $update)) {
303 - $comparePriceDollar = floatval($update['compare_price']);
306 + $comparePriceCentsIn = floatval($update['compare_price']);
304 307 // Mirror of the item_price negative guard. compare_price=0
305 308 // is a valid "no discount" sentinel; negative is not.
306 - if ($comparePriceDollar >= 0) {
307 - $comparePriceCents = Helper::toCent($comparePriceDollar);
309 + if ($comparePriceCentsIn >= 0) {
310 + $comparePriceCents = Helper::roundCent($comparePriceCentsIn);
308 311 // Effective item_price (in cents) for the comparison:
309 312 // the new value if this update sets it (and is valid),
310 313 // otherwise the already-persisted value from the DB.
311 314 // Falling back to 0 would re-introduce the bypass
@@ -416,9 +419,9 @@
416 419 $itemPrice = Arr::get($raw, 'item_price');
417 420 if ($itemPrice !== null && $itemPrice !== '') {
418 421 $price = floatval($itemPrice);
419 422 if ($price >= 0) {
420 - $topLevelDelta['item_price'] = Helper::toCent($price);
423 + $topLevelDelta['item_price'] = Helper::roundCent($price);
421 424 }
422 425 }
423 426
424 427 $comparePrice = Arr::get($raw, 'compare_price');
@@ -424,16 +427,17 @@
424 427 $comparePrice = Arr::get($raw, 'compare_price');
425 428 if ($comparePrice !== null && $comparePrice !== '') {
426 429 $compare = floatval($comparePrice);
427 430 if ($compare >= 0) {
428 - $topLevelDelta['_compare_price_dollars'] = $compare;
431 + $topLevelDelta['_compare_price_cents'] = $compare;
429 432 }
430 433 }
431 434
432 435 // SKU uniqueness — only apply to a single variant to avoid duplicates.
433 - $sku = Arr::get($raw, 'sku');
434 - if ($sku !== null && $sku !== '' && count($variantIds) === 1) {
435 - $topLevelDelta['sku'] = sanitize_text_field($sku);
436 + // An empty string means "clear the SKU" (stored as NULL; MySQL NULL is unique-safe).
437 + // Read from $data (post-validation, post-sanitization) not $raw.
438 + if (count($variantIds) === 1 && array_key_exists('sku', $data)) {
439 + $topLevelDelta['sku'] = Arr::get($data, 'sku');
436 440 }
437 441
438 442 $manageStock = Arr::get($raw, 'manage_stock');
439 443 if ($manageStock !== null) {
@@ -464,9 +468,9 @@
464 468 $itemCost = Arr::get($raw, 'item_cost');
465 469 if ($itemCost !== null && $itemCost !== '') {
466 470 $cost = floatval($itemCost);
467 471 if ($cost >= 0) {
468 - $topLevelDelta['item_cost'] = Helper::toCent($cost);
472 + $topLevelDelta['item_cost'] = Helper::roundCent($cost);
469 473 }
470 474 }
471 475
472 476 $rawOtherInfo = Arr::get($raw, 'other_info');
@@ -477,8 +481,19 @@
477 481 if (empty($topLevelDelta) && ($otherInfoDelta === null || empty($otherInfoDelta))) {
478 482 return $this->sendError(['message' => __('No valid updates provided.', 'fluent-cart')], 422);
479 483 }
480 484
485 + // Setting variants to subscription requires a billing interval in the
486 + // same request — a subscription without one can never bill. Checked
487 + // before the transaction so bad input fails fast with no rollback.
488 + // (An invalid interval was already dropped by sanitizeOtherInfoDelta.)
489 + if (is_array($otherInfoDelta)
490 + && Arr::get($otherInfoDelta, 'payment_type') === 'subscription'
491 + && empty($otherInfoDelta['repeat_interval'])
492 + ) {
493 + return $this->sendError(['message' => __('A valid billing interval is required for subscription variants.', 'fluent-cart')], 422);
494 + }
495 +
481 496 $db = ProductVariation::query()->getConnection();
482 497 $now = gmdate('Y-m-d H:i:s');
483 498 $updatedProductId = 0;
484 499 $batchData = [];
@@ -501,8 +516,13 @@
501 516 return $this->sendError(['message' => __('All variants must belong to the same product.', 'fluent-cart')], 422);
502 517 }
503 518 $updatedProductId = (int) $distinctPostIds->first();
504 519
520 + // Prepare pass: build and validate every row update BEFORE writing
521 + // anything, so a validation failure returns early with no UPDATE
522 + // executed (the rollbacks below only release the row locks).
523 + $preparedUpdates = [];
524 +
505 525 foreach ($ownedRows as $existingVariant) {
506 526 $vid = (int) $existingVariant->id;
507 527 $rowUpdate = [];
508 528
@@ -509,10 +529,10 @@
509 529 if (isset($topLevelDelta['item_price'])) {
510 530 $rowUpdate['item_price'] = $topLevelDelta['item_price'];
511 531 }
512 532
513 - if (isset($topLevelDelta['_compare_price_dollars'])) {
514 - $compareCents = Helper::toCent($topLevelDelta['_compare_price_dollars']);
533 + if (isset($topLevelDelta['_compare_price_cents'])) {
534 + $compareCents = Helper::roundCent($topLevelDelta['_compare_price_cents']);
515 535 $itemPriceCents = isset($rowUpdate['item_price'])
516 536 ? (int) $rowUpdate['item_price']
517 537 : (int) $existingVariant->item_price;
518 538 $rowUpdate['compare_price'] = ($compareCents > 0 && $compareCents >= $itemPriceCents)
@@ -553,12 +573,37 @@
553 573 'manage_setup_fee', 'signup_fee', 'signup_fee_name', 'times', 'trial_days'] as $subKey) {
554 574 unset($merged[$subKey]);
555 575 }
556 576 }
577 +
557 578 if ($paymentType === 'subscription' && array_key_exists('signup_fee', $otherInfoDelta)) {
558 - $merged['signup_fee'] = Helper::toCent(floatval($otherInfoDelta['signup_fee']));
579 + $merged['signup_fee'] = Helper::roundCent($otherInfoDelta['signup_fee']);
559 580 }
560 581
582 + // `installment` is not an accepted delta key (see sanitizeOtherInfoDelta),
583 + // so the stored flag on the row decides whether this is an installment
584 + // plan. Re-check only when the request changes `times`, so an unrelated
585 + // bulk price edit on a legacy row still saves. The payment_type gate
586 + // matters: `times` is stripped from $merged for a one-time variant
587 + // above, while a stale `installment` may survive in its stored JSON.
588 + if ($paymentType === 'subscription' && array_key_exists('times', $otherInfoDelta)) {
589 + $timesError = Helper::installmentTimesError($merged);
590 + if ($timesError) {
591 + $db->rollBack();
592 + return $this->sendError(['message' => $timesError], 422);
593 + }
594 + }
595 +
596 + // billing_summary embeds the row's own price, so one client-sent
597 + // value can never fit a group of variants with different prices —
598 + // recompute per row from the effective price/interval/times.
599 + if ($paymentType === 'subscription') {
600 + $effectivePriceCents = isset($rowUpdate['item_price'])
601 + ? (int) $rowUpdate['item_price']
602 + : (int) $existingVariant->item_price;
603 + $merged['billing_summary'] = $this->buildBillingSummary($effectivePriceCents, $merged);
604 + }
605 +
561 606 $merged['is_bundle_product'] = Arr::get($existingOtherInfo, 'is_bundle_product', 'no');
562 607 $merged['bundle_child_ids'] = Arr::get($existingOtherInfo, 'bundle_child_ids', []);
563 608
564 609 $rowUpdate['other_info'] = $merged;
@@ -567,17 +612,37 @@
567 612 $rowUpdate['payment_type'] = $otherInfoDelta['payment_type'] === 'subscription'
568 613 ? 'subscription'
569 614 : 'onetime';
570 615 }
616 + } elseif (isset($rowUpdate['item_price']) && $existingVariant->payment_type === 'subscription') {
617 + // Price-only bulk edit on a subscription row: the stored
618 + // summary embeds the old price — refresh it from the new one.
619 + // Write back the raw stored JSON, not the accessor output:
620 + // getOtherInfoAttribute() injects virtual defaults (and
621 + // downgrades installment to 'no' while Pro is inactive) that
622 + // an unrelated price edit must not persist.
623 + $rawOtherInfoJson = Arr::get($existingVariant->getAttributes(), 'other_info');
624 + $rawOtherInfo = (is_string($rawOtherInfoJson) && $rawOtherInfoJson !== '')
625 + ? json_decode($rawOtherInfoJson, true)
626 + : [];
627 + $rawOtherInfo = is_array($rawOtherInfo) ? $rawOtherInfo : [];
628 + $accessorOtherInfo = is_array($existingVariant->other_info) ? $existingVariant->other_info : [];
629 + $rawOtherInfo['billing_summary'] = $this->buildBillingSummary((int) $rowUpdate['item_price'], $accessorOtherInfo);
630 + $rowUpdate['other_info'] = $rawOtherInfo;
571 631 }
572 632
573 633 if (!empty($rowUpdate)) {
574 634 $rowUpdate['updated_at'] = $now;
575 - ProductVariation::query()->where('id', $vid)->update($rowUpdate);
576 - $batchData[] = array_merge(['id' => $vid], $rowUpdate);
635 + $preparedUpdates[$vid] = $rowUpdate;
577 636 }
578 637 }
579 638
639 + // Write pass: every row validated above, apply the updates.
640 + foreach ($preparedUpdates as $vid => $rowUpdate) {
641 + ProductVariation::query()->where('id', $vid)->update($rowUpdate);
642 + $batchData[] = array_merge(['id' => $vid], $rowUpdate);
643 + }
644 +
580 645 $db->commit();
581 646 } catch (\Throwable $e) {
582 647 $db->rollBack();
583 648 return $this->sendError(['message' => __('Failed to update variants.', 'fluent-cart')], 500);
@@ -595,8 +660,33 @@
595 660 ]);
596 661 }
597 662
598 663 /**
664 + * Build the per-variant billing summary string, mirroring the admin JS
665 + * (ProductEditModel.onChangePricingPayment): "{price} {interval} {occurrence}".
666 + */
667 + private function buildBillingSummary($priceCents, array $otherInfo)
668 + {
669 + $interval = Arr::get($otherInfo, 'repeat_interval', '');
670 + if (!$interval) {
671 + return '';
672 + }
673 +
674 + // A valid installment count is always >= 2 (Helper::installmentTimesError);
675 + // legacy garbage like 1 or -1 must not surface as "for -1 Times".
676 + $times = (int) Arr::get($otherInfo, 'times', 0);
677 + $occurrence = $times >= 2
678 + /* translators: %1$s: number of installment payments */
679 + ? sprintf(__('for %1$s Times', 'fluent-cart'), $times)
680 + : __('Until Cancel', 'fluent-cart');
681 +
682 + $price = 0 + round(((int) $priceCents) / 100, 2);
683 +
684 + /* translators: %1$s: price, %2$s: billing interval (e.g. monthly), %3$s: occurrence (e.g. Until Cancel) */
685 + return sprintf(__('%1$s %2$s %3$s', 'fluent-cart'), $price, $interval, $occurrence);
686 + }
687 +
688 + /**
599 689 * Sanitize the other_info delta for group bulk update.
600 690 * Only known sub-keys are allowed; unknown keys are dropped to prevent
601 691 * arbitrary data injection into the JSON column.
602 692 */
@@ -601,14 +691,15 @@
601 691 * arbitrary data injection into the JSON column.
602 692 */
603 693 private function sanitizeOtherInfoDelta(array $raw)
604 694 {
695 + // billing_summary is intentionally NOT accepted — it embeds each row's
696 + // own price, so groupBulkUpdate() recomputes it server-side per variant.
605 697 $allowed = [
606 698 'description' => 'sanitize_textarea_field',
607 699 'tax_inclusion' => 'sanitize_text_field',
608 700 'package_slug' => 'sanitize_text_field',
609 701 'weight_unit' => 'sanitize_text_field',
610 - 'billing_summary' => 'sanitize_textarea_field',
611 702 'manage_setup_fee' => 'sanitize_text_field',
612 703 'signup_fee_name' => 'sanitize_text_field',
613 704 'times' => 'sanitize_text_field',
614 705 'repeat_interval' => 'sanitize_text_field',
@@ -626,8 +717,17 @@
626 717 }
627 718 $delta[$key] = $sanitizer($value);
628 719 }
629 720
721 + // repeat_interval is an enum, not free text — an unknown value would be
722 + // stored verbatim and surface in billing summaries ("9.99 garbage …").
723 + if (isset($delta['repeat_interval'])) {
724 + $validIntervals = array_column(Helper::getAvailableSubscriptionIntervalOptions(), 'value');
725 + if (!in_array($delta['repeat_interval'], $validIntervals, true)) {
726 + unset($delta['repeat_interval']);
727 + }
728 + }
729 +
630 730 // Enum-validated fields — unknown values are dropped rather than stored.
631 731 $paymentType = Arr::get($raw, 'payment_type');
632 732 if ($paymentType !== null && $paymentType !== '') {
633 733 $paymentType = sanitize_text_field($paymentType);
@@ -656,10 +756,10 @@
656 756 }
657 757 $delta[$key] = floatval($value);
658 758 }
659 759
660 - // signup_fee is stored in dollars here; groupBulkUpdate() converts to cents
661 - // via Helper::toCent() when payment_type is subscription.
760 + // signup_fee arrives in cents; groupBulkUpdate() normalizes it with
761 + // Helper::roundCent() when payment_type is subscription.
662 762 $signupFee = Arr::get($raw, 'signup_fee');
663 763 if ($signupFee !== null && $signupFee !== '') {
664 764 $delta['signup_fee'] = floatval($signupFee);
665 765 }