PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.3
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Http / Controllers / ProductVariationController.php

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

679 lines 28.5 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 // An empty string means "clear the SKU" (stored as NULL; MySQL NULL is unique-safe).
434 // Read from $data (post-validation, post-sanitization) not $raw.
435 if (count($variantIds) === 1 && array_key_exists('sku', $data)) {
436 $topLevelDelta['sku'] = Arr::get($data, 'sku');
437 }
438
439 $manageStock = Arr::get($raw, 'manage_stock');
440 if ($manageStock !== null) {
441 $topLevelDelta['manage_stock'] = (int) $manageStock;
442 }
443
444 $totalStock = Arr::get($raw, 'total_stock');
445 if ($totalStock !== null && $totalStock !== '') {
446 $topLevelDelta['total_stock'] = absint($totalStock);
447 }
448
449 $fulfillmentType = Arr::get($raw, 'fulfillment_type');
450 if ($fulfillmentType !== null && $fulfillmentType !== '') {
451 $val = sanitize_text_field($fulfillmentType);
452 if (in_array($val, ['physical', 'digital'], true)) {
453 $topLevelDelta['fulfillment_type'] = $val;
454 }
455 }
456
457 $manageCost = Arr::get($raw, 'manage_cost');
458 if ($manageCost !== null && $manageCost !== '') {
459 $val = sanitize_text_field($manageCost);
460 if (in_array($val, ['true', 'false'], true)) {
461 $topLevelDelta['manage_cost'] = $val;
462 }
463 }
464
465 $itemCost = Arr::get($raw, 'item_cost');
466 if ($itemCost !== null && $itemCost !== '') {
467 $cost = floatval($itemCost);
468 if ($cost >= 0) {
469 $topLevelDelta['item_cost'] = Helper::toCent($cost);
470 }
471 }
472
473 $rawOtherInfo = Arr::get($raw, 'other_info');
474 if (is_array($rawOtherInfo)) {
475 $otherInfoDelta = $this->sanitizeOtherInfoDelta($rawOtherInfo);
476 }
477
478 if (empty($topLevelDelta) && ($otherInfoDelta === null || empty($otherInfoDelta))) {
479 return $this->sendError(['message' => __('No valid updates provided.', 'fluent-cart')], 422);
480 }
481
482 $db = ProductVariation::query()->getConnection();
483 $now = gmdate('Y-m-d H:i:s');
484 $updatedProductId = 0;
485 $batchData = [];
486
487 $db->beginTransaction();
488 try {
489 $ownedRows = ProductVariation::query()
490 ->whereIn('id', $variantIds)
491 ->lockForUpdate()
492 ->get(['id', 'post_id', 'item_price', 'compare_price', 'other_info', 'manage_stock', 'total_stock', 'payment_type']);
493
494 if ($ownedRows->count() !== count($variantIds)) {
495 $db->rollBack();
496 return $this->sendError(['message' => __('One or more variant IDs do not exist.', 'fluent-cart')], 404);
497 }
498
499 $distinctPostIds = $ownedRows->pluck('post_id')->unique();
500 if ($distinctPostIds->count() !== 1) {
501 $db->rollBack();
502 return $this->sendError(['message' => __('All variants must belong to the same product.', 'fluent-cart')], 422);
503 }
504 $updatedProductId = (int) $distinctPostIds->first();
505
506 foreach ($ownedRows as $existingVariant) {
507 $vid = (int) $existingVariant->id;
508 $rowUpdate = [];
509
510 if (isset($topLevelDelta['item_price'])) {
511 $rowUpdate['item_price'] = $topLevelDelta['item_price'];
512 }
513
514 if (isset($topLevelDelta['_compare_price_dollars'])) {
515 $compareCents = Helper::toCent($topLevelDelta['_compare_price_dollars']);
516 $itemPriceCents = isset($rowUpdate['item_price'])
517 ? (int) $rowUpdate['item_price']
518 : (int) $existingVariant->item_price;
519 $rowUpdate['compare_price'] = ($compareCents > 0 && $compareCents >= $itemPriceCents)
520 ? $compareCents
521 : 0;
522 } elseif (isset($rowUpdate['item_price'])) {
523 $existingCompare = (int) $existingVariant->compare_price;
524 if ($existingCompare > 0 && $existingCompare < $rowUpdate['item_price']) {
525 $rowUpdate['compare_price'] = 0;
526 }
527 }
528
529 foreach (['sku', 'manage_stock', 'total_stock', 'fulfillment_type', 'manage_cost', 'item_cost'] as $field) {
530 if (array_key_exists($field, $topLevelDelta)) {
531 $rowUpdate[$field] = $topLevelDelta[$field];
532 }
533 }
534
535 if (isset($rowUpdate['manage_stock']) || isset($rowUpdate['total_stock'])) {
536 $manageStock = isset($rowUpdate['manage_stock']) ? $rowUpdate['manage_stock'] : (int) $existingVariant->manage_stock;
537 $totalStock = isset($rowUpdate['total_stock']) ? $rowUpdate['total_stock'] : (int) $existingVariant->total_stock;
538 $rowUpdate['stock_status'] = ($manageStock && $totalStock > 0) ? Helper::IN_STOCK : Helper::OUT_OF_STOCK;
539 if (!$manageStock) {
540 $rowUpdate['stock_status'] = Helper::IN_STOCK;
541 }
542 }
543
544 if ($otherInfoDelta !== null && !empty($otherInfoDelta)) {
545 $existingOtherInfo = is_array($existingVariant->other_info) ? $existingVariant->other_info : [];
546 $merged = array_merge($existingOtherInfo, $otherInfoDelta);
547
548 // Prefer payment_type from the merged other_info; fall back
549 // to the top-level column so signup_fee is converted to cents
550 // even when the request omits payment_type entirely.
551 $paymentType = Arr::get($merged, 'payment_type') ?: $existingVariant->payment_type;
552 if ($paymentType === 'onetime') {
553 foreach (['repeat_interval', 'interval', 'interval_count', 'billing_summary',
554 'manage_setup_fee', 'signup_fee', 'signup_fee_name', 'times', 'trial_days'] as $subKey) {
555 unset($merged[$subKey]);
556 }
557 }
558 if ($paymentType === 'subscription' && array_key_exists('signup_fee', $otherInfoDelta)) {
559 $merged['signup_fee'] = Helper::toCent(floatval($otherInfoDelta['signup_fee']));
560 }
561
562 $merged['is_bundle_product'] = Arr::get($existingOtherInfo, 'is_bundle_product', 'no');
563 $merged['bundle_child_ids'] = Arr::get($existingOtherInfo, 'bundle_child_ids', []);
564
565 $rowUpdate['other_info'] = $merged;
566
567 if (isset($otherInfoDelta['payment_type'])) {
568 $rowUpdate['payment_type'] = $otherInfoDelta['payment_type'] === 'subscription'
569 ? 'subscription'
570 : 'onetime';
571 }
572 }
573
574 if (!empty($rowUpdate)) {
575 $rowUpdate['updated_at'] = $now;
576 ProductVariation::query()->where('id', $vid)->update($rowUpdate);
577 $batchData[] = array_merge(['id' => $vid], $rowUpdate);
578 }
579 }
580
581 $db->commit();
582 } catch (\Throwable $e) {
583 $db->rollBack();
584 return $this->sendError(['message' => __('Failed to update variants.', 'fluent-cart')], 500);
585 }
586
587 do_action('fluent_cart/product/variants_updated', [
588 'post_id' => $updatedProductId,
589 'variants' => $batchData,
590 ]);
591
592 /* translators: %1$s: number of variants updated */
593 return $this->sendSuccess([
594 'message' => sprintf(__('%1$s variants updated successfully.', 'fluent-cart'), count($variantIds)),
595 'updated' => count($variantIds),
596 ]);
597 }
598
599 /**
600 * Sanitize the other_info delta for group bulk update.
601 * Only known sub-keys are allowed; unknown keys are dropped to prevent
602 * arbitrary data injection into the JSON column.
603 */
604 private function sanitizeOtherInfoDelta(array $raw)
605 {
606 $allowed = [
607 'description' => 'sanitize_textarea_field',
608 'tax_inclusion' => 'sanitize_text_field',
609 'package_slug' => 'sanitize_text_field',
610 'weight_unit' => 'sanitize_text_field',
611 'billing_summary' => 'sanitize_textarea_field',
612 'manage_setup_fee' => 'sanitize_text_field',
613 'signup_fee_name' => 'sanitize_text_field',
614 'times' => 'sanitize_text_field',
615 'repeat_interval' => 'sanitize_text_field',
616 'interval' => 'sanitize_text_field',
617 ];
618 $numericFields = ['weight', 'length', 'width', 'height'];
619 $intFields = ['interval_count', 'trial_days'];
620
621 $delta = [];
622
623 foreach ($allowed as $key => $sanitizer) {
624 $value = Arr::get($raw, $key);
625 if ($value === null || $value === '') {
626 continue;
627 }
628 $delta[$key] = $sanitizer($value);
629 }
630
631 // Enum-validated fields — unknown values are dropped rather than stored.
632 $paymentType = Arr::get($raw, 'payment_type');
633 if ($paymentType !== null && $paymentType !== '') {
634 $paymentType = sanitize_text_field($paymentType);
635 if (in_array($paymentType, ['onetime', 'subscription'], true)) {
636 $delta['payment_type'] = $paymentType;
637 }
638 }
639
640 $taxExempt = Arr::get($raw, 'tax_exempt');
641 if ($taxExempt !== null) {
642 $delta['tax_exempt'] = sanitize_text_field($taxExempt) === 'yes' ? 'yes' : 'no';
643 }
644
645 $taxClass = Arr::get($raw, 'tax_class');
646 if ($taxClass !== null && $taxClass !== '') {
647 $taxClass = sanitize_text_field($taxClass);
648 if (TaxClass::query()->where('slug', $taxClass)->exists()) {
649 $delta['tax_class'] = $taxClass;
650 }
651 }
652
653 foreach ($numericFields as $key) {
654 $value = Arr::get($raw, $key);
655 if ($value === null || $value === '') {
656 continue;
657 }
658 $delta[$key] = floatval($value);
659 }
660
661 // signup_fee is stored in dollars here; groupBulkUpdate() converts to cents
662 // via Helper::toCent() when payment_type is subscription.
663 $signupFee = Arr::get($raw, 'signup_fee');
664 if ($signupFee !== null && $signupFee !== '') {
665 $delta['signup_fee'] = floatval($signupFee);
666 }
667
668 foreach ($intFields as $key) {
669 $value = Arr::get($raw, $key);
670 if ($value === null || $value === '') {
671 continue;
672 }
673 $delta[$key] = intval($value);
674 }
675
676 return $delta;
677 }
678 }
679