PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.1
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.1, at app/Http/Controllers/ProductVariationController.php

776 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 // Setting variants to subscription requires a billing interval in the
484 // same request — a subscription without one can never bill. Checked
485 // before the transaction so bad input fails fast with no rollback.
486 // (An invalid interval was already dropped by sanitizeOtherInfoDelta.)
487 if (is_array($otherInfoDelta)
488 && Arr::get($otherInfoDelta, 'payment_type') === 'subscription'
489 && empty($otherInfoDelta['repeat_interval'])
490 ) {
491 return $this->sendError(['message' => __('A valid billing interval is required for subscription variants.', 'fluent-cart')], 422);
492 }
493
494 $db = ProductVariation::query()->getConnection();
495 $now = gmdate('Y-m-d H:i:s');
496 $updatedProductId = 0;
497 $batchData = [];
498
499 $db->beginTransaction();
500 try {
501 $ownedRows = ProductVariation::query()
502 ->whereIn('id', $variantIds)
503 ->lockForUpdate()
504 ->get(['id', 'post_id', 'item_price', 'compare_price', 'other_info', 'manage_stock', 'total_stock', 'payment_type']);
505
506 if ($ownedRows->count() !== count($variantIds)) {
507 $db->rollBack();
508 return $this->sendError(['message' => __('One or more variant IDs do not exist.', 'fluent-cart')], 404);
509 }
510
511 $distinctPostIds = $ownedRows->pluck('post_id')->unique();
512 if ($distinctPostIds->count() !== 1) {
513 $db->rollBack();
514 return $this->sendError(['message' => __('All variants must belong to the same product.', 'fluent-cart')], 422);
515 }
516 $updatedProductId = (int) $distinctPostIds->first();
517
518 // Prepare pass: build and validate every row update BEFORE writing
519 // anything, so a validation failure returns early with no UPDATE
520 // executed (the rollbacks below only release the row locks).
521 $preparedUpdates = [];
522
523 foreach ($ownedRows as $existingVariant) {
524 $vid = (int) $existingVariant->id;
525 $rowUpdate = [];
526
527 if (isset($topLevelDelta['item_price'])) {
528 $rowUpdate['item_price'] = $topLevelDelta['item_price'];
529 }
530
531 if (isset($topLevelDelta['_compare_price_dollars'])) {
532 $compareCents = Helper::toCent($topLevelDelta['_compare_price_dollars']);
533 $itemPriceCents = isset($rowUpdate['item_price'])
534 ? (int) $rowUpdate['item_price']
535 : (int) $existingVariant->item_price;
536 $rowUpdate['compare_price'] = ($compareCents > 0 && $compareCents >= $itemPriceCents)
537 ? $compareCents
538 : 0;
539 } elseif (isset($rowUpdate['item_price'])) {
540 $existingCompare = (int) $existingVariant->compare_price;
541 if ($existingCompare > 0 && $existingCompare < $rowUpdate['item_price']) {
542 $rowUpdate['compare_price'] = 0;
543 }
544 }
545
546 foreach (['sku', 'manage_stock', 'total_stock', 'fulfillment_type', 'manage_cost', 'item_cost'] as $field) {
547 if (array_key_exists($field, $topLevelDelta)) {
548 $rowUpdate[$field] = $topLevelDelta[$field];
549 }
550 }
551
552 if (isset($rowUpdate['manage_stock']) || isset($rowUpdate['total_stock'])) {
553 $manageStock = isset($rowUpdate['manage_stock']) ? $rowUpdate['manage_stock'] : (int) $existingVariant->manage_stock;
554 $totalStock = isset($rowUpdate['total_stock']) ? $rowUpdate['total_stock'] : (int) $existingVariant->total_stock;
555 $rowUpdate['stock_status'] = ($manageStock && $totalStock > 0) ? Helper::IN_STOCK : Helper::OUT_OF_STOCK;
556 if (!$manageStock) {
557 $rowUpdate['stock_status'] = Helper::IN_STOCK;
558 }
559 }
560
561 if ($otherInfoDelta !== null && !empty($otherInfoDelta)) {
562 $existingOtherInfo = is_array($existingVariant->other_info) ? $existingVariant->other_info : [];
563 $merged = array_merge($existingOtherInfo, $otherInfoDelta);
564
565 // Prefer payment_type from the merged other_info; fall back
566 // to the top-level column so signup_fee is converted to cents
567 // even when the request omits payment_type entirely.
568 $paymentType = Arr::get($merged, 'payment_type') ?: $existingVariant->payment_type;
569 if ($paymentType === 'onetime') {
570 foreach (['repeat_interval', 'interval', 'interval_count', 'billing_summary',
571 'manage_setup_fee', 'signup_fee', 'signup_fee_name', 'times', 'trial_days'] as $subKey) {
572 unset($merged[$subKey]);
573 }
574 }
575
576 if ($paymentType === 'subscription' && array_key_exists('signup_fee', $otherInfoDelta)) {
577 $merged['signup_fee'] = Helper::toCent(floatval($otherInfoDelta['signup_fee']));
578 }
579
580 // `installment` is not an accepted delta key (see sanitizeOtherInfoDelta),
581 // so the stored flag on the row decides whether this is an installment
582 // plan. Re-check only when the request changes `times`, so an unrelated
583 // bulk price edit on a legacy row still saves. The payment_type gate
584 // matters: `times` is stripped from $merged for a one-time variant
585 // above, while a stale `installment` may survive in its stored JSON.
586 if ($paymentType === 'subscription' && array_key_exists('times', $otherInfoDelta)) {
587 $timesError = Helper::installmentTimesError($merged);
588 if ($timesError) {
589 $db->rollBack();
590 return $this->sendError(['message' => $timesError], 422);
591 }
592 }
593
594 // billing_summary embeds the row's own price, so one client-sent
595 // value can never fit a group of variants with different prices —
596 // recompute per row from the effective price/interval/times.
597 if ($paymentType === 'subscription') {
598 $effectivePriceCents = isset($rowUpdate['item_price'])
599 ? (int) $rowUpdate['item_price']
600 : (int) $existingVariant->item_price;
601 $merged['billing_summary'] = $this->buildBillingSummary($effectivePriceCents, $merged);
602 }
603
604 $merged['is_bundle_product'] = Arr::get($existingOtherInfo, 'is_bundle_product', 'no');
605 $merged['bundle_child_ids'] = Arr::get($existingOtherInfo, 'bundle_child_ids', []);
606
607 $rowUpdate['other_info'] = $merged;
608
609 if (isset($otherInfoDelta['payment_type'])) {
610 $rowUpdate['payment_type'] = $otherInfoDelta['payment_type'] === 'subscription'
611 ? 'subscription'
612 : 'onetime';
613 }
614 } elseif (isset($rowUpdate['item_price']) && $existingVariant->payment_type === 'subscription') {
615 // Price-only bulk edit on a subscription row: the stored
616 // summary embeds the old price — refresh it from the new one.
617 // Write back the raw stored JSON, not the accessor output:
618 // getOtherInfoAttribute() injects virtual defaults (and
619 // downgrades installment to 'no' while Pro is inactive) that
620 // an unrelated price edit must not persist.
621 $rawOtherInfoJson = Arr::get($existingVariant->getAttributes(), 'other_info');
622 $rawOtherInfo = (is_string($rawOtherInfoJson) && $rawOtherInfoJson !== '')
623 ? json_decode($rawOtherInfoJson, true)
624 : [];
625 $rawOtherInfo = is_array($rawOtherInfo) ? $rawOtherInfo : [];
626 $accessorOtherInfo = is_array($existingVariant->other_info) ? $existingVariant->other_info : [];
627 $rawOtherInfo['billing_summary'] = $this->buildBillingSummary((int) $rowUpdate['item_price'], $accessorOtherInfo);
628 $rowUpdate['other_info'] = $rawOtherInfo;
629 }
630
631 if (!empty($rowUpdate)) {
632 $rowUpdate['updated_at'] = $now;
633 $preparedUpdates[$vid] = $rowUpdate;
634 }
635 }
636
637 // Write pass: every row validated above, apply the updates.
638 foreach ($preparedUpdates as $vid => $rowUpdate) {
639 ProductVariation::query()->where('id', $vid)->update($rowUpdate);
640 $batchData[] = array_merge(['id' => $vid], $rowUpdate);
641 }
642
643 $db->commit();
644 } catch (\Throwable $e) {
645 $db->rollBack();
646 return $this->sendError(['message' => __('Failed to update variants.', 'fluent-cart')], 500);
647 }
648
649 do_action('fluent_cart/product/variants_updated', [
650 'post_id' => $updatedProductId,
651 'variants' => $batchData,
652 ]);
653
654 /* translators: %1$s: number of variants updated */
655 return $this->sendSuccess([
656 'message' => sprintf(__('%1$s variants updated successfully.', 'fluent-cart'), count($variantIds)),
657 'updated' => count($variantIds),
658 ]);
659 }
660
661 /**
662 * Build the per-variant billing summary string, mirroring the admin JS
663 * (ProductEditModel.onChangePricingPayment): "{price} {interval} {occurrence}".
664 */
665 private function buildBillingSummary($priceCents, array $otherInfo)
666 {
667 $interval = Arr::get($otherInfo, 'repeat_interval', '');
668 if (!$interval) {
669 return '';
670 }
671
672 // A valid installment count is always >= 2 (Helper::installmentTimesError);
673 // legacy garbage like 1 or -1 must not surface as "for -1 Times".
674 $times = (int) Arr::get($otherInfo, 'times', 0);
675 $occurrence = $times >= 2
676 /* translators: %1$s: number of installment payments */
677 ? sprintf(__('for %1$s Times', 'fluent-cart'), $times)
678 : __('Until Cancel', 'fluent-cart');
679
680 $price = 0 + round(((int) $priceCents) / 100, 2);
681
682 /* translators: %1$s: price, %2$s: billing interval (e.g. monthly), %3$s: occurrence (e.g. Until Cancel) */
683 return sprintf(__('%1$s %2$s %3$s', 'fluent-cart'), $price, $interval, $occurrence);
684 }
685
686 /**
687 * Sanitize the other_info delta for group bulk update.
688 * Only known sub-keys are allowed; unknown keys are dropped to prevent
689 * arbitrary data injection into the JSON column.
690 */
691 private function sanitizeOtherInfoDelta(array $raw)
692 {
693 // billing_summary is intentionally NOT accepted — it embeds each row's
694 // own price, so groupBulkUpdate() recomputes it server-side per variant.
695 $allowed = [
696 'description' => 'sanitize_textarea_field',
697 'tax_inclusion' => 'sanitize_text_field',
698 'package_slug' => 'sanitize_text_field',
699 'weight_unit' => 'sanitize_text_field',
700 'manage_setup_fee' => 'sanitize_text_field',
701 'signup_fee_name' => 'sanitize_text_field',
702 'times' => 'sanitize_text_field',
703 'repeat_interval' => 'sanitize_text_field',
704 'interval' => 'sanitize_text_field',
705 ];
706 $numericFields = ['weight', 'length', 'width', 'height'];
707 $intFields = ['interval_count', 'trial_days'];
708
709 $delta = [];
710
711 foreach ($allowed as $key => $sanitizer) {
712 $value = Arr::get($raw, $key);
713 if ($value === null || $value === '') {
714 continue;
715 }
716 $delta[$key] = $sanitizer($value);
717 }
718
719 // repeat_interval is an enum, not free text — an unknown value would be
720 // stored verbatim and surface in billing summaries ("9.99 garbage …").
721 if (isset($delta['repeat_interval'])) {
722 $validIntervals = array_column(Helper::getAvailableSubscriptionIntervalOptions(), 'value');
723 if (!in_array($delta['repeat_interval'], $validIntervals, true)) {
724 unset($delta['repeat_interval']);
725 }
726 }
727
728 // Enum-validated fields — unknown values are dropped rather than stored.
729 $paymentType = Arr::get($raw, 'payment_type');
730 if ($paymentType !== null && $paymentType !== '') {
731 $paymentType = sanitize_text_field($paymentType);
732 if (in_array($paymentType, ['onetime', 'subscription'], true)) {
733 $delta['payment_type'] = $paymentType;
734 }
735 }
736
737 $taxExempt = Arr::get($raw, 'tax_exempt');
738 if ($taxExempt !== null) {
739 $delta['tax_exempt'] = sanitize_text_field($taxExempt) === 'yes' ? 'yes' : 'no';
740 }
741
742 $taxClass = Arr::get($raw, 'tax_class');
743 if ($taxClass !== null && $taxClass !== '') {
744 $taxClass = sanitize_text_field($taxClass);
745 if (TaxClass::query()->where('slug', $taxClass)->exists()) {
746 $delta['tax_class'] = $taxClass;
747 }
748 }
749
750 foreach ($numericFields as $key) {
751 $value = Arr::get($raw, $key);
752 if ($value === null || $value === '') {
753 continue;
754 }
755 $delta[$key] = floatval($value);
756 }
757
758 // signup_fee is stored in dollars here; groupBulkUpdate() converts to cents
759 // via Helper::toCent() when payment_type is subscription.
760 $signupFee = Arr::get($raw, 'signup_fee');
761 if ($signupFee !== null && $signupFee !== '') {
762 $delta['signup_fee'] = floatval($signupFee);
763 }
764
765 foreach ($intFields as $key) {
766 $value = Arr::get($raw, $key);
767 if ($value === null || $value === '') {
768 continue;
769 }
770 $delta[$key] = intval($value);
771 }
772
773 return $delta;
774 }
775 }
776