PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Http / Controllers / ProductVariationController.php

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

778 lines 34.4 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 // 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;
776 }
777 }
778