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 +105 -20 1.5.4 → 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,9 +427,9 @@
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.
@@ -465,9 +468,9 @@
465 468 $itemCost = Arr::get($raw, 'item_cost');
466 469 if ($itemCost !== null && $itemCost !== '') {
467 470 $cost = floatval($itemCost);
468 471 if ($cost >= 0) {
469 - $topLevelDelta['item_cost'] = Helper::toCent($cost);
472 + $topLevelDelta['item_cost'] = Helper::roundCent($cost);
470 473 }
471 474 }
472 475
473 476 $rawOtherInfo = Arr::get($raw, 'other_info');
@@ -478,8 +481,19 @@
478 481 if (empty($topLevelDelta) && ($otherInfoDelta === null || empty($otherInfoDelta))) {
479 482 return $this->sendError(['message' => __('No valid updates provided.', 'fluent-cart')], 422);
480 483 }
481 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 +
482 496 $db = ProductVariation::query()->getConnection();
483 497 $now = gmdate('Y-m-d H:i:s');
484 498 $updatedProductId = 0;
485 499 $batchData = [];
@@ -502,8 +516,13 @@
502 516 return $this->sendError(['message' => __('All variants must belong to the same product.', 'fluent-cart')], 422);
503 517 }
504 518 $updatedProductId = (int) $distinctPostIds->first();
505 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 +
506 525 foreach ($ownedRows as $existingVariant) {
507 526 $vid = (int) $existingVariant->id;
508 527 $rowUpdate = [];
509 528
@@ -510,10 +529,10 @@
510 529 if (isset($topLevelDelta['item_price'])) {
511 530 $rowUpdate['item_price'] = $topLevelDelta['item_price'];
512 531 }
513 532
514 - if (isset($topLevelDelta['_compare_price_dollars'])) {
515 - $compareCents = Helper::toCent($topLevelDelta['_compare_price_dollars']);
533 + if (isset($topLevelDelta['_compare_price_cents'])) {
534 + $compareCents = Helper::roundCent($topLevelDelta['_compare_price_cents']);
516 535 $itemPriceCents = isset($rowUpdate['item_price'])
517 536 ? (int) $rowUpdate['item_price']
518 537 : (int) $existingVariant->item_price;
519 538 $rowUpdate['compare_price'] = ($compareCents > 0 && $compareCents >= $itemPriceCents)
@@ -554,10 +573,11 @@
554 573 'manage_setup_fee', 'signup_fee', 'signup_fee_name', 'times', 'trial_days'] as $subKey) {
555 574 unset($merged[$subKey]);
556 575 }
557 576 }
577 +
558 578 if ($paymentType === 'subscription' && array_key_exists('signup_fee', $otherInfoDelta)) {
559 - $merged['signup_fee'] = Helper::toCent(floatval($otherInfoDelta['signup_fee']));
579 + $merged['signup_fee'] = Helper::roundCent($otherInfoDelta['signup_fee']);
560 580 }
561 581
562 582 // `installment` is not an accepted delta key (see sanitizeOtherInfoDelta),
563 583 // so the stored flag on the row decides whether this is an installment
@@ -572,8 +592,18 @@
572 592 return $this->sendError(['message' => $timesError], 422);
573 593 }
574 594 }
575 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 +
576 606 $merged['is_bundle_product'] = Arr::get($existingOtherInfo, 'is_bundle_product', 'no');
577 607 $merged['bundle_child_ids'] = Arr::get($existingOtherInfo, 'bundle_child_ids', []);
578 608
579 609 $rowUpdate['other_info'] = $merged;
@@ -582,17 +612,37 @@
582 612 $rowUpdate['payment_type'] = $otherInfoDelta['payment_type'] === 'subscription'
583 613 ? 'subscription'
584 614 : 'onetime';
585 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;
586 631 }
587 632
588 633 if (!empty($rowUpdate)) {
589 634 $rowUpdate['updated_at'] = $now;
590 - ProductVariation::query()->where('id', $vid)->update($rowUpdate);
591 - $batchData[] = array_merge(['id' => $vid], $rowUpdate);
635 + $preparedUpdates[$vid] = $rowUpdate;
592 636 }
593 637 }
594 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 +
595 645 $db->commit();
596 646 } catch (\Throwable $e) {
597 647 $db->rollBack();
598 648 return $this->sendError(['message' => __('Failed to update variants.', 'fluent-cart')], 500);
@@ -610,8 +660,33 @@
610 660 ]);
611 661 }
612 662
613 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 + /**
614 689 * Sanitize the other_info delta for group bulk update.
615 690 * Only known sub-keys are allowed; unknown keys are dropped to prevent
616 691 * arbitrary data injection into the JSON column.
617 692 */
@@ -616,14 +691,15 @@
616 691 * arbitrary data injection into the JSON column.
617 692 */
618 693 private function sanitizeOtherInfoDelta(array $raw)
619 694 {
695 + // billing_summary is intentionally NOT accepted — it embeds each row's
696 + // own price, so groupBulkUpdate() recomputes it server-side per variant.
620 697 $allowed = [
621 698 'description' => 'sanitize_textarea_field',
622 699 'tax_inclusion' => 'sanitize_text_field',
623 700 'package_slug' => 'sanitize_text_field',
624 701 'weight_unit' => 'sanitize_text_field',
625 - 'billing_summary' => 'sanitize_textarea_field',
626 702 'manage_setup_fee' => 'sanitize_text_field',
627 703 'signup_fee_name' => 'sanitize_text_field',
628 704 'times' => 'sanitize_text_field',
629 705 'repeat_interval' => 'sanitize_text_field',
@@ -641,8 +717,17 @@
641 717 }
642 718 $delta[$key] = $sanitizer($value);
643 719 }
644 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 +
645 730 // Enum-validated fields — unknown values are dropped rather than stored.
646 731 $paymentType = Arr::get($raw, 'payment_type');
647 732 if ($paymentType !== null && $paymentType !== '') {
648 733 $paymentType = sanitize_text_field($paymentType);
@@ -671,10 +756,10 @@
671 756 }
672 757 $delta[$key] = floatval($value);
673 758 }
674 759
675 - // signup_fee is stored in dollars here; groupBulkUpdate() converts to cents
676 - // 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.
677 762 $signupFee = Arr::get($raw, 'signup_fee');
678 763 if ($signupFee !== null && $signupFee !== '') {
679 764 $delta['signup_fee'] = floatval($signupFee);
680 765 }