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

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

1,441 lines 53.3 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( 'init', [ $this, 'register_faq_post_type' ], 5 );
61 // FAQ-group meta must also be registered on rest_api_init so the CPT's
62 // REST controller includes it in its meta schema (same pattern as
63 // register_product_meta below).
64 add_action( 'rest_api_init', [ $this, 'register_faq_group_meta' ] );
65 add_action( 'init', [ $this, 'register_size_chart_post_type' ], 5 );
66 add_action( 'rest_api_init', [ $this, 'register_size_chart_meta' ] );
67 add_action( 'get_avatar_comment_types', [ $this, 'add_avatar_support_product_comment' ] );
68
69 // Invalidate resolved-FAQ caches whenever a group or product changes.
70 add_action( 'save_post_' . Helper::FAQ_POST_TYPE, [ '\StoreEngine\Classes\Faq', 'flush_cache' ] );
71 add_action( 'deleted_post', [ '\StoreEngine\Classes\Faq', 'flush_cache' ] );
72 add_action( 'save_post_' . Helper::PRODUCT_POST_TYPE, [ '\StoreEngine\Classes\Faq', 'flush_cache' ] );
73
74 // Same for resolved size charts (chart edits, product term changes).
75 add_action( 'save_post_' . Helper::SIZE_CHART_POST_TYPE, [ '\StoreEngine\Classes\SizeChart', 'flush_cache' ] );
76 add_action( 'deleted_post', [ '\StoreEngine\Classes\SizeChart', 'flush_cache' ] );
77 add_action( 'save_post_' . Helper::PRODUCT_POST_TYPE, [ '\StoreEngine\Classes\SizeChart', 'flush_cache' ] );
78
79 add_action( 'init', [ $this, 'maybe_flush_rewrite_rules' ], 5 );
80 add_action( 'storeengine/flush_rewrite_rules', [ __CLASS__, 'flush_rewrite_rules' ] );
81
82 add_action( 'rest_api_init', [ $this, 'register_product_meta' ] );
83 add_action( 'rest_api_init', [ $this, 'register_coupon_meta' ] );
84 add_action( 'rest_api_init', [ $this, 'register_category_rest_fields' ] );
85 }
86
87 const SCHEMA_HASH_OPTION = 'storeengine_schema_hash';
88
89 /**
90 * Re-runs dbDelta when any schema file in includes/database/ changes.
91 * Avoids the need to bump STOREENGINE_DB_VERSION on every column addition.
92 */
93 public static function maybe_sync_schema(): void {
94 $hash = self::compute_schema_hash( STOREENGINE_ROOT_DIR_PATH . 'includes/database' );
95 if ( ! $hash || $hash === get_option( self::SCHEMA_HASH_OPTION ) ) {
96 return;
97 }
98
99 self::create_initial_custom_table();
100 update_option( self::SCHEMA_HASH_OPTION, $hash, true );
101
102 /**
103 * Fires after core schema files were re-applied via dbDelta.
104 * Addons should hook here to re-sync their own tables.
105 */
106 do_action( 'storeengine/schema_synced' );
107 }
108
109 public static function compute_schema_hash( string $dir ): string {
110 $files = glob( rtrim( $dir, '/' ) . '/*.php' );
111 if ( empty( $files ) ) {
112 return '';
113 }
114 sort( $files );
115 $parts = [];
116 foreach ( $files as $file ) {
117 $parts[] = md5_file( $file );
118 }
119 return md5( implode( '', $parts ) );
120 }
121
122 public function maybe_flush_rewrite_rules() {
123 if ( 'yes' === get_option( 'storeengine_required_rewrite_flush' ) ) {
124 update_option( 'storeengine_required_rewrite_flush', 'no' );
125 self::flush_rewrite_rules();
126 }
127 }
128
129 public static function flush_rewrite_rules() {
130 flush_rewrite_rules();
131 }
132
133 public function register_database_table_name() {
134 global $wpdb;
135
136 // @TODO add all the tables.
137 $tables = [
138 'payment_tokenmeta' => 'storeengine_payment_tokenmeta',
139 'order_itemmeta' => 'storeengine_order_item_meta',
140 'product_meta_lookup' => 'storeengine_product_meta_lookup',
141 'tax_rate_classes' => 'storeengine_tax_rate_classes',
142 'reserved_stock' => 'storeengine_reserved_stock',
143 'store_orders' => 'storeengine_orders',
144 'ordermeta' => 'storeengine_orders_meta',
145 ];
146
147 /**
148 * @XXX make sure to add the meta types for handling cache last change.
149 *
150 * @see Hooks::handle_cache_last_changed()
151 * @see wp_cache_set_last_changed()
152 */
153
154 foreach ( $tables as $name => $table ) {
155 $wpdb->$name = $wpdb->prefix . $table;
156 $wpdb->tables[] = $table;
157 }
158 }
159
160 public function wpdb_table_fix() {
161 $this->register_database_table_name();
162 }
163
164 public static function create_initial_custom_table() {
165 global $wpdb;
166
167 if ( ! function_exists( 'dbDelta' ) ) {
168 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
169 }
170
171 $charset_collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';
172
173 CreateIntegration::up( $wpdb->prefix, $charset_collate );
174 CreateProductPriceTable::up( $wpdb->prefix, $charset_collate );
175 CreateAttributeTaxonomies::up( $wpdb->prefix, $charset_collate );
176 CreateProductVariationsTable::up( $wpdb->prefix, $charset_collate );
177 CreateProductVariationMetaTable::up( $wpdb->prefix, $charset_collate );
178 CreateReservedStockTable::up( $wpdb->prefix, $charset_collate );
179 CreateStockMovementsTable::up( $wpdb->prefix, $charset_collate );
180 CreateVariationsTermsRelationTable::up( $wpdb->prefix, $charset_collate );
181 CreateOrderTable::up( $wpdb->prefix, $charset_collate );
182 CreateOrderMetaTable::up( $wpdb->prefix, $charset_collate );
183 CreateOrderProductLookup::up( $wpdb->prefix, $charset_collate );
184 CreateOrderItems::up( $wpdb->prefix, $charset_collate );
185 CreateOrderItemMeta::up( $wpdb->prefix, $charset_collate );
186 CreateOrderOperationalData::up( $wpdb->prefix, $charset_collate );
187 CreateOrderAddresses::up( $wpdb->prefix, $charset_collate );
188 CreateShippingZoneMethods::up( $wpdb->prefix, $charset_collate );
189 CreateShippingZones::up( $wpdb->prefix, $charset_collate );
190 CreateShippingZoneLocations::up( $wpdb->prefix, $charset_collate );
191 CreateApiKeys::up( $wpdb->prefix, $charset_collate );
192 CreateCart::up( $wpdb->prefix, $charset_collate );
193 CreatePaymentTokens::up( $wpdb->prefix, $charset_collate );
194 CreatePaymentTokenMeta::up( $wpdb->prefix, $charset_collate );
195 CreatePayouts::up( $wpdb->prefix, $charset_collate );
196 CreateTaxRateLocations::up( $wpdb->prefix, $charset_collate );
197 CreateTaxRates::up( $wpdb->prefix, $charset_collate );
198 CreateApiKeys::up( $wpdb->prefix, $charset_collate );
199 CreateLog::up( $wpdb->prefix, $charset_collate );
200 CreateEmailLog::up( $wpdb->prefix, $charset_collate );
201 CreateCustomerLookup::up( $wpdb->prefix, $charset_collate );
202 CreateDownloadableProductPermissions::up( $wpdb->prefix, $charset_collate );
203 CreateDownloadLog::up( $wpdb->prefix, $charset_collate );
204 CreateProductDownloadDirectories::up( $wpdb->prefix, $charset_collate );
205
206 // Backfill new columns on existing tables that dbDelta may have skipped.
207 self::ensure_variation_stock_columns();
208 self::ensure_stock_movements_vendor_column();
209
210 // Store DB Version
211 update_option( 'storeengine_db_version', STOREENGINE_DB_VERSION, false );
212 }
213
214 /**
215 * Adds the stock-tracking columns to wp_storeengine_product_variations on
216 * installs that pre-date the inventory feature. Idempotent — silently
217 * skips columns that already exist.
218 */
219 public static function ensure_variation_stock_columns(): void {
220 global $wpdb;
221
222 $table = $wpdb->prefix . 'storeengine_product_variations';
223
224 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DESCRIBE on a prepared %i identifier; schema introspection.
225 $existing = $wpdb->get_col( $wpdb->prepare( 'DESCRIBE %i', $table ), 0 );
226
227 if ( ! is_array( $existing ) || empty( $existing ) ) {
228 return;
229 }
230
231 $columns = [
232 'manage_stock' => 'ALTER TABLE %i ADD COLUMN manage_stock TINYINT(1) NOT NULL DEFAULT 0',
233 'stock_quantity' => 'ALTER TABLE %i ADD COLUMN stock_quantity INT(11) DEFAULT NULL',
234 'stock_status' => "ALTER TABLE %i ADD COLUMN stock_status VARCHAR(32) NOT NULL DEFAULT 'instock'",
235 'backorders' => "ALTER TABLE %i ADD COLUMN backorders VARCHAR(16) NOT NULL DEFAULT 'no'",
236 'low_stock_threshold' => 'ALTER TABLE %i ADD COLUMN low_stock_threshold INT(11) DEFAULT NULL',
237 'cost_price' => 'ALTER TABLE %i ADD COLUMN cost_price decimal(26,8) DEFAULT NULL',
238 'barcode' => 'ALTER TABLE %i ADD COLUMN barcode VARCHAR(64) DEFAULT NULL',
239 ];
240
241 foreach ( $columns as $column => $sql ) {
242 if ( ! in_array( $column, $existing, true ) ) {
243 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared %i identifier; column definition is a literal; idempotent schema upgrade.
244 $wpdb->query( $wpdb->prepare( $sql, $table ) );
245 }
246 }
247
248 // Ensure indexes exist.
249 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
250 foreach ( [ 'stock_status', 'sku', 'barcode' ] as $index_name ) {
251 $has_index = $wpdb->get_var( $wpdb->prepare(
252 "SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = %s AND table_name = %s AND index_name = %s",
253 DB_NAME,
254 $table,
255 $index_name
256 ) );
257
258 if ( ! (int) $has_index ) {
259 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared %i identifiers; idempotent index creation on a StoreEngine table.
260 $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD KEY %i (%i)', $table, $index_name, $index_name ) );
261 }
262 }
263 // phpcs:enable
264 }
265
266 /**
267 * Adds the `vendor_id` column to wp_storeengine_stock_movements on
268 * installs that pre-date the multi-vendor scoping feature, then back-fills
269 * existing rows from the product's post_author. Idempotent.
270 */
271 public static function ensure_stock_movements_vendor_column(): void {
272 global $wpdb;
273
274 $table = $wpdb->prefix . 'storeengine_stock_movements';
275
276 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DESCRIBE on a prepared %i identifier; schema introspection.
277 $existing = $wpdb->get_col( $wpdb->prepare( 'DESCRIBE %i', $table ), 0 );
278
279 if ( ! is_array( $existing ) || empty( $existing ) ) {
280 return;
281 }
282
283 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
284 if ( ! in_array( 'vendor_id', $existing, true ) ) {
285 $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD COLUMN vendor_id BIGINT(20) UNSIGNED DEFAULT NULL', $table ) );
286 $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD KEY vendor_id (vendor_id)', $table ) );
287 }
288
289 // One-shot back-fill from product.post_author. Keyed on a flag option
290 // so re-running install() on a populated table is a no-op.
291 if ( ! get_option( 'storeengine_stock_movements_vendor_backfill_v1' ) ) {
292 $wpdb->query( $wpdb->prepare(
293 "UPDATE %i m
294 INNER JOIN {$wpdb->posts} p ON p.ID = m.product_id
295 SET m.vendor_id = p.post_author
296 WHERE m.vendor_id IS NULL",
297 $table
298 ) );
299 update_option( 'storeengine_stock_movements_vendor_backfill_v1', 1, false );
300 }
301 // phpcs:enable
302 }
303
304 public function register_product_post_type() {
305 $permalinks = Helper::get_permalink_structure();
306 $shop_page_id = Helper::get_settings( 'shop_page' );
307 $has_archive = get_post( $shop_page_id ) ? urldecode( get_page_uri( $shop_page_id ) ) : 'shop';
308
309 // Registering Product CPT
310 register_post_type( Helper::PRODUCT_POST_TYPE, [
311 'labels' => [
312 'name' => esc_html__( 'Products', 'storeengine' ),
313 'add_new' => esc_html__( 'Add New Product', 'storeengine' ),
314 'singular_name' => esc_html__( 'Product', 'storeengine' ),
315 'search_items' => esc_html__( 'Search Products', 'storeengine' ),
316 'parent_item_colon' => esc_html__( 'Parent Products:', 'storeengine' ),
317 'not_found' => esc_html__( 'No Products found.', 'storeengine' ),
318 'not_found_in_trash' => esc_html__( 'No Products found in Trash.', 'storeengine' ),
319 'archives' => esc_html__( 'Product archives', 'storeengine' ),
320 ],
321 'public' => true,
322 'publicly_queryable' => true,
323 'show_ui' => true,
324 'show_in_menu' => false,
325 'show_in_admin_bar' => false,
326 'show_in_nav_menus' => false,
327 'hierarchical' => true,
328 'query_var' => true,
329 'delete_with_user' => false,
330 'supports' => [
331 'title',
332 'editor',
333 'author',
334 'thumbnail',
335 'excerpt',
336 'trackbacks',
337 'custom-fields',
338 'comments',
339 'post-formats',
340 ],
341 'has_archive' => $has_archive,
342 'rewrite' => [ 'slug' => $permalinks['product_rewrite_slug'] ],
343 'show_in_rest' => true,
344 'rest_base' => Helper::PRODUCT_POST_TYPE,
345 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1',
346 'rest_controller_class' => 'WP_REST_Posts_Controller',
347 'capability_type' => 'post',
348 'capabilities' => [
349 'edit_post' => 'edit_storeengine_product',
350 'read_post' => 'read_storeengine_product',
351 'delete_post' => 'delete_storeengine_product',
352 'edit_posts' => 'edit_storeengine_products',
353 'edit_others_posts' => 'edit_storeengine_others_products',
354 'publish_posts' => 'publish_storeengine_products',
355 'read_private_posts' => 'read_private_storeengine_products',
356 ],
357 ] );
358
359 // Registering Product Category Taxonomy
360 register_taxonomy( Helper::PRODUCT_CATEGORY_TAXONOMY, Helper::PRODUCT_POST_TYPE, [
361 'hierarchical' => true,
362 'query_var' => true,
363 'public' => true,
364 'show_ui' => false,
365 'show_admin_column' => false,
366 '_builtin' => true,
367 'capabilities' => [
368 'manage_terms' => 'manage_categories',
369 'edit_terms' => 'edit_categories',
370 'delete_terms' => 'delete_categories',
371 'assign_terms' => 'assign_categories',
372 ],
373 'show_in_rest' => true,
374 'rest_base' => Helper::PRODUCT_CATEGORY_TAXONOMY,
375 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1',
376 'rest_controller_class' => 'WP_REST_Terms_Controller',
377 'rewrite' => [
378 'slug' => $permalinks['category_rewrite_slug'],
379 'with_front' => false,
380 'hierarchical' => true,
381 ],
382 ] );
383
384 // Registering Product Tag Taxonomy
385 register_taxonomy( Helper::PRODUCT_TAG_TAXONOMY, Helper::PRODUCT_POST_TYPE, [
386 'hierarchical' => false,
387 'query_var' => true,
388 'public' => true,
389 'show_ui' => false,
390 'show_admin_column' => false,
391 '_builtin' => true,
392 'capabilities' => [
393 'manage_terms' => 'manage_post_tags',
394 'edit_terms' => 'edit_post_tags',
395 'delete_terms' => 'delete_post_tags',
396 'assign_terms' => 'assign_post_tags',
397 ],
398 'show_in_rest' => true,
399 'rest_base' => Helper::PRODUCT_TAG_TAXONOMY,
400 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1',
401 'rest_controller_class' => 'WP_REST_Terms_Controller',
402 'rewrite' => [
403 'slug' => $permalinks['tag_rewrite_slug'],
404 'with_front' => false,
405 ],
406 ] );
407
408 // Registering Product Attributes As Taxonomy
409 foreach ( ( new Attributes() )->get_all_names() as $slug => ['label' => $label, 'public' => $public] ) {
410 self::register_attribute_taxonomy( $slug, $label, $public );
411 }
412 }
413
414 public function register_coupon_post_type() {
415 register_post_type( Helper::COUPON_POST_TYPE, [
416 'labels' => [
417 'name' => esc_html__( 'Coupon', 'storeengine' ),
418 'add_new' => esc_html__( 'Add New Coupon', 'storeengine' ),
419 'singular_name' => esc_html__( 'Coupon', 'storeengine' ),
420 'search_items' => esc_html__( 'Search Coupon', 'storeengine' ),
421 'parent_item_colon' => esc_html__( 'Parent Coupons:', 'storeengine' ),
422 'not_found' => esc_html__( 'No Coupons found.', 'storeengine' ),
423 'not_found_in_trash' => esc_html__( 'No Coupons found in Trash.', 'storeengine' ),
424 'archives' => esc_html__( 'Coupon archives', 'storeengine' ),
425 ],
426 // A coupon is a configuration entity, not front-end content: it has no
427 // meaningful single page or archive. Keeping it publicly queryable also
428 // registered `storeengine_coupon` as a PUBLIC query var, which collided
429 // with the apply-coupon URL param (`/cart/?storeengine_coupon=CODE`) —
430 // WP treated the request as a single-coupon query and 404'd instead of
431 // loading the cart. Non-public frees that param and removes the dead
432 // /coupon/* URLs. Admin (show_ui) + REST (show_in_rest) are unaffected.
433 'public' => false,
434 'publicly_queryable' => false,
435 'show_ui' => true,
436 'show_in_menu' => false,
437 'show_in_admin_bar' => false,
438 'show_in_nav_menus' => false,
439 'hierarchical' => true,
440 'query_var' => false,
441 'delete_with_user' => false,
442 'supports' => [ 'title', 'editor', 'author', 'custom-fields' ],
443 'has_archive' => false,
444 'rewrite' => false,
445 'show_in_rest' => true,
446 'rest_base' => Helper::COUPON_POST_TYPE,
447 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1',
448 'rest_controller_class' => 'WP_REST_Posts_Controller',
449 'capability_type' => 'post',
450 'capabilities' => [
451 'edit_post' => 'edit_storeengine_coupon',
452 'read_post' => 'read_storeengine_coupon',
453 'delete_post' => 'delete_storeengine_coupon',
454 'edit_posts' => 'edit_storeengine_coupons',
455 'edit_others_posts' => 'edit_others_storeengine_coupons',
456 'publish_posts' => 'publish_storeengine_coupons',
457 'read_private_posts' => 'read_private_storeengine_coupons',
458 ],
459 ] );
460 }
461
462 /**
463 * FAQ groups — the reusable, rule-targeted unit of the FAQ system.
464 *
465 * A group is a post: name = post_title, Q&A items live in the
466 * `_storeengine_faq_items` meta. Display rules (`_apply_all`, category / brand
467 * / product id metas) decide which products render the group. Products may
468 * also attach groups directly, exclude an inherited group, or add their own
469 * inline Q&A (`_storeengine_product_faqs`). Managed from the StoreEngine admin
470 * (Products → FAQs) via the storeengine/v1 REST controller, so the CPT is
471 * intentionally not shown in the native menu.
472 */
473 public function register_faq_post_type() {
474 register_post_type( Helper::FAQ_POST_TYPE, [
475 'labels' => [
476 'name' => esc_html__( 'FAQ Groups', 'storeengine' ),
477 'add_new' => esc_html__( 'Add New FAQ Group', 'storeengine' ),
478 'singular_name' => esc_html__( 'FAQ Group', 'storeengine' ),
479 'search_items' => esc_html__( 'Search FAQ Groups', 'storeengine' ),
480 'not_found' => esc_html__( 'No FAQ groups found.', 'storeengine' ),
481 'not_found_in_trash' => esc_html__( 'No FAQ groups found in Trash.', 'storeengine' ),
482 ],
483 'public' => false,
484 'publicly_queryable' => false,
485 'show_ui' => false,
486 'show_in_menu' => false,
487 'show_in_admin_bar' => false,
488 'show_in_nav_menus' => false,
489 'hierarchical' => false,
490 'query_var' => false,
491 'delete_with_user' => false,
492 'supports' => [ 'title', 'author', 'page-attributes', 'custom-fields' ],
493 'has_archive' => false,
494 'rewrite' => false,
495 'show_in_rest' => true,
496 'rest_base' => 'faq-groups',
497 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1',
498 'rest_controller_class' => 'WP_REST_Posts_Controller',
499 'capability_type' => 'post',
500 'map_meta_cap' => true,
501 ] );
502 }
503
504 /**
505 * REST-exposed meta for FAQ groups (Q&A items + display rules).
506 *
507 * Registered on rest_api_init (mirrors register_product_meta) so the CPT's
508 * REST controller picks up the full schema — array-of-objects meta only
509 * round-trips reliably when registered this way with an explicit context.
510 */
511 public function register_faq_group_meta() {
512 // FAQ groups are an admin-managed library: only users who can manage the
513 // store may write this meta (the CPT is capability_type 'post', so this
514 // auth_callback is what keeps Author/Editor roles from injecting FAQ
515 // content via the REST route).
516 $auth_callback = static function () {
517 return current_user_can( 'manage_options' );
518 };
519
520 register_meta( 'post', '_storeengine_faq_items', [
521 'object_subtype' => Helper::FAQ_POST_TYPE,
522 'type' => 'array',
523 'single' => true,
524 'auth_callback' => $auth_callback,
525 'sanitize_callback' => static function ( $items ) {
526 if ( ! is_array( $items ) ) {
527 return [];
528 }
529 return array_values( array_map( static function ( $item ) {
530 return [
531 'question' => sanitize_text_field( $item['question'] ?? '' ),
532 'answer' => wp_kses_post( $item['answer'] ?? '' ),
533 ];
534 }, $items ) );
535 },
536 'show_in_rest' => [
537 'schema' => [
538 'title' => __( 'FAQ items', 'storeengine' ),
539 'description' => __( 'Question/answer pairs in this group.', 'storeengine' ),
540 'context' => [ 'view', 'edit' ],
541 'type' => 'array',
542 'items' => [
543 'type' => 'object',
544 'properties' => [
545 'question' => [ 'type' => 'string' ],
546 'answer' => [ 'type' => 'string' ],
547 ],
548 ],
549 ],
550 ],
551 ] );
552
553 register_meta( 'post', '_storeengine_faq_apply_all', [
554 'object_subtype' => Helper::FAQ_POST_TYPE,
555 'type' => 'boolean',
556 'single' => true,
557 'default' => false,
558 'auth_callback' => $auth_callback,
559 'sanitize_callback' => 'rest_sanitize_boolean',
560 'show_in_rest' => true,
561 ] );
562
563 foreach ( [ '_storeengine_faq_category_ids', '_storeengine_faq_brand_ids', '_storeengine_faq_product_ids' ] as $key ) {
564 register_meta( 'post', $key, [
565 'object_subtype' => Helper::FAQ_POST_TYPE,
566 'type' => 'array',
567 'single' => true,
568 'auth_callback' => $auth_callback,
569 'sanitize_callback' => static function ( $ids ) {
570 return is_array( $ids ) ? array_values( array_filter( array_map( 'absint', $ids ) ) ) : [];
571 },
572 'show_in_rest' => [
573 'schema' => [
574 'context' => [ 'view', 'edit' ],
575 'type' => 'array',
576 'items' => [ 'type' => 'integer' ],
577 ],
578 ],
579 ] );
580 }
581 }
582
583 /**
584 * Size chart library. An admin-managed set of measurement tables, each with
585 * display rules (all / category / brand / product) — mirrors the FAQ group
586 * CPT above. Deliberately not shown in the native menu; it is managed from
587 * StoreEngine admin (Products → Size Charts) over the storeengine/v1 REST
588 * controller.
589 */
590 public function register_size_chart_post_type() {
591 register_post_type( Helper::SIZE_CHART_POST_TYPE, [
592 'labels' => [
593 'name' => esc_html__( 'Size Charts', 'storeengine' ),
594 'add_new' => esc_html__( 'Add New Size Chart', 'storeengine' ),
595 'singular_name' => esc_html__( 'Size Chart', 'storeengine' ),
596 'search_items' => esc_html__( 'Search Size Charts', 'storeengine' ),
597 'not_found' => esc_html__( 'No size charts found.', 'storeengine' ),
598 'not_found_in_trash' => esc_html__( 'No size charts found in Trash.', 'storeengine' ),
599 ],
600 'public' => false,
601 'publicly_queryable' => false,
602 'show_ui' => false,
603 'show_in_menu' => false,
604 'show_in_admin_bar' => false,
605 'show_in_nav_menus' => false,
606 'hierarchical' => false,
607 'query_var' => false,
608 'delete_with_user' => false,
609 // 'editor' holds the "how to measure" content, so merchants get a
610 // real editor with media-library images rather than a plain field.
611 'supports' => [ 'title', 'editor', 'author', 'page-attributes', 'custom-fields' ],
612 'has_archive' => false,
613 'rewrite' => false,
614 'show_in_rest' => true,
615 'rest_base' => 'size-charts',
616 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1',
617 'rest_controller_class' => 'WP_REST_Posts_Controller',
618 'capability_type' => 'post',
619 'map_meta_cap' => true,
620 ] );
621 }
622
623 /**
624 * REST-exposed meta for size charts (measurement tables, the "how to
625 * measure" note, and the display rules).
626 *
627 * Registered on rest_api_init for the same reason as the FAQ meta above:
628 * array-of-objects meta only round-trips reliably when the CPT's REST
629 * controller can see the full schema with an explicit context.
630 */
631 public function register_size_chart_meta() {
632 // Same rationale as FAQ groups: the CPT is capability_type 'post', so
633 // this auth_callback is what stops Author/Editor roles writing charts.
634 $auth_callback = static function () {
635 return current_user_can( 'manage_options' );
636 };
637
638 register_meta( 'post', '_storeengine_size_tables', [
639 'object_subtype' => Helper::SIZE_CHART_POST_TYPE,
640 'type' => 'array',
641 'single' => true,
642 'auth_callback' => $auth_callback,
643 'sanitize_callback' => static function ( $tables ) {
644 if ( ! is_array( $tables ) ) {
645 return [];
646 }
647
648 return array_values( array_map( static function ( $table ) {
649 $columns = is_array( $table['columns'] ?? null ) ? $table['columns'] : [];
650 $rows = is_array( $table['rows'] ?? null ) ? $table['rows'] : [];
651
652 return [
653 'title' => sanitize_text_field( $table['title'] ?? '' ),
654 // Free-text so merchants can use cm / in / EU / UK etc.
655 'unit' => sanitize_text_field( $table['unit'] ?? '' ),
656 'columns' => array_values( array_map( 'sanitize_text_field', $columns ) ),
657 'rows' => array_values( array_map( static function ( $row ) {
658 return array_values( array_map( 'sanitize_text_field', is_array( $row ) ? $row : [] ) );
659 }, $rows ) ),
660 ];
661 }, $tables ) );
662 },
663 'show_in_rest' => [
664 'schema' => [
665 'title' => __( 'Size tables', 'storeengine' ),
666 'description' => __( 'Measurement tables in this chart.', 'storeengine' ),
667 'context' => [ 'view', 'edit' ],
668 'type' => 'array',
669 'items' => [
670 'type' => 'object',
671 'properties' => [
672 'title' => [ 'type' => 'string' ],
673 'unit' => [ 'type' => 'string' ],
674 'columns' => [
675 'type' => 'array',
676 'items' => [ 'type' => 'string' ],
677 ],
678 'rows' => [
679 'type' => 'array',
680 'items' => [
681 'type' => 'array',
682 'items' => [ 'type' => 'string' ],
683 ],
684 ],
685 ],
686 ],
687 ],
688 ],
689 ] );
690
691 register_meta( 'post', '_storeengine_size_apply_all', [
692 'object_subtype' => Helper::SIZE_CHART_POST_TYPE,
693 'type' => 'boolean',
694 'single' => true,
695 'default' => false,
696 'auth_callback' => $auth_callback,
697 'sanitize_callback' => 'rest_sanitize_boolean',
698 'show_in_rest' => true,
699 ] );
700
701 foreach ( [ '_storeengine_size_category_ids', '_storeengine_size_brand_ids', '_storeengine_size_product_ids' ] as $key ) {
702 register_meta( 'post', $key, [
703 'object_subtype' => Helper::SIZE_CHART_POST_TYPE,
704 'type' => 'array',
705 'single' => true,
706 'auth_callback' => $auth_callback,
707 'sanitize_callback' => static function ( $ids ) {
708 return is_array( $ids ) ? array_values( array_filter( array_map( 'absint', $ids ) ) ) : [];
709 },
710 'show_in_rest' => [
711 'schema' => [
712 'context' => [ 'view', 'edit' ],
713 'type' => 'array',
714 'items' => [ 'type' => 'integer' ],
715 ],
716 ],
717 ] );
718 }
719 }
720
721 public function add_avatar_support_product_comment( array $comment_types ): array {
722 return array_merge( $comment_types, [ 'storeengine_product' ] );
723 }
724
725 public function register_product_meta() {
726 $product_meta = [
727 '_storeengine_product_shipping_type' => 'string',
728 '_storeengine_product_physical_weight' => 'string',
729 '_storeengine_product_physical_weight_unit' => 'string',
730 '_storeengine_product_physical_length' => 'string',
731 '_storeengine_product_physical_width' => 'string',
732 '_storeengine_product_physical_height' => 'string',
733 '_storeengine_product_physical_dimension_unit' => 'string',
734 '_storeengine_product_digital_auto_complete' => 'boolean',
735 '_storeengine_product_hide' => 'boolean',
736 '_storeengine_product_enable_license_creation' => 'boolean',
737 '_storeengine_manage_stock' => 'boolean',
738 '_storeengine_stock_quantity' => 'integer',
739 '_storeengine_low_stock_threshold' => 'integer',
740 '_storeengine_sold_individually' => 'boolean',
741 '_storeengine_cost_price' => 'number',
742 '_storeengine_barcode' => 'string',
743 // Simple-product SKU (variable products keep per-variant SKUs in
744 // the variations table). Registered here so REST treats it as a
745 // known meta and POS / inventory queries find it consistently.
746 '_storeengine_sku' => 'string',
747 ];
748
749 foreach ( $product_meta as $meta_key => $product_meta_value_type ) {
750 register_meta( 'post', $meta_key, [
751 'object_subtype' => Helper::PRODUCT_POST_TYPE,
752 'type' => $product_meta_value_type,
753 'single' => true,
754 'show_in_rest' => true,
755 ] );
756 }
757
758 register_meta( 'post', '_storeengine_stock_status', [
759 'object_subtype' => Helper::PRODUCT_POST_TYPE,
760 'type' => 'string',
761 'single' => true,
762 'show_in_rest' => [
763 'schema' => [
764 'title' => __( 'Stock Status', 'storeengine' ),
765 'description' => __( 'Product stock status.', 'storeengine' ),
766 'context' => [ 'view', 'edit' ],
767 'type' => 'string',
768 'enum' => [ 'instock', 'outofstock', 'onbackorder' ],
769 'default' => 'instock',
770 ],
771 ],
772 ] );
773
774 register_meta( 'post', '_storeengine_backorders', [
775 'object_subtype' => Helper::PRODUCT_POST_TYPE,
776 'type' => 'string',
777 'single' => true,
778 'show_in_rest' => [
779 'schema' => [
780 'title' => __( 'Backorders', 'storeengine' ),
781 'description' => __( 'Backorder policy.', 'storeengine' ),
782 'context' => [ 'view', 'edit' ],
783 'type' => 'string',
784 'enum' => [ 'no', 'notify', 'yes' ],
785 'default' => 'no',
786 ],
787 ],
788 ] );
789
790 register_meta( 'post', '_storeengine_product_description_editor_type', [
791 'object_subtype' => Helper::PRODUCT_POST_TYPE,
792 'type' => 'string',
793 'single' => true,
794 'show_in_rest' => true,
795 'default' => 'classic',
796 ] );
797
798 register_meta( 'post', '_storeengine_product_gallery_ids', [
799 'object_subtype' => Helper::PRODUCT_POST_TYPE,
800 'type' => 'array',
801 'single' => true,
802 'show_in_rest' => [
803 'schema' => [
804 'title' => __( 'Gallery', 'storeengine' ),
805 'description' => __( 'An array of image IDs for the product.', 'storeengine' ),
806 'context' => [ 'view', 'edit' ],
807 'type' => 'array',
808 'items' => [ 'type' => 'integer' ],
809 ],
810 ],
811 ] );
812
813 // Unified gallery — ordered mix of images and videos so the storefront
814 // carousel honours the exact order set in the editor. Each item is
815 // either { type:'image', id } or { type:'video', url, poster_id }. This
816 // is the source of truth; the legacy `_storeengine_product_gallery_ids`
817 // (images) and `featured_media` are still kept in sync for consumers
818 // that read them (archive thumbnails, pro addons).
819 register_meta( 'post', '_storeengine_product_gallery', [
820 // Protected-meta parity with _storeengine_product_downloadable_files:
821 // the raw REST meta write path bypasses the product setter, so sanitise
822 // here and gate writes to users who can edit the product.
823 'sanitize_callback' => static function ( $meta_value ) {
824 if ( ! is_array( $meta_value ) ) {
825 return $meta_value;
826 }
827
828 return array_map(
829 static function ( $item ) {
830 if ( ! is_array( $item ) ) {
831 return $item;
832 }
833 $clean = [];
834 if ( isset( $item['type'] ) ) {
835 $clean['type'] = 'video' === $item['type'] ? 'video' : 'image';
836 }
837 if ( isset( $item['id'] ) ) {
838 $clean['id'] = absint( $item['id'] );
839 }
840 if ( isset( $item['url'] ) ) {
841 $clean['url'] = esc_url_raw( (string) $item['url'] );
842 }
843 if ( isset( $item['poster_id'] ) ) {
844 $clean['poster_id'] = absint( $item['poster_id'] );
845 }
846
847 return $clean;
848 },
849 $meta_value
850 );
851 },
852 'auth_callback' => static function ( $allowed, $meta_key, $object_id ) {
853 return current_user_can( 'edit_post', $object_id );
854 },
855 'object_subtype' => Helper::PRODUCT_POST_TYPE,
856 'type' => 'array',
857 'single' => true,
858 'show_in_rest' => [
859 'schema' => [
860 'title' => __( 'Gallery', 'storeengine' ),
861 'context' => [ 'view', 'edit' ],
862 'type' => 'array',
863 'items' => [
864 'type' => 'object',
865 'properties' => [
866 'type' => [ 'type' => 'string' ],
867 'id' => [ 'type' => 'integer' ],
868 'url' => [ 'type' => 'string' ],
869 'poster_id' => [ 'type' => 'integer' ],
870 ],
871 'additionalProperties' => false,
872 ],
873 ],
874 ],
875 ] );
876
877 // Product gallery videos — each item is a URL (YouTube / Vimeo / any
878 // oEmbed provider, or a direct/media-library video file) plus an
879 // optional poster image (WP attachment id). Kept in sync from the
880 // unified gallery for backward compatibility.
881 register_meta( 'post', '_storeengine_product_gallery_videos', [
882 // Protected-meta parity with _storeengine_product_downloadable_files:
883 // sanitise the raw REST meta write path and gate writes to editors.
884 'sanitize_callback' => static function ( $meta_value ) {
885 if ( ! is_array( $meta_value ) ) {
886 return $meta_value;
887 }
888
889 return array_map(
890 static function ( $item ) {
891 if ( ! is_array( $item ) ) {
892 return $item;
893 }
894 $clean = [];
895 if ( isset( $item['url'] ) ) {
896 $clean['url'] = esc_url_raw( (string) $item['url'] );
897 }
898 if ( isset( $item['poster_id'] ) ) {
899 $clean['poster_id'] = absint( $item['poster_id'] );
900 }
901
902 return $clean;
903 },
904 $meta_value
905 );
906 },
907 'auth_callback' => static function ( $allowed, $meta_key, $object_id ) {
908 return current_user_can( 'edit_post', $object_id );
909 },
910 'object_subtype' => Helper::PRODUCT_POST_TYPE,
911 'type' => 'array',
912 'single' => true,
913 'show_in_rest' => [
914 'schema' => [
915 'title' => __( 'Gallery videos', 'storeengine' ),
916 'description' => __( 'Product gallery videos (url + optional poster image id).', 'storeengine' ),
917 'context' => [ 'view', 'edit' ],
918 'type' => 'array',
919 'items' => [
920 'type' => 'object',
921 'properties' => [
922 'url' => [ 'type' => 'string' ],
923 'poster_id' => [ 'type' => 'integer' ],
924 ],
925 'additionalProperties' => false,
926 ],
927 ],
928 ],
929 ] );
930
931 register_meta( 'post', '_storeengine_product_faqs', [
932 'object_subtype' => Helper::PRODUCT_POST_TYPE,
933 'type' => 'array',
934 'single' => true,
935 'show_in_rest' => [
936 'schema' => [
937 'title' => __( 'Product FAQs', 'storeengine' ),
938 'description' => __( 'Inline question/answer pairs for the product.', 'storeengine' ),
939 'context' => [ 'view', 'edit' ],
940 'type' => 'array',
941 'items' => [
942 'type' => 'object',
943 'properties' => [
944 'question' => [ 'type' => 'string' ],
945 'answer' => [ 'type' => 'string' ],
946 ],
947 ],
948 ],
949 ],
950 ] );
951
952 register_meta( 'post', '_storeengine_product_tax_status', [
953 'object_subtype' => Helper::PRODUCT_POST_TYPE,
954 'type' => 'string',
955 'single' => true,
956 'show_in_rest' => [
957 'schema' => [
958 'title' => __( 'Tax Status', 'storeengine' ),
959 'description' => __( 'Product tax status.', 'storeengine' ),
960 'context' => [ 'view', 'edit' ],
961 'type' => 'string',
962 'enum' => [ ProductTaxStatus::TAXABLE, ProductTaxStatus::SHIPPING, ProductTaxStatus::NONE ],
963 'default' => ProductTaxStatus::TAXABLE,
964 ],
965 ],
966 ] );
967
968 register_meta( 'post', '_storeengine_product_download_type', [
969 'object_subtype' => Helper::PRODUCT_POST_TYPE,
970 'type' => 'string',
971 'single' => true,
972 'show_in_rest' => [
973 'schema' => [
974 'title' => __( 'Download Type', 'storeengine' ),
975 'description' => __( 'Download type (e.g. versioned).', 'storeengine' ),
976 'context' => [ 'view', 'edit' ],
977 'type' => 'string',
978 'enum' => [ ProductDownloadType::INSTANT, ProductDownloadType::VERSIONED ],
979 'default' => ProductDownloadType::INSTANT,
980 ],
981 ],
982 ] );
983
984 register_meta( 'post', '_storeengine_product_downloadable_files', [
985 'object_subtype' => Helper::PRODUCT_POST_TYPE,
986 'type' => 'array',
987 'single' => true,
988 'show_in_rest' => [
989 'schema' => [
990 'type' => 'array',
991 'items' => [
992 'type' => 'object',
993 'properties' => [
994 'id' => [
995 'type' => 'string',
996 'description' => __( 'The unique identifier for the downloadable file.', 'storeengine' ),
997 ],
998 'attachment_id' => [
999 'type' => 'integer',
1000 'description' => __( 'The unique identifier for the downloadable file.', 'storeengine' ),
1001 ],
1002 'name' => [
1003 'type' => 'string',
1004 'description' => __( 'The name of the downloadable file.', 'storeengine' ),
1005 ],
1006 'file' => [
1007 'type' => 'string',
1008 'format' => 'uri',
1009 'description' => __( 'The URL of the downloadable file.', 'storeengine' ),
1010 ],
1011 'enabled' => [
1012 'type' => 'boolean',
1013 'description' => __( 'Enable or disable the downloadable file.', 'storeengine' ),
1014 ],
1015 ],
1016 ],
1017 'description' => __( 'An array of downloadable files for the product.', 'storeengine' ),
1018 // Edit-only: these are the raw download file URLs / attachment ids.
1019 // Exposing them in `view` context would let any anonymous reader of a
1020 // published product pull the file links without a purchase/permission
1021 // check. The product editor uses `edit` context (capability-gated);
1022 // the storefront serves files via the permission-checked download
1023 // handler, not this meta.
1024 'context' => [ 'edit' ],
1025 ],
1026 ],
1027 // The REST meta write path (core WP_REST_Post_Meta_Fields) persists this
1028 // value directly with update_metadata — it never runs the product object's
1029 // set_downloadable_files() setter, so the traversal filter there would be
1030 // bypassed by a raw `meta` payload. Sanitize here too, closing the write
1031 // path a self-registered vendor uses to plant a `../../wp-config.php` file.
1032 // (Arbitrary absolute reads remain blocked at serve time by
1033 // DownloadHandler::is_allowed_local_path().)
1034 'sanitize_callback' => static function ( $meta_value ) {
1035 return is_array( $meta_value )
1036 ? \StoreEngine\Classes\AbstractProduct::sanitize_downloadable_files( $meta_value )
1037 : $meta_value;
1038 },
1039 'auth_callback' => static function ( $allowed, $meta_key, $object_id ) {
1040 // Protected meta — only users who can edit this product may write it.
1041 return current_user_can( 'edit_post', $object_id );
1042 },
1043 ] );
1044
1045 register_meta( 'post', '_storeengine_upsell_ids', [
1046 'object_subtype' => Helper::PRODUCT_POST_TYPE,
1047 'type' => 'array',
1048 'single' => true,
1049 'show_in_rest' => [
1050 'schema' => [
1051 'type' => 'array',
1052 'items' => [
1053 'type' => 'integer',
1054 ],
1055 'description' => 'An array of upsell product IDs for the product',
1056 'context' => [ 'view', 'edit' ],
1057 ],
1058 ],
1059 ] );
1060
1061 register_meta( 'post', '_storeengine_crosssell_ids', [
1062 'object_subtype' => Helper::PRODUCT_POST_TYPE,
1063 'type' => 'array',
1064 'single' => true,
1065 'show_in_rest' => [
1066 'schema' => [
1067 'type' => 'array',
1068 'items' => [
1069 'type' => 'integer',
1070 ],
1071 'description' => 'An array of cross-sell product IDs for the product',
1072 'context' => [ 'view', 'edit' ],
1073 ],
1074 ],
1075 ] );
1076
1077 register_meta( 'post', '_storeengine_product_purchase_redirect_type', [
1078 'object_subtype' => Helper::PRODUCT_POST_TYPE,
1079 'type' => 'string',
1080 'single' => true,
1081 'show_in_rest' => [
1082 'schema' => [
1083 'title' => __( 'After Purchase Redirect Type', 'storeengine' ),
1084 'description' => __( 'Purchase Redirect Type (e.g. page)', 'storeengine' ),
1085 'context' => [ 'view', 'edit' ],
1086 'type' => 'string',
1087 'enum' => [
1088 PurchaseRedirectType::DEFAULT,
1089 PurchaseRedirectType::PAGE,
1090 PurchaseRedirectType::URL,
1091 ],
1092 'default' => PurchaseRedirectType::DEFAULT,
1093 ],
1094 ],
1095 ] );
1096
1097 register_meta( 'post', '_storeengine_product_purchase_redirect_url', [
1098 'object_subtype' => Helper::PRODUCT_POST_TYPE,
1099 'type' => 'string',
1100 'single' => true,
1101 'sanitize_callback' => function ( $value ) {
1102 // Accept integer IDs
1103 if ( is_numeric( $value ) ) {
1104 return (string) intval( $value );
1105 }
1106
1107 // Accept valid URLs
1108 if ( filter_var( $value, FILTER_VALIDATE_URL ) ) {
1109 return esc_url_raw( $value );
1110 }
1111
1112 return '';
1113 },
1114 'show_in_rest' => [
1115 'schema' => [
1116 'title' => __( 'After Purchase Redirect URL', 'storeengine' ),
1117 'description' => __( 'Redirect specific page/url after product purchase.', 'storeengine' ),
1118 'context' => [ 'view', 'edit' ],
1119 'type' => 'string',
1120 'default' => '',
1121 ],
1122 ],
1123 ] );
1124 }
1125
1126 public function register_coupon_meta() {
1127 $course_meta = [
1128 '_storeengine_coupon_name' => 'string',
1129 '_storeengine_coupon_type' => 'string',
1130 '_storeengine_coupon_amount' => 'number',
1131 '_storeengine_coupon_time_type' => 'string',
1132 '_storeengine_coupon_customer_usage_limit' => 'integer',
1133 '_storeengine_coupon_type_of_min_requirement' => 'string',
1134 '_storeengine_coupon_min_purchase_quantity' => 'number',
1135 '_storeengine_coupon_min_purchase_amount' => 'number',
1136 '_storeengine_coupon_who_can_use' => 'string',
1137 '_storeengine_coupon_usage_count' => 'number',
1138 ];
1139
1140 foreach ( $course_meta as $meta_key => $meta_value_type ) {
1141 register_meta( 'post', $meta_key, [
1142 'object_subtype' => Helper::COUPON_POST_TYPE,
1143 'type' => $meta_value_type,
1144 'single' => true,
1145 'show_in_rest' => true,
1146 ] );
1147 }
1148
1149 // Coupon Hard limits.
1150 foreach ( [ '_storeengine_coupon_usage_limit', '_storeengine_coupon_customer_usage_limit' ] as $limit_type ) {
1151 register_meta( 'post', $limit_type, [
1152 'object_subtype' => Helper::COUPON_POST_TYPE,
1153 'default' => 0,
1154 'type' => 'integer',
1155 'single' => true,
1156 'show_in_rest' => true,
1157 ] );
1158 }
1159
1160 // Coupon Used by (user ids).
1161 register_meta( 'post', '_storeengine_coupon_used_by', [
1162 'object_subtype' => Helper::COUPON_POST_TYPE,
1163 'type' => 'number',
1164 'single' => false,
1165 'show_in_rest' => true,
1166 ] );
1167
1168 // Coupon Start Time
1169 register_meta( 'post', '_storeengine_coupon_start_date_time', [
1170 'object_subtype' => Helper::COUPON_POST_TYPE,
1171 'type' => 'object',
1172 'single' => true,
1173 'show_in_rest' => [
1174 'schema' => [
1175 'additionalProperties' => true,
1176 'items' => [
1177 'type' => 'object',
1178 'properties' => [
1179 'date' => [ 'type' => 'string' ],
1180 'time' => [ 'type' => 'string' ],
1181 'timezone' => [ 'type' => 'string' ],
1182 ],
1183 ],
1184 ],
1185 ],
1186 ] );
1187
1188 // Coupon End Time
1189 register_meta( 'post', '_storeengine_coupon_end_date_time', [
1190 'object_subtype' => Helper::COUPON_POST_TYPE,
1191 'type' => 'object',
1192 'single' => true,
1193 'show_in_rest' => [
1194 'schema' => [
1195 'additionalProperties' => true,
1196 'items' => [
1197 'type' => 'object',
1198 'properties' => [
1199 'date' => [ 'type' => 'string' ],
1200 'time' => [ 'type' => 'string' ],
1201 'timezone' => [ 'type' => 'string' ],
1202 ],
1203 ],
1204 ],
1205 ],
1206 ] );
1207
1208 register_meta( 'post', '_storeengine_coupon_valid_customers', [
1209 'object_subtype' => Helper::COUPON_POST_TYPE,
1210 'type' => 'array',
1211 'single' => true,
1212 'show_in_rest' => [
1213 'schema' => [
1214 'type' => 'array',
1215 'items' => [
1216 'type' => 'integer',
1217 ],
1218 ],
1219 ],
1220 'sanitize_callback' => function ( $value ) {
1221 return array_values(
1222 array_filter(
1223 array_map( 'absint', (array) $value )
1224 )
1225 );
1226 }
1227 ] );
1228
1229 register_meta( 'post', '_storeengine_coupon_valid_prices', [
1230 'object_subtype' => Helper::COUPON_POST_TYPE,
1231 'type' => 'array',
1232 'single' => true,
1233 'show_in_rest' => [
1234 'schema' => [
1235 'type' => 'array',
1236 'items' => [
1237 'type' => 'object',
1238 'properties' => [
1239 'product_id' => [ 'type' => 'number' ],
1240 'price_ids' => [
1241 'type' => 'array',
1242 'items' => [ 'type' => 'number' ]
1243 ],
1244 ],
1245 ],
1246 ],
1247 ],
1248 'sanitize_callback' => function ( $value ) {
1249 if ( ! is_array( $value ) ) {
1250 return [];
1251 }
1252
1253 $sanitized = [];
1254 foreach ( $value as $row ) {
1255 // Each item must be an array/object
1256 if ( ! is_array( $row ) ) {
1257 continue;
1258 }
1259
1260 $product_id = absint( $row['product_id'] );
1261
1262 $price_ids = [];
1263 if ( isset( $row['price_ids'] ) && is_array( $row['price_ids'] ) ) {
1264 $price_ids = array_values(
1265 array_unique(
1266 array_filter(
1267 array_map( 'absint', $row['price_ids'] )
1268 )
1269 )
1270 );
1271 }
1272
1273 $sanitized[] = [
1274 'product_id' => $product_id,
1275 'price_ids' => $price_ids,
1276 ];
1277 }
1278
1279 return $sanitized;
1280 }
1281 ] );
1282
1283 // Product / category include & exclude restrictions. Meta keys omit the
1284 // `coupon_` segment on purpose so Coupon::get() maps them to the
1285 // settings keys its getters read (see Coupon::$internal_meta_keys).
1286 $id_list_sanitizer = function ( $value ) {
1287 return array_values( array_unique( array_filter( array_map( 'absint', (array) $value ) ) ) );
1288 };
1289 $id_list_meta = [
1290 '_storeengine_product_ids',
1291 '_storeengine_excluded_product_ids',
1292 '_storeengine_product_categories',
1293 '_storeengine_excluded_product_categories',
1294 ];
1295 foreach ( $id_list_meta as $meta_key ) {
1296 register_meta( 'post', $meta_key, [
1297 'object_subtype' => Helper::COUPON_POST_TYPE,
1298 'type' => 'array',
1299 'single' => true,
1300 'show_in_rest' => [
1301 'schema' => [
1302 'type' => 'array',
1303 'items' => [ 'type' => 'integer' ],
1304 ],
1305 ],
1306 'sanitize_callback' => $id_list_sanitizer,
1307 ] );
1308 }
1309
1310 // Allowed billing emails (exact match or *@domain.com wildcard).
1311 register_meta( 'post', '_storeengine_email_restrictions', [
1312 'object_subtype' => Helper::COUPON_POST_TYPE,
1313 'type' => 'array',
1314 'single' => true,
1315 'show_in_rest' => [
1316 'schema' => [
1317 'type' => 'array',
1318 'items' => [ 'type' => 'string' ],
1319 ],
1320 ],
1321 'sanitize_callback' => function ( $value ) {
1322 return array_values( array_filter( array_map( static function ( $email ) {
1323 return strtolower( trim( sanitize_text_field( $email ) ) );
1324 }, (array) $value ) ) );
1325 },
1326 ] );
1327
1328 // Exclude on-sale items from this coupon.
1329 register_meta( 'post', '_storeengine_exclude_sale_items', [
1330 'object_subtype' => Helper::COUPON_POST_TYPE,
1331 'type' => 'boolean',
1332 'single' => true,
1333 'default' => false,
1334 'show_in_rest' => true,
1335 ] );
1336
1337 // Recurring (subscription) discount: keep applying the discount on
1338 // renewals. `_limit` is the number of renewals to discount (0 = forever).
1339 register_meta( 'post', '_storeengine_coupon_recurring_discount', [
1340 'object_subtype' => Helper::COUPON_POST_TYPE,
1341 'type' => 'boolean',
1342 'single' => true,
1343 'default' => false,
1344 'show_in_rest' => true,
1345 ] );
1346 register_meta( 'post', '_storeengine_coupon_recurring_discount_limit', [
1347 'object_subtype' => Helper::COUPON_POST_TYPE,
1348 'type' => 'integer',
1349 'single' => true,
1350 'default' => 0,
1351 'show_in_rest' => true,
1352 'sanitize_callback' => 'absint',
1353 ] );
1354
1355 // Auto-apply this coupon to every eligible cart (no code entry needed).
1356 register_meta( 'post', '_storeengine_coupon_auto_apply', [
1357 'object_subtype' => Helper::COUPON_POST_TYPE,
1358 'type' => 'boolean',
1359 'single' => true,
1360 'default' => false,
1361 'show_in_rest' => true,
1362 ] );
1363 }
1364
1365 public function register_category_rest_fields() {
1366 register_rest_field(
1367 Helper::PRODUCT_CATEGORY_TAXONOMY,
1368 'parent_name',
1369 [
1370 'get_callback' => static function ( array $term ): string {
1371 if ( empty( $term['parent'] ) ) {
1372 return '';
1373 }
1374 $parent = get_term( (int) $term['parent'], Helper::PRODUCT_CATEGORY_TAXONOMY );
1375 return ( $parent && ! is_wp_error( $parent ) ) ? $parent->name : '';
1376 },
1377 'schema' => [
1378 'type' => 'string',
1379 'context' => [ 'view', 'edit' ],
1380 ],
1381 ]
1382 );
1383 }
1384
1385 public static function register_attribute_taxonomy( string $name, string $label, bool $public ) {
1386 return register_taxonomy( Helper::get_attribute_taxonomy_name( $name ), Helper::PRODUCT_POST_TYPE, [
1387 'label' => $label,
1388 'labels' => [
1389 /* translators: %s: attribute name */
1390 'name' => sprintf( _x( 'Product %s', 'Product Attribute', 'storeengine' ), $label ),
1391 'singular_name' => $label,
1392 /* translators: %s: attribute name */
1393 'search_items' => sprintf( __( 'Search %s', 'storeengine' ), $label ),
1394 /* translators: %s: attribute name */
1395 'all_items' => sprintf( __( 'All %s', 'storeengine' ), $label ),
1396 /* translators: %s: attribute name */
1397 'parent_item' => sprintf( __( 'Parent %s', 'storeengine' ), $label ),
1398 /* translators: %s: attribute name */
1399 'parent_item_colon' => sprintf( __( 'Parent %s:', 'storeengine' ), $label ),
1400 /* translators: %s: attribute name */
1401 'edit_item' => sprintf( _x( 'Edit %s', 'Product attribute edit button label', 'storeengine' ), $label ),
1402 /* translators: %s: attribute name */
1403 'update_item' => sprintf( __( 'Update %s', 'storeengine' ), $label ),
1404 /* translators: %s: attribute name */
1405 'add_new_item' => sprintf( __( 'Add new %s', 'storeengine' ), $label ),
1406 /* translators: %s: attribute name */
1407 'new_item_name' => sprintf( __( 'New %s', 'storeengine' ), $label ),
1408 /* translators: %s: attribute name */
1409 'not_found' => sprintf( __( 'No &quot;%s&quot; found', 'storeengine' ), $label ),
1410 /* translators: %s: attribute name */
1411 'back_to_items' => sprintf( __( '&larr; Back to "%s" attributes', 'storeengine' ), $label ),
1412 ],
1413 'hierarchical' => false,
1414 'update_count_callback' => '_update_post_term_count',
1415 'show_ui' => false,
1416 'show_in_quick_edit' => false,
1417 'show_in_menu' => false,
1418 'meta_box_cb' => false,
1419 'query_var' => $public,
1420 'rewrite' => ! $public ? false : apply_filters( 'storeengine/attribute/rewrite_rule', [
1421 'slug' => Helper::get_attribute_taxonomy_name( $name ),
1422 'with_front' => false,
1423 ] ),
1424 'sort' => false,
1425 'public' => $public,
1426 'show_in_nav_menus' => $public && apply_filters( 'storeengine/attribute/show_in_nav_menus', false, $name ),
1427 'capabilities' => [
1428 'manage_terms' => 'manage_post_tags',
1429 'edit_terms' => 'edit_post_tags',
1430 'delete_terms' => 'delete_post_tags',
1431 'assign_terms' => 'assign_post_tags',
1432 ],
1433 'show_admin_column' => false,
1434 'show_in_rest' => true,
1435 'rest_base' => Helper::get_attribute_taxonomy_name( $name ),
1436 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1',
1437 'rest_controller_class' => 'WP_REST_Terms_Controller',
1438 ] );
1439 }
1440 }
1441