PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.1.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.1.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / api / product.php

product.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.1.0, at includes/api/product.php

887 lines 33.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\API;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 use StoreEngine\classes\AbstractProduct;
10 use StoreEngine\Classes\Price;
11 use StoreEngine\Classes\Product\VariableProduct;
12 use StoreEngine\Classes\SkuGenerator;
13 use StoreEngine\Classes\Variation;
14 use StoreEngine\Utils\Helper;
15 use Throwable;
16 use WP_Post;
17 use WP_REST_Request;
18
19 class Product {
20
21 public static function init() {
22 $self = new self();
23
24 add_filter( 'rest_prepare_' . Helper::PRODUCT_POST_TYPE, [ $self, 'extend_product_rest_response' ], 10, 3 );
25 add_action( 'rest_insert_storeengine_product', [ $self, 'save_product_data' ], 10, 3 );
26 add_action( 'rest_api_init', [ $self, 'register_stock_routes' ] );
27 }
28
29 public function register_stock_routes() {
30 register_rest_route( 'storeengine/v1', '/products/(?P<id>\d+)/stock-adjust', [
31 'methods' => 'POST',
32 'callback' => [ $this, 'rest_stock_adjust' ],
33 'permission_callback' => [ $this, 'rest_stock_permission' ],
34 'args' => [
35 'variation_id' => [ 'type' => 'integer', 'default' => 0 ],
36 'action' => [ 'type' => 'string', 'required' => true, 'enum' => [ 'add', 'remove', 'set' ] ],
37 'quantity' => [ 'type' => 'integer', 'required' => true, 'minimum' => 0 ],
38 'reason' => [ 'type' => 'string', 'default' => 'manual' ],
39 'note' => [ 'type' => 'string', 'default' => '' ],
40 ],
41 ] );
42
43 register_rest_route( 'storeengine/v1', '/products/(?P<id>\d+)/stock-movements', [
44 'methods' => 'GET',
45 'callback' => [ $this, 'rest_stock_movements' ],
46 'permission_callback' => [ $this, 'rest_stock_permission' ],
47 'args' => [
48 'variation_id' => [ 'type' => 'integer', 'default' => 0 ],
49 'per_page' => [ 'type' => 'integer', 'default' => 25, 'minimum' => 1, 'maximum' => 100 ],
50 'page' => [ 'type' => 'integer', 'default' => 1, 'minimum' => 1 ],
51 ],
52 ] );
53
54 // On-demand SKU / barcode generation for the "Generate" buttons in the
55 // product editor. Uses the same engine + pattern as auto-on-save.
56 register_rest_route( 'storeengine/v1', '/inventory/generate-code', [
57 'methods' => 'POST',
58 'callback' => [ $this, 'rest_generate_code' ],
59 'permission_callback' => fn() => current_user_can( 'edit_storeengine_products' ),
60 'args' => [
61 'type' => [ 'type' => 'string', 'required' => true, 'enum' => [ 'sku', 'barcode' ] ],
62 'name' => [ 'type' => 'string', 'default' => '' ],
63 'category_id' => [ 'type' => 'integer', 'default' => 0 ],
64 ],
65 ] );
66
67 // Batch resolve pasted SKUs / barcodes to product name + price for the
68 // Barcode Labels page. The standard product collection `search` only
69 // matches title/content, so it can't auto-fill a label from a raw code.
70 register_rest_route( 'storeengine/v1', '/inventory/resolve-codes', [
71 'methods' => 'POST',
72 'callback' => [ $this, 'rest_resolve_codes' ],
73 'permission_callback' => fn() => current_user_can( 'edit_storeengine_products' ),
74 'args' => [
75 'codes' => [
76 'type' => 'array',
77 'required' => true,
78 'items' => [ 'type' => 'string' ],
79 ],
80 ],
81 ] );
82 }
83
84 public function rest_generate_code( WP_REST_Request $request ) {
85 $type = (string) $request['type'];
86
87 if ( 'barcode' === $type ) {
88 return rest_ensure_response( [ 'value' => SkuGenerator::generate_barcode() ] );
89 }
90
91 $category = '';
92 $cat_id = (int) ( $request['category_id'] ?? 0 );
93 if ( $cat_id > 0 ) {
94 $term = get_term( $cat_id, Helper::PRODUCT_CATEGORY_TAXONOMY );
95 if ( $term && ! is_wp_error( $term ) ) {
96 $category = (string) $term->slug;
97 }
98 }
99
100 return rest_ensure_response( [
101 'value' => SkuGenerator::generate_sku( [
102 'name' => sanitize_text_field( (string) ( $request['name'] ?? '' ) ),
103 'category' => $category,
104 ] ),
105 ] );
106 }
107
108 /**
109 * Resolve a batch of SKUs / barcodes to printable label data
110 * (name, sku, barcode, selling price). Exact match only — keyed by the
111 * original code so the Barcode Labels page can auto-fill pasted entries.
112 * Codes with no match are simply omitted from the response map.
113 */
114 public function rest_resolve_codes( WP_REST_Request $request ) {
115 global $wpdb;
116
117 $codes = $request->get_param( 'codes' );
118 $codes = is_array( $codes ) ? $codes : [];
119 $codes = array_values( array_unique( array_filter(
120 array_map( static fn( $c ) => trim( sanitize_text_field( (string) $c ) ), $codes ),
121 static fn( $c ) => '' !== $c
122 ) ) );
123 $codes = array_slice( $codes, 0, 200 );
124
125 $out = [];
126 if ( empty( $codes ) ) {
127 return rest_ensure_response( (object) $out );
128 }
129
130 $variations_table = $wpdb->prefix . 'storeengine_product_variations';
131
132 foreach ( $codes as $code ) {
133 // Variant exact match (barcode or SKU). Variations store a price
134 // increment; the selling price is base + increment (mirrors the
135 // POS lookup controller).
136 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
137 $row = $wpdb->get_row( $wpdb->prepare(
138 "SELECT v.id, v.product_id, v.sku, v.barcode, v.price, p.post_title AS product_title
139 FROM {$variations_table} v
140 LEFT JOIN {$wpdb->posts} p ON p.ID = v.product_id
141 WHERE ( v.barcode = %s OR v.sku = %s )
142 AND p.post_status <> 'trash'
143 LIMIT 1",
144 $code, $code
145 ) );
146 // phpcs:enable
147
148 if ( $row ) {
149 // Object-level scope: never disclose a product the caller can't edit.
150 if ( ! $this->can_resolve_product( (int) $row->product_id ) ) {
151 continue;
152 }
153 $name = (string) ( $row->product_title ?? '' );
154 $vlabel = $this->resolve_variant_label( (int) $row->id );
155 if ( '' !== $vlabel ) {
156 $name = '' !== $name ? $name . '' . $vlabel : $vlabel;
157 }
158 $extra = null === $row->price ? 0.0 : (float) $row->price;
159 $out[ $code ] = [
160 'name' => $name,
161 'sku' => (string) $row->sku,
162 'barcode' => $row->barcode ? (string) $row->barcode : '',
163 'price' => $this->resolve_base_price( (int) $row->product_id ) + $extra,
164 ];
165 continue;
166 }
167
168 // Simple-product exact match by SKU / barcode postmeta.
169 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
170 $simple = $wpdb->get_row( $wpdb->prepare(
171 "SELECT p.ID AS product_id,
172 p.post_title AS product_title,
173 sku.meta_value AS sku,
174 bc.meta_value AS barcode
175 FROM {$wpdb->posts} p
176 LEFT JOIN {$wpdb->postmeta} sku ON sku.post_id = p.ID AND sku.meta_key = '_storeengine_sku'
177 LEFT JOIN {$wpdb->postmeta} bc ON bc.post_id = p.ID AND bc.meta_key = '_storeengine_barcode'
178 WHERE p.post_type = %s
179 AND p.post_status <> 'trash'
180 AND ( sku.meta_value = %s OR bc.meta_value = %s )
181 LIMIT 1",
182 Helper::PRODUCT_POST_TYPE, $code, $code
183 ) );
184 // phpcs:enable
185
186 if ( $simple ) {
187 // Object-level scope: never disclose a product the caller can't edit.
188 if ( ! $this->can_resolve_product( (int) $simple->product_id ) ) {
189 continue;
190 }
191 $prices = Helper::get_prices_array_by_product_id( (int) $simple->product_id );
192 $out[ $code ] = [
193 'name' => (string) ( $simple->product_title ?? '' ),
194 'sku' => (string) ( $simple->sku ?? '' ),
195 'barcode' => $simple->barcode ? (string) $simple->barcode : '',
196 'price' => isset( $prices[0]['price'] ) ? (float) $prices[0]['price'] : null,
197 ];
198 }
199 }
200
201 return rest_ensure_response( (object) $out );
202 }
203
204 /**
205 * Default (lowest-order) base price for a product, from the price table.
206 */
207 protected function resolve_base_price( int $product_id ): float {
208 if ( ! $product_id ) {
209 return 0.0;
210 }
211 global $wpdb;
212 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
213 $base = $wpdb->get_var( $wpdb->prepare(
214 "SELECT price FROM {$wpdb->prefix}storeengine_product_price WHERE product_id = %d ORDER BY `order` ASC LIMIT 1",
215 $product_id
216 ) );
217 // phpcs:enable
218 return null === $base ? 0.0 : (float) $base;
219 }
220
221 /**
222 * Human-readable variant attribute label, e.g. "Black / M".
223 */
224 protected function resolve_variant_label( int $variation_id ): string {
225 if ( ! $variation_id ) {
226 return '';
227 }
228 try {
229 $variation = ( new Variation( $variation_id ) )->get();
230 } catch ( Throwable $e ) {
231 return '';
232 }
233 if ( ! $variation ) {
234 return '';
235 }
236 $parts = [];
237 foreach ( $variation->get_attributes() as $attribute ) {
238 if ( ! empty( $attribute->name ) ) {
239 $parts[] = $attribute->name;
240 }
241 }
242 return implode( ' / ', $parts );
243 }
244
245 /**
246 * Permission gate for the per-product stock routes. The old gate just
247 * checked the plural `edit_storeengine_products` cap, which the multi-
248 * vendor addon grants to EVERY vendor — so any vendor could POST to any
249 * other vendor's product id and adjust their stock (or read their
250 * movement history). Now we also verify the caller owns the product
251 * being targeted.
252 *
253 * Uses the inventory addon's Authorization helper when available (same
254 * helper the sibling /inventory/adjust route uses, so behavior stays
255 * consistent across endpoints). When the addon isn't loaded the IDOR
256 * surface doesn't exist either — multi-vendor needs inventory — but we
257 * fall back to a direct post_author check to be safe.
258 */
259 public function rest_stock_permission( WP_REST_Request $request ): bool {
260 if ( ! current_user_can( 'edit_storeengine_products' ) ) {
261 return false;
262 }
263 $product_id = (int) $request['id'];
264 if ( $product_id <= 0 ) {
265 return false;
266 }
267 if ( class_exists( '\\StoreEngine\\Addons\\Inventory\\Classes\\Authorization' ) ) {
268 return \StoreEngine\Addons\Inventory\Classes\Authorization::can_modify_product( $product_id );
269 }
270 // Fallback: privileged roles bypass; everyone else must own the product.
271 if ( current_user_can( 'manage_options' ) ) {
272 return true;
273 }
274 return (int) get_post_field( 'post_author', $product_id ) === (int) get_current_user_id();
275 }
276
277 /**
278 * Object-level gate for cross-product code lookups (Barcode Labels resolve).
279 *
280 * The route only checks the broad `edit_storeengine_products` cap, which the
281 * multi-vendor addon grants to EVERY vendor — so on its own it is not a
282 * tenancy boundary. Without this check a vendor could resolve another
283 * seller's product (name / SKU / barcode / price) by guessing a code. Mirror
284 * rest_stock_permission's ownership logic so resolution is scoped to products
285 * the caller may actually edit.
286 */
287 protected function can_resolve_product( int $product_id ): bool {
288 if ( $product_id <= 0 ) {
289 return false;
290 }
291 if ( class_exists( '\\StoreEngine\\Addons\\Inventory\\Classes\\Authorization' ) ) {
292 return \StoreEngine\Addons\Inventory\Classes\Authorization::can_modify_product( $product_id );
293 }
294 // Fallback: privileged roles bypass; everyone else must own the product.
295 if ( current_user_can( 'manage_options' ) ) {
296 return true;
297 }
298 return (int) get_post_field( 'post_author', $product_id ) === (int) get_current_user_id();
299 }
300
301 public function rest_stock_adjust( \WP_REST_Request $request ) {
302 $product_id = (int) $request['id'];
303 $variation_id = (int) ( $request['variation_id'] ?? 0 );
304 $action = (string) $request['action'];
305 $quantity = (int) $request['quantity'];
306 $reason = sanitize_text_field( (string) ( $request['reason'] ?? 'manual' ) );
307 $note = sanitize_textarea_field( (string) ( $request['note'] ?? '' ) );
308
309 $result = \StoreEngine\Classes\StockManager::adjust_stock(
310 $product_id,
311 $variation_id,
312 $action,
313 $quantity,
314 $reason,
315 $note
316 );
317
318 if ( ! $result['ok'] ) {
319 return new \WP_Error( 'stock_adjust_failed', $result['message'] ?? 'Failed to adjust stock', [ 'status' => 400 ] );
320 }
321
322 return new \WP_REST_Response( $result, 200 );
323 }
324
325 public function rest_stock_movements( \WP_REST_Request $request ) {
326 $product_id = (int) $request['id'];
327 $variation_id = (int) ( $request['variation_id'] ?? 0 );
328 $per_page = max( 1, min( 100, (int) ( $request['per_page'] ?? 25 ) ) );
329 $page = max( 1, (int) ( $request['page'] ?? 1 ) );
330 $offset = ( $page - 1 ) * $per_page;
331
332 $rows = \StoreEngine\Classes\StockManager::get_movements( $product_id, $variation_id, $per_page, $offset );
333
334 return new \WP_REST_Response( [ 'items' => $rows ], 200 );
335 }
336
337 public function extend_product_rest_response( $item, $post, $request ) {
338 $context = $request->get_param( 'context' );
339
340 // Defence-in-depth: never expose downloadable-file URLs / attachment ids
341 // in public (view / embed) REST responses. The meta is registered
342 // edit-only (see Database::register_product_meta), so core normally
343 // strips it here already — this guarantees it even if that context filter
344 // is ever bypassed. Files are delivered through a permission-checked
345 // download handler, never this product object.
346 if ( 'edit' !== $context && isset( $item->data['meta']['_storeengine_product_downloadable_files'] ) ) {
347 unset( $item->data['meta']['_storeengine_product_downloadable_files'] );
348 }
349
350 $product = Helper::get_product( $item->data['id'] );
351 $item->data['product_type'] = $product->get_type();
352 // Admin edit context (product editor, coupon price picker, etc.) must
353 // see frontend-hidden prices so they remain selectable/manageable.
354 $item->data['prices'] = Helper::get_prices_array_by_product_id( $item->data['id'], $context, 'edit' === $context );
355
356 $can_see_inventory = current_user_can( 'edit_storeengine_products' );
357 $item->data['stock'] = self::build_stock_payload( $product, $can_see_inventory );
358
359 // Simple-product SKU / barcode. Variable products carry these per-
360 // variant in the `variants` array further down; simple products read
361 // from postmeta (legacy convention also used by inventory queries
362 // and SkuGenerator).
363 if ( 'simple' === $item->data['product_type'] ) {
364 $item->data['sku'] = (string) get_post_meta( $item->data['id'], '_storeengine_sku', true );
365 $item->data['barcode'] = (string) get_post_meta( $item->data['id'], '_storeengine_barcode', true );
366 }
367
368 $item->data['integrations'] = array_map( fn( $integration ) => [
369 'id' => $integration->integration->get_id(),
370 'product_id' => $integration->price->get_product_id(),
371 'price_id' => $integration->price->get_id(),
372 'integration_id' => $integration->integration->get_integration_id(),
373 'provider' => $integration->integration->get_provider(),
374 'course_ids' => 'storeengine/course-bundle' === $integration->integration->get_provider() ? get_post_meta( $integration->integration->get_integration_id(), 'academy_course_bundle_courses_ids', true ) ?? [] : [],
375 ], Helper::get_integrations_by_product_id( $item->data['id'] ) );
376
377 $attributes = [];
378
379 foreach ( $product->get_attributes() as $taxonomy => $terms ) {
380 $taxonomyKey = Helper::strip_attribute_taxonomy_name( $taxonomy );
381 $attributes[] = [
382 'label' => $taxonomyKey,
383 'ids' => array_map( fn( $term ) => $term->term_id, $terms ),
384 ];
385 }
386
387 $item->data['attributes'] = $attributes;
388
389 if ( 'bundled' === $item->data['product_type'] ) {
390 $item->data['bundles'] = $product->get_bundles();
391 }
392
393 if ( 'variable' === $item->data['product_type'] ) {
394 $item->data['variants'] = array_map( function ( $variant ) use ( $can_see_inventory ) {
395 $data = [];
396 $taxonomies = [];
397 foreach ( $variant->get_attributes() as $attribute ) {
398 if ( ! taxonomy_exists( $attribute->taxonomy ) ) {
399 continue;
400 }
401 $data[] = [
402 'label' => get_taxonomy( $attribute->taxonomy )->label,
403 'value' => $attribute->name,
404 ];
405
406 $taxonomies[ Helper::strip_attribute_taxonomy_name( $attribute->taxonomy ) ] = $attribute->term_id;
407 }
408
409 return [
410 'id' => $variant->get_id(),
411 'taxonomies' => $taxonomies,
412 'data' => $data,
413 'featured_image_id' => $variant->get_featured_image(),
414 'pricing_id' => $variant->get_pricing_id(),
415 'price' => $variant->get_price(),
416 'cost_price' => method_exists( $variant, 'get_cost_price' ) ? $variant->get_cost_price() : null,
417 'sku' => $variant->get_sku(),
418 'barcode' => method_exists( $variant, 'get_barcode' ) ? $variant->get_barcode() : null,
419 'stock' => self::build_stock_payload( $variant, $can_see_inventory ),
420 ];
421 }, $product->get_variants() );
422 }
423
424 return $item;
425 }
426
427 public static function build_stock_payload( $entity, bool $expose_inventory = false ): array {
428 $payload = [
429 'manages_stock' => false,
430 'stock_status' => 'instock',
431 'is_in_stock' => true,
432 'low_stock' => false,
433 'backorders' => 'no',
434 ];
435
436 if ( ! is_object( $entity ) ) {
437 return $payload;
438 }
439
440 if ( method_exists( $entity, 'manages_stock' ) ) {
441 $payload['manages_stock'] = (bool) $entity->manages_stock();
442 }
443
444 if ( method_exists( $entity, 'get_stock_status' ) ) {
445 $payload['stock_status'] = $entity->get_stock_status();
446 }
447
448 if ( method_exists( $entity, 'is_in_stock' ) ) {
449 $payload['is_in_stock'] = (bool) $entity->is_in_stock();
450 }
451
452 if ( method_exists( $entity, 'is_low_stock' ) ) {
453 $payload['low_stock'] = (bool) $entity->is_low_stock();
454 }
455
456 if ( method_exists( $entity, 'get_backorders' ) ) {
457 $payload['backorders'] = $entity->get_backorders();
458 }
459
460 if ( $expose_inventory ) {
461 if ( method_exists( $entity, 'get_stock_quantity' ) ) {
462 $payload['stock_quantity'] = $entity->get_stock_quantity();
463 }
464
465 if ( method_exists( $entity, 'get_low_stock_threshold' ) ) {
466 $payload['low_stock_threshold'] = $entity->get_low_stock_threshold();
467 }
468
469 if ( method_exists( $entity, 'is_sold_individually' ) ) {
470 $payload['sold_individually'] = (bool) $entity->is_sold_individually();
471 }
472 }
473
474 return $payload;
475 }
476
477 public function save_product_data( WP_Post $post, WP_REST_Request $request, bool $creating ) {
478 $this->save_attributes( $post, $request );
479 $this->save_stock_fields( $post, $request );
480
481 // @TODO Update price props, this will reduces extra ajax endpoint for saving/creating price
482 // Also, improve ux as adding price will no longer need product id.
483
484 // Updating custom sort-order.
485 if ( ! $creating ) {
486 $prices = $request->get_param( 'prices' );
487 if ( ! empty( $prices ) && is_array( $prices ) ) {
488 foreach ( $prices as $index => [ 'id' => $id, 'price_name' => $price_name ] ) {
489 $price = new Price( $id );
490 $price->set_name( $price_name );
491 $price->set_order( $index );
492 $price->save();
493 }
494 }
495 }
496 }
497
498
499 public function save_attributes( WP_Post $post, WP_REST_Request $request ) {
500 // Can be simple, variable, bundled, etc.
501 $old_type = get_post_meta( $post->ID, '_storeengine_product_type', true );
502 $product_type = $request->get_param( 'product_type' ) ?? 'simple';
503 $variants = $request->get_param( 'variants' );
504 $bundles = $request->get_param( 'bundles' );
505
506 // Handle variable/variations.
507 if ( 'variable' === $old_type && ( $old_type !== $product_type || empty( $variants ) || ! is_array( $variants ) ) ) {
508 $variants = [];
509 $product_type = 'simple';
510 $product = new VariableProduct( $post->ID );
511 foreach ( $product->get_variants() as $variation ) {
512 $variation->delete();
513 }
514 }
515
516 if ( $variants && is_array( $variants ) ) {
517 $product_type = 'variable';
518 $product = new VariableProduct( $post->ID );
519 $new_variations_data = [];
520 $edit_variations_data = [];
521
522 foreach ( $variants as $variant ) {
523 if ( ! isset( $variant['taxonomies'] ) || ! is_array( $variant['taxonomies'] ) ) {
524 continue;
525 }
526
527 if ( isset( $variant['id'] ) ) {
528 $edit_variations_data[ $variant['id'] ] = $variant;
529 } else {
530 $new_variations_data[] = $variant;
531 }
532 }
533
534 foreach ( $product->get_variants() as $variation ) {
535 if ( isset( $edit_variations_data[ $variation->get_id() ] ) ) {
536 $this->save_variation_data( $variation, $product->get_id(), $edit_variations_data[ $variation->get_id() ] );
537 } else {
538 $variation->delete();
539 }
540 }
541
542 if ( ! empty( $new_variations_data ) ) {
543 foreach ( $new_variations_data as $new_variation_data ) {
544 $this->save_variation_data( new Variation(), $product->get_id(), $new_variation_data );
545 }
546 }
547 }
548
549 // Handle product bundles.
550 if ( 'bundled' === $old_type && ( $old_type !== $product_type || empty( $bundles ) || ! is_array( $bundles ) ) ) {
551 $bundles = [];
552 delete_post_meta( $post->ID, '_storeengine_product_bundles' );
553 $product_type = 'simple';
554 }
555
556 if ( $bundles && is_array( $bundles ) ) {
557 $data = [];
558 $prices = [];
559
560 foreach ( $bundles as $bundle ) {
561 $product_id = absint( $bundle['product_id'] ?? 0 );
562 $price_id = absint( $bundle['price_id'] ?? 0 );
563 $quantity = absint( $bundle['quantity'] ?? 1 );
564
565 if ( ! $product_id || ! $price_id || ! $quantity ) {
566 continue;
567 }
568
569 try {
570 $price = new Price( $price_id );
571
572 $prices[] = $price->get_price();
573
574 $data[] = [
575 'product_id' => $price->get_product_id(),
576 'price_id' => $price->get_id(),
577 'quantity' => $quantity,
578 ];
579 } catch ( Throwable $e ) {
580 Helper::log_error( $e );
581 }
582 }
583
584 if ( ! empty( $data ) ) {
585 $min_max = array_unique( [ min( $prices ), max( $prices ) ] );
586 update_post_meta( $post->ID, '_storeengine_product_bundle_max_min_prices', $min_max );
587 update_post_meta( $post->ID, '_storeengine_product_bundles', $data );
588
589 $product_type = 'bundled';
590 }
591 }
592
593 if ( $product_type ) {
594 update_post_meta( $post->ID, '_storeengine_product_type', $product_type );
595 }
596
597 // Simple-product SKU + barcode. Variable products store these per-
598 // variant inside the variations save loop above; for simple products
599 // we mirror the legacy convention used by inventory queries and the
600 // POS lookup-controller — postmeta keys _storeengine_sku /
601 // _storeengine_barcode.
602 if ( 'simple' === $product_type ) {
603 if ( $request->has_param( 'sku' ) ) {
604 $sku = sanitize_text_field( (string) $request->get_param( 'sku' ) );
605 if ( '' === $sku ) {
606 delete_post_meta( $post->ID, '_storeengine_sku' );
607 } else {
608 update_post_meta( $post->ID, '_storeengine_sku', $sku );
609 }
610 }
611 if ( $request->has_param( 'barcode' ) ) {
612 $barcode = sanitize_text_field( (string) $request->get_param( 'barcode' ) );
613 if ( '' === $barcode ) {
614 delete_post_meta( $post->ID, '_storeengine_barcode' );
615 } else {
616 update_post_meta( $post->ID, '_storeengine_barcode', $barcode );
617 }
618 }
619
620 // Auto-generate when enabled and the field is still empty.
621 if ( Helper::get_settings( 'auto_generate_sku' ) && '' === (string) get_post_meta( $post->ID, '_storeengine_sku', true ) ) {
622 update_post_meta( $post->ID, '_storeengine_sku', SkuGenerator::generate_sku( [
623 'name' => $post->post_title,
624 'category' => SkuGenerator::product_category_slug( $post->ID ),
625 ] ) );
626 }
627 if ( Helper::get_settings( 'auto_generate_barcode' ) && '' === (string) get_post_meta( $post->ID, '_storeengine_barcode', true ) ) {
628 update_post_meta( $post->ID, '_storeengine_barcode', SkuGenerator::generate_barcode() );
629 }
630 }
631
632 $product = Helper::get_product( $post->ID );
633
634 $prices = [];
635
636 foreach ( $product->get_prices() as $price ) {
637 $prices[] = $price->get_price();
638 };
639
640 if ( $prices ) {
641 $min_max = array_unique( [ min( $prices ), max( $prices ) ] );
642
643 update_post_meta( $post->ID, '_storeengine_product_max_min_prices', $min_max );
644 }
645
646 $unformatted_attributes = $request->get_param( 'attributes' );
647 if ( ! is_array( $unformatted_attributes ) ) {
648 return;
649 }
650
651 $attributes = [];
652
653 foreach ( $unformatted_attributes as $unformatted_attribute ) {
654 $attributes[ $unformatted_attribute['label'] ] = $unformatted_attribute['ids'];
655 }
656
657 $unformatted_existence_attributes = $product->get_attributes();
658 $existence_attributes = [];
659
660 foreach ( $unformatted_existence_attributes as $taxonomy => $terms ) {
661 $taxonomyKey = Helper::strip_attribute_taxonomy_name( $taxonomy );
662 $existence_attributes[ $taxonomyKey ] = array_map( fn( $term ) => $term->term_id, $terms );
663 }
664
665 if ( $attributes !== $existence_attributes ) {
666 $product->set_attributes_order( array_map( fn( $taxonomy ) => Helper::get_attribute_taxonomy_name( $taxonomy ), array_keys( $attributes ) ) );
667
668 foreach ( $attributes as $key => $value ) {
669 wp_set_object_terms( $post->ID, array_map( fn( $val ) => (int) sanitize_text_field( $val ), $value ), Helper::get_attribute_taxonomy_name( sanitize_text_field( $key ) ) );
670 }
671
672 // Update the order.
673 $update_values = [];
674 $update_cases = [];
675
676 foreach ( $attributes as $taxonomy => $terms ) {
677 foreach ( $terms as $order => $term_id ) {
678 $update_cases[] = "WHEN tr.term_taxonomy_id = $term_id THEN $order";
679 $update_values[] = $term_id;
680 }
681 }
682
683 // Execute bulk UPDATE if there are items to update
684 if ( ! empty( $update_cases ) ) {
685 global $wpdb;
686 $update_query = "UPDATE {$wpdb->term_relationships} tr
687 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
688 SET tr.term_order = CASE " . implode( ' ', $update_cases ) . ' END
689 WHERE tr.object_id = %d
690 AND tr.term_taxonomy_id IN
691 (' . implode( ',', array_fill( 0, count( $update_values ), '%d' ) ) . ')';
692 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
693 $wpdb->query( $wpdb->prepare( $update_query, $post->ID, ...$update_values ) );
694 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
695 }
696 wp_cache_flush_group( AbstractProduct::CACHE_GROUP );
697 }
698 }
699
700 private function save_stock_fields( WP_Post $post, WP_REST_Request $request ) {
701 $stock = $request->get_param( 'stock' );
702
703 if ( ! is_array( $stock ) ) {
704 return;
705 }
706
707 $old_status = get_post_meta( $post->ID, '_storeengine_stock_status', true );
708
709 if ( array_key_exists( 'manages_stock', $stock ) ) {
710 update_post_meta( $post->ID, '_storeengine_manage_stock', (bool) $stock['manages_stock'] );
711 }
712
713 if ( array_key_exists( 'stock_quantity', $stock ) ) {
714 $qty = $stock['stock_quantity'];
715 if ( '' === $qty || null === $qty ) {
716 delete_post_meta( $post->ID, '_storeengine_stock_quantity' );
717 } else {
718 update_post_meta( $post->ID, '_storeengine_stock_quantity', (int) $qty );
719 }
720 }
721
722 if ( array_key_exists( 'stock_status', $stock ) ) {
723 $status = sanitize_text_field( $stock['stock_status'] );
724 if ( in_array( $status, [ 'instock', 'outofstock', 'onbackorder' ], true ) ) {
725 update_post_meta( $post->ID, '_storeengine_stock_status', $status );
726 }
727 }
728
729 if ( array_key_exists( 'backorders', $stock ) ) {
730 $backorders = sanitize_text_field( $stock['backorders'] );
731 if ( in_array( $backorders, [ 'no', 'notify', 'yes' ], true ) ) {
732 update_post_meta( $post->ID, '_storeengine_backorders', $backorders );
733 }
734 }
735
736 if ( array_key_exists( 'low_stock_threshold', $stock ) ) {
737 $threshold = $stock['low_stock_threshold'];
738 if ( '' === $threshold || null === $threshold ) {
739 delete_post_meta( $post->ID, '_storeengine_low_stock_threshold' );
740 } else {
741 update_post_meta( $post->ID, '_storeengine_low_stock_threshold', (int) $threshold );
742 }
743 }
744
745 if ( array_key_exists( 'sold_individually', $stock ) ) {
746 update_post_meta( $post->ID, '_storeengine_sold_individually', (bool) $stock['sold_individually'] );
747 }
748
749 // Sync stock_status from stock_quantity when manage_stock=true.
750 $manage_stock = (bool) get_post_meta( $post->ID, '_storeengine_manage_stock', true );
751
752 if ( $manage_stock ) {
753 $qty = (int) get_post_meta( $post->ID, '_storeengine_stock_quantity', true );
754 $backorders = get_post_meta( $post->ID, '_storeengine_backorders', true ) ?: 'no';
755 $allowed = in_array( $backorders, [ 'yes', 'notify' ], true );
756
757 if ( $qty <= 0 && ! $allowed ) {
758 $new_status = 'outofstock';
759 } elseif ( $qty <= 0 && $allowed ) {
760 $new_status = 'onbackorder';
761 } else {
762 $new_status = 'instock';
763 }
764
765 update_post_meta( $post->ID, '_storeengine_stock_status', $new_status );
766
767 if ( $old_status && $old_status !== $new_status ) {
768 do_action( 'storeengine/stock_status_changed', $post->ID, 0, $old_status, $new_status );
769 }
770 } elseif ( $old_status ) {
771 $current_status = get_post_meta( $post->ID, '_storeengine_stock_status', true );
772 if ( $current_status && $current_status !== $old_status ) {
773 do_action( 'storeengine/stock_status_changed', $post->ID, 0, $old_status, $current_status );
774 }
775 }
776
777 // Mirror simple-product aggregate qty into per-location stock at the
778 // default location when the inventory-pro addon is on. Listener:
779 // `storeengine/inventory/stock_quantity_set` action.
780 if ( $manage_stock ) {
781 $qty_for_mirror = (int) get_post_meta( $post->ID, '_storeengine_stock_quantity', true );
782 /**
783 * @see Variation::save() — same hook fires from variation saves.
784 */
785 do_action(
786 'storeengine/inventory/stock_quantity_set',
787 (int) $post->ID,
788 0,
789 $qty_for_mirror,
790 'editor'
791 );
792 }
793 }
794
795 private function save_variation_data( Variation $variation, int $product_id, array $data ) {
796 $variation->set_product_id( $product_id );
797 $price = isset( $data['price'] ) && is_numeric( $data['price'] ) ? (float) sanitize_text_field( $data['price'] ) : null;
798 $variation->set_price( $price );
799 $pricing_id = (int) sanitize_text_field( $data['pricing_id'] ?? 0 );
800 $variation->set_price_id( $pricing_id > 0 ? $pricing_id : null );
801 $featured_image_id = (int) sanitize_text_field( $data['featured_image_id'] ?? 0 );
802 $variation->set_featured_image( $featured_image_id > 0 ? $featured_image_id : null );
803 $variation->set_sku( sanitize_text_field( $data['sku'] ) );
804
805 if ( array_key_exists( 'barcode', $data ) ) {
806 $barcode = $data['barcode'];
807 $variation->set_barcode( ( null === $barcode || '' === $barcode ) ? null : sanitize_text_field( (string) $barcode ) );
808 }
809
810 if ( array_key_exists( 'cost_price', $data ) ) {
811 $cost = $data['cost_price'];
812 $variation->set_cost_price( ( '' === $cost || null === $cost ) ? null : (float) $cost );
813 }
814
815 $term_ids = array_map( fn( $term_id ) => (int) sanitize_text_field( $term_id ), $data['taxonomies'] );
816 $term_ids = array_values( $term_ids );
817 $variation->set_attributes( $term_ids );
818
819 $stock = $data['stock'] ?? [];
820
821 $old_status = method_exists( $variation, 'get_stock_status' ) ? $variation->get_stock_status() : null;
822
823 if ( is_array( $stock ) ) {
824 if ( array_key_exists( 'manages_stock', $stock ) ) {
825 $variation->set_manage_stock( (bool) $stock['manages_stock'] );
826 }
827
828 if ( array_key_exists( 'stock_quantity', $stock ) ) {
829 $qty = $stock['stock_quantity'];
830 $variation->set_stock_quantity( ( '' === $qty || null === $qty ) ? null : (int) $qty );
831 }
832
833 if ( array_key_exists( 'backorders', $stock ) ) {
834 $variation->set_backorders( sanitize_text_field( $stock['backorders'] ) );
835 }
836
837 if ( array_key_exists( 'low_stock_threshold', $stock ) ) {
838 $threshold = $stock['low_stock_threshold'];
839 $variation->set_low_stock_threshold( ( '' === $threshold || null === $threshold ) ? null : (int) $threshold );
840 }
841
842 $manage_stock_now = isset( $stock['manages_stock'] ) ? (bool) $stock['manages_stock'] : $variation->manages_stock();
843
844 if ( $manage_stock_now ) {
845 $qty_now = isset( $stock['stock_quantity'] ) ? (int) $stock['stock_quantity'] : (int) $variation->get_stock_quantity();
846 $backorders = isset( $stock['backorders'] ) ? sanitize_text_field( $stock['backorders'] ) : $variation->get_backorders();
847 $allowed = in_array( $backorders, [ 'yes', 'notify' ], true );
848
849 if ( $qty_now <= 0 && ! $allowed ) {
850 $new_status = 'outofstock';
851 } elseif ( $qty_now <= 0 && $allowed ) {
852 $new_status = 'onbackorder';
853 } else {
854 $new_status = 'instock';
855 }
856
857 $variation->new_data_set_stock_status( $new_status );
858 } elseif ( array_key_exists( 'stock_status', $stock ) ) {
859 $status = sanitize_text_field( $stock['stock_status'] );
860 if ( in_array( $status, [ 'instock', 'outofstock', 'onbackorder' ], true ) ) {
861 $variation->new_data_set_stock_status( $status );
862 }
863 }
864 }
865
866 // Auto-generate SKU/barcode for this variation when enabled and empty.
867 if ( '' === (string) $variation->get_sku() && Helper::get_settings( 'auto_generate_sku' ) ) {
868 $variation->set_sku( SkuGenerator::generate_sku( [
869 'name' => get_the_title( $product_id ),
870 'category' => SkuGenerator::product_category_slug( $product_id ),
871 ] ) );
872 }
873 if ( ! $variation->get_barcode() && Helper::get_settings( 'auto_generate_barcode' ) ) {
874 $variation->set_barcode( SkuGenerator::generate_barcode() );
875 }
876
877 $variation->save();
878
879 if ( $old_status && method_exists( $variation, 'get_stock_status' ) ) {
880 $new_status_after_save = $variation->get_stock_status();
881 if ( $new_status_after_save !== $old_status ) {
882 do_action( 'storeengine/stock_status_changed', $product_id, $variation->get_id(), $old_status, $new_status_after_save );
883 }
884 }
885 }
886 }
887