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

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