PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.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.2.0, at includes/api/product.php

1,239 lines 46.5 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 // Public: rendered Quick View (mini single-product) markup for the shop
55 // archive modal.
56 register_rest_route( 'storeengine/v1', '/products/(?P<id>\d+)/quick-view', [
57 'methods' => 'GET',
58 'callback' => [ $this, 'rest_quick_view' ],
59 'permission_callback' => '__return_true',
60 'args' => [
61 'id' => [ 'type' => 'integer' ],
62 ],
63 ] );
64
65 // Recently-viewed cards for a client-supplied list of product ids (order
66 // preserved). The list lives in the shopper's browser (localStorage); this
67 // only renders the cards.
68 register_rest_route( 'storeengine/v1', '/products/recently-viewed', [
69 'methods' => 'GET',
70 'callback' => [ $this, 'rest_recently_viewed' ],
71 'permission_callback' => '__return_true',
72 'args' => [
73 'ids' => [ 'type' => 'string', 'default' => '' ],
74 'exclude' => [ 'type' => 'integer', 'default' => 0 ],
75 ],
76 ] );
77
78 // Wishlist. Toggle/merge persist to user meta (logged-in only); guests
79 // keep their list in the browser. Cards renders for a given id list.
80 register_rest_route( 'storeengine/v1', '/wishlist/toggle', [
81 'methods' => 'POST',
82 'callback' => [ $this, 'rest_wishlist_toggle' ],
83 'permission_callback' => fn() => is_user_logged_in(),
84 'args' => [
85 'product_id' => [ 'type' => 'integer', 'required' => true ],
86 ],
87 ] );
88 register_rest_route( 'storeengine/v1', '/wishlist/merge', [
89 'methods' => 'POST',
90 'callback' => [ $this, 'rest_wishlist_merge' ],
91 'permission_callback' => fn() => is_user_logged_in(),
92 'args' => [
93 'ids' => [ 'type' => 'string', 'default' => '' ],
94 ],
95 ] );
96 register_rest_route( 'storeengine/v1', '/wishlist/cards', [
97 'methods' => 'GET',
98 'callback' => [ $this, 'rest_wishlist_cards' ],
99 'permission_callback' => '__return_true',
100 'args' => [
101 'ids' => [ 'type' => 'string', 'default' => '' ],
102 ],
103 ] );
104
105 // Product compare. Same split as the wishlist: toggle/merge persist to
106 // user meta for logged-in shoppers, guests keep the list in the browser,
107 // and table renders the comparison for a given id list.
108 register_rest_route( 'storeengine/v1', '/compare/toggle', [
109 'methods' => 'POST',
110 'callback' => [ $this, 'rest_compare_toggle' ],
111 'permission_callback' => fn() => is_user_logged_in(),
112 'args' => [
113 'product_id' => [ 'type' => 'integer', 'required' => true ],
114 ],
115 ] );
116 register_rest_route( 'storeengine/v1', '/compare/clear', [
117 'methods' => 'POST',
118 'callback' => [ $this, 'rest_compare_clear' ],
119 'permission_callback' => fn() => is_user_logged_in(),
120 ] );
121 register_rest_route( 'storeengine/v1', '/compare/merge', [
122 'methods' => 'POST',
123 'callback' => [ $this, 'rest_compare_merge' ],
124 'permission_callback' => fn() => is_user_logged_in(),
125 'args' => [
126 'ids' => [ 'type' => 'string', 'default' => '' ],
127 ],
128 ] );
129 register_rest_route( 'storeengine/v1', '/compare/table', [
130 'methods' => 'GET',
131 'callback' => [ $this, 'rest_compare_table' ],
132 'permission_callback' => '__return_true',
133 'args' => [
134 'ids' => [ 'type' => 'string', 'default' => '' ],
135 ],
136 ] );
137 // Minimal id/title/thumb for the docked tray — the full table would be
138 // far too much payload just to draw a row of thumbnails.
139 register_rest_route( 'storeengine/v1', '/compare/items', [
140 'methods' => 'GET',
141 'callback' => [ $this, 'rest_compare_items' ],
142 'permission_callback' => '__return_true',
143 'args' => [
144 'ids' => [ 'type' => 'string', 'default' => '' ],
145 ],
146 ] );
147
148 // On-demand SKU / barcode generation for the "Generate" buttons in the
149 // product editor. Uses the same engine + pattern as auto-on-save.
150 register_rest_route( 'storeengine/v1', '/inventory/generate-code', [
151 'methods' => 'POST',
152 'callback' => [ $this, 'rest_generate_code' ],
153 'permission_callback' => fn() => current_user_can( 'edit_storeengine_products' ),
154 'args' => [
155 'type' => [ 'type' => 'string', 'required' => true, 'enum' => [ 'sku', 'barcode' ] ],
156 'name' => [ 'type' => 'string', 'default' => '' ],
157 'category_id' => [ 'type' => 'integer', 'default' => 0 ],
158 ],
159 ] );
160
161 // Batch resolve pasted SKUs / barcodes to product name + price for the
162 // Barcode Labels page. The standard product collection `search` only
163 // matches title/content, so it can't auto-fill a label from a raw code.
164 register_rest_route( 'storeengine/v1', '/inventory/resolve-codes', [
165 'methods' => 'POST',
166 'callback' => [ $this, 'rest_resolve_codes' ],
167 'permission_callback' => fn() => current_user_can( 'edit_storeengine_products' ),
168 'args' => [
169 'codes' => [
170 'type' => 'array',
171 'required' => true,
172 'items' => [ 'type' => 'string' ],
173 ],
174 ],
175 ] );
176 }
177
178 public function rest_generate_code( WP_REST_Request $request ) {
179 $type = (string) $request['type'];
180
181 if ( 'barcode' === $type ) {
182 return rest_ensure_response( [ 'value' => SkuGenerator::generate_barcode() ] );
183 }
184
185 $category = '';
186 $cat_id = (int) ( $request['category_id'] ?? 0 );
187 if ( $cat_id > 0 ) {
188 $term = get_term( $cat_id, Helper::PRODUCT_CATEGORY_TAXONOMY );
189 if ( $term && ! is_wp_error( $term ) ) {
190 $category = (string) $term->slug;
191 }
192 }
193
194 return rest_ensure_response( [
195 'value' => SkuGenerator::generate_sku( [
196 'name' => sanitize_text_field( (string) ( $request['name'] ?? '' ) ),
197 'category' => $category,
198 ] ),
199 ] );
200 }
201
202 /**
203 * Resolve a batch of SKUs / barcodes to printable label data
204 * (name, sku, barcode, selling price). Exact match only — keyed by the
205 * original code so the Barcode Labels page can auto-fill pasted entries.
206 * Codes with no match are simply omitted from the response map.
207 */
208 public function rest_resolve_codes( WP_REST_Request $request ) {
209 global $wpdb;
210
211 $codes = $request->get_param( 'codes' );
212 $codes = is_array( $codes ) ? $codes : [];
213 $codes = array_values( array_unique( array_filter(
214 array_map( static fn( $c ) => trim( sanitize_text_field( (string) $c ) ), $codes ),
215 static fn( $c ) => '' !== $c
216 ) ) );
217 $codes = array_slice( $codes, 0, 200 );
218
219 $out = [];
220 if ( empty( $codes ) ) {
221 return rest_ensure_response( (object) $out );
222 }
223
224 $variations_table = $wpdb->prefix . 'storeengine_product_variations';
225
226 foreach ( $codes as $code ) {
227 // Variant exact match (barcode or SKU). Variations store a price
228 // increment; the selling price is base + increment (mirrors the
229 // POS lookup controller).
230 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- %i identifier + %s values bound via prepare() on a custom StoreEngine table joined to core posts; per-code barcode/SKU lookup, not cacheable.
231 $row = $wpdb->get_row( $wpdb->prepare(
232 "SELECT v.id, v.product_id, v.sku, v.barcode, v.price, p.post_title AS product_title
233 FROM %i v
234 LEFT JOIN {$wpdb->posts} p ON p.ID = v.product_id
235 WHERE ( v.barcode = %s OR v.sku = %s )
236 AND p.post_status <> 'trash'
237 LIMIT 1",
238 $variations_table, $code, $code
239 ) );
240 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
241
242 if ( $row ) {
243 // Object-level scope: never disclose a product the caller can't edit.
244 if ( ! $this->can_resolve_product( (int) $row->product_id ) ) {
245 continue;
246 }
247 $name = (string) ( $row->product_title ?? '' );
248 $vlabel = $this->resolve_variant_label( (int) $row->id );
249 if ( '' !== $vlabel ) {
250 $name = '' !== $name ? $name . '' . $vlabel : $vlabel;
251 }
252 $extra = null === $row->price ? 0.0 : (float) $row->price;
253 $out[ $code ] = [
254 'name' => $name,
255 'sku' => (string) $row->sku,
256 'barcode' => $row->barcode ? (string) $row->barcode : '',
257 'price' => $this->resolve_base_price( (int) $row->product_id ) + $extra,
258 ];
259 continue;
260 }
261
262 // Simple-product exact match by SKU / barcode postmeta.
263 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
264 $simple = $wpdb->get_row( $wpdb->prepare(
265 "SELECT p.ID AS product_id,
266 p.post_title AS product_title,
267 sku.meta_value AS sku,
268 bc.meta_value AS barcode
269 FROM {$wpdb->posts} p
270 LEFT JOIN {$wpdb->postmeta} sku ON sku.post_id = p.ID AND sku.meta_key = '_storeengine_sku'
271 LEFT JOIN {$wpdb->postmeta} bc ON bc.post_id = p.ID AND bc.meta_key = '_storeengine_barcode'
272 WHERE p.post_type = %s
273 AND p.post_status <> 'trash'
274 AND ( sku.meta_value = %s OR bc.meta_value = %s )
275 LIMIT 1",
276 Helper::PRODUCT_POST_TYPE, $code, $code
277 ) );
278 // phpcs:enable
279
280 if ( $simple ) {
281 // Object-level scope: never disclose a product the caller can't edit.
282 if ( ! $this->can_resolve_product( (int) $simple->product_id ) ) {
283 continue;
284 }
285 $prices = Helper::get_prices_array_by_product_id( (int) $simple->product_id );
286 $out[ $code ] = [
287 'name' => (string) ( $simple->product_title ?? '' ),
288 'sku' => (string) ( $simple->sku ?? '' ),
289 'barcode' => $simple->barcode ? (string) $simple->barcode : '',
290 'price' => isset( $prices[0]['price'] ) ? (float) $prices[0]['price'] : null,
291 ];
292 }
293 }
294
295 return rest_ensure_response( (object) $out );
296 }
297
298 /**
299 * Default (lowest-order) base price for a product, from the price table.
300 */
301 protected function resolve_base_price( int $product_id ): float {
302 if ( ! $product_id ) {
303 return 0.0;
304 }
305 global $wpdb;
306 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
307 $base = $wpdb->get_var( $wpdb->prepare(
308 "SELECT price FROM {$wpdb->prefix}storeengine_product_price WHERE product_id = %d ORDER BY `order` ASC LIMIT 1",
309 $product_id
310 ) );
311 // phpcs:enable
312 return null === $base ? 0.0 : (float) $base;
313 }
314
315 /**
316 * Human-readable variant attribute label, e.g. "Black / M".
317 */
318 protected function resolve_variant_label( int $variation_id ): string {
319 if ( ! $variation_id ) {
320 return '';
321 }
322 try {
323 $variation = ( new Variation( $variation_id ) )->get();
324 } catch ( Throwable $e ) {
325 return '';
326 }
327 if ( ! $variation ) {
328 return '';
329 }
330 $parts = [];
331 foreach ( $variation->get_attributes() as $attribute ) {
332 if ( ! empty( $attribute->name ) ) {
333 $parts[] = $attribute->name;
334 }
335 }
336 return implode( ' / ', $parts );
337 }
338
339 /**
340 * Permission gate for the per-product stock routes. The old gate just
341 * checked the plural `edit_storeengine_products` cap, which the multi-
342 * vendor addon grants to EVERY vendor — so any vendor could POST to any
343 * other vendor's product id and adjust their stock (or read their
344 * movement history). Now we also verify the caller owns the product
345 * being targeted.
346 *
347 * Uses the inventory addon's Authorization helper when available (same
348 * helper the sibling /inventory/adjust route uses, so behavior stays
349 * consistent across endpoints). When the addon isn't loaded the IDOR
350 * surface doesn't exist either — multi-vendor needs inventory — but we
351 * fall back to a direct post_author check to be safe.
352 */
353 /**
354 * Render the Quick View (mini single-product) markup for the archive modal.
355 */
356 public function rest_quick_view( \WP_REST_Request $request ) {
357 $product_id = absint( $request['id'] );
358 $product = Helper::get_product( $product_id );
359
360 if ( ! $product || 'publish' !== get_post_status( $product_id ) ) {
361 return new \WP_Error( 'storeengine_not_found', __( 'Product not found.', 'storeengine' ), [ 'status' => 404 ] );
362 }
363
364 global $post;
365 $post = get_post( $product_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
366 $GLOBALS['product'] = $product; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
367 setup_postdata( $post );
368
369 ob_start();
370 Helper::get_template( 'single-product/quick-view.php', [ 'product' => $product ] );
371 $html = ob_get_clean();
372
373 wp_reset_postdata();
374
375 $response = [
376 'id' => $product_id,
377 'permalink' => get_permalink( $product_id ),
378 'html' => $html,
379 ];
380
381 // Variable products need their variation matrix client-side to wire the
382 // picker + variation-aware price placeholder. On a full product page this
383 // is localized as `StoreEngineProductVariations`, but the archive page
384 // (which hosts Quick View) never localizes it, so ship it in the payload.
385 if ( $product instanceof \StoreEngine\Classes\Product\VariableProduct && 'variable' === $product->get_type() ) {
386 $response['variations'] = \StoreEngine\Assets::get_product_variations( $product );
387 }
388
389 return rest_ensure_response( $response );
390 }
391
392 /**
393 * Render "recently viewed" product cards for a client-supplied id list.
394 * Order follows the given ids (most-recent first); the current product is
395 * excluded. Returns rendered loop-card HTML.
396 */
397 public function rest_recently_viewed( \WP_REST_Request $request ) {
398 if ( ! Helper::get_settings( 'enable_recently_viewed', true ) ) {
399 return rest_ensure_response( [ 'html' => '' ] );
400 }
401
402 $ids = array_values( array_filter( array_map(
403 'absint',
404 explode( ',', (string) $request->get_param( 'ids' ) )
405 ) ) );
406
407 $exclude = absint( $request->get_param( 'exclude' ) );
408 if ( $exclude ) {
409 $ids = array_values( array_diff( $ids, [ $exclude ] ) );
410 }
411 $ids = array_slice( array_unique( $ids ), 0, 12 );
412
413 return rest_ensure_response( [ 'html' => $this->render_product_cards( $ids ) ] );
414 }
415
416 /**
417 * Render loop-card HTML for the given product ids, in the order given.
418 * Shared by Recently Viewed and Wishlist.
419 *
420 * @param int[] $ids
421 */
422 private function render_product_cards( array $ids ): string {
423 $ids = array_values( array_filter( array_map( 'absint', $ids ) ) );
424 if ( empty( $ids ) ) {
425 return '';
426 }
427
428 $products_per_row = Helper::get_settings( 'product_archive_products_per_row', (object) [
429 'desktop' => 3,
430 'tablet' => 2,
431 'mobile' => 1,
432 ] );
433 $grid_class = Helper::get_responsive_column( [
434 'desktop' => (int) ( $products_per_row->desktop ?? 3 ),
435 'tablet' => (int) ( $products_per_row->tablet ?? 2 ),
436 'mobile' => (int) ( $products_per_row->mobile ?? 1 ),
437 ] );
438
439 $query = new \WP_Query( [
440 'post_type' => Helper::PRODUCT_POST_TYPE,
441 'post_status' => 'publish',
442 'post__in' => $ids,
443 'orderby' => 'post__in',
444 'posts_per_page' => count( $ids ),
445 'no_found_rows' => true,
446 'ignore_sticky_posts' => true,
447 ] );
448
449 ob_start();
450 if ( $query->have_posts() ) {
451 while ( $query->have_posts() ) {
452 $query->the_post();
453 Helper::get_template( 'content-product.php', [ 'grid_class' => $grid_class ] );
454 }
455 }
456 wp_reset_postdata();
457
458 return (string) ob_get_clean();
459 }
460
461 /** Toggle a product in the logged-in user's wishlist (user meta). */
462 public function rest_wishlist_toggle( \WP_REST_Request $request ) {
463 if ( ! Helper::get_settings( 'enable_wishlist', false ) ) {
464 return new \WP_Error( 'storeengine_wishlist_disabled', __( 'Wishlist is disabled.', 'storeengine' ), [ 'status' => 403 ] );
465 }
466 $product_id = absint( $request->get_param( 'product_id' ) );
467 if ( ! $product_id || Helper::PRODUCT_POST_TYPE !== get_post_type( $product_id ) ) {
468 return new \WP_Error( 'storeengine_not_found', __( 'Product not found.', 'storeengine' ), [ 'status' => 404 ] );
469 }
470
471 $ids = storeengine_get_user_wishlist();
472 $in = in_array( $product_id, $ids, true );
473 $ids = $in ? array_values( array_diff( $ids, [ $product_id ] ) ) : array_merge( $ids, [ $product_id ] );
474 $ids = storeengine_set_user_wishlist( $ids );
475
476 return rest_ensure_response( [
477 'ids' => array_map( 'strval', $ids ),
478 'count' => count( $ids ),
479 'in_wishlist' => ! $in,
480 ] );
481 }
482
483 /** Merge a guest's browser wishlist into the account on login. */
484 public function rest_wishlist_merge( \WP_REST_Request $request ) {
485 if ( ! Helper::get_settings( 'enable_wishlist', false ) ) {
486 return new \WP_Error( 'storeengine_wishlist_disabled', __( 'Wishlist is disabled.', 'storeengine' ), [ 'status' => 403 ] );
487 }
488 $incoming = array_filter( array_map( 'absint', explode( ',', (string) $request->get_param( 'ids' ) ) ) );
489 $ids = storeengine_set_user_wishlist( array_merge( storeengine_get_user_wishlist(), $incoming ) );
490
491 return rest_ensure_response( [
492 'ids' => array_map( 'strval', $ids ),
493 'count' => count( $ids ),
494 ] );
495 }
496
497 /** Toggle a product in the logged-in user's compare list (user meta). */
498 public function rest_compare_toggle( \WP_REST_Request $request ) {
499 if ( ! Helper::get_settings( 'enable_product_compare', false ) ) {
500 return new \WP_Error( 'storeengine_compare_disabled', __( 'Product compare is disabled.', 'storeengine' ), [ 'status' => 403 ] );
501 }
502 $product_id = absint( $request->get_param( 'product_id' ) );
503 if ( ! $product_id || Helper::PRODUCT_POST_TYPE !== get_post_type( $product_id ) ) {
504 return new \WP_Error( 'storeengine_not_found', __( 'Product not found.', 'storeengine' ), [ 'status' => 404 ] );
505 }
506
507 $ids = storeengine_get_user_compare();
508 $in = in_array( $product_id, $ids, true );
509 $ids = $in ? array_values( array_diff( $ids, [ $product_id ] ) ) : array_merge( $ids, [ $product_id ] );
510 // set_user_compare() applies the cap, so the response is authoritative
511 // even when the shopper just pushed past the limit.
512 $ids = storeengine_set_user_compare( $ids );
513
514 return rest_ensure_response( [
515 'ids' => array_map( 'strval', $ids ),
516 'count' => count( $ids ),
517 'in_compare' => in_array( $product_id, $ids, true ),
518 'max' => \StoreEngine\Classes\ProductCompare::max(),
519 ] );
520 }
521
522 /** Empty the logged-in user's compare list. */
523 public function rest_compare_clear() {
524 if ( ! Helper::get_settings( 'enable_product_compare', false ) ) {
525 return new \WP_Error( 'storeengine_compare_disabled', __( 'Product compare is disabled.', 'storeengine' ), [ 'status' => 403 ] );
526 }
527
528 storeengine_set_user_compare( [] );
529
530 return rest_ensure_response( [ 'ids' => [], 'count' => 0 ] );
531 }
532
533 /** Merge a guest's browser compare list into the account on login. */
534 public function rest_compare_merge( \WP_REST_Request $request ) {
535 if ( ! Helper::get_settings( 'enable_product_compare', false ) ) {
536 return new \WP_Error( 'storeengine_compare_disabled', __( 'Product compare is disabled.', 'storeengine' ), [ 'status' => 403 ] );
537 }
538 $incoming = array_filter( array_map( 'absint', explode( ',', (string) $request->get_param( 'ids' ) ) ) );
539 $ids = storeengine_set_user_compare( array_merge( storeengine_get_user_compare(), $incoming ) );
540
541 return rest_ensure_response( [
542 'ids' => array_map( 'strval', $ids ),
543 'count' => count( $ids ),
544 ] );
545 }
546
547 /** Render the comparison table for a client-supplied id list. */
548 public function rest_compare_table( \WP_REST_Request $request ) {
549 if ( ! Helper::get_settings( 'enable_product_compare', false ) ) {
550 return rest_ensure_response( [ 'html' => '' ] );
551 }
552 $ids = array_values( array_unique( array_filter( array_map(
553 'absint',
554 explode( ',', (string) $request->get_param( 'ids' ) )
555 ) ) ) );
556
557 return rest_ensure_response( [
558 'html' => \StoreEngine\Classes\ProductCompare::get_table_html( $ids ),
559 ] );
560 }
561
562 /** Minimal product info for the docked compare tray. */
563 public function rest_compare_items( \WP_REST_Request $request ) {
564 if ( ! Helper::get_settings( 'enable_product_compare', false ) ) {
565 return rest_ensure_response( [ 'items' => [] ] );
566 }
567
568 $ids = array_slice(
569 array_values( array_unique( array_filter( array_map(
570 'absint',
571 explode( ',', (string) $request->get_param( 'ids' ) )
572 ) ) ) ),
573 0,
574 \StoreEngine\Classes\ProductCompare::max()
575 );
576
577 $items = [];
578 foreach ( $ids as $id ) {
579 if ( Helper::PRODUCT_POST_TYPE !== get_post_type( $id ) || 'publish' !== get_post_status( $id ) ) {
580 continue;
581 }
582 $thumb = get_the_post_thumbnail_url( $id, 'thumbnail' );
583 $items[] = [
584 'id' => (string) $id,
585 'title' => get_the_title( $id ),
586 'url' => get_permalink( $id ),
587 'thumb' => $thumb ?: storeengine_placeholder_image_src(),
588 ];
589 }
590
591 return rest_ensure_response( [ 'items' => $items ] );
592 }
593
594 /** Render wishlist product cards for a client-supplied id list. */
595 public function rest_wishlist_cards( \WP_REST_Request $request ) {
596 if ( ! Helper::get_settings( 'enable_wishlist', false ) ) {
597 return rest_ensure_response( [ 'html' => '' ] );
598 }
599 $ids = array_slice(
600 array_values( array_unique( array_filter( array_map(
601 'absint',
602 explode( ',', (string) $request->get_param( 'ids' ) )
603 ) ) ) ),
604 0,
605 100
606 );
607
608 return rest_ensure_response( [ 'html' => $this->render_product_cards( $ids ) ] );
609 }
610
611 public function rest_stock_permission( WP_REST_Request $request ): bool {
612 if ( ! current_user_can( 'edit_storeengine_products' ) ) {
613 return false;
614 }
615 $product_id = (int) $request['id'];
616 if ( $product_id <= 0 ) {
617 return false;
618 }
619 if ( class_exists( '\\StoreEngine\\Addons\\Inventory\\Classes\\Authorization' ) ) {
620 return \StoreEngine\Addons\Inventory\Classes\Authorization::can_modify_product( $product_id );
621 }
622 // Fallback: privileged roles bypass; everyone else must own the product.
623 if ( current_user_can( 'manage_options' ) ) {
624 return true;
625 }
626 return (int) get_post_field( 'post_author', $product_id ) === (int) get_current_user_id();
627 }
628
629 /**
630 * Object-level gate for cross-product code lookups (Barcode Labels resolve).
631 *
632 * The route only checks the broad `edit_storeengine_products` cap, which the
633 * multi-vendor addon grants to EVERY vendor — so on its own it is not a
634 * tenancy boundary. Without this check a vendor could resolve another
635 * seller's product (name / SKU / barcode / price) by guessing a code. Mirror
636 * rest_stock_permission's ownership logic so resolution is scoped to products
637 * the caller may actually edit.
638 */
639 protected function can_resolve_product( int $product_id ): bool {
640 if ( $product_id <= 0 ) {
641 return false;
642 }
643 if ( class_exists( '\\StoreEngine\\Addons\\Inventory\\Classes\\Authorization' ) ) {
644 return \StoreEngine\Addons\Inventory\Classes\Authorization::can_modify_product( $product_id );
645 }
646 // Fallback: privileged roles bypass; everyone else must own the product.
647 if ( current_user_can( 'manage_options' ) ) {
648 return true;
649 }
650 return (int) get_post_field( 'post_author', $product_id ) === (int) get_current_user_id();
651 }
652
653 public function rest_stock_adjust( \WP_REST_Request $request ) {
654 $product_id = (int) $request['id'];
655 $variation_id = (int) ( $request['variation_id'] ?? 0 );
656 $action = (string) $request['action'];
657 $quantity = (int) $request['quantity'];
658 $reason = sanitize_text_field( (string) ( $request['reason'] ?? 'manual' ) );
659 $note = sanitize_textarea_field( (string) ( $request['note'] ?? '' ) );
660
661 $result = \StoreEngine\Classes\StockManager::adjust_stock(
662 $product_id,
663 $variation_id,
664 $action,
665 $quantity,
666 $reason,
667 $note
668 );
669
670 if ( ! $result['ok'] ) {
671 return new \WP_Error( 'stock_adjust_failed', $result['message'] ?? 'Failed to adjust stock', [ 'status' => 400 ] );
672 }
673
674 return new \WP_REST_Response( $result, 200 );
675 }
676
677 public function rest_stock_movements( \WP_REST_Request $request ) {
678 $product_id = (int) $request['id'];
679 $variation_id = (int) ( $request['variation_id'] ?? 0 );
680 $per_page = max( 1, min( 100, (int) ( $request['per_page'] ?? 25 ) ) );
681 $page = max( 1, (int) ( $request['page'] ?? 1 ) );
682 $offset = ( $page - 1 ) * $per_page;
683
684 $rows = \StoreEngine\Classes\StockManager::get_movements( $product_id, $variation_id, $per_page, $offset );
685
686 return new \WP_REST_Response( [ 'items' => $rows ], 200 );
687 }
688
689 public function extend_product_rest_response( $item, $post, $request ) {
690 $context = $request->get_param( 'context' );
691
692 // Defence-in-depth: never expose downloadable-file URLs / attachment ids
693 // in public (view / embed) REST responses. The meta is registered
694 // edit-only (see Database::register_product_meta), so core normally
695 // strips it here already — this guarantees it even if that context filter
696 // is ever bypassed. Files are delivered through a permission-checked
697 // download handler, never this product object.
698 if ( 'edit' !== $context && isset( $item->data['meta']['_storeengine_product_downloadable_files'] ) ) {
699 unset( $item->data['meta']['_storeengine_product_downloadable_files'] );
700 }
701
702 $product = Helper::get_product( $item->data['id'] );
703 $item->data['product_type'] = $product->get_type();
704 // Admin edit context (product editor, coupon price picker, etc.) must
705 // see frontend-hidden prices so they remain selectable/manageable.
706 $item->data['prices'] = Helper::get_prices_array_by_product_id( $item->data['id'], $context, 'edit' === $context );
707
708 $can_see_inventory = current_user_can( 'edit_storeengine_products' );
709 $item->data['stock'] = self::build_stock_payload( $product, $can_see_inventory );
710
711 // Simple-product SKU / barcode. Variable products carry these per-
712 // variant in the `variants` array further down; simple products read
713 // from postmeta (legacy convention also used by inventory queries
714 // and SkuGenerator).
715 if ( 'simple' === $item->data['product_type'] ) {
716 $item->data['sku'] = (string) get_post_meta( $item->data['id'], '_storeengine_sku', true );
717 $item->data['barcode'] = (string) get_post_meta( $item->data['id'], '_storeengine_barcode', true );
718 }
719
720 $item->data['integrations'] = array_map( fn( $integration ) => [
721 'id' => $integration->integration->get_id(),
722 'product_id' => $integration->price->get_product_id(),
723 'price_id' => $integration->price->get_id(),
724 'integration_id' => $integration->integration->get_integration_id(),
725 'provider' => $integration->integration->get_provider(),
726 'course_ids' => 'storeengine/course-bundle' === $integration->integration->get_provider() ? get_post_meta( $integration->integration->get_integration_id(), 'academy_course_bundle_courses_ids', true ) ?? [] : [],
727 ], Helper::get_integrations_by_product_id( $item->data['id'] ) );
728
729 $attributes = [];
730
731 foreach ( $product->get_attributes() as $taxonomy => $terms ) {
732 $taxonomyKey = Helper::strip_attribute_taxonomy_name( $taxonomy );
733 $attributes[] = [
734 'label' => $taxonomyKey,
735 'ids' => array_map( fn( $term ) => $term->term_id, $terms ),
736 ];
737 }
738
739 $item->data['attributes'] = $attributes;
740
741 if ( 'bundled' === $item->data['product_type'] ) {
742 $item->data['bundles'] = $product->get_bundles();
743 }
744
745 if ( 'variable' === $item->data['product_type'] ) {
746 $item->data['variants'] = array_map( function ( $variant ) use ( $can_see_inventory ) {
747 $data = [];
748 $taxonomies = [];
749 foreach ( $variant->get_attributes() as $attribute ) {
750 if ( ! taxonomy_exists( $attribute->taxonomy ) ) {
751 continue;
752 }
753 $data[] = [
754 'label' => get_taxonomy( $attribute->taxonomy )->label,
755 'value' => $attribute->name,
756 ];
757
758 $taxonomies[ Helper::strip_attribute_taxonomy_name( $attribute->taxonomy ) ] = $attribute->term_id;
759 }
760
761 return [
762 'id' => $variant->get_id(),
763 'taxonomies' => $taxonomies,
764 'data' => $data,
765 'featured_image_id' => $variant->get_featured_image(),
766 'pricing_id' => $variant->get_pricing_id(),
767 'price' => $variant->get_price(),
768 'cost_price' => method_exists( $variant, 'get_cost_price' ) ? $variant->get_cost_price() : null,
769 'sku' => $variant->get_sku(),
770 'barcode' => method_exists( $variant, 'get_barcode' ) ? $variant->get_barcode() : null,
771 'stock' => self::build_stock_payload( $variant, $can_see_inventory ),
772 ];
773 }, $product->get_variants() );
774 }
775
776 return $item;
777 }
778
779 public static function build_stock_payload( $entity, bool $expose_inventory = false ): array {
780 $payload = [
781 'manages_stock' => false,
782 'stock_status' => 'instock',
783 'is_in_stock' => true,
784 'low_stock' => false,
785 'backorders' => 'no',
786 ];
787
788 if ( ! is_object( $entity ) ) {
789 return $payload;
790 }
791
792 if ( method_exists( $entity, 'manages_stock' ) ) {
793 $payload['manages_stock'] = (bool) $entity->manages_stock();
794 }
795
796 if ( method_exists( $entity, 'get_stock_status' ) ) {
797 $payload['stock_status'] = $entity->get_stock_status();
798 }
799
800 if ( method_exists( $entity, 'is_in_stock' ) ) {
801 $payload['is_in_stock'] = (bool) $entity->is_in_stock();
802 }
803
804 if ( method_exists( $entity, 'is_low_stock' ) ) {
805 $payload['low_stock'] = (bool) $entity->is_low_stock();
806 }
807
808 if ( method_exists( $entity, 'get_backorders' ) ) {
809 $payload['backorders'] = $entity->get_backorders();
810 }
811
812 if ( $expose_inventory ) {
813 if ( method_exists( $entity, 'get_stock_quantity' ) ) {
814 $payload['stock_quantity'] = $entity->get_stock_quantity();
815 }
816
817 if ( method_exists( $entity, 'get_low_stock_threshold' ) ) {
818 $payload['low_stock_threshold'] = $entity->get_low_stock_threshold();
819 }
820
821 if ( method_exists( $entity, 'is_sold_individually' ) ) {
822 $payload['sold_individually'] = (bool) $entity->is_sold_individually();
823 }
824 }
825
826 return $payload;
827 }
828
829 public function save_product_data( WP_Post $post, WP_REST_Request $request, bool $creating ) {
830 $this->save_attributes( $post, $request );
831 $this->save_stock_fields( $post, $request );
832
833 // @TODO Update price props, this will reduces extra ajax endpoint for saving/creating price
834 // Also, improve ux as adding price will no longer need product id.
835
836 // Updating custom sort-order.
837 if ( ! $creating ) {
838 $prices = $request->get_param( 'prices' );
839 if ( ! empty( $prices ) && is_array( $prices ) ) {
840 foreach ( $prices as $index => [ 'id' => $id, 'price_name' => $price_name ] ) {
841 $price = new Price( $id );
842 $price->set_name( $price_name );
843 $price->set_order( $index );
844 $price->save();
845 }
846 }
847 }
848 }
849
850
851 public function save_attributes( WP_Post $post, WP_REST_Request $request ) {
852 // Can be simple, variable, bundled, etc.
853 $old_type = get_post_meta( $post->ID, '_storeengine_product_type', true );
854 $product_type = $request->get_param( 'product_type' ) ?? 'simple';
855 $variants = $request->get_param( 'variants' );
856 $bundles = $request->get_param( 'bundles' );
857
858 // Handle variable/variations.
859 if ( 'variable' === $old_type && ( $old_type !== $product_type || empty( $variants ) || ! is_array( $variants ) ) ) {
860 $variants = [];
861 $product_type = 'simple';
862 $product = new VariableProduct( $post->ID );
863 foreach ( $product->get_variants() as $variation ) {
864 $variation->delete();
865 }
866 }
867
868 if ( $variants && is_array( $variants ) ) {
869 $product_type = 'variable';
870 $product = new VariableProduct( $post->ID );
871 $new_variations_data = [];
872 $edit_variations_data = [];
873
874 foreach ( $variants as $variant ) {
875 if ( ! isset( $variant['taxonomies'] ) || ! is_array( $variant['taxonomies'] ) ) {
876 continue;
877 }
878
879 if ( isset( $variant['id'] ) ) {
880 $edit_variations_data[ $variant['id'] ] = $variant;
881 } else {
882 $new_variations_data[] = $variant;
883 }
884 }
885
886 foreach ( $product->get_variants() as $variation ) {
887 if ( isset( $edit_variations_data[ $variation->get_id() ] ) ) {
888 $this->save_variation_data( $variation, $product->get_id(), $edit_variations_data[ $variation->get_id() ] );
889 } else {
890 $variation->delete();
891 }
892 }
893
894 if ( ! empty( $new_variations_data ) ) {
895 foreach ( $new_variations_data as $new_variation_data ) {
896 $this->save_variation_data( new Variation(), $product->get_id(), $new_variation_data );
897 }
898 }
899 }
900
901 // Handle product bundles.
902 if ( 'bundled' === $old_type && ( $old_type !== $product_type || empty( $bundles ) || ! is_array( $bundles ) ) ) {
903 $bundles = [];
904 delete_post_meta( $post->ID, '_storeengine_product_bundles' );
905 $product_type = 'simple';
906 }
907
908 if ( $bundles && is_array( $bundles ) ) {
909 $data = [];
910 $prices = [];
911
912 foreach ( $bundles as $bundle ) {
913 $product_id = absint( $bundle['product_id'] ?? 0 );
914 $price_id = absint( $bundle['price_id'] ?? 0 );
915 $quantity = absint( $bundle['quantity'] ?? 1 );
916
917 if ( ! $product_id || ! $price_id || ! $quantity ) {
918 continue;
919 }
920
921 try {
922 $price = new Price( $price_id );
923
924 $prices[] = $price->get_price();
925
926 $data[] = [
927 'product_id' => $price->get_product_id(),
928 'price_id' => $price->get_id(),
929 'quantity' => $quantity,
930 ];
931 } catch ( Throwable $e ) {
932 Helper::log_error( $e );
933 }
934 }
935
936 if ( ! empty( $data ) ) {
937 $min_max = array_unique( [ min( $prices ), max( $prices ) ] );
938 update_post_meta( $post->ID, '_storeengine_product_bundle_max_min_prices', $min_max );
939 update_post_meta( $post->ID, '_storeengine_product_bundles', $data );
940
941 $product_type = 'bundled';
942 }
943 }
944
945 if ( $product_type ) {
946 update_post_meta( $post->ID, '_storeengine_product_type', $product_type );
947 }
948
949 // Simple-product SKU + barcode. Variable products store these per-
950 // variant inside the variations save loop above; for simple products
951 // we mirror the legacy convention used by inventory queries and the
952 // POS lookup-controller — postmeta keys _storeengine_sku /
953 // _storeengine_barcode.
954 if ( 'simple' === $product_type ) {
955 if ( $request->has_param( 'sku' ) ) {
956 $sku = sanitize_text_field( (string) $request->get_param( 'sku' ) );
957 if ( '' === $sku ) {
958 delete_post_meta( $post->ID, '_storeengine_sku' );
959 } else {
960 update_post_meta( $post->ID, '_storeengine_sku', $sku );
961 }
962 }
963 if ( $request->has_param( 'barcode' ) ) {
964 $barcode = sanitize_text_field( (string) $request->get_param( 'barcode' ) );
965 if ( '' === $barcode ) {
966 delete_post_meta( $post->ID, '_storeengine_barcode' );
967 } else {
968 update_post_meta( $post->ID, '_storeengine_barcode', $barcode );
969 }
970 }
971
972 // Auto-generate when enabled and the field is still empty.
973 if ( Helper::get_settings( 'auto_generate_sku' ) && '' === (string) get_post_meta( $post->ID, '_storeengine_sku', true ) ) {
974 update_post_meta( $post->ID, '_storeengine_sku', SkuGenerator::generate_sku( [
975 'name' => $post->post_title,
976 'category' => SkuGenerator::product_category_slug( $post->ID ),
977 ] ) );
978 }
979 if ( Helper::get_settings( 'auto_generate_barcode' ) && '' === (string) get_post_meta( $post->ID, '_storeengine_barcode', true ) ) {
980 update_post_meta( $post->ID, '_storeengine_barcode', SkuGenerator::generate_barcode() );
981 }
982 }
983
984 $product = Helper::get_product( $post->ID );
985
986 $prices = [];
987
988 foreach ( $product->get_prices() as $price ) {
989 $prices[] = $price->get_price();
990 };
991
992 if ( $prices ) {
993 $min_max = array_unique( [ min( $prices ), max( $prices ) ] );
994
995 update_post_meta( $post->ID, '_storeengine_product_max_min_prices', $min_max );
996 }
997
998 $unformatted_attributes = $request->get_param( 'attributes' );
999 if ( ! is_array( $unformatted_attributes ) ) {
1000 return;
1001 }
1002
1003 $attributes = [];
1004
1005 foreach ( $unformatted_attributes as $unformatted_attribute ) {
1006 $attributes[ $unformatted_attribute['label'] ] = $unformatted_attribute['ids'];
1007 }
1008
1009 $unformatted_existence_attributes = $product->get_attributes();
1010 $existence_attributes = [];
1011
1012 foreach ( $unformatted_existence_attributes as $taxonomy => $terms ) {
1013 $taxonomyKey = Helper::strip_attribute_taxonomy_name( $taxonomy );
1014 $existence_attributes[ $taxonomyKey ] = array_map( fn( $term ) => $term->term_id, $terms );
1015 }
1016
1017 if ( $attributes !== $existence_attributes ) {
1018 $product->set_attributes_order( array_map( fn( $taxonomy ) => Helper::get_attribute_taxonomy_name( $taxonomy ), array_keys( $attributes ) ) );
1019
1020 foreach ( $attributes as $key => $value ) {
1021 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 ) ) );
1022 }
1023
1024 // Update the order.
1025 $update_values = [];
1026 $update_cases = [];
1027
1028 foreach ( $attributes as $taxonomy => $terms ) {
1029 foreach ( $terms as $order => $term_id ) {
1030 $update_cases[] = "WHEN tr.term_taxonomy_id = $term_id THEN $order";
1031 $update_values[] = $term_id;
1032 }
1033 }
1034
1035 // Execute bulk UPDATE if there are items to update
1036 if ( ! empty( $update_cases ) ) {
1037 global $wpdb;
1038 $update_query = "UPDATE {$wpdb->term_relationships} tr
1039 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1040 SET tr.term_order = CASE " . implode( ' ', $update_cases ) . ' END
1041 WHERE tr.object_id = %d
1042 AND tr.term_taxonomy_id IN
1043 (' . implode( ',', array_fill( 0, count( $update_values ), '%d' ) ) . ')';
1044 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
1045 $wpdb->query( $wpdb->prepare( $update_query, $post->ID, ...$update_values ) );
1046 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
1047 }
1048 wp_cache_flush_group( AbstractProduct::CACHE_GROUP );
1049 }
1050 }
1051
1052 private function save_stock_fields( WP_Post $post, WP_REST_Request $request ) {
1053 $stock = $request->get_param( 'stock' );
1054
1055 if ( ! is_array( $stock ) ) {
1056 return;
1057 }
1058
1059 $old_status = get_post_meta( $post->ID, '_storeengine_stock_status', true );
1060
1061 if ( array_key_exists( 'manages_stock', $stock ) ) {
1062 update_post_meta( $post->ID, '_storeengine_manage_stock', (bool) $stock['manages_stock'] );
1063 }
1064
1065 if ( array_key_exists( 'stock_quantity', $stock ) ) {
1066 $qty = $stock['stock_quantity'];
1067 if ( '' === $qty || null === $qty ) {
1068 delete_post_meta( $post->ID, '_storeengine_stock_quantity' );
1069 } else {
1070 update_post_meta( $post->ID, '_storeengine_stock_quantity', (int) $qty );
1071 }
1072 }
1073
1074 if ( array_key_exists( 'stock_status', $stock ) ) {
1075 $status = sanitize_text_field( $stock['stock_status'] );
1076 if ( in_array( $status, [ 'instock', 'outofstock', 'onbackorder' ], true ) ) {
1077 update_post_meta( $post->ID, '_storeengine_stock_status', $status );
1078 }
1079 }
1080
1081 if ( array_key_exists( 'backorders', $stock ) ) {
1082 $backorders = sanitize_text_field( $stock['backorders'] );
1083 if ( in_array( $backorders, [ 'no', 'notify', 'yes' ], true ) ) {
1084 update_post_meta( $post->ID, '_storeengine_backorders', $backorders );
1085 }
1086 }
1087
1088 if ( array_key_exists( 'low_stock_threshold', $stock ) ) {
1089 $threshold = $stock['low_stock_threshold'];
1090 if ( '' === $threshold || null === $threshold ) {
1091 delete_post_meta( $post->ID, '_storeengine_low_stock_threshold' );
1092 } else {
1093 update_post_meta( $post->ID, '_storeengine_low_stock_threshold', (int) $threshold );
1094 }
1095 }
1096
1097 if ( array_key_exists( 'sold_individually', $stock ) ) {
1098 update_post_meta( $post->ID, '_storeengine_sold_individually', (bool) $stock['sold_individually'] );
1099 }
1100
1101 // Sync stock_status from stock_quantity when manage_stock=true.
1102 $manage_stock = (bool) get_post_meta( $post->ID, '_storeengine_manage_stock', true );
1103
1104 if ( $manage_stock ) {
1105 $qty = (int) get_post_meta( $post->ID, '_storeengine_stock_quantity', true );
1106 $backorders = get_post_meta( $post->ID, '_storeengine_backorders', true ) ?: 'no';
1107 $allowed = in_array( $backorders, [ 'yes', 'notify' ], true );
1108
1109 if ( $qty <= 0 && ! $allowed ) {
1110 $new_status = 'outofstock';
1111 } elseif ( $qty <= 0 && $allowed ) {
1112 $new_status = 'onbackorder';
1113 } else {
1114 $new_status = 'instock';
1115 }
1116
1117 update_post_meta( $post->ID, '_storeengine_stock_status', $new_status );
1118
1119 if ( $old_status && $old_status !== $new_status ) {
1120 do_action( 'storeengine/stock_status_changed', $post->ID, 0, $old_status, $new_status );
1121 }
1122 } elseif ( $old_status ) {
1123 $current_status = get_post_meta( $post->ID, '_storeengine_stock_status', true );
1124 if ( $current_status && $current_status !== $old_status ) {
1125 do_action( 'storeengine/stock_status_changed', $post->ID, 0, $old_status, $current_status );
1126 }
1127 }
1128
1129 // Mirror simple-product aggregate qty into per-location stock at the
1130 // default location when the inventory-pro addon is on. Listener:
1131 // `storeengine/inventory/stock_quantity_set` action.
1132 if ( $manage_stock ) {
1133 $qty_for_mirror = (int) get_post_meta( $post->ID, '_storeengine_stock_quantity', true );
1134 /**
1135 * @see Variation::save() — same hook fires from variation saves.
1136 */
1137 do_action(
1138 'storeengine/inventory/stock_quantity_set',
1139 (int) $post->ID,
1140 0,
1141 $qty_for_mirror,
1142 'editor'
1143 );
1144 }
1145 }
1146
1147 private function save_variation_data( Variation $variation, int $product_id, array $data ) {
1148 $variation->set_product_id( $product_id );
1149 $price = isset( $data['price'] ) && is_numeric( $data['price'] ) ? (float) sanitize_text_field( $data['price'] ) : null;
1150 $variation->set_price( $price );
1151 $pricing_id = (int) sanitize_text_field( $data['pricing_id'] ?? 0 );
1152 $variation->set_price_id( $pricing_id > 0 ? $pricing_id : null );
1153 $featured_image_id = (int) sanitize_text_field( $data['featured_image_id'] ?? 0 );
1154 $variation->set_featured_image( $featured_image_id > 0 ? $featured_image_id : null );
1155 $variation->set_sku( sanitize_text_field( $data['sku'] ) );
1156
1157 if ( array_key_exists( 'barcode', $data ) ) {
1158 $barcode = $data['barcode'];
1159 $variation->set_barcode( ( null === $barcode || '' === $barcode ) ? null : sanitize_text_field( (string) $barcode ) );
1160 }
1161
1162 if ( array_key_exists( 'cost_price', $data ) ) {
1163 $cost = $data['cost_price'];
1164 $variation->set_cost_price( ( '' === $cost || null === $cost ) ? null : (float) $cost );
1165 }
1166
1167 $term_ids = array_map( fn( $term_id ) => (int) sanitize_text_field( $term_id ), $data['taxonomies'] );
1168 $term_ids = array_values( $term_ids );
1169 $variation->set_attributes( $term_ids );
1170
1171 $stock = $data['stock'] ?? [];
1172
1173 $old_status = method_exists( $variation, 'get_stock_status' ) ? $variation->get_stock_status() : null;
1174
1175 if ( is_array( $stock ) ) {
1176 if ( array_key_exists( 'manages_stock', $stock ) ) {
1177 $variation->set_manage_stock( (bool) $stock['manages_stock'] );
1178 }
1179
1180 if ( array_key_exists( 'stock_quantity', $stock ) ) {
1181 $qty = $stock['stock_quantity'];
1182 $variation->set_stock_quantity( ( '' === $qty || null === $qty ) ? null : (int) $qty );
1183 }
1184
1185 if ( array_key_exists( 'backorders', $stock ) ) {
1186 $variation->set_backorders( sanitize_text_field( $stock['backorders'] ) );
1187 }
1188
1189 if ( array_key_exists( 'low_stock_threshold', $stock ) ) {
1190 $threshold = $stock['low_stock_threshold'];
1191 $variation->set_low_stock_threshold( ( '' === $threshold || null === $threshold ) ? null : (int) $threshold );
1192 }
1193
1194 $manage_stock_now = isset( $stock['manages_stock'] ) ? (bool) $stock['manages_stock'] : $variation->manages_stock();
1195
1196 if ( $manage_stock_now ) {
1197 $qty_now = isset( $stock['stock_quantity'] ) ? (int) $stock['stock_quantity'] : (int) $variation->get_stock_quantity();
1198 $backorders = isset( $stock['backorders'] ) ? sanitize_text_field( $stock['backorders'] ) : $variation->get_backorders();
1199 $allowed = in_array( $backorders, [ 'yes', 'notify' ], true );
1200
1201 if ( $qty_now <= 0 && ! $allowed ) {
1202 $new_status = 'outofstock';
1203 } elseif ( $qty_now <= 0 && $allowed ) {
1204 $new_status = 'onbackorder';
1205 } else {
1206 $new_status = 'instock';
1207 }
1208
1209 $variation->new_data_set_stock_status( $new_status );
1210 } elseif ( array_key_exists( 'stock_status', $stock ) ) {
1211 $status = sanitize_text_field( $stock['stock_status'] );
1212 if ( in_array( $status, [ 'instock', 'outofstock', 'onbackorder' ], true ) ) {
1213 $variation->new_data_set_stock_status( $status );
1214 }
1215 }
1216 }
1217
1218 // Auto-generate SKU/barcode for this variation when enabled and empty.
1219 if ( '' === (string) $variation->get_sku() && Helper::get_settings( 'auto_generate_sku' ) ) {
1220 $variation->set_sku( SkuGenerator::generate_sku( [
1221 'name' => get_the_title( $product_id ),
1222 'category' => SkuGenerator::product_category_slug( $product_id ),
1223 ] ) );
1224 }
1225 if ( ! $variation->get_barcode() && Helper::get_settings( 'auto_generate_barcode' ) ) {
1226 $variation->set_barcode( SkuGenerator::generate_barcode() );
1227 }
1228
1229 $variation->save();
1230
1231 if ( $old_status && method_exists( $variation, 'get_stock_status' ) ) {
1232 $new_status_after_save = $variation->get_stock_status();
1233 if ( $new_status_after_save !== $old_status ) {
1234 do_action( 'storeengine/stock_status_changed', $product_id, $variation->get_id(), $old_status, $new_status_after_save );
1235 }
1236 }
1237 }
1238 }
1239