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.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 1.3.19 All 49 releases
← All changes | app/Http/Controllers/ProductVariationController.php +666 -9 1.3.27 → 1.6.5 View file →
@@ -2,11 +2,15 @@
2 2
3 3 namespace FluentCart\App\Http\Controllers;
4 4
5 5 use FluentCart\Api\Resource\ProductVariationResource;
6 +use FluentCart\App\Helpers\Helper;
7 +use FluentCart\App\Http\Requests\BulkUpdateVariantRequest;
8 +use FluentCart\App\Http\Requests\GroupBulkUpdateVariantRequest;
6 9 use FluentCart\App\Http\Requests\ProductVariationRequest;
7 10 use FluentCart\App\Models\Product;
8 11 use FluentCart\App\Models\ProductVariation;
12 +use FluentCart\App\Models\TaxClass;
9 13 use FluentCart\Framework\Http\Request\Request;
10 14 use FluentCart\Framework\Support\Arr;
11 15
12 16 class ProductVariationController extends Controller
@@ -12,11 +16,12 @@
12 16 class ProductVariationController extends Controller
13 17 {
14 18 public function index(Request $request): array
15 19 {
16 - //
17 -
18 - $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') ?: [];
19 24 $variants = ProductVariationResource::get($parameters);
20 25
21 26 return [
22 27 'variants' => $variants['variants'],
@@ -24,9 +29,9 @@
24 29 }
25 30
26 31 public function find(Request $request, ProductVariation $product): array
27 32 {
28 - //
33 + return [];
29 34 }
30 35
31 36 public function create(ProductVariationRequest $request)
32 37 {
@@ -37,9 +42,11 @@
37 42
38 43 $product = Product::query()->with('detail')->findOrFail($productId);
39 44
40 45 $variationData = Arr::get($data, 'variants', []);
41 - $variationData['other_info']['is_bundle_product'] = $product->isBundleProduct()?'yes':'no';
46 + $otherInfo = is_array(Arr::get($variationData, 'other_info')) ? Arr::get($variationData, 'other_info') : [];
47 + $otherInfo['is_bundle_product'] = $product->isBundleProduct() ? 'yes' : 'no';
48 + $variationData['other_info'] = $otherInfo;
42 49 $variationData['detail_id'] = Arr::get($product, 'detail.id', null);
43 50
44 51 $isCreated = ProductVariationResource::create($variationData);
45 52
@@ -70,11 +77,57 @@
70 77 }
71 78 return $this->response->sendSuccess($isUpdated);
72 79 }
73 80
81 + public function updateTaxSettings(Request $request, $variantId)
82 + {
83 + $variantId = absint($variantId);
84 + $variant = ProductVariation::query()->find($variantId);
85 +
86 + if (!$variant) {
87 + return $this->sendError([
88 + 'message' => __('Variant not found', 'fluent-cart')
89 + ]);
90 + }
91 +
92 + $taxExempt = sanitize_text_field($request->get('tax_exempt', 'no'));
93 + $taxClassSlug = sanitize_text_field($request->get('tax_class', ''));
94 + $otherInfo = $variant->other_info ?: [];
95 +
96 + if (!$taxClassSlug) {
97 + $taxClassSlug = sanitize_text_field(Arr::get($otherInfo, 'tax_class', 'standard'));
98 + }
99 +
100 + if (!$taxClassSlug) {
101 + $taxClassSlug = 'standard';
102 + }
103 +
104 + if (!TaxClass::query()->where('slug', $taxClassSlug)->exists()) {
105 + return $this->sendError([
106 + 'message' => __('Invalid tax class', 'fluent-cart')
107 + ], 422);
108 + }
109 +
110 + $otherInfo['tax_exempt'] = $taxExempt === 'yes' ? 'yes' : 'no';
111 + $otherInfo['tax_class'] = $taxClassSlug;
112 +
113 + $variant->update([
114 + 'other_info' => $otherInfo
115 + ]);
116 +
117 + return $this->sendSuccess([
118 + 'message' => $otherInfo['tax_exempt'] === 'yes'
119 + ? __('Variation is now tax exempt', 'fluent-cart')
120 + : __('Tax will be charged on this variation', 'fluent-cart'),
121 + 'tax_exempt' => $otherInfo['tax_exempt'],
122 + 'tax_class' => $otherInfo['tax_class'],
123 + 'tax_class_slug' => $otherInfo['tax_class']
124 + ]);
125 + }
126 +
74 127 public function delete(Request $request, $variantId)
75 128 {
76 -
129 + $variantId = absint($variantId);
77 130 $isDeleted = ProductVariationResource::delete($variantId);
78 131
79 132 if (is_wp_error($isDeleted)) {
80 133 return $isDeleted;
@@ -83,9 +136,9 @@
83 136 }
84 137
85 138 public function setMedia(Request $request, $variantId)
86 139 {
87 -
140 + $variantId = absint($variantId);
88 141 $data = $request->getSafe([
89 142 'media.*.id' => 'intval',
90 143 'media.*.title' => 'sanitize_text_field',
91 144 'media.*.url' => function ($value) {
@@ -105,10 +158,9 @@
105 158 }
106 159
107 160 public function updatePricingTable(Request $request, $variantId)
108 161 {
109 -
110 - // Use sanitize_textarea_field to retain newlines
162 + $variantId = absint($variantId);
111 163 $data['description'] = sanitize_textarea_field($request->get('description'));
112 164
113 165 $isUpdated = ProductVariationResource::updatePricingTable($data, $variantId);
114 166
@@ -115,6 +167,611 @@
115 167 if (is_wp_error($isUpdated)) {
116 168 return $isUpdated;
117 169 }
118 170 return $this->response->sendSuccess($isUpdated);
171 + }
172 +
173 + public function bulkUpdate(BulkUpdateVariantRequest $request)
174 + {
175 + // FormRequest already validated 'updates' is a non-empty array AND
176 + // capped its size to MAX_UPDATES_PER_REQUEST via sanitize(). Read
177 + // through getSafe so the capped value flows through, not the raw
178 + // request input — without this, a caller could POST a 100K-element
179 + // array and still see it iterated in the loop below.
180 + $data = $request->getSafe($request->sanitize());
181 + $updates = Arr::get($data, 'updates', []);
182 +
183 + if (empty($updates)) {
184 + return $this->sendError(['message' => __('No updates provided.', 'fluent-cart')], 422);
185 + }
186 +
187 + // Merge updates that target the same variant id into a single row.
188 + // Without this, a caller sending [{id:5, item_price:100}, {id:5,
189 + // compare_price:50}] would produce two separate UPDATE statements:
190 + // the first sets item_price=100, the second sets compare_price=50.
191 + // The mirror price-invariant check below reads existing prices ONCE
192 + // (snapshot from the locked SELECT), so the second row's check
193 + // compares against the snapshot — NOT the running state from the
194 + // first row — and can persist compare_price < the just-updated
195 + // item_price (negative discount, exactly the invariant the price
196 + // checks exist to prevent). Last-write-wins per field is the
197 + // expected admin-UI semantic when two payload rows touch the same
198 + // variant.
199 + $merged = [];
200 + foreach ($updates as $update) {
201 + $id = absint(Arr::get($update, 'id', 0));
202 + if (!$id) {
203 + continue;
204 + }
205 + if (isset($merged[$id])) {
206 + $merged[$id] = array_merge($merged[$id], $update);
207 + } else {
208 + $merged[$id] = $update;
209 + }
210 + $merged[$id]['id'] = $id;
211 + }
212 + $updates = array_values($merged);
213 + $candidateIds = array_keys($merged);
214 +
215 + if (empty($candidateIds)) {
216 + return $this->sendError(['message' => __('No valid updates provided.', 'fluent-cart')], 422);
217 + }
218 +
219 + // Everything from here on — scope check, price preload, sanitization,
220 + // per-row update — runs INSIDE a single transaction with row-level
221 + // locks on the candidate variants. Without the locks, two parallel
222 + // admins editing the same variant set could interleave reads and
223 + // writes: A reads existing prices, B reads same existing prices,
224 + // both sanitize based on stale snapshots, and one update silently
225 + // overwrites the other's compare_price/item_price decision. The
226 + // ProductDetail save path in syncVariantOption already uses this
227 + // same lock pattern (round-3 fix) — bringing bulkUpdate into line
228 + // closes the equivalent gap on the variants table.
229 + $now = gmdate('Y-m-d H:i:s');
230 + $db = ProductVariation::query()->getConnection();
231 + $updatedProductId = 0;
232 + $batchData = [];
233 + $db->beginTransaction();
234 + try {
235 + // Scope check (locked). Every variant ID in the batch must
236 + // (a) exist and (b) belong to the same product. Without (b)
237 + // a caller with the generic products/edit capability could
238 + // mix IDs from multiple products in one request and modify
239 + // variants on products they were never working on
240 + // (cross-product side-channel via the bulk endpoint).
241 + // Loaded with lockForUpdate so the existing item_price /
242 + // compare_price values used below as baselines for the
243 + // price-relationship checks reflect the committed state at
244 + // write time, not a stale pre-transaction read.
245 + $ownedRows = ProductVariation::query()
246 + ->whereIn('id', $candidateIds)
247 + ->lockForUpdate()
248 + ->get(['id', 'post_id', 'item_price', 'compare_price']);
249 +
250 + if ($ownedRows->count() !== count($candidateIds)) {
251 + $db->rollBack();
252 + return $this->sendError([
253 + 'message' => __('One or more variant IDs do not exist.', 'fluent-cart'),
254 + ], 404);
255 + }
256 +
257 + $distinctPostIds = $ownedRows->pluck('post_id')->unique();
258 + if ($distinctPostIds->count() !== 1) {
259 + $db->rollBack();
260 + return $this->sendError([
261 + 'message' => __('All updates must target variants on the same product.', 'fluent-cart'),
262 + ], 422);
263 + }
264 + $updatedProductId = (int) $distinctPostIds->first();
265 +
266 + // Maps of existing prices (in cents, as stored). Used to validate
267 + // BOTH directions of the price-relationship invariant:
268 + // - compare_price set without item_price → use existing item_price
269 + // as the baseline so a low compare_price below the persisted
270 + // item_price is rejected (round 4 fix).
271 + // - item_price set without compare_price → check that the new
272 + // item_price doesn't leave the persisted compare_price below
273 + // it (round 5 mirror; same invariant, opposite direction).
274 + $existingItemPriceCents = [];
275 + $existingComparePriceCents = [];
276 + foreach ($ownedRows as $variant) {
277 + $vid = (int) $variant->id;
278 + $existingItemPriceCents[$vid] = (int) $variant->item_price;
279 + $existingComparePriceCents[$vid] = (int) $variant->compare_price;
280 + }
281 +
282 + $allowedStatuses = ['active', 'inactive'];
283 +
284 + foreach ($updates as $update) {
285 + $id = absint(Arr::get($update, 'id', 0));
286 + if (!$id) {
287 + continue;
288 + }
289 +
290 + $row = ['id' => $id];
291 +
292 + if (array_key_exists('item_price', $update)) {
293 + // Submitted in CENTS. roundCent() normalizes float artifacts
294 + // without scaling; it does not multiply by 100.
295 + $itemPriceCentsIn = floatval($update['item_price']);
296 + // Reject negative prices outright rather than coerce to 0 —
297 + // a caller submitting -50 has either bad client logic or
298 + // hostile intent; either way we should not silently
299 + // substitute a price they didn't choose.
300 + if ($itemPriceCentsIn >= 0) {
301 + $row['item_price'] = Helper::roundCent($itemPriceCentsIn);
302 + }
303 + }
304 +
305 + if (array_key_exists('compare_price', $update)) {
306 + $comparePriceCentsIn = floatval($update['compare_price']);
307 + // Mirror of the item_price negative guard. compare_price=0
308 + // is a valid "no discount" sentinel; negative is not.
309 + if ($comparePriceCentsIn >= 0) {
310 + $comparePriceCents = Helper::roundCent($comparePriceCentsIn);
311 + // Effective item_price (in cents) for the comparison:
312 + // the new value if this update sets it (and is valid),
313 + // otherwise the already-persisted value from the DB.
314 + // Falling back to 0 would re-introduce the bypass
315 + // where compare_price could land below the existing
316 + // item_price.
317 + $itemPriceCents = array_key_exists('item_price', $row)
318 + ? (int) $row['item_price']
319 + : ($existingItemPriceCents[$id] ?? 0);
320 +
321 + $row['compare_price'] = ($comparePriceCents > 0 && (!$itemPriceCents || $comparePriceCents >= $itemPriceCents))
322 + ? $comparePriceCents
323 + : 0;
324 + }
325 + }
326 +
327 + // Mirror invariant: if the caller raised item_price WITHOUT
328 + // touching compare_price, and the persisted compare_price is
329 + // now below the new item_price, zero compare_price out in the
330 + // same UPDATE. Without this, raising item_price alone leaves
331 + // a stale compare_price < item_price (a negative discount the
332 + // storefront would render as garbage).
333 + if (array_key_exists('item_price', $row) && !array_key_exists('compare_price', $row)) {
334 + $existingCompare = $existingComparePriceCents[$id] ?? 0;
335 + if ($existingCompare > 0 && $existingCompare < (int) $row['item_price']) {
336 + $row['compare_price'] = 0;
337 + }
338 + }
339 +
340 + if (array_key_exists('item_status', $update)) {
341 + $status = sanitize_text_field($update['item_status']);
342 + if (in_array($status, $allowedStatuses)) {
343 + $row['item_status'] = $status;
344 + }
345 + }
346 +
347 + if (count($row) > 1) {
348 + $batchData[] = $row;
349 + }
350 + }
351 +
352 + if (empty($batchData)) {
353 + $db->rollBack();
354 + return $this->sendError(['message' => __('No valid updates provided.', 'fluent-cart')], 422);
355 + }
356 +
357 + // Per-row UPDATE (not one bulk statement) because each row may
358 + // have a different subset of columns to update. Inside the same
359 + // transaction as the locked scope-check above, so a mid-loop
360 + // failure rolls back the whole batch — no partial commit.
361 + foreach ($batchData as $row) {
362 + $id = (int) $row['id'];
363 + unset($row['id']);
364 + if (empty($row)) {
365 + continue;
366 + }
367 + // Stamp updated_at explicitly — the query-builder update
368 + // bypasses Eloquent's auto-timestamps (model events,
369 + // observers, $timestamps property). Without this, every
370 + // bulk-edited variant would keep its old updated_at and
371 + // forensics / cache-invalidation that relies on the
372 + // timestamp would silently miss the change.
373 + $row['updated_at'] = $now;
374 + ProductVariation::query()->where('id', $id)->update($row);
375 + }
376 + $db->commit();
377 + } catch (\Throwable $e) {
378 + $db->rollBack();
379 + return $this->sendError([
380 + 'message' => __('Failed to update variants.', 'fluent-cart'),
381 + ], 500);
382 + }
383 +
384 + // Mirror the free-side canonical variant-update event so cache
385 + // invalidators, search indexers, audit loggers, and webhook
386 + // subscribers listening on this hook see our bulk writes too.
387 + // Free fires this from ProductResource.php after its non-advanced
388 + // batchUpdate; without firing it here, our writes are silent.
389 + do_action('fluent_cart/product/variants_updated', [
390 + 'post_id' => $updatedProductId,
391 + 'variants' => $batchData,
392 + ]);
393 +
394 + return $this->sendSuccess([
395 + 'message' => __('Variants updated successfully.', 'fluent-cart'),
396 + 'updated' => count($batchData),
397 + ]);
398 + }
399 +
400 + /**
401 + * Group bulk update — partial update with PATCH semantics.
402 + * Any field left null in the payload is skipped; only provided non-null
403 + * fields are written to each variant in the group. For other_info, only
404 + * the supplied non-null sub-keys are merged into the existing JSON.
405 + */
406 + public function groupBulkUpdate(GroupBulkUpdateVariantRequest $request)
407 + {
408 + $data = $request->getSafe($request->sanitize());
409 + $variantIds = Arr::get($data, 'variant_ids', []);
410 +
411 + if (empty($variantIds)) {
412 + return $this->sendError(['message' => __('No valid variant IDs provided.', 'fluent-cart')], 422);
413 + }
414 +
415 + $raw = $request->all();
416 + $topLevelDelta = [];
417 + $otherInfoDelta = null;
418 +
419 + $itemPrice = Arr::get($raw, 'item_price');
420 + if ($itemPrice !== null && $itemPrice !== '') {
421 + $price = floatval($itemPrice);
422 + if ($price >= 0) {
423 + $topLevelDelta['item_price'] = Helper::roundCent($price);
424 + }
425 + }
426 +
427 + $comparePrice = Arr::get($raw, 'compare_price');
428 + if ($comparePrice !== null && $comparePrice !== '') {
429 + $compare = floatval($comparePrice);
430 + if ($compare >= 0) {
431 + $topLevelDelta['_compare_price_cents'] = $compare;
432 + }
433 + }
434 +
435 + // SKU uniqueness — only apply to a single variant to avoid duplicates.
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');
440 + }
441 +
442 + $manageStock = Arr::get($raw, 'manage_stock');
443 + if ($manageStock !== null) {
444 + $topLevelDelta['manage_stock'] = (int) $manageStock;
445 + }
446 +
447 + $totalStock = Arr::get($raw, 'total_stock');
448 + if ($totalStock !== null && $totalStock !== '') {
449 + $topLevelDelta['total_stock'] = absint($totalStock);
450 + }
451 +
452 + $fulfillmentType = Arr::get($raw, 'fulfillment_type');
453 + if ($fulfillmentType !== null && $fulfillmentType !== '') {
454 + $val = sanitize_text_field($fulfillmentType);
455 + if (in_array($val, ['physical', 'digital'], true)) {
456 + $topLevelDelta['fulfillment_type'] = $val;
457 + }
458 + }
459 +
460 + $manageCost = Arr::get($raw, 'manage_cost');
461 + if ($manageCost !== null && $manageCost !== '') {
462 + $val = sanitize_text_field($manageCost);
463 + if (in_array($val, ['true', 'false'], true)) {
464 + $topLevelDelta['manage_cost'] = $val;
465 + }
466 + }
467 +
468 + $itemCost = Arr::get($raw, 'item_cost');
469 + if ($itemCost !== null && $itemCost !== '') {
470 + $cost = floatval($itemCost);
471 + if ($cost >= 0) {
472 + $topLevelDelta['item_cost'] = Helper::roundCent($cost);
473 + }
474 + }
475 +
476 + $rawOtherInfo = Arr::get($raw, 'other_info');
477 + if (is_array($rawOtherInfo)) {
478 + $otherInfoDelta = $this->sanitizeOtherInfoDelta($rawOtherInfo);
479 + }
480 +
481 + if (empty($topLevelDelta) && ($otherInfoDelta === null || empty($otherInfoDelta))) {
482 + return $this->sendError(['message' => __('No valid updates provided.', 'fluent-cart')], 422);
483 + }
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 +
496 + $db = ProductVariation::query()->getConnection();
497 + $now = gmdate('Y-m-d H:i:s');
498 + $updatedProductId = 0;
499 + $batchData = [];
500 +
501 + $db->beginTransaction();
502 + try {
503 + $ownedRows = ProductVariation::query()
504 + ->whereIn('id', $variantIds)
505 + ->lockForUpdate()
506 + ->get(['id', 'post_id', 'item_price', 'compare_price', 'other_info', 'manage_stock', 'total_stock', 'payment_type']);
507 +
508 + if ($ownedRows->count() !== count($variantIds)) {
509 + $db->rollBack();
510 + return $this->sendError(['message' => __('One or more variant IDs do not exist.', 'fluent-cart')], 404);
511 + }
512 +
513 + $distinctPostIds = $ownedRows->pluck('post_id')->unique();
514 + if ($distinctPostIds->count() !== 1) {
515 + $db->rollBack();
516 + return $this->sendError(['message' => __('All variants must belong to the same product.', 'fluent-cart')], 422);
517 + }
518 + $updatedProductId = (int) $distinctPostIds->first();
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 +
525 + foreach ($ownedRows as $existingVariant) {
526 + $vid = (int) $existingVariant->id;
527 + $rowUpdate = [];
528 +
529 + if (isset($topLevelDelta['item_price'])) {
530 + $rowUpdate['item_price'] = $topLevelDelta['item_price'];
531 + }
532 +
533 + if (isset($topLevelDelta['_compare_price_cents'])) {
534 + $compareCents = Helper::roundCent($topLevelDelta['_compare_price_cents']);
535 + $itemPriceCents = isset($rowUpdate['item_price'])
536 + ? (int) $rowUpdate['item_price']
537 + : (int) $existingVariant->item_price;
538 + $rowUpdate['compare_price'] = ($compareCents > 0 && $compareCents >= $itemPriceCents)
539 + ? $compareCents
540 + : 0;
541 + } elseif (isset($rowUpdate['item_price'])) {
542 + $existingCompare = (int) $existingVariant->compare_price;
543 + if ($existingCompare > 0 && $existingCompare < $rowUpdate['item_price']) {
544 + $rowUpdate['compare_price'] = 0;
545 + }
546 + }
547 +
548 + foreach (['sku', 'manage_stock', 'total_stock', 'fulfillment_type', 'manage_cost', 'item_cost'] as $field) {
549 + if (array_key_exists($field, $topLevelDelta)) {
550 + $rowUpdate[$field] = $topLevelDelta[$field];
551 + }
552 + }
553 +
554 + if (isset($rowUpdate['manage_stock']) || isset($rowUpdate['total_stock'])) {
555 + $manageStock = isset($rowUpdate['manage_stock']) ? $rowUpdate['manage_stock'] : (int) $existingVariant->manage_stock;
556 + $totalStock = isset($rowUpdate['total_stock']) ? $rowUpdate['total_stock'] : (int) $existingVariant->total_stock;
557 + $rowUpdate['stock_status'] = ($manageStock && $totalStock > 0) ? Helper::IN_STOCK : Helper::OUT_OF_STOCK;
558 + if (!$manageStock) {
559 + $rowUpdate['stock_status'] = Helper::IN_STOCK;
560 + }
561 + }
562 +
563 + if ($otherInfoDelta !== null && !empty($otherInfoDelta)) {
564 + $existingOtherInfo = is_array($existingVariant->other_info) ? $existingVariant->other_info : [];
565 + $merged = array_merge($existingOtherInfo, $otherInfoDelta);
566 +
567 + // Prefer payment_type from the merged other_info; fall back
568 + // to the top-level column so signup_fee is converted to cents
569 + // even when the request omits payment_type entirely.
570 + $paymentType = Arr::get($merged, 'payment_type') ?: $existingVariant->payment_type;
571 + if ($paymentType === 'onetime') {
572 + foreach (['repeat_interval', 'interval', 'interval_count', 'billing_summary',
573 + 'manage_setup_fee', 'signup_fee', 'signup_fee_name', 'times', 'trial_days'] as $subKey) {
574 + unset($merged[$subKey]);
575 + }
576 + }
577 +
578 + if ($paymentType === 'subscription' && array_key_exists('signup_fee', $otherInfoDelta)) {
579 + $merged['signup_fee'] = Helper::roundCent($otherInfoDelta['signup_fee']);
580 + }
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 +
606 + $merged['is_bundle_product'] = Arr::get($existingOtherInfo, 'is_bundle_product', 'no');
607 + $merged['bundle_child_ids'] = Arr::get($existingOtherInfo, 'bundle_child_ids', []);
608 +
609 + $rowUpdate['other_info'] = $merged;
610 +
611 + if (isset($otherInfoDelta['payment_type'])) {
612 + $rowUpdate['payment_type'] = $otherInfoDelta['payment_type'] === 'subscription'
613 + ? 'subscription'
614 + : 'onetime';
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;
631 + }
632 +
633 + if (!empty($rowUpdate)) {
634 + $rowUpdate['updated_at'] = $now;
635 + $preparedUpdates[$vid] = $rowUpdate;
636 + }
637 + }
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 +
645 + $db->commit();
646 + } catch (\Throwable $e) {
647 + $db->rollBack();
648 + return $this->sendError(['message' => __('Failed to update variants.', 'fluent-cart')], 500);
649 + }
650 +
651 + do_action('fluent_cart/product/variants_updated', [
652 + 'post_id' => $updatedProductId,
653 + 'variants' => $batchData,
654 + ]);
655 +
656 + /* translators: %1$s: number of variants updated */
657 + return $this->sendSuccess([
658 + 'message' => sprintf(__('%1$s variants updated successfully.', 'fluent-cart'), count($variantIds)),
659 + 'updated' => count($variantIds),
660 + ]);
661 + }
662 +
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 + /**
689 + * Sanitize the other_info delta for group bulk update.
690 + * Only known sub-keys are allowed; unknown keys are dropped to prevent
691 + * arbitrary data injection into the JSON column.
692 + */
693 + private function sanitizeOtherInfoDelta(array $raw)
694 + {
695 + // billing_summary is intentionally NOT accepted — it embeds each row's
696 + // own price, so groupBulkUpdate() recomputes it server-side per variant.
697 + $allowed = [
698 + 'description' => 'sanitize_textarea_field',
699 + 'tax_inclusion' => 'sanitize_text_field',
700 + 'package_slug' => 'sanitize_text_field',
701 + 'weight_unit' => 'sanitize_text_field',
702 + 'manage_setup_fee' => 'sanitize_text_field',
703 + 'signup_fee_name' => 'sanitize_text_field',
704 + 'times' => 'sanitize_text_field',
705 + 'repeat_interval' => 'sanitize_text_field',
706 + 'interval' => 'sanitize_text_field',
707 + ];
708 + $numericFields = ['weight', 'length', 'width', 'height'];
709 + $intFields = ['interval_count', 'trial_days'];
710 +
711 + $delta = [];
712 +
713 + foreach ($allowed as $key => $sanitizer) {
714 + $value = Arr::get($raw, $key);
715 + if ($value === null || $value === '') {
716 + continue;
717 + }
718 + $delta[$key] = $sanitizer($value);
719 + }
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 +
730 + // Enum-validated fields — unknown values are dropped rather than stored.
731 + $paymentType = Arr::get($raw, 'payment_type');
732 + if ($paymentType !== null && $paymentType !== '') {
733 + $paymentType = sanitize_text_field($paymentType);
734 + if (in_array($paymentType, ['onetime', 'subscription'], true)) {
735 + $delta['payment_type'] = $paymentType;
736 + }
737 + }
738 +
739 + $taxExempt = Arr::get($raw, 'tax_exempt');
740 + if ($taxExempt !== null) {
741 + $delta['tax_exempt'] = sanitize_text_field($taxExempt) === 'yes' ? 'yes' : 'no';
742 + }
743 +
744 + $taxClass = Arr::get($raw, 'tax_class');
745 + if ($taxClass !== null && $taxClass !== '') {
746 + $taxClass = sanitize_text_field($taxClass);
747 + if (TaxClass::query()->where('slug', $taxClass)->exists()) {
748 + $delta['tax_class'] = $taxClass;
749 + }
750 + }
751 +
752 + foreach ($numericFields as $key) {
753 + $value = Arr::get($raw, $key);
754 + if ($value === null || $value === '') {
755 + continue;
756 + }
757 + $delta[$key] = floatval($value);
758 + }
759 +
760 + // signup_fee arrives in cents; groupBulkUpdate() normalizes it with
761 + // Helper::roundCent() when payment_type is subscription.
762 + $signupFee = Arr::get($raw, 'signup_fee');
763 + if ($signupFee !== null && $signupFee !== '') {
764 + $delta['signup_fee'] = floatval($signupFee);
765 + }
766 +
767 + foreach ($intFields as $key) {
768 + $value = Arr::get($raw, $key);
769 + if ($value === null || $value === '') {
770 + continue;
771 + }
772 + $delta[$key] = intval($value);
773 + }
774 +
775 + return $delta;
119 776 }
120 777 }