PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.0
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
fluent-cart / app / Http / Controllers / ProductVariationController.php

ProductVariationController.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.0, at app/Http/Controllers/ProductVariationController.php

773 lines 34.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Http\Controllers;
4
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;
9 use FluentCart\App\Http\Requests\ProductVariationRequest;
10 use FluentCart\App\Models\Product;
11 use FluentCart\App\Models\ProductVariation;
12 use FluentCart\App\Models\TaxClass;
13 use FluentCart\Framework\Http\Request\Request;
14 use FluentCart\Framework\Support\Arr;
15
16 class ProductVariationController extends Controller
17 {
18 public function index(Request $request): array
19 {
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') ?: [];
24 $variants = ProductVariationResource::get($parameters);
25
26 return [
27 'variants' => $variants['variants'],
28 ];
29 }
30
31 public function find(Request $request, ProductVariation $product): array
32 {
33 return [];
34 }
35
36 public function create(ProductVariationRequest $request)
37 {
38
39 $data = $request->getSafe($request->sanitize());
40 $productId = Arr::get($data, 'variants.post_id');
41
42
43 $product = Product::query()->with('detail')->findOrFail($productId);
44
45 $variationData = Arr::get($data, 'variants', []);
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;
49 $variationData['detail_id'] = Arr::get($product, 'detail.id', null);
50
51 $isCreated = ProductVariationResource::create($variationData);
52
53 if (is_wp_error($isCreated)) {
54 return $isCreated;
55 }
56 return $this->response->sendSuccess($isCreated);
57 }
58
59 public function update(ProductVariationRequest $request, $variantId)
60 {
61
62 $data = $request->getSafe($request->sanitize());
63
64 $productId = Arr::get($data, 'variants.post_id');
65
66 $product = Product::query()->with('detail')->findOrFail($productId);
67
68 $isUpdated = ProductVariationResource::update(
69 Arr::get($data, 'variants', []),
70 $variantId,
71 [
72 'detail_id' => Arr::get($product, 'detail.id', null)
73 ]);
74
75 if (is_wp_error($isUpdated)) {
76 return $isUpdated;
77 }
78 return $this->response->sendSuccess($isUpdated);
79 }
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
127 public function delete(Request $request, $variantId)
128 {
129 $variantId = absint($variantId);
130 $isDeleted = ProductVariationResource::delete($variantId);
131
132 if (is_wp_error($isDeleted)) {
133 return $isDeleted;
134 }
135 return $this->response->sendSuccess($isDeleted);
136 }
137
138 public function setMedia(Request $request, $variantId)
139 {
140 $variantId = absint($variantId);
141 $data = $request->getSafe([
142 'media.*.id' => 'intval',
143 'media.*.title' => 'sanitize_text_field',
144 'media.*.url' => function ($value) {
145 if (empty($value)) {
146 return '';
147 }
148
149 return sanitize_url($value);
150 },
151 ]);
152 $isSetMedia = ProductVariationResource::setImage(Arr::get($data, 'media', []), $variantId);
153
154 if (is_wp_error($isSetMedia)) {
155 return $isSetMedia;
156 }
157 return $this->response->sendSuccess($isSetMedia);
158 }
159
160 public function updatePricingTable(Request $request, $variantId)
161 {
162 $variantId = absint($variantId);
163 $data['description'] = sanitize_textarea_field($request->get('description'));
164
165 $isUpdated = ProductVariationResource::updatePricingTable($data, $variantId);
166
167 if (is_wp_error($isUpdated)) {
168 return $isUpdated;
169 }
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 $itemPriceDollar = floatval($update['item_price']);
294 // Reject negative prices outright rather than coerce to 0 —
295 // a caller submitting -50 has either bad client logic or
296 // hostile intent; either way we should not silently
297 // substitute a price they didn't choose.
298 if ($itemPriceDollar >= 0) {
299 $row['item_price'] = Helper::toCent($itemPriceDollar);
300 }
301 }
302
303 if (array_key_exists('compare_price', $update)) {
304 $comparePriceDollar = floatval($update['compare_price']);
305 // Mirror of the item_price negative guard. compare_price=0
306 // is a valid "no discount" sentinel; negative is not.
307 if ($comparePriceDollar >= 0) {
308 $comparePriceCents = Helper::toCent($comparePriceDollar);
309 // Effective item_price (in cents) for the comparison:
310 // the new value if this update sets it (and is valid),
311 // otherwise the already-persisted value from the DB.
312 // Falling back to 0 would re-introduce the bypass
313 // where compare_price could land below the existing
314 // item_price.
315 $itemPriceCents = array_key_exists('item_price', $row)
316 ? (int) $row['item_price']
317 : ($existingItemPriceCents[$id] ?? 0);
318
319 $row['compare_price'] = ($comparePriceCents > 0 && (!$itemPriceCents || $comparePriceCents >= $itemPriceCents))
320 ? $comparePriceCents
321 : 0;
322 }
323 }
324
325 // Mirror invariant: if the caller raised item_price WITHOUT
326 // touching compare_price, and the persisted compare_price is
327 // now below the new item_price, zero compare_price out in the
328 // same UPDATE. Without this, raising item_price alone leaves
329 // a stale compare_price < item_price (a negative discount the
330 // storefront would render as garbage).
331 if (array_key_exists('item_price', $row) && !array_key_exists('compare_price', $row)) {
332 $existingCompare = $existingComparePriceCents[$id] ?? 0;
333 if ($existingCompare > 0 && $existingCompare < (int) $row['item_price']) {
334 $row['compare_price'] = 0;
335 }
336 }
337
338 if (array_key_exists('item_status', $update)) {
339 $status = sanitize_text_field($update['item_status']);
340 if (in_array($status, $allowedStatuses)) {
341 $row['item_status'] = $status;
342 }
343 }
344
345 if (count($row) > 1) {
346 $batchData[] = $row;
347 }
348 }
349
350 if (empty($batchData)) {
351 $db->rollBack();
352 return $this->sendError(['message' => __('No valid updates provided.', 'fluent-cart')], 422);
353 }
354
355 // Per-row UPDATE (not one bulk statement) because each row may
356 // have a different subset of columns to update. Inside the same
357 // transaction as the locked scope-check above, so a mid-loop
358 // failure rolls back the whole batch — no partial commit.
359 foreach ($batchData as $row) {
360 $id = (int) $row['id'];
361 unset($row['id']);
362 if (empty($row)) {
363 continue;
364 }
365 // Stamp updated_at explicitly — the query-builder update
366 // bypasses Eloquent's auto-timestamps (model events,
367 // observers, $timestamps property). Without this, every
368 // bulk-edited variant would keep its old updated_at and
369 // forensics / cache-invalidation that relies on the
370 // timestamp would silently miss the change.
371 $row['updated_at'] = $now;
372 ProductVariation::query()->where('id', $id)->update($row);
373 }
374 $db->commit();
375 } catch (\Throwable $e) {
376 $db->rollBack();
377 return $this->sendError([
378 'message' => __('Failed to update variants.', 'fluent-cart'),
379 ], 500);
380 }
381
382 // Mirror the free-side canonical variant-update event so cache
383 // invalidators, search indexers, audit loggers, and webhook
384 // subscribers listening on this hook see our bulk writes too.
385 // Free fires this from ProductResource.php after its non-advanced
386 // batchUpdate; without firing it here, our writes are silent.
387 do_action('fluent_cart/product/variants_updated', [
388 'post_id' => $updatedProductId,
389 'variants' => $batchData,
390 ]);
391
392 return $this->sendSuccess([
393 'message' => __('Variants updated successfully.', 'fluent-cart'),
394 'updated' => count($batchData),
395 ]);
396 }
397
398 /**
399 * Group bulk update — partial update with PATCH semantics.
400 * Any field left null in the payload is skipped; only provided non-null
401 * fields are written to each variant in the group. For other_info, only
402 * the supplied non-null sub-keys are merged into the existing JSON.
403 */
404 public function groupBulkUpdate(GroupBulkUpdateVariantRequest $request)
405 {
406 $data = $request->getSafe($request->sanitize());
407 $variantIds = Arr::get($data, 'variant_ids', []);
408
409 if (empty($variantIds)) {
410 return $this->sendError(['message' => __('No valid variant IDs provided.', 'fluent-cart')], 422);
411 }
412
413 $raw = $request->all();
414 $topLevelDelta = [];
415 $otherInfoDelta = null;
416
417 $itemPrice = Arr::get($raw, 'item_price');
418 if ($itemPrice !== null && $itemPrice !== '') {
419 $price = floatval($itemPrice);
420 if ($price >= 0) {
421 $topLevelDelta['item_price'] = Helper::toCent($price);
422 }
423 }
424
425 $comparePrice = Arr::get($raw, 'compare_price');
426 if ($comparePrice !== null && $comparePrice !== '') {
427 $compare = floatval($comparePrice);
428 if ($compare >= 0) {
429 $topLevelDelta['_compare_price_dollars'] = $compare;
430 }
431 }
432
433 // SKU uniqueness — only apply to a single variant to avoid duplicates.
434 // An empty string means "clear the SKU" (stored as NULL; MySQL NULL is unique-safe).
435 // Read from $data (post-validation, post-sanitization) not $raw.
436 if (count($variantIds) === 1 && array_key_exists('sku', $data)) {
437 $topLevelDelta['sku'] = Arr::get($data, 'sku');
438 }
439
440 $manageStock = Arr::get($raw, 'manage_stock');
441 if ($manageStock !== null) {
442 $topLevelDelta['manage_stock'] = (int) $manageStock;
443 }
444
445 $totalStock = Arr::get($raw, 'total_stock');
446 if ($totalStock !== null && $totalStock !== '') {
447 $topLevelDelta['total_stock'] = absint($totalStock);
448 }
449
450 $fulfillmentType = Arr::get($raw, 'fulfillment_type');
451 if ($fulfillmentType !== null && $fulfillmentType !== '') {
452 $val = sanitize_text_field($fulfillmentType);
453 if (in_array($val, ['physical', 'digital'], true)) {
454 $topLevelDelta['fulfillment_type'] = $val;
455 }
456 }
457
458 $manageCost = Arr::get($raw, 'manage_cost');
459 if ($manageCost !== null && $manageCost !== '') {
460 $val = sanitize_text_field($manageCost);
461 if (in_array($val, ['true', 'false'], true)) {
462 $topLevelDelta['manage_cost'] = $val;
463 }
464 }
465
466 $itemCost = Arr::get($raw, 'item_cost');
467 if ($itemCost !== null && $itemCost !== '') {
468 $cost = floatval($itemCost);
469 if ($cost >= 0) {
470 $topLevelDelta['item_cost'] = Helper::toCent($cost);
471 }
472 }
473
474 $rawOtherInfo = Arr::get($raw, 'other_info');
475 if (is_array($rawOtherInfo)) {
476 $otherInfoDelta = $this->sanitizeOtherInfoDelta($rawOtherInfo);
477 }
478
479 if (empty($topLevelDelta) && ($otherInfoDelta === null || empty($otherInfoDelta))) {
480 return $this->sendError(['message' => __('No valid updates provided.', 'fluent-cart')], 422);
481 }
482
483 $db = ProductVariation::query()->getConnection();
484 $now = gmdate('Y-m-d H:i:s');
485 $updatedProductId = 0;
486 $batchData = [];
487
488 $db->beginTransaction();
489 try {
490 $ownedRows = ProductVariation::query()
491 ->whereIn('id', $variantIds)
492 ->lockForUpdate()
493 ->get(['id', 'post_id', 'item_price', 'compare_price', 'other_info', 'manage_stock', 'total_stock', 'payment_type']);
494
495 if ($ownedRows->count() !== count($variantIds)) {
496 $db->rollBack();
497 return $this->sendError(['message' => __('One or more variant IDs do not exist.', 'fluent-cart')], 404);
498 }
499
500 $distinctPostIds = $ownedRows->pluck('post_id')->unique();
501 if ($distinctPostIds->count() !== 1) {
502 $db->rollBack();
503 return $this->sendError(['message' => __('All variants must belong to the same product.', 'fluent-cart')], 422);
504 }
505 $updatedProductId = (int) $distinctPostIds->first();
506
507 // Prepare pass: build and validate every row update BEFORE writing
508 // anything, so a validation failure returns early with no UPDATE
509 // executed (the rollbacks below only release the row locks).
510 $preparedUpdates = [];
511
512 foreach ($ownedRows as $existingVariant) {
513 $vid = (int) $existingVariant->id;
514 $rowUpdate = [];
515
516 if (isset($topLevelDelta['item_price'])) {
517 $rowUpdate['item_price'] = $topLevelDelta['item_price'];
518 }
519
520 if (isset($topLevelDelta['_compare_price_dollars'])) {
521 $compareCents = Helper::toCent($topLevelDelta['_compare_price_dollars']);
522 $itemPriceCents = isset($rowUpdate['item_price'])
523 ? (int) $rowUpdate['item_price']
524 : (int) $existingVariant->item_price;
525 $rowUpdate['compare_price'] = ($compareCents > 0 && $compareCents >= $itemPriceCents)
526 ? $compareCents
527 : 0;
528 } elseif (isset($rowUpdate['item_price'])) {
529 $existingCompare = (int) $existingVariant->compare_price;
530 if ($existingCompare > 0 && $existingCompare < $rowUpdate['item_price']) {
531 $rowUpdate['compare_price'] = 0;
532 }
533 }
534
535 foreach (['sku', 'manage_stock', 'total_stock', 'fulfillment_type', 'manage_cost', 'item_cost'] as $field) {
536 if (array_key_exists($field, $topLevelDelta)) {
537 $rowUpdate[$field] = $topLevelDelta[$field];
538 }
539 }
540
541 if (isset($rowUpdate['manage_stock']) || isset($rowUpdate['total_stock'])) {
542 $manageStock = isset($rowUpdate['manage_stock']) ? $rowUpdate['manage_stock'] : (int) $existingVariant->manage_stock;
543 $totalStock = isset($rowUpdate['total_stock']) ? $rowUpdate['total_stock'] : (int) $existingVariant->total_stock;
544 $rowUpdate['stock_status'] = ($manageStock && $totalStock > 0) ? Helper::IN_STOCK : Helper::OUT_OF_STOCK;
545 if (!$manageStock) {
546 $rowUpdate['stock_status'] = Helper::IN_STOCK;
547 }
548 }
549
550 if ($otherInfoDelta !== null && !empty($otherInfoDelta)) {
551 $existingOtherInfo = is_array($existingVariant->other_info) ? $existingVariant->other_info : [];
552 $merged = array_merge($existingOtherInfo, $otherInfoDelta);
553
554 // Prefer payment_type from the merged other_info; fall back
555 // to the top-level column so signup_fee is converted to cents
556 // even when the request omits payment_type entirely.
557 $paymentType = Arr::get($merged, 'payment_type') ?: $existingVariant->payment_type;
558 if ($paymentType === 'onetime') {
559 foreach (['repeat_interval', 'interval', 'interval_count', 'billing_summary',
560 'manage_setup_fee', 'signup_fee', 'signup_fee_name', 'times', 'trial_days'] as $subKey) {
561 unset($merged[$subKey]);
562 }
563 }
564
565 // A subscription row without a billing interval is unusable —
566 // reject the whole batch (an invalid interval is silently
567 // dropped by sanitizeOtherInfoDelta, so it can be missing here).
568 // Runs in the prepare pass: nothing has been written yet.
569 if ($paymentType === 'subscription' && !Arr::get($merged, 'repeat_interval')) {
570 $db->rollBack();
571 return $this->sendError(['message' => __('A valid billing interval is required for subscription variants.', 'fluent-cart')], 422);
572 }
573 if ($paymentType === 'subscription' && array_key_exists('signup_fee', $otherInfoDelta)) {
574 $merged['signup_fee'] = Helper::toCent(floatval($otherInfoDelta['signup_fee']));
575 }
576
577 // `installment` is not an accepted delta key (see sanitizeOtherInfoDelta),
578 // so the stored flag on the row decides whether this is an installment
579 // plan. Re-check only when the request changes `times`, so an unrelated
580 // bulk price edit on a legacy row still saves. The payment_type gate
581 // matters: `times` is stripped from $merged for a one-time variant
582 // above, while a stale `installment` may survive in its stored JSON.
583 if ($paymentType === 'subscription' && array_key_exists('times', $otherInfoDelta)) {
584 $timesError = Helper::installmentTimesError($merged);
585 if ($timesError) {
586 $db->rollBack();
587 return $this->sendError(['message' => $timesError], 422);
588 }
589 }
590
591 // billing_summary embeds the row's own price, so one client-sent
592 // value can never fit a group of variants with different prices —
593 // recompute per row from the effective price/interval/times.
594 if ($paymentType === 'subscription') {
595 $effectivePriceCents = isset($rowUpdate['item_price'])
596 ? (int) $rowUpdate['item_price']
597 : (int) $existingVariant->item_price;
598 $merged['billing_summary'] = $this->buildBillingSummary($effectivePriceCents, $merged);
599 }
600
601 $merged['is_bundle_product'] = Arr::get($existingOtherInfo, 'is_bundle_product', 'no');
602 $merged['bundle_child_ids'] = Arr::get($existingOtherInfo, 'bundle_child_ids', []);
603
604 $rowUpdate['other_info'] = $merged;
605
606 if (isset($otherInfoDelta['payment_type'])) {
607 $rowUpdate['payment_type'] = $otherInfoDelta['payment_type'] === 'subscription'
608 ? 'subscription'
609 : 'onetime';
610 }
611 } elseif (isset($rowUpdate['item_price']) && $existingVariant->payment_type === 'subscription') {
612 // Price-only bulk edit on a subscription row: the stored
613 // summary embeds the old price — refresh it from the new one.
614 // Write back the raw stored JSON, not the accessor output:
615 // getOtherInfoAttribute() injects virtual defaults (and
616 // downgrades installment to 'no' while Pro is inactive) that
617 // an unrelated price edit must not persist.
618 $rawOtherInfoJson = Arr::get($existingVariant->getAttributes(), 'other_info');
619 $rawOtherInfo = (is_string($rawOtherInfoJson) && $rawOtherInfoJson !== '')
620 ? json_decode($rawOtherInfoJson, true)
621 : [];
622 $rawOtherInfo = is_array($rawOtherInfo) ? $rawOtherInfo : [];
623 $accessorOtherInfo = is_array($existingVariant->other_info) ? $existingVariant->other_info : [];
624 $rawOtherInfo['billing_summary'] = $this->buildBillingSummary((int) $rowUpdate['item_price'], $accessorOtherInfo);
625 $rowUpdate['other_info'] = $rawOtherInfo;
626 }
627
628 if (!empty($rowUpdate)) {
629 $rowUpdate['updated_at'] = $now;
630 $preparedUpdates[$vid] = $rowUpdate;
631 }
632 }
633
634 // Write pass: every row validated above, apply the updates.
635 foreach ($preparedUpdates as $vid => $rowUpdate) {
636 ProductVariation::query()->where('id', $vid)->update($rowUpdate);
637 $batchData[] = array_merge(['id' => $vid], $rowUpdate);
638 }
639
640 $db->commit();
641 } catch (\Throwable $e) {
642 $db->rollBack();
643 return $this->sendError(['message' => __('Failed to update variants.', 'fluent-cart')], 500);
644 }
645
646 do_action('fluent_cart/product/variants_updated', [
647 'post_id' => $updatedProductId,
648 'variants' => $batchData,
649 ]);
650
651 /* translators: %1$s: number of variants updated */
652 return $this->sendSuccess([
653 'message' => sprintf(__('%1$s variants updated successfully.', 'fluent-cart'), count($variantIds)),
654 'updated' => count($variantIds),
655 ]);
656 }
657
658 /**
659 * Build the per-variant billing summary string, mirroring the admin JS
660 * (ProductEditModel.onChangePricingPayment): "{price} {interval} {occurrence}".
661 */
662 private function buildBillingSummary($priceCents, array $otherInfo)
663 {
664 $interval = Arr::get($otherInfo, 'repeat_interval', '');
665 if (!$interval) {
666 return '';
667 }
668
669 // A valid installment count is always >= 2 (Helper::installmentTimesError);
670 // legacy garbage like 1 or -1 must not surface as "for -1 Times".
671 $times = (int) Arr::get($otherInfo, 'times', 0);
672 $occurrence = $times >= 2
673 /* translators: %1$s: number of installment payments */
674 ? sprintf(__('for %1$s Times', 'fluent-cart'), $times)
675 : __('Until Cancel', 'fluent-cart');
676
677 $price = 0 + round(((int) $priceCents) / 100, 2);
678
679 /* translators: %1$s: price, %2$s: billing interval (e.g. monthly), %3$s: occurrence (e.g. Until Cancel) */
680 return sprintf(__('%1$s %2$s %3$s', 'fluent-cart'), $price, $interval, $occurrence);
681 }
682
683 /**
684 * Sanitize the other_info delta for group bulk update.
685 * Only known sub-keys are allowed; unknown keys are dropped to prevent
686 * arbitrary data injection into the JSON column.
687 */
688 private function sanitizeOtherInfoDelta(array $raw)
689 {
690 // billing_summary is intentionally NOT accepted — it embeds each row's
691 // own price, so groupBulkUpdate() recomputes it server-side per variant.
692 $allowed = [
693 'description' => 'sanitize_textarea_field',
694 'tax_inclusion' => 'sanitize_text_field',
695 'package_slug' => 'sanitize_text_field',
696 'weight_unit' => 'sanitize_text_field',
697 'manage_setup_fee' => 'sanitize_text_field',
698 'signup_fee_name' => 'sanitize_text_field',
699 'times' => 'sanitize_text_field',
700 'repeat_interval' => 'sanitize_text_field',
701 'interval' => 'sanitize_text_field',
702 ];
703 $numericFields = ['weight', 'length', 'width', 'height'];
704 $intFields = ['interval_count', 'trial_days'];
705
706 $delta = [];
707
708 foreach ($allowed as $key => $sanitizer) {
709 $value = Arr::get($raw, $key);
710 if ($value === null || $value === '') {
711 continue;
712 }
713 $delta[$key] = $sanitizer($value);
714 }
715
716 // repeat_interval is an enum, not free text — an unknown value would be
717 // stored verbatim and surface in billing summaries ("9.99 garbage …").
718 if (isset($delta['repeat_interval'])) {
719 $validIntervals = array_column(Helper::getAvailableSubscriptionIntervalOptions(), 'value');
720 if (!in_array($delta['repeat_interval'], $validIntervals, true)) {
721 unset($delta['repeat_interval']);
722 }
723 }
724
725 // Enum-validated fields — unknown values are dropped rather than stored.
726 $paymentType = Arr::get($raw, 'payment_type');
727 if ($paymentType !== null && $paymentType !== '') {
728 $paymentType = sanitize_text_field($paymentType);
729 if (in_array($paymentType, ['onetime', 'subscription'], true)) {
730 $delta['payment_type'] = $paymentType;
731 }
732 }
733
734 $taxExempt = Arr::get($raw, 'tax_exempt');
735 if ($taxExempt !== null) {
736 $delta['tax_exempt'] = sanitize_text_field($taxExempt) === 'yes' ? 'yes' : 'no';
737 }
738
739 $taxClass = Arr::get($raw, 'tax_class');
740 if ($taxClass !== null && $taxClass !== '') {
741 $taxClass = sanitize_text_field($taxClass);
742 if (TaxClass::query()->where('slug', $taxClass)->exists()) {
743 $delta['tax_class'] = $taxClass;
744 }
745 }
746
747 foreach ($numericFields as $key) {
748 $value = Arr::get($raw, $key);
749 if ($value === null || $value === '') {
750 continue;
751 }
752 $delta[$key] = floatval($value);
753 }
754
755 // signup_fee is stored in dollars here; groupBulkUpdate() converts to cents
756 // via Helper::toCent() when payment_type is subscription.
757 $signupFee = Arr::get($raw, 'signup_fee');
758 if ($signupFee !== null && $signupFee !== '') {
759 $delta['signup_fee'] = floatval($signupFee);
760 }
761
762 foreach ($intFields as $key) {
763 $value = Arr::get($raw, $key);
764 if ($value === null || $value === '') {
765 continue;
766 }
767 $delta[$key] = intval($value);
768 }
769
770 return $delta;
771 }
772 }
773