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 / classes / abstract-product.php

abstract-product.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/classes/abstract-product.php

1,073 lines 29.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\Classes;
4
5 use StoreEngine\Classes\Data\AttributeData;
6 use StoreEngine\Classes\Data\IntegrationRepositoryData;
7 use StoreEngine\Classes\enums\ProductTaxStatus;
8 use StoreEngine\Classes\enums\PurchaseRedirectType;
9 use StoreEngine\Classes\Exceptions\StoreEngineException;
10 use StoreEngine\Classes\Exceptions\StoreEngineInvalidArgumentException;
11 use StoreEngine\Utils\Formatting;
12 use StoreEngine\Utils\Helper;
13 use StoreEngine\Utils\TaxUtil;
14
15 /**
16 * @TODO convert into entity class.
17 */
18 class AbstractProduct {
19
20 protected int $id;
21
22 protected array $data = [
23 'post_author' => 0,
24 'post_parent' => 0,
25 'post_date' => '',
26 'post_date_gmt' => '',
27 'post_content' => '',
28 'post_title' => '',
29 'post_excerpt' => '',
30 'post_type' => 'storeengine_product',
31 'post_status' => 'publish',
32 'comment_status' => '',
33 'ping_status' => '',
34 'post_name' => '',
35 'post_modified' => '',
36 'post_modified_gmt' => '',
37 'comment_count' => 0,
38 ];
39
40 protected array $meta_data = [
41 'product_hide' => false,
42 'product_type' => 'simple',
43 'product_shipping_type' => 'digital',
44 'product_digital_auto_complete' => true,
45 'product_physical_weight' => '',
46 'product_physical_weight_unit' => '',
47 'product_physical_length' => '',
48 'product_physical_width' => '',
49 'product_physical_height' => '',
50 'product_physical_dimension_unit' => '',
51 'product_gallery_ids' => [],
52 'product_downloadable_files' => [],
53 'product_attributes_order' => [],
54 'product_tax_status' => ProductTaxStatus::TAXABLE,
55 'product_tax_class' => '',
56 'upsell_ids' => [],
57 'crosssell_ids' => [],
58 'product_purchase_redirect_type' => PurchaseRedirectType::DEFAULT,
59 'product_purchase_redirect_url' => '',
60 'manage_stock' => false,
61 'stock_quantity' => null,
62 'stock_status' => 'instock',
63 'backorders' => 'no',
64 'low_stock_threshold' => null,
65 'sold_individually' => false,
66 'cost_price' => null,
67 'barcode' => null,
68 // Per-product FAQ: inline { question, answer } items authored on the
69 // product (shown in "product only" mode). See storeengine_get_product_faqs().
70 'product_faqs' => [],
71 ];
72
73 protected array $serialized_data = [
74 'product_gallery_ids',
75 'product_downloadable_files',
76 'product_attributes_order',
77 'upsell_ids',
78 'crosssell_ids',
79 'product_faqs',
80 ];
81
82
83 protected array $new_data = [];
84
85 protected array $new_meta_data = [];
86
87 public const CACHE_KEY = 'storeengine_product_';
88
89 public const CACHE_GROUP = 'storeengine_products';
90
91
92 public function __construct( int $id = 0 ) {
93 $this->id = $id;
94 $this->get(); // load if id is present.
95 }
96
97 public function get() {
98 if ( ! $this->id ) {
99 return false;
100 }
101
102 $product = get_post( $this->id, ARRAY_A );
103
104 if ( ! $product || 'storeengine_product' !== $product['post_type'] ) {
105 $this->id = 0;
106 } else {
107 $this->set_data( $product );
108
109 $post_metas = get_metadata( 'post', $this->id );
110 $post_metas = array_map( fn( $meta ) => $meta[0], $post_metas );
111
112 $this->set_metadata( $post_metas );
113 }
114
115 return $this;
116 }
117
118 public function set_data( array $data ) {
119 $this->id = $data['ID'];
120 $this->data = array_intersect_key( $data, array_flip( array_keys( $this->data ) ) );
121 }
122
123 public function set_metadata( array $data ) {
124 $outsider_metadata = array_filter( $data, fn( $value, $key ) => 0 !== strpos( $key, '_storeengine_' ), ARRAY_FILTER_USE_BOTH );
125
126 foreach ( $this->meta_data as $meta_key => $meta_data ) {
127 $db_meta_key = '_storeengine_' . $meta_key;
128 if ( ! array_key_exists( $db_meta_key, $data ) ) {
129 continue;
130 }
131
132 $post_meta = $data[ $db_meta_key ];
133
134 if ( in_array( $meta_key, $this->serialized_data, true ) ) {
135 $this->meta_data[ $meta_key ] = $post_meta ? maybe_unserialize( $post_meta ) : [];
136 } else {
137 $this->meta_data[ $meta_key ] = $post_meta;
138 }
139 }
140
141 foreach ( $outsider_metadata as $meta_key => $meta_data ) {
142 $this->meta_data[ $meta_key ] = $meta_data;
143 }
144 }
145
146 public function get_id(): int {
147 return $this->id;
148 }
149
150 public function get_parent_id(): int {
151 return (int) $this->get_prop( 'post_parent' );
152 }
153
154 public function get_type() {
155 return $this->get_meta_prop( 'product_type', 'simple' );
156 }
157
158 public function get_name() {
159 return $this->get_prop( 'post_title' );
160 }
161
162 public function get_content() {
163 return $this->get_prop( 'post_content' );
164 }
165
166 public function get_slug() {
167 return $this->get_prop( 'post_name' );
168 }
169
170 public function get_permalink() {
171 return $this->get_id() ? get_permalink( $this->get_id() ) : false;
172 }
173
174 public function get_status() {
175 return $this->get_prop( 'post_status' );
176 }
177
178 public function get_shipping_type() {
179 return $this->get_meta_prop( 'product_shipping_type' );
180 }
181
182 public function auto_complete_digital(): bool {
183 return Formatting::string_to_bool( $this->get_meta_prop( 'product_digital_auto_complete' ) ) && 'digital' === $this->get_shipping_type();
184 }
185
186 public function get_attributes_order() {
187 $data = $this->get_meta_prop( 'product_attributes_order', '[]' );
188
189 if ( ! is_array( $data ) ) {
190 $data = maybe_unserialize( $data );
191 }
192
193 if ( ! is_array( $data ) ) {
194 $data = json_decode( $data, true );
195 }
196
197 return $data;
198 }
199
200 protected function get_prop( string $name, $default = '' ) {
201 if ( array_key_exists( $name, $this->new_data ) ) {
202 return $this->new_data[ $name ];
203 }
204
205 return $this->data[ $name ] ?? $default;
206 }
207
208 protected function get_meta_prop( string $name, $default = '' ) {
209 if ( array_key_exists( $name, $this->new_meta_data ) ) {
210 return $this->new_meta_data[ $name ];
211 }
212
213 return empty( $this->meta_data[ $name ] ) ? $default : $this->meta_data[ $name ];
214 }
215
216 /**
217 * Get Product Prices.
218 *
219 * @param bool $force_show_hidden
220 *
221 * @return Price[]
222 */
223 public function get_prices( bool $force_show_hidden = false ): array {
224 if ( 0 === $this->id ) {
225 return [];
226 }
227
228 $where = [
229 [
230 'key' => 'product_id',
231 'value' => $this->id,
232 ],
233 [
234 'key' => 'price_type',
235 'value' => $this->get_price_types(),
236 'compare' => 'IN',
237 ]
238 ];
239
240 if ( Helper::is_request( 'frontend' ) && ! $force_show_hidden && apply_filters( 'storeengine/product/get_prices/exclude_hidden', true ) ) {
241 $where[] = [
242 'key' => 'settings',
243 'value' => '%"is_hidden";b:1%',
244 'compare' => 'NOT LIKE',
245 ];
246 }
247
248 $query = new PriceCollection( [ 'per_page' => -1, 'where' => $where ] );
249
250 return array_values( $query->get_results() );
251 }
252
253 public function get_price_types(): array {
254 return apply_filters( 'storeengine/product/get_price_types', [ 'onetime' ], $this );
255 }
256
257 /**
258 * @return IntegrationRepositoryData[]
259 * @throws StoreEngineException
260 */
261 public function get_integrations(): array {
262 if ( 0 === $this->id ) {
263 return [];
264 }
265
266 $has_cache = wp_cache_get( self::CACHE_KEY . $this->id . '_integrations', self::CACHE_GROUP );
267 if ( $has_cache && is_array( $has_cache ) ) {
268 return array_map( fn( $result ) => new IntegrationRepositoryData(
269 ( new Integration() )->set_data( $result ),
270 new Price( $result->price_id )
271 ), $has_cache );
272 }
273
274 global $wpdb;
275 $integrations = [];
276 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery
277 $results = $wpdb->get_results(
278 $wpdb->prepare( "
279 SELECT
280 i.id,
281 i.product_id,
282 i.price_id,
283 i.provider,
284 i.integration_id,
285 i.variation_id,
286 i.created_at,
287 i.updated_at,
288 pr.price_name,
289 pr.price_type,
290 pr.price,
291 pr.compare_price,
292 pr.settings,
293 pr.`order`
294 FROM {$wpdb->prefix}storeengine_integrations i
295 JOIN {$wpdb->prefix}storeengine_product_price pr ON pr.id = i.price_id
296 WHERE i.product_id = %d ORDER BY pr.`order`", $this->get_id()
297 )
298 );
299
300 if ( ! $results ) {
301 return $integrations;
302 }
303
304 /**
305 * @TODO prepare data and store ids in cache
306 * @see get_prices()
307 */
308 wp_cache_set( self::CACHE_KEY . $this->id . '_integrations', $results, self::CACHE_GROUP );
309 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery
310
311 foreach ( $results as $result ) {
312 $integrations[] = new IntegrationRepositoryData( ( new Integration() )->set_data( $result ), new Price( $result->price_id ) );
313 }
314
315
316 return $integrations;
317 }
318
319 public function get_weight(): float {
320 return (float) $this->get_meta_prop( 'product_physical_weight', 0 );
321 }
322
323 public function get_weight_unit(): ?string {
324 return $this->get_meta_prop( 'product_physical_weight_unit', 'g' );
325 }
326
327 public function get_length(): float {
328 return (float) $this->get_meta_prop( 'product_physical_length', 0 );
329 }
330
331 public function get_width(): float {
332 return (float) $this->get_meta_prop( 'product_physical_width', 0 );
333 }
334
335 public function get_height(): float {
336 return (float) $this->get_meta_prop( 'product_physical_height', 0 );
337 }
338
339 public function get_dimension_unit(): ?string {
340 return $this->get_meta_prop( 'product_physical_dimension_unit', 'mm' );
341 }
342
343 public function get_dimensions(): array {
344 return [
345 'length' => $this->get_length(),
346 'width' => $this->get_width(),
347 'height' => $this->get_height(),
348 ];
349 }
350
351 public function get_formatted_dimensions(): string {
352 return apply_filters( 'storeengine/product/dimensions', Formatting::format_dimensions( $this->get_dimensions( false ) ), $this );
353 }
354
355 public function get_catalog_visibility(): string {
356 // @TODO: Need to implement this feature with taxonomy.
357 return $this->is_hide() ? 'hidden' : 'visible';
358 }
359
360 public function get_product_gallery(): array {
361 $values = $this->get_meta_prop( 'product_gallery_ids' );
362 return is_array( $values ) ? $values : [];
363 }
364
365 public function get_upsell_ids() {
366 return $this->get_meta_prop( 'upsell_ids' );
367 }
368
369 public function get_faqs(): array {
370 $values = $this->get_meta_prop( 'product_faqs' );
371 return is_array( $values ) ? $values : [];
372 }
373
374 public function get_crosssell_ids() {
375 return $this->get_meta_prop( 'crosssell_ids' );
376 }
377
378 public function get_upsell_products(): array {
379 $ids = $this->get_upsell_ids();
380 if ( ! is_array( $ids ) ) {
381 $ids = [];
382 }
383
384 return array_filter( array_map( fn( $id ) => Helper::get_product( absint( $id ) ), array_unique( $ids ) ) );
385 }
386
387 public function get_crosssell_products(): array {
388 $ids = $this->get_crosssell_ids();
389 if ( ! is_array( $ids ) ) {
390 $ids = [];
391 }
392
393 return array_filter( array_map( fn( $id ) => Helper::get_product( absint( $id ) ), array_unique( $ids ) ) );
394 }
395
396 public function get_downloadable_files() {
397 return $this->get_meta_prop( 'product_downloadable_files', [] );
398 }
399
400 public function get_published_date() {
401 return $this->get_prop( 'post_date' );
402 }
403
404 public function get_published_date_gmt() {
405 return $this->get_prop( 'post_date_gmt' );
406 }
407
408 public function get_updated_date() {
409 return $this->get_prop( 'post_modified' );
410 }
411
412 public function get_updated_date_gmt() {
413 return $this->get_prop( 'post_modified_gmt' );
414 }
415
416 public function is_downloadable(): bool {
417 return ! empty( $this->get_downloadable_files() );
418 }
419
420 public function is_hide(): bool {
421 return Formatting::string_to_bool( $this->get_meta_prop( 'product_hide' ) );
422 }
423
424 /**
425 * @return AttributeData[][]
426 */
427 public function get_attributes(): array {
428 $cache_key = self::CACHE_KEY . $this->get_id() . '_attributes';
429 $has_cache = wp_cache_get( $cache_key, self::CACHE_GROUP );
430 if ( $has_cache ) {
431 return $has_cache;
432 }
433
434 global $wpdb;
435 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
436 $results = $wpdb->get_results(
437 $wpdb->prepare(
438 "
439 SELECT t.term_id, tt.term_taxonomy_id, t.name, t.slug, tt.description, tt.taxonomy, tt.count, tr.term_order
440 FROM {$wpdb->term_relationships} tr
441 JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
442 JOIN $wpdb->terms t ON tt.term_id = t.term_id
443 WHERE tr.object_id = %d AND tt.taxonomy LIKE %s ORDER BY tr.term_order
444 ",
445 $this->get_id(),
446 'se_pa_%'
447 )
448 );
449
450 if ( ! $results ) {
451 return [];
452 }
453
454 $attributes = [];
455
456 foreach ( $results as $result ) {
457 if ( ! taxonomy_exists( $result->taxonomy ) ) {
458 continue;
459 }
460
461 $attributes[ $result->taxonomy ][] = ( new AttributeData() )->set_data( $result );
462 }
463
464 $ordered_attributes = [];
465 $attributes_order = $this->get_attributes_order();
466
467 foreach ( $attributes_order as $taxonomy ) {
468 if ( isset( $attributes[ $taxonomy ] ) ) {
469 $ordered_attributes[ $taxonomy ] = $attributes[ $taxonomy ];
470 }
471 }
472
473 wp_cache_set( $cache_key, $ordered_attributes, self::CACHE_GROUP );
474
475 return $ordered_attributes;
476 }
477
478 public function manages_stock(): bool {
479 return Formatting::string_to_bool( $this->get_meta_prop( 'manage_stock', false ) );
480 }
481
482 public function get_stock_quantity( string $context = 'view' ): ?int {
483 $value = $this->get_meta_prop( 'stock_quantity', null );
484
485 if ( null === $value || '' === $value ) {
486 return null;
487 }
488
489 return (int) $value;
490 }
491
492 public function set_stock_quantity( ?int $qty ): void {
493 $this->new_meta_data['stock_quantity'] = $qty;
494 }
495
496 public function get_stock_status(): string {
497 // When stock is tracked, derive the status from the on-hand quantity so
498 // it can never drift out of sync (a tracked product at 0 must read
499 // "outofstock" even if a stale status meta still says "instock").
500 if ( $this->manages_stock() ) {
501 $qty = $this->get_stock_quantity();
502 if ( null !== $qty ) {
503 if ( $qty > 0 ) {
504 return 'instock';
505 }
506 return $this->backorders_allowed() ? 'onbackorder' : 'outofstock';
507 }
508 }
509
510 $status = $this->get_meta_prop( 'stock_status', 'instock' );
511
512 if ( ! in_array( $status, [ 'instock', 'outofstock', 'onbackorder' ], true ) ) {
513 $status = 'instock';
514 }
515
516 return $status;
517 }
518
519 public function set_stock_status( string $status ): void {
520 if ( ! in_array( $status, [ 'instock', 'outofstock', 'onbackorder' ], true ) ) {
521 $status = 'instock';
522 }
523
524 $from = $this->get_stock_status();
525
526 $this->new_meta_data['stock_status'] = $status;
527
528 if ( $from !== $status && $this->get_id() ) {
529 update_post_meta( $this->get_id(), '_storeengine_stock_status', $status );
530 $this->meta_data['stock_status'] = $status;
531
532 /**
533 * Fires when the stock status of a product or variation transitions.
534 *
535 * @param int $product_id
536 * @param int $variation_id
537 * @param string $from
538 * @param string $to
539 */
540 do_action( 'storeengine/stock_status_changed', $this->get_id(), 0, $from, $status );
541 }
542 }
543
544 public function get_backorders(): string {
545 $value = $this->get_meta_prop( 'backorders', 'no' );
546
547 if ( ! in_array( $value, [ 'no', 'notify', 'yes' ], true ) ) {
548 $value = 'no';
549 }
550
551 return $value;
552 }
553
554 public function backorders_allowed(): bool {
555 return in_array( $this->get_backorders(), [ 'yes', 'notify' ], true );
556 }
557
558 public function backorders_require_notification(): bool {
559 return 'notify' === $this->get_backorders();
560 }
561
562 public function get_low_stock_threshold(): ?int {
563 $value = $this->get_meta_prop( 'low_stock_threshold', null );
564
565 if ( null === $value || '' === $value ) {
566 $global = (int) get_option( 'storeengine_low_stock_threshold', 0 );
567
568 return $global > 0 ? $global : null;
569 }
570
571 return (int) $value;
572 }
573
574 public function is_sold_individually(): bool {
575 return Formatting::string_to_bool( $this->get_meta_prop( 'sold_individually', false ) );
576 }
577
578 public function get_reserved_stock( ?int $variation_id = null ): int {
579 global $wpdb;
580
581 $table = $wpdb->prefix . 'storeengine_reserved_stock';
582
583 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared (%d/%s) query on a custom StoreEngine table; $table is a plugin-internal name, reserved-stock sum not cacheable per request.
584 $qty = $wpdb->get_var( $wpdb->prepare(
585 "SELECT COALESCE(SUM(stock_quantity),0) FROM {$table} WHERE product_id = %d AND variation_id = %d AND expires > %s",
586 $this->get_id(),
587 $variation_id ? $variation_id : 0,
588 current_time( 'mysql', true )
589 ) );
590 // phpcs:enable
591
592 return (int) $qty;
593 }
594
595 public function get_available_stock_quantity( ?int $variation_id = null ): ?int {
596 $qty = $this->get_stock_quantity();
597
598 if ( null === $qty ) {
599 return null;
600 }
601
602 return max( 0, $qty - $this->get_reserved_stock( $variation_id ) );
603 }
604
605 public function has_enough_stock( int $qty ): bool {
606 if ( ! $this->manages_stock() ) {
607 return $this->is_in_stock();
608 }
609
610 if ( $this->backorders_allowed() ) {
611 return true;
612 }
613
614 $available = $this->get_available_stock_quantity();
615
616 return null === $available || $available >= $qty;
617 }
618
619 public function is_low_stock(): bool {
620 if ( ! $this->manages_stock() ) {
621 return false;
622 }
623
624 $threshold = $this->get_low_stock_threshold();
625 $qty = $this->get_stock_quantity();
626
627 if ( null === $threshold || null === $qty ) {
628 return false;
629 }
630
631 return $qty > 0 && $qty <= $threshold;
632 }
633
634 public function is_in_stock(): bool {
635 if ( $this->manages_stock() ) {
636 // Use AVAILABLE qty (stock minus active checkout reservations)
637 // so the storefront badge agrees with what cart::add_to_cart
638 // will accept. Without this, a single pending-payment order
639 // can leave the badge saying "In stock" while every add-to-cart
640 // click fails with "not enough stock".
641 $qty = $this->get_available_stock_quantity();
642
643 if ( null === $qty ) {
644 return $this->backorders_allowed();
645 }
646
647 return $qty > 0 || $this->backorders_allowed();
648 }
649
650 $status = $this->get_stock_status();
651
652 return 'instock' === $status || ( 'onbackorder' === $status && $this->backorders_allowed() );
653 }
654
655 public function is_on_backorder( int $qty_in_cart = 0 ): bool {
656 if ( ! $this->manages_stock() || ! $this->backorders_allowed() ) {
657 return 'onbackorder' === $this->get_stock_status();
658 }
659
660 $qty = $this->get_stock_quantity();
661
662 return null !== $qty && ( $qty - $qty_in_cart ) < 0;
663 }
664
665 public function reduce_stock( int $qty, ?int $variation_id = null ): int {
666 if ( ! $this->manages_stock() ) {
667 return 0;
668 }
669
670 $current = (int) $this->get_stock_quantity();
671 $new_qty = $current - $qty;
672
673 update_post_meta( $this->get_id(), '_storeengine_stock_quantity', $new_qty );
674 $this->meta_data['stock_quantity'] = $new_qty;
675
676 $this->maybe_sync_stock_status_from_quantity( $new_qty, $current );
677
678 return $new_qty;
679 }
680
681 public function increase_stock( int $qty, ?int $variation_id = null ): int {
682 if ( ! $this->manages_stock() ) {
683 return 0;
684 }
685
686 $current = (int) $this->get_stock_quantity();
687 $new_qty = $current + $qty;
688
689 update_post_meta( $this->get_id(), '_storeengine_stock_quantity', $new_qty );
690 $this->meta_data['stock_quantity'] = $new_qty;
691
692 $this->maybe_sync_stock_status_from_quantity( $new_qty, $current );
693
694 return $new_qty;
695 }
696
697 protected function maybe_sync_stock_status_from_quantity( int $new_qty, int $old_qty ): void {
698 $old_status = $this->get_stock_status();
699
700 if ( $new_qty <= 0 && ! $this->backorders_allowed() ) {
701 $new_status = 'outofstock';
702 } elseif ( $new_qty <= 0 && $this->backorders_allowed() ) {
703 $new_status = 'onbackorder';
704 } else {
705 $new_status = 'instock';
706 }
707
708 if ( $old_status !== $new_status ) {
709 $this->set_stock_status( $new_status );
710 }
711 }
712
713 /**
714 * Checks if a product is virtual (has no shipping).
715 *
716 * @return bool
717 */
718 public function is_virtual(): bool {
719 return apply_filters( 'storeengine/product/is_virtual', 'digital' === $this->get_shipping_type(), $this );
720 }
721
722 /**
723 * Returns whether the product is visible in the catalog.
724 *
725 * @return bool
726 */
727 public function is_visible(): bool {
728 return apply_filters( 'storeengine/product/is_visible', $this->is_visible_core(), $this->get_id() );
729 }
730
731 protected function is_visible_core(): bool {
732 $visible = 'visible' === $this->get_catalog_visibility() || ( is_search() && 'search' === $this->get_catalog_visibility() ) || ( ! is_search() && 'catalog' === $this->get_catalog_visibility() );
733
734 if ( 'trash' === $this->get_status() ) {
735 $visible = false;
736 } elseif ( 'publish' !== $this->get_status() && ! current_user_can( 'edit_post', $this->get_id() ) ) {
737 $visible = false;
738 }
739
740 if ( $this->get_parent_id() ) {
741 $parent_product = Helper::get_product( $this->get_parent_id() );
742
743 if ( $parent_product && 'publish' !== $parent_product->get_status() && ! current_user_can( 'edit_post', $parent_product->get_id() ) ) {
744 $visible = false;
745 }
746 }
747
748 if ( 'yes' === get_option( 'storeengine/hide_out_of_stock_items' ) && ! $this->is_in_stock() ) {
749 $visible = false;
750 }
751
752 return $visible;
753 }
754
755 /**
756 * Checks if a product needs shipping.
757 *
758 * @return bool
759 */
760 public function needs_shipping(): bool {
761 return apply_filters( 'storeengine/product/needs_shipping', ! $this->is_virtual(), $this );
762 }
763
764 public function set_name( string $name ) {
765 $this->new_data['post_title'] = $name;
766 }
767
768 public function set_type( string $value ) {
769 $this->new_meta_data['product_type'] = $value;
770 }
771
772 public function is_type( $type ): bool {
773 return is_array( $type ) ? in_array( $this->get_type(), $type, true ) : $type === $this->get_type();
774 }
775
776 public function set_author_id( int $author ) {
777 $this->new_data['post_author'] = $author;
778 }
779
780 public function set_parent( int $id ) {
781 $this->new_data['post_parent'] = $id;
782 }
783
784 public function set_content( string $content ) {
785 $this->new_data['post_content'] = $content;
786 }
787
788 public function set_excerpt( string $value ) {
789 $this->new_data['post_excerpt'] = $value;
790 }
791
792 public function set_status( string $value ) {
793 $this->new_data['post_status'] = $value;
794 }
795
796 public function set_shipping_type( string $value ) {
797 $this->new_meta_data['product_shipping_type'] = $value;
798 }
799
800 public function set_digital_auto_complete( $value ) {
801 $this->new_meta_data['product_digital_auto_complete'] = Formatting::string_to_bool( $value );
802 }
803
804 public function set_hide( $value ) {
805 $this->new_meta_data['product_hide'] = Formatting::string_to_bool( $value );
806 }
807
808 public function set_attributes_order( array $value ) {
809 $this->new_meta_data['product_attributes_order'] = $value;
810 }
811
812 public function set_slug( string $value ) {
813 $this->new_data['post_name'] = $value;
814 }
815
816 public function set_published_date( string $value ) {
817 $this->new_data['post_date'] = $value;
818 }
819
820 public function set_published_date_gmt( string $value ) {
821 $this->new_data['post_date_gmt'] = $value;
822 }
823
824 public function set_updated_date( string $value ) {
825 $this->new_data['post_modified'] = $value;
826 }
827
828 public function set_updated_date_gmt( string $value ) {
829 $this->new_data['post_modified_gmt'] = $value;
830 }
831
832 public function set_weight( float $value ) {
833 $this->new_meta_data['product_physical_weight'] = $value;
834 }
835
836 public function set_weight_unit( string $value ) {
837 $this->new_meta_data['product_physical_weight_unit'] = $value;
838 }
839
840 public function set_length( float $value ) {
841 $this->new_meta_data['product_physical_length'] = $value;
842 }
843
844 public function set_width( float $value ) {
845 $this->new_meta_data['product_physical_width'] = $value;
846 }
847
848 public function set_height( float $value ) {
849 $this->new_meta_data['product_physical_height'] = $value;
850 }
851
852 public function set_dimension_unit( string $value ) {
853 $this->new_meta_data['product_physical_dimension_unit'] = $value;
854 }
855
856 public function set_downloadable_files( array $value ) {
857 $this->new_meta_data['product_downloadable_files'] = self::sanitize_downloadable_files( $value );
858 }
859
860 /**
861 * Drop downloadable-file entries whose location contains a path-traversal
862 * segment ("../" / "..\"). URLs and normal uploads-relative paths are kept;
863 * arbitrary absolute reads are additionally blocked at serve time by
864 * DownloadHandler::is_allowed_local_path(). Defence in depth against a
865 * crafted file URL being used to read arbitrary server files.
866 *
867 * Public so the REST meta write path (core `WP_REST_Post_Meta_Fields`, which
868 * never calls the product-object setter) can reuse it as a `sanitize_callback`
869 * — see Database::register_product_meta().
870 *
871 * @param array $files Downloadable files (keyed by file id/hash).
872 * @return array Sanitised list, original keys preserved.
873 */
874 public static function sanitize_downloadable_files( array $files ): array {
875 $traversal = '#(^|[/\\\\])\.\.([/\\\\]|$)#';
876 $safe = [];
877 foreach ( $files as $key => $entry ) {
878 if ( ! is_array( $entry ) ) {
879 continue;
880 }
881 $file = isset( $entry['file'] ) ? (string) $entry['file'] : '';
882 if ( preg_match( $traversal, $file ) || preg_match( $traversal, rawurldecode( $file ) ) ) {
883 continue; // Reject traversal attempt.
884 }
885 $safe[ $key ] = $entry;
886 }
887 return $safe;
888 }
889
890 public function set_upsell_ids( array $value ) {
891 $this->new_meta_data['upsell_ids'] = $value;
892 }
893
894 public function set_crosssell_ids( array $value ) {
895 $this->new_meta_data['crosssell_ids'] = $value;
896 }
897
898 public function get_tax_status() {
899 return $this->get_meta_prop( 'product_tax_status' );
900 }
901
902 public function get_tax_class( string $context = 'view' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
903 return $this->get_meta_prop( 'product_tax_class' );
904 }
905
906 public function get_purchase_note( string $context = 'view' ) {
907 return $this->get_meta_prop( 'purchase_note' );
908 }
909
910 public function get_purchase_redirect_type() {
911 return $this->get_meta_prop( 'product_purchase_redirect_type' );
912 }
913
914 public function get_raw_purchase_redirect_url() {
915 return $this->get_meta_prop( 'product_purchase_redirect_url' );
916 }
917
918 public function get_purchase_redirect_url() {
919 if ( PurchaseRedirectType::PAGE === $this->get_purchase_redirect_type() ) {
920 return get_the_permalink( (int) $this->get_raw_purchase_redirect_url() );
921 }
922
923 return $this->get_raw_purchase_redirect_url();
924 }
925
926 /**
927 * @param ?string $value
928 *
929 * @return void
930 */
931 public function set_tax_status( ?string $value ) {
932 // Set default if empty.
933 if ( empty( $status ) ) {
934 $status = ProductTaxStatus::TAXABLE;
935 }
936
937 $status = strtolower( $status );
938
939 $tax_options = [
940 ProductTaxStatus::TAXABLE,
941 ProductTaxStatus::SHIPPING,
942 ProductTaxStatus::NONE,
943 ];
944
945 if ( ! in_array( $status, $tax_options, true ) ) {
946 throw new StoreEngineException( esc_html__( 'Invalid product tax status.', 'storeengine' ), 'product_invalid_tax_status' );
947 }
948
949 $this->new_meta_data['product_tax_status'] = $value;
950 }
951
952 public function set_tax_class( string $class ) {
953 $class = sanitize_title( $class );
954 $class = 'standard' === $class ? '' : $class;
955 $valid_classes = $this->get_valid_tax_classes();
956
957 if ( ! in_array( $class, $valid_classes, true ) ) {
958 $class = '';
959 }
960
961 $this->new_meta_data['product_tax_class'] = $class;
962 }
963
964 public function set_purchase_note( string $purchase_note ) {
965 $this->new_meta_data['product_purchase_note'] = $purchase_note;
966 }
967
968 /**
969 * Return an array of valid tax classes
970 *
971 * @return array valid tax classes
972 */
973 protected function get_valid_tax_classes(): array {
974 return Tax::get_tax_class_slugs();
975 }
976
977 /**
978 * Returns whether the product-pricing is taxable.
979 *
980 * @return bool
981 */
982 public function is_taxable(): bool {
983 /**
984 * Filters whether a product is taxable.
985 *
986 * @param bool $taxable Whether the product is taxable.
987 * @param AbstractProduct $price Product object.
988 */
989 return apply_filters( 'storeengine/product/is_taxable', ProductTaxStatus::TAXABLE === $this->get_tax_status() && TaxUtil::is_tax_enabled(), $this );
990 }
991
992 /**
993 * Returns whether the product-pricing shipping is taxable.
994 *
995 * @return bool
996 */
997 public function is_shipping_taxable(): bool {
998 return $this->needs_shipping() && ( ProductTaxStatus::TAXABLE === $this->get_tax_status() || ProductTaxStatus::TAXABLE === $this->get_tax_status() );
999 }
1000
1001 public function get_metadata( string $name, bool $single = true ) {
1002 if ( array_key_exists( $name, $this->meta_data ) ) {
1003 return $this->meta_data[ $name ];
1004 }
1005
1006 if ( strpos( $name, '_storeengine_' ) === 0 ) {
1007 return $this->get_meta_prop( str_replace( '_storeengine_', '', $name ), false );
1008 }
1009
1010 return get_post_meta( $this->id, $name, $single );
1011 }
1012
1013 public function update_metadata( string $name, $value ) {
1014 if ( 0 === strpos( $name, '_storeengine_' ) ) {
1015 $this->new_meta_data[ str_replace( '_storeengine_', '', $name ) ] = $value;
1016 } else {
1017 $this->meta_data[ $name ] = $value;
1018 }
1019
1020 return update_post_meta( $this->id, $name, $value );
1021 }
1022
1023 public function save() {
1024 // setup default data.
1025 if ( empty( $this->new_meta_data ) ) {
1026 foreach ( $this->meta_data as $name => $value ) {
1027 if ( '' === $value || [] === $value ) {
1028 continue;
1029 }
1030
1031 $this->new_meta_data[ $name ] = $value;
1032 }
1033 }
1034
1035 if ( empty( $this->new_meta_data ) && empty( $this->new_data ) ) {
1036 return;
1037 }
1038
1039 $this->save_core_data();
1040 $this->save_meta_data();
1041 wp_cache_flush_group( self::CACHE_GROUP );
1042 wp_cache_set_last_changed( self::CACHE_GROUP );
1043 }
1044
1045 protected function save_core_data() {
1046 if ( 0 === $this->id ) {
1047 $product_id = wp_insert_post( array_merge( $this->data, $this->new_data ), true );
1048
1049 if ( is_wp_error( $product_id ) ) {
1050 throw StoreEngineException::from_wp_error( $product_id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1051 }
1052
1053 $this->id = $product_id;
1054 } else {
1055 $updated = wp_update_post( array_merge( $this->data, $this->new_data, array( 'ID' => $this->id ) ), true );
1056 if ( is_wp_error( $updated ) ) {
1057 throw StoreEngineException::from_wp_error( $updated ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1058 }
1059 }
1060 }
1061
1062 protected function save_meta_data() {
1063 $new_meta_data = $this->new_meta_data;
1064 if ( 0 === count( $new_meta_data ) ) {
1065 return;
1066 }
1067
1068 foreach ( $new_meta_data as $meta_key => $new_meta_datum ) {
1069 update_post_meta( $this->id, '_storeengine_' . $meta_key, $new_meta_datum );
1070 }
1071 }
1072 }
1073