PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.0.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.0.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.0.0, at includes/database.php

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