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 / database.php

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

1,030 lines 36.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit; // Exit if accessed directly.
7 }
8
9 use StoreEngine\Database\{CreateApiKeys,
10 CreateAttributeTaxonomies,
11 CreateCart,
12 CreateCustomerLookup,
13 CreateDownloadableProductPermissions,
14 CreateDownloadLog,
15 CreateEmailLog,
16 CreateIntegration,
17 CreateLog,
18 CreateOrderAddresses,
19 CreateOrderItems,
20 CreateOrderMetaTable,
21 CreateOrderOperationalData,
22 CreateOrderProductLookup,
23 CreateOrderTable,
24 CreatePaymentTokenMeta,
25 CreatePaymentTokens,
26 CreatePayouts,
27 CreateProductVariationMetaTable,
28 CreateShippingZoneLocations,
29 CreateVariationsTermsRelationTable,
30 CreateProductDownloadDirectories,
31 CreateProductPriceTable,
32 CreateProductVariationsTable,
33 CreateReservedStockTable,
34 CreateStockMovementsTable,
35 CreateShippingZoneMethods,
36 CreateShippingZones,
37 CreateTaxRateLocations,
38 CreateTaxRates
39 };
40 use StoreEngine\Classes\enums\ProductDownloadType;
41 use StoreEngine\Classes\enums\ProductTaxStatus;
42 use StoreEngine\Classes\enums\PurchaseRedirectType;
43 use StoreEngine\database\CreateOrderItemMeta;
44 use StoreEngine\Traits\Singleton;
45 use StoreEngine\Classes\Attributes;
46 use StoreEngine\Utils\Helper;
47
48 class Database {
49
50 use Singleton;
51
52 protected function __construct() {
53 $this->register_database_table_name();
54
55 add_action( 'switch_blog', [ $this, 'wpdb_table_fix' ], 0 );
56
57 add_action( 'init', [ __CLASS__, 'maybe_sync_schema' ], 4 );
58 add_action( 'init', [ $this, 'register_product_post_type' ], 5 );
59 add_action( 'init', [ $this, 'register_coupon_post_type' ], 5 );
60 add_action( 'get_avatar_comment_types', [ $this, 'add_avatar_support_product_comment' ] );
61
62 add_action( 'init', [ $this, 'maybe_flush_rewrite_rules' ], 5 );
63 add_action( 'storeengine/flush_rewrite_rules', [ __CLASS__, 'flush_rewrite_rules' ] );
64
65 add_action( 'rest_api_init', [ $this, 'register_product_meta' ] );
66 add_action( 'rest_api_init', [ $this, 'register_coupon_meta' ] );
67 add_action( 'rest_api_init', [ $this, 'register_category_rest_fields' ] );
68 }
69
70 const SCHEMA_HASH_OPTION = 'storeengine_schema_hash';
71
72 /**
73 * Re-runs dbDelta when any schema file in includes/database/ changes.
74 * Avoids the need to bump STOREENGINE_DB_VERSION on every column addition.
75 */
76 public static function maybe_sync_schema(): void {
77 $hash = self::compute_schema_hash( STOREENGINE_ROOT_DIR_PATH . 'includes/database' );
78 if ( ! $hash || $hash === get_option( self::SCHEMA_HASH_OPTION ) ) {
79 return;
80 }
81
82 self::create_initial_custom_table();
83 update_option( self::SCHEMA_HASH_OPTION, $hash, true );
84
85 /**
86 * Fires after core schema files were re-applied via dbDelta.
87 * Addons should hook here to re-sync their own tables.
88 */
89 do_action( 'storeengine/schema_synced' );
90 }
91
92 public static function compute_schema_hash( string $dir ): string {
93 $files = glob( rtrim( $dir, '/' ) . '/*.php' );
94 if ( empty( $files ) ) {
95 return '';
96 }
97 sort( $files );
98 $parts = [];
99 foreach ( $files as $file ) {
100 $parts[] = md5_file( $file );
101 }
102 return md5( implode( '', $parts ) );
103 }
104
105 public function maybe_flush_rewrite_rules() {
106 if ( 'yes' === get_option( 'storeengine_required_rewrite_flush' ) ) {
107 update_option( 'storeengine_required_rewrite_flush', 'no' );
108 self::flush_rewrite_rules();
109 }
110 }
111
112 public static function flush_rewrite_rules() {
113 flush_rewrite_rules();
114 }
115
116 public function register_database_table_name() {
117 global $wpdb;
118
119 // @TODO add all the tables.
120 $tables = [
121 'payment_tokenmeta' => 'storeengine_payment_tokenmeta',
122 'order_itemmeta' => 'storeengine_order_item_meta',
123 'product_meta_lookup' => 'storeengine_product_meta_lookup',
124 'tax_rate_classes' => 'storeengine_tax_rate_classes',
125 'reserved_stock' => 'storeengine_reserved_stock',
126 'store_orders' => 'storeengine_orders',
127 'ordermeta' => 'storeengine_orders_meta',
128 ];
129
130 /**
131 * @XXX make sure to add the meta types for handling cache last change.
132 *
133 * @see Hooks::handle_cache_last_changed()
134 * @see wp_cache_set_last_changed()
135 */
136
137 foreach ( $tables as $name => $table ) {
138 $wpdb->$name = $wpdb->prefix . $table;
139 $wpdb->tables[] = $table;
140 }
141 }
142
143 public function wpdb_table_fix() {
144 $this->register_database_table_name();
145 }
146
147 public static function create_initial_custom_table() {
148 global $wpdb;
149
150 if ( ! function_exists( 'dbDelta' ) ) {
151 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
152 }
153
154 $charset_collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';
155
156 CreateIntegration::up( $wpdb->prefix, $charset_collate );
157 CreateProductPriceTable::up( $wpdb->prefix, $charset_collate );
158 CreateAttributeTaxonomies::up( $wpdb->prefix, $charset_collate );
159 CreateProductVariationsTable::up( $wpdb->prefix, $charset_collate );
160 CreateProductVariationMetaTable::up( $wpdb->prefix, $charset_collate );
161 CreateReservedStockTable::up( $wpdb->prefix, $charset_collate );
162 CreateStockMovementsTable::up( $wpdb->prefix, $charset_collate );
163 CreateVariationsTermsRelationTable::up( $wpdb->prefix, $charset_collate );
164 CreateOrderTable::up( $wpdb->prefix, $charset_collate );
165 CreateOrderMetaTable::up( $wpdb->prefix, $charset_collate );
166 CreateOrderProductLookup::up( $wpdb->prefix, $charset_collate );
167 CreateOrderItems::up( $wpdb->prefix, $charset_collate );
168 CreateOrderItemMeta::up( $wpdb->prefix, $charset_collate );
169 CreateOrderOperationalData::up( $wpdb->prefix, $charset_collate );
170 CreateOrderAddresses::up( $wpdb->prefix, $charset_collate );
171 CreateShippingZoneMethods::up( $wpdb->prefix, $charset_collate );
172 CreateShippingZones::up( $wpdb->prefix, $charset_collate );
173 CreateShippingZoneLocations::up( $wpdb->prefix, $charset_collate );
174 CreateApiKeys::up( $wpdb->prefix, $charset_collate );
175 CreateCart::up( $wpdb->prefix, $charset_collate );
176 CreatePaymentTokens::up( $wpdb->prefix, $charset_collate );
177 CreatePaymentTokenMeta::up( $wpdb->prefix, $charset_collate );
178 CreatePayouts::up( $wpdb->prefix, $charset_collate );
179 CreateTaxRateLocations::up( $wpdb->prefix, $charset_collate );
180 CreateTaxRates::up( $wpdb->prefix, $charset_collate );
181 CreateApiKeys::up( $wpdb->prefix, $charset_collate );
182 CreateLog::up( $wpdb->prefix, $charset_collate );
183 CreateEmailLog::up( $wpdb->prefix, $charset_collate );
184 CreateCustomerLookup::up( $wpdb->prefix, $charset_collate );
185 CreateDownloadableProductPermissions::up( $wpdb->prefix, $charset_collate );
186 CreateDownloadLog::up( $wpdb->prefix, $charset_collate );
187 CreateProductDownloadDirectories::up( $wpdb->prefix, $charset_collate );
188
189 // Backfill new columns on existing tables that dbDelta may have skipped.
190 self::ensure_variation_stock_columns();
191 self::ensure_stock_movements_vendor_column();
192
193 // Store DB Version
194 update_option( 'storeengine_db_version', STOREENGINE_DB_VERSION, false );
195 }
196
197 /**
198 * Adds the stock-tracking columns to wp_storeengine_product_variations on
199 * installs that pre-date the inventory feature. Idempotent — silently
200 * skips columns that already exist.
201 */
202 public static function ensure_variation_stock_columns(): void {
203 global $wpdb;
204
205 $table = $wpdb->prefix . 'storeengine_product_variations';
206
207 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
208 $existing = $wpdb->get_col( "DESCRIBE {$table}", 0 );
209 // phpcs:enable
210
211 if ( ! is_array( $existing ) || empty( $existing ) ) {
212 return;
213 }
214
215 $columns = [
216 'manage_stock' => "ALTER TABLE {$table} ADD COLUMN manage_stock TINYINT(1) NOT NULL DEFAULT 0",
217 'stock_quantity' => "ALTER TABLE {$table} ADD COLUMN stock_quantity INT(11) DEFAULT NULL",
218 'stock_status' => "ALTER TABLE {$table} ADD COLUMN stock_status VARCHAR(32) NOT NULL DEFAULT 'instock'",
219 'backorders' => "ALTER TABLE {$table} ADD COLUMN backorders VARCHAR(16) NOT NULL DEFAULT 'no'",
220 'low_stock_threshold' => "ALTER TABLE {$table} ADD COLUMN low_stock_threshold INT(11) DEFAULT NULL",
221 'cost_price' => "ALTER TABLE {$table} ADD COLUMN cost_price decimal(26,8) DEFAULT NULL",
222 'barcode' => "ALTER TABLE {$table} ADD COLUMN barcode VARCHAR(64) DEFAULT NULL",
223 ];
224
225 foreach ( $columns as $column => $sql ) {
226 if ( ! in_array( $column, $existing, true ) ) {
227 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared
228 $wpdb->query( $sql );
229 // phpcs:enable
230 }
231 }
232
233 // Ensure indexes exist.
234 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
235 foreach ( [ 'stock_status', 'sku', 'barcode' ] as $index_name ) {
236 $has_index = $wpdb->get_var( $wpdb->prepare(
237 "SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = %s AND table_name = %s AND index_name = %s",
238 DB_NAME,
239 $table,
240 $index_name
241 ) );
242
243 if ( ! (int) $has_index ) {
244 $wpdb->query( "ALTER TABLE {$table} ADD KEY {$index_name} ({$index_name})" );
245 }
246 }
247 // phpcs:enable
248 }
249
250 /**
251 * Adds the `vendor_id` column to wp_storeengine_stock_movements on
252 * installs that pre-date the multi-vendor scoping feature, then back-fills
253 * existing rows from the product's post_author. Idempotent.
254 */
255 public static function ensure_stock_movements_vendor_column(): void {
256 global $wpdb;
257
258 $table = $wpdb->prefix . 'storeengine_stock_movements';
259
260 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
261 $existing = $wpdb->get_col( "DESCRIBE {$table}", 0 );
262 // phpcs:enable
263
264 if ( ! is_array( $existing ) || empty( $existing ) ) {
265 return;
266 }
267
268 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared
269 if ( ! in_array( 'vendor_id', $existing, true ) ) {
270 $wpdb->query( "ALTER TABLE {$table} ADD COLUMN vendor_id BIGINT(20) UNSIGNED DEFAULT NULL" );
271 $wpdb->query( "ALTER TABLE {$table} ADD KEY vendor_id (vendor_id)" );
272 }
273
274 // One-shot back-fill from product.post_author. Keyed on a flag option
275 // so re-running install() on a populated table is a no-op.
276 if ( ! get_option( 'storeengine_stock_movements_vendor_backfill_v1' ) ) {
277 $wpdb->query(
278 "UPDATE {$table} m
279 INNER JOIN {$wpdb->posts} p ON p.ID = m.product_id
280 SET m.vendor_id = p.post_author
281 WHERE m.vendor_id IS NULL"
282 );
283 update_option( 'storeengine_stock_movements_vendor_backfill_v1', 1, false );
284 }
285 // phpcs:enable
286 }
287
288 public function register_product_post_type() {
289 $permalinks = Helper::get_permalink_structure();
290 $shop_page_id = Helper::get_settings( 'shop_page' );
291 $has_archive = get_post( $shop_page_id ) ? urldecode( get_page_uri( $shop_page_id ) ) : 'shop';
292
293 // Registering Product CPT
294 register_post_type( Helper::PRODUCT_POST_TYPE, [
295 'labels' => [
296 'name' => esc_html__( 'Products', 'storeengine' ),
297 'add_new' => esc_html__( 'Add New Product', 'storeengine' ),
298 'singular_name' => esc_html__( 'Product', 'storeengine' ),
299 'search_items' => esc_html__( 'Search Products', 'storeengine' ),
300 'parent_item_colon' => esc_html__( 'Parent Products:', 'storeengine' ),
301 'not_found' => esc_html__( 'No Products found.', 'storeengine' ),
302 'not_found_in_trash' => esc_html__( 'No Products found in Trash.', 'storeengine' ),
303 'archives' => esc_html__( 'Product archives', 'storeengine' ),
304 ],
305 'public' => true,
306 'publicly_queryable' => true,
307 'show_ui' => true,
308 'show_in_menu' => false,
309 'show_in_admin_bar' => false,
310 'show_in_nav_menus' => false,
311 'hierarchical' => true,
312 'query_var' => true,
313 'delete_with_user' => false,
314 'supports' => [
315 'title',
316 'editor',
317 'author',
318 'thumbnail',
319 'excerpt',
320 'trackbacks',
321 'custom-fields',
322 'comments',
323 'post-formats',
324 ],
325 'has_archive' => $has_archive,
326 'rewrite' => [ 'slug' => $permalinks['product_rewrite_slug'] ],
327 'show_in_rest' => true,
328 'rest_base' => Helper::PRODUCT_POST_TYPE,
329 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1',
330 'rest_controller_class' => 'WP_REST_Posts_Controller',
331 'capability_type' => 'post',
332 'capabilities' => [
333 'edit_post' => 'edit_storeengine_product',
334 'read_post' => 'read_storeengine_product',
335 'delete_post' => 'delete_storeengine_product',
336 'edit_posts' => 'edit_storeengine_products',
337 'edit_others_posts' => 'edit_storeengine_others_products',
338 'publish_posts' => 'publish_storeengine_products',
339 'read_private_posts' => 'read_private_storeengine_products',
340 ],
341 ] );
342
343 // Registering Product Category Taxonomy
344 register_taxonomy( Helper::PRODUCT_CATEGORY_TAXONOMY, Helper::PRODUCT_POST_TYPE, [
345 'hierarchical' => true,
346 'query_var' => true,
347 'public' => true,
348 'show_ui' => false,
349 'show_admin_column' => false,
350 '_builtin' => true,
351 'capabilities' => [
352 'manage_terms' => 'manage_categories',
353 'edit_terms' => 'edit_categories',
354 'delete_terms' => 'delete_categories',
355 'assign_terms' => 'assign_categories',
356 ],
357 'show_in_rest' => true,
358 'rest_base' => Helper::PRODUCT_CATEGORY_TAXONOMY,
359 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1',
360 'rest_controller_class' => 'WP_REST_Terms_Controller',
361 'rewrite' => [
362 'slug' => $permalinks['category_rewrite_slug'],
363 'with_front' => false,
364 'hierarchical' => true,
365 ],
366 ] );
367
368 // Registering Product Tag Taxonomy
369 register_taxonomy( Helper::PRODUCT_TAG_TAXONOMY, Helper::PRODUCT_POST_TYPE, [
370 'hierarchical' => false,
371 'query_var' => true,
372 'public' => true,
373 'show_ui' => false,
374 'show_admin_column' => false,
375 '_builtin' => true,
376 'capabilities' => [
377 'manage_terms' => 'manage_post_tags',
378 'edit_terms' => 'edit_post_tags',
379 'delete_terms' => 'delete_post_tags',
380 'assign_terms' => 'assign_post_tags',
381 ],
382 'show_in_rest' => true,
383 'rest_base' => Helper::PRODUCT_TAG_TAXONOMY,
384 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1',
385 'rest_controller_class' => 'WP_REST_Terms_Controller',
386 'rewrite' => [
387 'slug' => $permalinks['tag_rewrite_slug'],
388 'with_front' => false,
389 ],
390 ] );
391
392 // Registering Product Attributes As Taxonomy
393 foreach ( ( new Attributes() )->get_all_names() as $slug => ['label' => $label, 'public' => $public] ) {
394 self::register_attribute_taxonomy( $slug, $label, $public );
395 }
396 }
397
398 public function register_coupon_post_type() {
399 register_post_type( Helper::COUPON_POST_TYPE, [
400 'labels' => [
401 'name' => esc_html__( 'Coupon', 'storeengine' ),
402 'add_new' => esc_html__( 'Add New Coupon', 'storeengine' ),
403 'singular_name' => esc_html__( 'Coupon', 'storeengine' ),
404 'search_items' => esc_html__( 'Search Coupon', 'storeengine' ),
405 'parent_item_colon' => esc_html__( 'Parent Coupons:', 'storeengine' ),
406 'not_found' => esc_html__( 'No Coupons found.', 'storeengine' ),
407 'not_found_in_trash' => esc_html__( 'No Coupons found in Trash.', 'storeengine' ),
408 'archives' => esc_html__( 'Coupon archives', 'storeengine' ),
409 ],
410 'public' => true,
411 'publicly_queryable' => true,
412 'show_ui' => true,
413 'show_in_menu' => false,
414 'show_in_admin_bar' => false,
415 'show_in_nav_menus' => false,
416 'hierarchical' => true,
417 'query_var' => true,
418 'delete_with_user' => false,
419 'supports' => [ 'title', 'editor', 'author', 'custom-fields' ],
420 'has_archive' => true,
421 'rewrite' => [ 'slug' => 'coupon' ],
422 'show_in_rest' => true,
423 'rest_base' => Helper::COUPON_POST_TYPE,
424 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1',
425 'rest_controller_class' => 'WP_REST_Posts_Controller',
426 'capability_type' => 'post',
427 'capabilities' => [
428 'edit_post' => 'edit_storeengine_coupon',
429 'read_post' => 'read_storeengine_coupon',
430 'delete_post' => 'delete_storeengine_coupon',
431 'edit_posts' => 'edit_storeengine_coupons',
432 'edit_others_posts' => 'edit_others_storeengine_coupons',
433 'publish_posts' => 'publish_storeengine_coupons',
434 'read_private_posts' => 'read_private_storeengine_coupons',
435 ],
436 ] );
437 }
438
439 public function add_avatar_support_product_comment( array $comment_types ): array {
440 return array_merge( $comment_types, [ 'storeengine_product' ] );
441 }
442
443 public function register_product_meta() {
444 $product_meta = [
445 '_storeengine_product_shipping_type' => 'string',
446 '_storeengine_product_physical_weight' => 'string',
447 '_storeengine_product_physical_weight_unit' => 'string',
448 '_storeengine_product_physical_length' => 'string',
449 '_storeengine_product_physical_width' => 'string',
450 '_storeengine_product_physical_height' => 'string',
451 '_storeengine_product_physical_dimension_unit' => 'string',
452 '_storeengine_product_digital_auto_complete' => 'boolean',
453 '_storeengine_product_hide' => 'boolean',
454 '_storeengine_product_enable_license_creation' => 'boolean',
455 '_storeengine_manage_stock' => 'boolean',
456 '_storeengine_stock_quantity' => 'integer',
457 '_storeengine_low_stock_threshold' => 'integer',
458 '_storeengine_sold_individually' => 'boolean',
459 '_storeengine_cost_price' => 'number',
460 '_storeengine_barcode' => 'string',
461 // Simple-product SKU (variable products keep per-variant SKUs in
462 // the variations table). Registered here so REST treats it as a
463 // known meta and POS / inventory queries find it consistently.
464 '_storeengine_sku' => 'string',
465 ];
466
467 foreach ( $product_meta as $meta_key => $product_meta_value_type ) {
468 register_meta( 'post', $meta_key, [
469 'object_subtype' => Helper::PRODUCT_POST_TYPE,
470 'type' => $product_meta_value_type,
471 'single' => true,
472 'show_in_rest' => true,
473 ] );
474 }
475
476 register_meta( 'post', '_storeengine_stock_status', [
477 'object_subtype' => Helper::PRODUCT_POST_TYPE,
478 'type' => 'string',
479 'single' => true,
480 'show_in_rest' => [
481 'schema' => [
482 'title' => __( 'Stock Status', 'storeengine' ),
483 'description' => __( 'Product stock status.', 'storeengine' ),
484 'context' => [ 'view', 'edit' ],
485 'type' => 'string',
486 'enum' => [ 'instock', 'outofstock', 'onbackorder' ],
487 'default' => 'instock',
488 ],
489 ],
490 ] );
491
492 register_meta( 'post', '_storeengine_backorders', [
493 'object_subtype' => Helper::PRODUCT_POST_TYPE,
494 'type' => 'string',
495 'single' => true,
496 'show_in_rest' => [
497 'schema' => [
498 'title' => __( 'Backorders', 'storeengine' ),
499 'description' => __( 'Backorder policy.', 'storeengine' ),
500 'context' => [ 'view', 'edit' ],
501 'type' => 'string',
502 'enum' => [ 'no', 'notify', 'yes' ],
503 'default' => 'no',
504 ],
505 ],
506 ] );
507
508 register_meta( 'post', '_storeengine_product_description_editor_type', [
509 'object_subtype' => Helper::PRODUCT_POST_TYPE,
510 'type' => 'string',
511 'single' => true,
512 'show_in_rest' => true,
513 'default' => 'classic',
514 ] );
515
516 register_meta( 'post', '_storeengine_product_gallery_ids', [
517 'object_subtype' => Helper::PRODUCT_POST_TYPE,
518 'type' => 'array',
519 'single' => true,
520 'show_in_rest' => [
521 'schema' => [
522 'title' => __( 'Gallery', 'storeengine' ),
523 'description' => __( 'An array of image IDs for the product.', 'storeengine' ),
524 'context' => [ 'view', 'edit' ],
525 'type' => 'array',
526 'items' => [ 'type' => 'integer' ],
527 ],
528 ],
529 ] );
530
531 register_meta( 'post', '_storeengine_product_tax_status', [
532 'object_subtype' => Helper::PRODUCT_POST_TYPE,
533 'type' => 'string',
534 'single' => true,
535 'show_in_rest' => [
536 'schema' => [
537 'title' => __( 'Tax Status', 'storeengine' ),
538 'description' => __( 'Product tax status.', 'storeengine' ),
539 'context' => [ 'view', 'edit' ],
540 'type' => 'string',
541 'enum' => [ ProductTaxStatus::TAXABLE, ProductTaxStatus::SHIPPING, ProductTaxStatus::NONE ],
542 'default' => ProductTaxStatus::TAXABLE,
543 ],
544 ],
545 ] );
546
547 register_meta( 'post', '_storeengine_product_download_type', [
548 'object_subtype' => Helper::PRODUCT_POST_TYPE,
549 'type' => 'string',
550 'single' => true,
551 'show_in_rest' => [
552 'schema' => [
553 'title' => __( 'Download Type', 'storeengine' ),
554 'description' => __( 'Download type (e.g. versioned).', 'storeengine' ),
555 'context' => [ 'view', 'edit' ],
556 'type' => 'string',
557 'enum' => [ ProductDownloadType::INSTANT, ProductDownloadType::VERSIONED ],
558 'default' => ProductDownloadType::INSTANT,
559 ],
560 ],
561 ] );
562
563 register_meta( 'post', '_storeengine_product_downloadable_files', [
564 'object_subtype' => Helper::PRODUCT_POST_TYPE,
565 'type' => 'array',
566 'single' => true,
567 'show_in_rest' => [
568 'schema' => [
569 'type' => 'array',
570 'items' => [
571 'type' => 'object',
572 'properties' => [
573 'id' => [
574 'type' => 'string',
575 'description' => __( 'The unique identifier for the downloadable file.', 'storeengine' ),
576 ],
577 'attachment_id' => [
578 'type' => 'integer',
579 'description' => __( 'The unique identifier for the downloadable file.', 'storeengine' ),
580 ],
581 'name' => [
582 'type' => 'string',
583 'description' => __( 'The name of the downloadable file.', 'storeengine' ),
584 ],
585 'file' => [
586 'type' => 'string',
587 'format' => 'uri',
588 'description' => __( 'The URL of the downloadable file.', 'storeengine' ),
589 ],
590 'enabled' => [
591 'type' => 'boolean',
592 'description' => __( 'Enable or disable the downloadable file.', 'storeengine' ),
593 ],
594 ],
595 ],
596 'description' => __( 'An array of downloadable files for the product.', 'storeengine' ),
597 // Edit-only: these are the raw download file URLs / attachment ids.
598 // Exposing them in `view` context would let any anonymous reader of a
599 // published product pull the file links without a purchase/permission
600 // check. The product editor uses `edit` context (capability-gated);
601 // the storefront serves files via the permission-checked download
602 // handler, not this meta.
603 'context' => [ 'edit' ],
604 ],
605 ],
606 'auth_callback' => static function ( $allowed, $meta_key, $object_id ) {
607 // Protected meta — only users who can edit this product may write it.
608 return current_user_can( 'edit_post', $object_id );
609 },
610 ] );
611
612 register_meta( 'post', '_storeengine_upsell_ids', [
613 'object_subtype' => Helper::PRODUCT_POST_TYPE,
614 'type' => 'array',
615 'single' => true,
616 'show_in_rest' => [
617 'schema' => [
618 'type' => 'array',
619 'items' => [
620 'type' => 'integer',
621 ],
622 'description' => 'An array of upsell product IDs for the product',
623 'context' => [ 'view', 'edit' ],
624 ],
625 ],
626 ] );
627
628 register_meta( 'post', '_storeengine_crosssell_ids', [
629 'object_subtype' => Helper::PRODUCT_POST_TYPE,
630 'type' => 'array',
631 'single' => true,
632 'show_in_rest' => [
633 'schema' => [
634 'type' => 'array',
635 'items' => [
636 'type' => 'integer',
637 ],
638 'description' => 'An array of cross-sell product IDs for the product',
639 'context' => [ 'view', 'edit' ],
640 ],
641 ],
642 ] );
643
644 register_meta( 'post', '_storeengine_product_purchase_redirect_type', [
645 'object_subtype' => Helper::PRODUCT_POST_TYPE,
646 'type' => 'string',
647 'single' => true,
648 'show_in_rest' => [
649 'schema' => [
650 'title' => __( 'After Purchase Redirect Type', 'storeengine' ),
651 'description' => __( 'Purchase Redirect Type (e.g. page)', 'storeengine' ),
652 'context' => [ 'view', 'edit' ],
653 'type' => 'string',
654 'enum' => [
655 PurchaseRedirectType::DEFAULT,
656 PurchaseRedirectType::PAGE,
657 PurchaseRedirectType::URL,
658 ],
659 'default' => PurchaseRedirectType::DEFAULT,
660 ],
661 ],
662 ] );
663
664 register_meta( 'post', '_storeengine_product_purchase_redirect_url', [
665 'object_subtype' => Helper::PRODUCT_POST_TYPE,
666 'type' => 'string',
667 'single' => true,
668 'sanitize_callback' => function ( $value ) {
669 // Accept integer IDs
670 if ( is_numeric( $value ) ) {
671 return (string) intval( $value );
672 }
673
674 // Accept valid URLs
675 if ( filter_var( $value, FILTER_VALIDATE_URL ) ) {
676 return esc_url_raw( $value );
677 }
678
679 return '';
680 },
681 'show_in_rest' => [
682 'schema' => [
683 'title' => __( 'After Purchase Redirect URL', 'storeengine' ),
684 'description' => __( 'Redirect specific page/url after product purchase.', 'storeengine' ),
685 'context' => [ 'view', 'edit' ],
686 'type' => 'string',
687 'default' => '',
688 ],
689 ],
690 ] );
691 }
692
693 public function register_coupon_meta() {
694 $course_meta = [
695 '_storeengine_coupon_name' => 'string',
696 '_storeengine_coupon_type' => 'string',
697 '_storeengine_coupon_amount' => 'number',
698 '_storeengine_coupon_time_type' => 'string',
699 '_storeengine_coupon_customer_usage_limit' => 'integer',
700 '_storeengine_coupon_type_of_min_requirement' => 'string',
701 '_storeengine_coupon_min_purchase_quantity' => 'number',
702 '_storeengine_coupon_min_purchase_amount' => 'number',
703 '_storeengine_coupon_who_can_use' => 'string',
704 '_storeengine_coupon_usage_count' => 'number',
705 ];
706
707 foreach ( $course_meta as $meta_key => $meta_value_type ) {
708 register_meta( 'post', $meta_key, [
709 'object_subtype' => Helper::COUPON_POST_TYPE,
710 'type' => $meta_value_type,
711 'single' => true,
712 'show_in_rest' => true,
713 ] );
714 }
715
716 // Coupon Hard limits.
717 foreach ( [ '_storeengine_coupon_usage_limit', '_storeengine_coupon_customer_usage_limit' ] as $limit_type ) {
718 register_meta( 'post', $limit_type, [
719 'object_subtype' => Helper::COUPON_POST_TYPE,
720 'default' => 0,
721 'type' => 'integer',
722 'single' => true,
723 'show_in_rest' => true,
724 ] );
725 }
726
727 // Coupon Used by (user ids).
728 register_meta( 'post', '_storeengine_coupon_used_by', [
729 'object_subtype' => Helper::COUPON_POST_TYPE,
730 'type' => 'number',
731 'single' => false,
732 'show_in_rest' => true,
733 ] );
734
735 // Coupon Start Time
736 register_meta( 'post', '_storeengine_coupon_start_date_time', [
737 'object_subtype' => Helper::COUPON_POST_TYPE,
738 'type' => 'object',
739 'single' => true,
740 'show_in_rest' => [
741 'schema' => [
742 'additionalProperties' => true,
743 'items' => [
744 'type' => 'object',
745 'properties' => [
746 'date' => [ 'type' => 'string' ],
747 'time' => [ 'type' => 'string' ],
748 'timezone' => [ 'type' => 'string' ],
749 ],
750 ],
751 ],
752 ],
753 ] );
754
755 // Coupon End Time
756 register_meta( 'post', '_storeengine_coupon_end_date_time', [
757 'object_subtype' => Helper::COUPON_POST_TYPE,
758 'type' => 'object',
759 'single' => true,
760 'show_in_rest' => [
761 'schema' => [
762 'additionalProperties' => true,
763 'items' => [
764 'type' => 'object',
765 'properties' => [
766 'date' => [ 'type' => 'string' ],
767 'time' => [ 'type' => 'string' ],
768 'timezone' => [ 'type' => 'string' ],
769 ],
770 ],
771 ],
772 ],
773 ] );
774
775 register_meta( 'post', '_storeengine_coupon_valid_customers', [
776 'object_subtype' => Helper::COUPON_POST_TYPE,
777 'type' => 'array',
778 'single' => true,
779 'show_in_rest' => [
780 'schema' => [
781 'type' => 'array',
782 'items' => [
783 'type' => 'integer',
784 ],
785 ],
786 ],
787 'sanitize_callback' => function ( $value ) {
788 return array_values(
789 array_filter(
790 array_map( 'absint', (array) $value )
791 )
792 );
793 }
794 ] );
795
796 register_meta( 'post', '_storeengine_coupon_valid_prices', [
797 'object_subtype' => Helper::COUPON_POST_TYPE,
798 'type' => 'array',
799 'single' => true,
800 'show_in_rest' => [
801 'schema' => [
802 'type' => 'array',
803 'items' => [
804 'type' => 'object',
805 'properties' => [
806 'product_id' => [ 'type' => 'number' ],
807 'price_ids' => [
808 'type' => 'array',
809 'items' => [ 'type' => 'number' ]
810 ],
811 ],
812 ],
813 ],
814 ],
815 'sanitize_callback' => function ( $value ) {
816 if ( ! is_array( $value ) ) {
817 return [];
818 }
819
820 $sanitized = [];
821 foreach ( $value as $row ) {
822 // Each item must be an array/object
823 if ( ! is_array( $row ) ) {
824 continue;
825 }
826
827 $product_id = absint( $row['product_id'] );
828
829 $price_ids = [];
830 if ( isset( $row['price_ids'] ) && is_array( $row['price_ids'] ) ) {
831 $price_ids = array_values(
832 array_unique(
833 array_filter(
834 array_map( 'absint', $row['price_ids'] )
835 )
836 )
837 );
838 }
839
840 $sanitized[] = [
841 'product_id' => $product_id,
842 'price_ids' => $price_ids,
843 ];
844 }
845
846 return $sanitized;
847 }
848 ] );
849
850 // Product / category include & exclude restrictions. Meta keys omit the
851 // `coupon_` segment on purpose so Coupon::get() maps them to the
852 // settings keys its getters read (see Coupon::$internal_meta_keys).
853 $id_list_sanitizer = function ( $value ) {
854 return array_values( array_unique( array_filter( array_map( 'absint', (array) $value ) ) ) );
855 };
856 $id_list_meta = [
857 '_storeengine_product_ids',
858 '_storeengine_excluded_product_ids',
859 '_storeengine_product_categories',
860 '_storeengine_excluded_product_categories',
861 ];
862 foreach ( $id_list_meta as $meta_key ) {
863 register_meta( 'post', $meta_key, [
864 'object_subtype' => Helper::COUPON_POST_TYPE,
865 'type' => 'array',
866 'single' => true,
867 'show_in_rest' => [
868 'schema' => [
869 'type' => 'array',
870 'items' => [ 'type' => 'integer' ],
871 ],
872 ],
873 'sanitize_callback' => $id_list_sanitizer,
874 ] );
875 }
876
877 // Allowed billing emails (exact match or *@domain.com wildcard).
878 register_meta( 'post', '_storeengine_email_restrictions', [
879 'object_subtype' => Helper::COUPON_POST_TYPE,
880 'type' => 'array',
881 'single' => true,
882 'show_in_rest' => [
883 'schema' => [
884 'type' => 'array',
885 'items' => [ 'type' => 'string' ],
886 ],
887 ],
888 'sanitize_callback' => function ( $value ) {
889 return array_values( array_filter( array_map( static function ( $email ) {
890 return strtolower( trim( sanitize_text_field( $email ) ) );
891 }, (array) $value ) ) );
892 },
893 ] );
894
895 // Exclude on-sale items from this coupon.
896 register_meta( 'post', '_storeengine_exclude_sale_items', [
897 'object_subtype' => Helper::COUPON_POST_TYPE,
898 'type' => 'boolean',
899 'single' => true,
900 'default' => false,
901 'show_in_rest' => true,
902 ] );
903
904 // Recurring (subscription) discount: keep applying the discount on
905 // renewals. `_limit` is the number of renewals to discount (0 = forever).
906 register_meta( 'post', '_storeengine_coupon_recurring_discount', [
907 'object_subtype' => Helper::COUPON_POST_TYPE,
908 'type' => 'boolean',
909 'single' => true,
910 'default' => false,
911 'show_in_rest' => true,
912 ] );
913 register_meta( 'post', '_storeengine_coupon_recurring_discount_limit', [
914 'object_subtype' => Helper::COUPON_POST_TYPE,
915 'type' => 'integer',
916 'single' => true,
917 'default' => 0,
918 'show_in_rest' => true,
919 'sanitize_callback' => 'absint',
920 ] );
921
922 // BOGO (Buy X Get Y) configuration.
923 foreach ( [ '_storeengine_coupon_bogo_buy_qty', '_storeengine_coupon_bogo_get_qty' ] as $bogo_qty_meta ) {
924 register_meta( 'post', $bogo_qty_meta, [
925 'object_subtype' => Helper::COUPON_POST_TYPE,
926 'type' => 'integer',
927 'single' => true,
928 'default' => 1,
929 'show_in_rest' => true,
930 'sanitize_callback' => 'absint',
931 ] );
932 }
933 register_meta( 'post', '_storeengine_coupon_bogo_discount', [
934 'object_subtype' => Helper::COUPON_POST_TYPE,
935 'type' => 'number',
936 'single' => true,
937 'default' => 100,
938 'show_in_rest' => true,
939 'sanitize_callback' => function ( $value ) {
940 return min( 100, max( 0, (float) $value ) );
941 },
942 ] );
943
944 // Auto-apply this coupon to every eligible cart (no code entry needed).
945 register_meta( 'post', '_storeengine_coupon_auto_apply', [
946 'object_subtype' => Helper::COUPON_POST_TYPE,
947 'type' => 'boolean',
948 'single' => true,
949 'default' => false,
950 'show_in_rest' => true,
951 ] );
952 }
953
954 public function register_category_rest_fields() {
955 register_rest_field(
956 Helper::PRODUCT_CATEGORY_TAXONOMY,
957 'parent_name',
958 [
959 'get_callback' => static function ( array $term ): string {
960 if ( empty( $term['parent'] ) ) {
961 return '';
962 }
963 $parent = get_term( (int) $term['parent'], Helper::PRODUCT_CATEGORY_TAXONOMY );
964 return ( $parent && ! is_wp_error( $parent ) ) ? $parent->name : '';
965 },
966 'schema' => [
967 'type' => 'string',
968 'context' => [ 'view', 'edit' ],
969 ],
970 ]
971 );
972 }
973
974 public static function register_attribute_taxonomy( string $name, string $label, bool $public ) {
975 return register_taxonomy( Helper::get_attribute_taxonomy_name( $name ), Helper::PRODUCT_POST_TYPE, [
976 'label' => $label,
977 'labels' => [
978 /* translators: %s: attribute name */
979 'name' => sprintf( _x( 'Product %s', 'Product Attribute', 'storeengine' ), $label ),
980 'singular_name' => $label,
981 /* translators: %s: attribute name */
982 'search_items' => sprintf( __( 'Search %s', 'storeengine' ), $label ),
983 /* translators: %s: attribute name */
984 'all_items' => sprintf( __( 'All %s', 'storeengine' ), $label ),
985 /* translators: %s: attribute name */
986 'parent_item' => sprintf( __( 'Parent %s', 'storeengine' ), $label ),
987 /* translators: %s: attribute name */
988 'parent_item_colon' => sprintf( __( 'Parent %s:', 'storeengine' ), $label ),
989 /* translators: %s: attribute name */
990 'edit_item' => sprintf( _x( 'Edit %s', 'Product attribute edit button label', 'storeengine' ), $label ),
991 /* translators: %s: attribute name */
992 'update_item' => sprintf( __( 'Update %s', 'storeengine' ), $label ),
993 /* translators: %s: attribute name */
994 'add_new_item' => sprintf( __( 'Add new %s', 'storeengine' ), $label ),
995 /* translators: %s: attribute name */
996 'new_item_name' => sprintf( __( 'New %s', 'storeengine' ), $label ),
997 /* translators: %s: attribute name */
998 'not_found' => sprintf( __( 'No &quot;%s&quot; found', 'storeengine' ), $label ),
999 /* translators: %s: attribute name */
1000 'back_to_items' => sprintf( __( '&larr; Back to "%s" attributes', 'storeengine' ), $label ),
1001 ],
1002 'hierarchical' => false,
1003 'update_count_callback' => '_update_post_term_count',
1004 'show_ui' => false,
1005 'show_in_quick_edit' => false,
1006 'show_in_menu' => false,
1007 'meta_box_cb' => false,
1008 'query_var' => $public,
1009 'rewrite' => ! $public ? false : apply_filters( 'storeengine/attribute/rewrite_rule', [
1010 'slug' => Helper::get_attribute_taxonomy_name( $name ),
1011 'with_front' => false,
1012 ] ),
1013 'sort' => false,
1014 'public' => $public,
1015 'show_in_nav_menus' => $public && apply_filters( 'storeengine/attribute/show_in_nav_menus', false, $name ),
1016 'capabilities' => [
1017 'manage_terms' => 'manage_post_tags',
1018 'edit_terms' => 'edit_post_tags',
1019 'delete_terms' => 'delete_post_tags',
1020 'assign_terms' => 'assign_post_tags',
1021 ],
1022 'show_admin_column' => false,
1023 'show_in_rest' => true,
1024 'rest_base' => Helper::get_attribute_taxonomy_name( $name ),
1025 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1',
1026 'rest_controller_class' => 'WP_REST_Terms_Controller',
1027 ] );
1028 }
1029 }
1030