register_database_table_name(); add_action( 'switch_blog', [ $this, 'wpdb_table_fix' ], 0 ); add_action( 'init', [ __CLASS__, 'maybe_sync_schema' ], 4 ); add_action( 'init', [ $this, 'register_product_post_type' ], 5 ); add_action( 'init', [ $this, 'register_coupon_post_type' ], 5 ); add_action( 'get_avatar_comment_types', [ $this, 'add_avatar_support_product_comment' ] ); add_action( 'init', [ $this, 'maybe_flush_rewrite_rules' ], 5 ); add_action( 'storeengine/flush_rewrite_rules', [ __CLASS__, 'flush_rewrite_rules' ] ); add_action( 'rest_api_init', [ $this, 'register_product_meta' ] ); add_action( 'rest_api_init', [ $this, 'register_coupon_meta' ] ); add_action( 'rest_api_init', [ $this, 'register_category_rest_fields' ] ); } const SCHEMA_HASH_OPTION = 'storeengine_schema_hash'; /** * Re-runs dbDelta when any schema file in includes/database/ changes. * Avoids the need to bump STOREENGINE_DB_VERSION on every column addition. */ public static function maybe_sync_schema(): void { $hash = self::compute_schema_hash( STOREENGINE_ROOT_DIR_PATH . 'includes/database' ); if ( ! $hash || $hash === get_option( self::SCHEMA_HASH_OPTION ) ) { return; } self::create_initial_custom_table(); update_option( self::SCHEMA_HASH_OPTION, $hash, true ); /** * Fires after core schema files were re-applied via dbDelta. * Addons should hook here to re-sync their own tables. */ do_action( 'storeengine/schema_synced' ); } public static function compute_schema_hash( string $dir ): string { $files = glob( rtrim( $dir, '/' ) . '/*.php' ); if ( empty( $files ) ) { return ''; } sort( $files ); $parts = []; foreach ( $files as $file ) { $parts[] = md5_file( $file ); } return md5( implode( '', $parts ) ); } public function maybe_flush_rewrite_rules() { if ( 'yes' === get_option( 'storeengine_required_rewrite_flush' ) ) { update_option( 'storeengine_required_rewrite_flush', 'no' ); self::flush_rewrite_rules(); } } public static function flush_rewrite_rules() { flush_rewrite_rules(); } public function register_database_table_name() { global $wpdb; // @TODO add all the tables. $tables = [ 'payment_tokenmeta' => 'storeengine_payment_tokenmeta', 'order_itemmeta' => 'storeengine_order_item_meta', 'product_meta_lookup' => 'storeengine_product_meta_lookup', 'tax_rate_classes' => 'storeengine_tax_rate_classes', 'reserved_stock' => 'storeengine_reserved_stock', 'store_orders' => 'storeengine_orders', 'ordermeta' => 'storeengine_orders_meta', ]; /** * @XXX make sure to add the meta types for handling cache last change. * * @see Hooks::handle_cache_last_changed() * @see wp_cache_set_last_changed() */ foreach ( $tables as $name => $table ) { $wpdb->$name = $wpdb->prefix . $table; $wpdb->tables[] = $table; } } public function wpdb_table_fix() { $this->register_database_table_name(); } public static function create_initial_custom_table() { global $wpdb; if ( ! function_exists( 'dbDelta' ) ) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; } $charset_collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : ''; CreateIntegration::up( $wpdb->prefix, $charset_collate ); CreateProductPriceTable::up( $wpdb->prefix, $charset_collate ); CreateAttributeTaxonomies::up( $wpdb->prefix, $charset_collate ); CreateProductVariationsTable::up( $wpdb->prefix, $charset_collate ); CreateProductVariationMetaTable::up( $wpdb->prefix, $charset_collate ); CreateReservedStockTable::up( $wpdb->prefix, $charset_collate ); CreateStockMovementsTable::up( $wpdb->prefix, $charset_collate ); CreateVariationsTermsRelationTable::up( $wpdb->prefix, $charset_collate ); CreateOrderTable::up( $wpdb->prefix, $charset_collate ); CreateOrderMetaTable::up( $wpdb->prefix, $charset_collate ); CreateOrderProductLookup::up( $wpdb->prefix, $charset_collate ); CreateOrderItems::up( $wpdb->prefix, $charset_collate ); CreateOrderItemMeta::up( $wpdb->prefix, $charset_collate ); CreateOrderOperationalData::up( $wpdb->prefix, $charset_collate ); CreateOrderAddresses::up( $wpdb->prefix, $charset_collate ); CreateShippingZoneMethods::up( $wpdb->prefix, $charset_collate ); CreateShippingZones::up( $wpdb->prefix, $charset_collate ); CreateShippingZoneLocations::up( $wpdb->prefix, $charset_collate ); CreateApiKeys::up( $wpdb->prefix, $charset_collate ); CreateCart::up( $wpdb->prefix, $charset_collate ); CreatePaymentTokens::up( $wpdb->prefix, $charset_collate ); CreatePaymentTokenMeta::up( $wpdb->prefix, $charset_collate ); CreatePayouts::up( $wpdb->prefix, $charset_collate ); CreateTaxRateLocations::up( $wpdb->prefix, $charset_collate ); CreateTaxRates::up( $wpdb->prefix, $charset_collate ); CreateApiKeys::up( $wpdb->prefix, $charset_collate ); CreateLog::up( $wpdb->prefix, $charset_collate ); CreateEmailLog::up( $wpdb->prefix, $charset_collate ); CreateCustomerLookup::up( $wpdb->prefix, $charset_collate ); CreateDownloadableProductPermissions::up( $wpdb->prefix, $charset_collate ); CreateDownloadLog::up( $wpdb->prefix, $charset_collate ); CreateProductDownloadDirectories::up( $wpdb->prefix, $charset_collate ); // Backfill new columns on existing tables that dbDelta may have skipped. self::ensure_variation_stock_columns(); self::ensure_stock_movements_vendor_column(); // Store DB Version update_option( 'storeengine_db_version', STOREENGINE_DB_VERSION, false ); } /** * Adds the stock-tracking columns to wp_storeengine_product_variations on * installs that pre-date the inventory feature. Idempotent — silently * skips columns that already exist. */ public static function ensure_variation_stock_columns(): void { global $wpdb; $table = $wpdb->prefix . 'storeengine_product_variations'; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared $existing = $wpdb->get_col( "DESCRIBE {$table}", 0 ); // phpcs:enable if ( ! is_array( $existing ) || empty( $existing ) ) { return; } $columns = [ 'manage_stock' => "ALTER TABLE {$table} ADD COLUMN manage_stock TINYINT(1) NOT NULL DEFAULT 0", 'stock_quantity' => "ALTER TABLE {$table} ADD COLUMN stock_quantity INT(11) DEFAULT NULL", 'stock_status' => "ALTER TABLE {$table} ADD COLUMN stock_status VARCHAR(32) NOT NULL DEFAULT 'instock'", 'backorders' => "ALTER TABLE {$table} ADD COLUMN backorders VARCHAR(16) NOT NULL DEFAULT 'no'", 'low_stock_threshold' => "ALTER TABLE {$table} ADD COLUMN low_stock_threshold INT(11) DEFAULT NULL", 'cost_price' => "ALTER TABLE {$table} ADD COLUMN cost_price decimal(26,8) DEFAULT NULL", 'barcode' => "ALTER TABLE {$table} ADD COLUMN barcode VARCHAR(64) DEFAULT NULL", ]; foreach ( $columns as $column => $sql ) { if ( ! in_array( $column, $existing, true ) ) { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared $wpdb->query( $sql ); // phpcs:enable } } // Ensure indexes exist. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared foreach ( [ 'stock_status', 'sku', 'barcode' ] as $index_name ) { $has_index = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = %s AND table_name = %s AND index_name = %s", DB_NAME, $table, $index_name ) ); if ( ! (int) $has_index ) { $wpdb->query( "ALTER TABLE {$table} ADD KEY {$index_name} ({$index_name})" ); } } // phpcs:enable } /** * Adds the `vendor_id` column to wp_storeengine_stock_movements on * installs that pre-date the multi-vendor scoping feature, then back-fills * existing rows from the product's post_author. Idempotent. */ public static function ensure_stock_movements_vendor_column(): void { global $wpdb; $table = $wpdb->prefix . 'storeengine_stock_movements'; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared $existing = $wpdb->get_col( "DESCRIBE {$table}", 0 ); // phpcs:enable if ( ! is_array( $existing ) || empty( $existing ) ) { return; } // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared if ( ! in_array( 'vendor_id', $existing, true ) ) { $wpdb->query( "ALTER TABLE {$table} ADD COLUMN vendor_id BIGINT(20) UNSIGNED DEFAULT NULL" ); $wpdb->query( "ALTER TABLE {$table} ADD KEY vendor_id (vendor_id)" ); } // One-shot back-fill from product.post_author. Keyed on a flag option // so re-running install() on a populated table is a no-op. if ( ! get_option( 'storeengine_stock_movements_vendor_backfill_v1' ) ) { $wpdb->query( "UPDATE {$table} m INNER JOIN {$wpdb->posts} p ON p.ID = m.product_id SET m.vendor_id = p.post_author WHERE m.vendor_id IS NULL" ); update_option( 'storeengine_stock_movements_vendor_backfill_v1', 1, false ); } // phpcs:enable } public function register_product_post_type() { $permalinks = Helper::get_permalink_structure(); $shop_page_id = Helper::get_settings( 'shop_page' ); $has_archive = get_post( $shop_page_id ) ? urldecode( get_page_uri( $shop_page_id ) ) : 'shop'; // Registering Product CPT register_post_type( Helper::PRODUCT_POST_TYPE, [ 'labels' => [ 'name' => esc_html__( 'Products', 'storeengine' ), 'add_new' => esc_html__( 'Add New Product', 'storeengine' ), 'singular_name' => esc_html__( 'Product', 'storeengine' ), 'search_items' => esc_html__( 'Search Products', 'storeengine' ), 'parent_item_colon' => esc_html__( 'Parent Products:', 'storeengine' ), 'not_found' => esc_html__( 'No Products found.', 'storeengine' ), 'not_found_in_trash' => esc_html__( 'No Products found in Trash.', 'storeengine' ), 'archives' => esc_html__( 'Product archives', 'storeengine' ), ], 'public' => true, 'publicly_queryable' => true, 'show_ui' => true, 'show_in_menu' => false, 'show_in_admin_bar' => false, 'show_in_nav_menus' => false, 'hierarchical' => true, 'query_var' => true, 'delete_with_user' => false, 'supports' => [ 'title', 'editor', 'author', 'thumbnail', 'excerpt', 'trackbacks', 'custom-fields', 'comments', 'post-formats', ], 'has_archive' => $has_archive, 'rewrite' => [ 'slug' => $permalinks['product_rewrite_slug'] ], 'show_in_rest' => true, 'rest_base' => Helper::PRODUCT_POST_TYPE, 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1', 'rest_controller_class' => 'WP_REST_Posts_Controller', 'capability_type' => 'post', 'capabilities' => [ 'edit_post' => 'edit_storeengine_product', 'read_post' => 'read_storeengine_product', 'delete_post' => 'delete_storeengine_product', 'edit_posts' => 'edit_storeengine_products', 'edit_others_posts' => 'edit_storeengine_others_products', 'publish_posts' => 'publish_storeengine_products', 'read_private_posts' => 'read_private_storeengine_products', ], ] ); // Registering Product Category Taxonomy register_taxonomy( Helper::PRODUCT_CATEGORY_TAXONOMY, Helper::PRODUCT_POST_TYPE, [ 'hierarchical' => true, 'query_var' => true, 'public' => true, 'show_ui' => false, 'show_admin_column' => false, '_builtin' => true, 'capabilities' => [ 'manage_terms' => 'manage_categories', 'edit_terms' => 'edit_categories', 'delete_terms' => 'delete_categories', 'assign_terms' => 'assign_categories', ], 'show_in_rest' => true, 'rest_base' => Helper::PRODUCT_CATEGORY_TAXONOMY, 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1', 'rest_controller_class' => 'WP_REST_Terms_Controller', 'rewrite' => [ 'slug' => $permalinks['category_rewrite_slug'], 'with_front' => false, 'hierarchical' => true, ], ] ); // Registering Product Tag Taxonomy register_taxonomy( Helper::PRODUCT_TAG_TAXONOMY, Helper::PRODUCT_POST_TYPE, [ 'hierarchical' => false, 'query_var' => true, 'public' => true, 'show_ui' => false, 'show_admin_column' => false, '_builtin' => true, 'capabilities' => [ 'manage_terms' => 'manage_post_tags', 'edit_terms' => 'edit_post_tags', 'delete_terms' => 'delete_post_tags', 'assign_terms' => 'assign_post_tags', ], 'show_in_rest' => true, 'rest_base' => Helper::PRODUCT_TAG_TAXONOMY, 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1', 'rest_controller_class' => 'WP_REST_Terms_Controller', 'rewrite' => [ 'slug' => $permalinks['tag_rewrite_slug'], 'with_front' => false, ], ] ); // Registering Product Attributes As Taxonomy foreach ( ( new Attributes() )->get_all_names() as $slug => ['label' => $label, 'public' => $public] ) { self::register_attribute_taxonomy( $slug, $label, $public ); } } public function register_coupon_post_type() { register_post_type( Helper::COUPON_POST_TYPE, [ 'labels' => [ 'name' => esc_html__( 'Coupon', 'storeengine' ), 'add_new' => esc_html__( 'Add New Coupon', 'storeengine' ), 'singular_name' => esc_html__( 'Coupon', 'storeengine' ), 'search_items' => esc_html__( 'Search Coupon', 'storeengine' ), 'parent_item_colon' => esc_html__( 'Parent Coupons:', 'storeengine' ), 'not_found' => esc_html__( 'No Coupons found.', 'storeengine' ), 'not_found_in_trash' => esc_html__( 'No Coupons found in Trash.', 'storeengine' ), 'archives' => esc_html__( 'Coupon archives', 'storeengine' ), ], 'public' => true, 'publicly_queryable' => true, 'show_ui' => true, 'show_in_menu' => false, 'show_in_admin_bar' => false, 'show_in_nav_menus' => false, 'hierarchical' => true, 'query_var' => true, 'delete_with_user' => false, 'supports' => [ 'title', 'editor', 'author', 'custom-fields' ], 'has_archive' => true, 'rewrite' => [ 'slug' => 'coupon' ], 'show_in_rest' => true, 'rest_base' => Helper::COUPON_POST_TYPE, 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1', 'rest_controller_class' => 'WP_REST_Posts_Controller', 'capability_type' => 'post', 'capabilities' => [ 'edit_post' => 'edit_storeengine_coupon', 'read_post' => 'read_storeengine_coupon', 'delete_post' => 'delete_storeengine_coupon', 'edit_posts' => 'edit_storeengine_coupons', 'edit_others_posts' => 'edit_others_storeengine_coupons', 'publish_posts' => 'publish_storeengine_coupons', 'read_private_posts' => 'read_private_storeengine_coupons', ], ] ); } public function add_avatar_support_product_comment( array $comment_types ): array { return array_merge( $comment_types, [ 'storeengine_product' ] ); } public function register_product_meta() { $product_meta = [ '_storeengine_product_shipping_type' => 'string', '_storeengine_product_physical_weight' => 'string', '_storeengine_product_physical_weight_unit' => 'string', '_storeengine_product_physical_length' => 'string', '_storeengine_product_physical_width' => 'string', '_storeengine_product_physical_height' => 'string', '_storeengine_product_physical_dimension_unit' => 'string', '_storeengine_product_digital_auto_complete' => 'boolean', '_storeengine_product_hide' => 'boolean', '_storeengine_product_enable_license_creation' => 'boolean', '_storeengine_manage_stock' => 'boolean', '_storeengine_stock_quantity' => 'integer', '_storeengine_low_stock_threshold' => 'integer', '_storeengine_sold_individually' => 'boolean', '_storeengine_cost_price' => 'number', '_storeengine_barcode' => 'string', // Simple-product SKU (variable products keep per-variant SKUs in // the variations table). Registered here so REST treats it as a // known meta and POS / inventory queries find it consistently. '_storeengine_sku' => 'string', ]; foreach ( $product_meta as $meta_key => $product_meta_value_type ) { register_meta( 'post', $meta_key, [ 'object_subtype' => Helper::PRODUCT_POST_TYPE, 'type' => $product_meta_value_type, 'single' => true, 'show_in_rest' => true, ] ); } register_meta( 'post', '_storeengine_stock_status', [ 'object_subtype' => Helper::PRODUCT_POST_TYPE, 'type' => 'string', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'title' => __( 'Stock Status', 'storeengine' ), 'description' => __( 'Product stock status.', 'storeengine' ), 'context' => [ 'view', 'edit' ], 'type' => 'string', 'enum' => [ 'instock', 'outofstock', 'onbackorder' ], 'default' => 'instock', ], ], ] ); register_meta( 'post', '_storeengine_backorders', [ 'object_subtype' => Helper::PRODUCT_POST_TYPE, 'type' => 'string', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'title' => __( 'Backorders', 'storeengine' ), 'description' => __( 'Backorder policy.', 'storeengine' ), 'context' => [ 'view', 'edit' ], 'type' => 'string', 'enum' => [ 'no', 'notify', 'yes' ], 'default' => 'no', ], ], ] ); register_meta( 'post', '_storeengine_product_description_editor_type', [ 'object_subtype' => Helper::PRODUCT_POST_TYPE, 'type' => 'string', 'single' => true, 'show_in_rest' => true, 'default' => 'classic', ] ); register_meta( 'post', '_storeengine_product_gallery_ids', [ 'object_subtype' => Helper::PRODUCT_POST_TYPE, 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'title' => __( 'Gallery', 'storeengine' ), 'description' => __( 'An array of image IDs for the product.', 'storeengine' ), 'context' => [ 'view', 'edit' ], 'type' => 'array', 'items' => [ 'type' => 'integer' ], ], ], ] ); register_meta( 'post', '_storeengine_product_tax_status', [ 'object_subtype' => Helper::PRODUCT_POST_TYPE, 'type' => 'string', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'title' => __( 'Tax Status', 'storeengine' ), 'description' => __( 'Product tax status.', 'storeengine' ), 'context' => [ 'view', 'edit' ], 'type' => 'string', 'enum' => [ ProductTaxStatus::TAXABLE, ProductTaxStatus::SHIPPING, ProductTaxStatus::NONE ], 'default' => ProductTaxStatus::TAXABLE, ], ], ] ); register_meta( 'post', '_storeengine_product_download_type', [ 'object_subtype' => Helper::PRODUCT_POST_TYPE, 'type' => 'string', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'title' => __( 'Download Type', 'storeengine' ), 'description' => __( 'Download type (e.g. versioned).', 'storeengine' ), 'context' => [ 'view', 'edit' ], 'type' => 'string', 'enum' => [ ProductDownloadType::INSTANT, ProductDownloadType::VERSIONED ], 'default' => ProductDownloadType::INSTANT, ], ], ] ); register_meta( 'post', '_storeengine_product_downloadable_files', [ 'object_subtype' => Helper::PRODUCT_POST_TYPE, 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'type' => 'string', 'description' => __( 'The unique identifier for the downloadable file.', 'storeengine' ), ], 'attachment_id' => [ 'type' => 'integer', 'description' => __( 'The unique identifier for the downloadable file.', 'storeengine' ), ], 'name' => [ 'type' => 'string', 'description' => __( 'The name of the downloadable file.', 'storeengine' ), ], 'file' => [ 'type' => 'string', 'format' => 'uri', 'description' => __( 'The URL of the downloadable file.', 'storeengine' ), ], 'enabled' => [ 'type' => 'boolean', 'description' => __( 'Enable or disable the downloadable file.', 'storeengine' ), ], ], ], 'description' => __( 'An array of downloadable files for the product.', 'storeengine' ), // Edit-only: these are the raw download file URLs / attachment ids. // Exposing them in `view` context would let any anonymous reader of a // published product pull the file links without a purchase/permission // check. The product editor uses `edit` context (capability-gated); // the storefront serves files via the permission-checked download // handler, not this meta. 'context' => [ 'edit' ], ], ], 'auth_callback' => static function ( $allowed, $meta_key, $object_id ) { // Protected meta — only users who can edit this product may write it. return current_user_can( 'edit_post', $object_id ); }, ] ); register_meta( 'post', '_storeengine_upsell_ids', [ 'object_subtype' => Helper::PRODUCT_POST_TYPE, 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'integer', ], 'description' => 'An array of upsell product IDs for the product', 'context' => [ 'view', 'edit' ], ], ], ] ); register_meta( 'post', '_storeengine_crosssell_ids', [ 'object_subtype' => Helper::PRODUCT_POST_TYPE, 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'integer', ], 'description' => 'An array of cross-sell product IDs for the product', 'context' => [ 'view', 'edit' ], ], ], ] ); register_meta( 'post', '_storeengine_product_purchase_redirect_type', [ 'object_subtype' => Helper::PRODUCT_POST_TYPE, 'type' => 'string', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'title' => __( 'After Purchase Redirect Type', 'storeengine' ), 'description' => __( 'Purchase Redirect Type (e.g. page)', 'storeengine' ), 'context' => [ 'view', 'edit' ], 'type' => 'string', 'enum' => [ PurchaseRedirectType::DEFAULT, PurchaseRedirectType::PAGE, PurchaseRedirectType::URL, ], 'default' => PurchaseRedirectType::DEFAULT, ], ], ] ); register_meta( 'post', '_storeengine_product_purchase_redirect_url', [ 'object_subtype' => Helper::PRODUCT_POST_TYPE, 'type' => 'string', 'single' => true, 'sanitize_callback' => function ( $value ) { // Accept integer IDs if ( is_numeric( $value ) ) { return (string) intval( $value ); } // Accept valid URLs if ( filter_var( $value, FILTER_VALIDATE_URL ) ) { return esc_url_raw( $value ); } return ''; }, 'show_in_rest' => [ 'schema' => [ 'title' => __( 'After Purchase Redirect URL', 'storeengine' ), 'description' => __( 'Redirect specific page/url after product purchase.', 'storeengine' ), 'context' => [ 'view', 'edit' ], 'type' => 'string', 'default' => '', ], ], ] ); } public function register_coupon_meta() { $course_meta = [ '_storeengine_coupon_name' => 'string', '_storeengine_coupon_type' => 'string', '_storeengine_coupon_amount' => 'number', '_storeengine_coupon_time_type' => 'string', '_storeengine_coupon_customer_usage_limit' => 'integer', '_storeengine_coupon_type_of_min_requirement' => 'string', '_storeengine_coupon_min_purchase_quantity' => 'number', '_storeengine_coupon_min_purchase_amount' => 'number', '_storeengine_coupon_who_can_use' => 'string', '_storeengine_coupon_usage_count' => 'number', ]; foreach ( $course_meta as $meta_key => $meta_value_type ) { register_meta( 'post', $meta_key, [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => $meta_value_type, 'single' => true, 'show_in_rest' => true, ] ); } // Coupon Hard limits. foreach ( [ '_storeengine_coupon_usage_limit', '_storeengine_coupon_customer_usage_limit' ] as $limit_type ) { register_meta( 'post', $limit_type, [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'default' => 0, 'type' => 'integer', 'single' => true, 'show_in_rest' => true, ] ); } // Coupon Used by (user ids). register_meta( 'post', '_storeengine_coupon_used_by', [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'number', 'single' => false, 'show_in_rest' => true, ] ); // Coupon Start Time register_meta( 'post', '_storeengine_coupon_start_date_time', [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'object', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'additionalProperties' => true, 'items' => [ 'type' => 'object', 'properties' => [ 'date' => [ 'type' => 'string' ], 'time' => [ 'type' => 'string' ], 'timezone' => [ 'type' => 'string' ], ], ], ], ], ] ); // Coupon End Time register_meta( 'post', '_storeengine_coupon_end_date_time', [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'object', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'additionalProperties' => true, 'items' => [ 'type' => 'object', 'properties' => [ 'date' => [ 'type' => 'string' ], 'time' => [ 'type' => 'string' ], 'timezone' => [ 'type' => 'string' ], ], ], ], ], ] ); register_meta( 'post', '_storeengine_coupon_valid_customers', [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'integer', ], ], ], 'sanitize_callback' => function ( $value ) { return array_values( array_filter( array_map( 'absint', (array) $value ) ) ); } ] ); register_meta( 'post', '_storeengine_coupon_valid_prices', [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'product_id' => [ 'type' => 'number' ], 'price_ids' => [ 'type' => 'array', 'items' => [ 'type' => 'number' ] ], ], ], ], ], 'sanitize_callback' => function ( $value ) { if ( ! is_array( $value ) ) { return []; } $sanitized = []; foreach ( $value as $row ) { // Each item must be an array/object if ( ! is_array( $row ) ) { continue; } $product_id = absint( $row['product_id'] ); $price_ids = []; if ( isset( $row['price_ids'] ) && is_array( $row['price_ids'] ) ) { $price_ids = array_values( array_unique( array_filter( array_map( 'absint', $row['price_ids'] ) ) ) ); } $sanitized[] = [ 'product_id' => $product_id, 'price_ids' => $price_ids, ]; } return $sanitized; } ] ); // Product / category include & exclude restrictions. Meta keys omit the // `coupon_` segment on purpose so Coupon::get() maps them to the // settings keys its getters read (see Coupon::$internal_meta_keys). $id_list_sanitizer = function ( $value ) { return array_values( array_unique( array_filter( array_map( 'absint', (array) $value ) ) ) ); }; $id_list_meta = [ '_storeengine_product_ids', '_storeengine_excluded_product_ids', '_storeengine_product_categories', '_storeengine_excluded_product_categories', ]; foreach ( $id_list_meta as $meta_key ) { register_meta( 'post', $meta_key, [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ], ], ], 'sanitize_callback' => $id_list_sanitizer, ] ); } // Allowed billing emails (exact match or *@domain.com wildcard). register_meta( 'post', '_storeengine_email_restrictions', [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ], ], ], 'sanitize_callback' => function ( $value ) { return array_values( array_filter( array_map( static function ( $email ) { return strtolower( trim( sanitize_text_field( $email ) ) ); }, (array) $value ) ) ); }, ] ); // Exclude on-sale items from this coupon. register_meta( 'post', '_storeengine_exclude_sale_items', [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'boolean', 'single' => true, 'default' => false, 'show_in_rest' => true, ] ); // Recurring (subscription) discount: keep applying the discount on // renewals. `_limit` is the number of renewals to discount (0 = forever). register_meta( 'post', '_storeengine_coupon_recurring_discount', [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'boolean', 'single' => true, 'default' => false, 'show_in_rest' => true, ] ); register_meta( 'post', '_storeengine_coupon_recurring_discount_limit', [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'integer', 'single' => true, 'default' => 0, 'show_in_rest' => true, 'sanitize_callback' => 'absint', ] ); // BOGO (Buy X Get Y) configuration. foreach ( [ '_storeengine_coupon_bogo_buy_qty', '_storeengine_coupon_bogo_get_qty' ] as $bogo_qty_meta ) { register_meta( 'post', $bogo_qty_meta, [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'integer', 'single' => true, 'default' => 1, 'show_in_rest' => true, 'sanitize_callback' => 'absint', ] ); } register_meta( 'post', '_storeengine_coupon_bogo_discount', [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'number', 'single' => true, 'default' => 100, 'show_in_rest' => true, 'sanitize_callback' => function ( $value ) { return min( 100, max( 0, (float) $value ) ); }, ] ); // Auto-apply this coupon to every eligible cart (no code entry needed). register_meta( 'post', '_storeengine_coupon_auto_apply', [ 'object_subtype' => Helper::COUPON_POST_TYPE, 'type' => 'boolean', 'single' => true, 'default' => false, 'show_in_rest' => true, ] ); } public function register_category_rest_fields() { register_rest_field( Helper::PRODUCT_CATEGORY_TAXONOMY, 'parent_name', [ 'get_callback' => static function ( array $term ): string { if ( empty( $term['parent'] ) ) { return ''; } $parent = get_term( (int) $term['parent'], Helper::PRODUCT_CATEGORY_TAXONOMY ); return ( $parent && ! is_wp_error( $parent ) ) ? $parent->name : ''; }, 'schema' => [ 'type' => 'string', 'context' => [ 'view', 'edit' ], ], ] ); } public static function register_attribute_taxonomy( string $name, string $label, bool $public ) { return register_taxonomy( Helper::get_attribute_taxonomy_name( $name ), Helper::PRODUCT_POST_TYPE, [ 'label' => $label, 'labels' => [ /* translators: %s: attribute name */ 'name' => sprintf( _x( 'Product %s', 'Product Attribute', 'storeengine' ), $label ), 'singular_name' => $label, /* translators: %s: attribute name */ 'search_items' => sprintf( __( 'Search %s', 'storeengine' ), $label ), /* translators: %s: attribute name */ 'all_items' => sprintf( __( 'All %s', 'storeengine' ), $label ), /* translators: %s: attribute name */ 'parent_item' => sprintf( __( 'Parent %s', 'storeengine' ), $label ), /* translators: %s: attribute name */ 'parent_item_colon' => sprintf( __( 'Parent %s:', 'storeengine' ), $label ), /* translators: %s: attribute name */ 'edit_item' => sprintf( _x( 'Edit %s', 'Product attribute edit button label', 'storeengine' ), $label ), /* translators: %s: attribute name */ 'update_item' => sprintf( __( 'Update %s', 'storeengine' ), $label ), /* translators: %s: attribute name */ 'add_new_item' => sprintf( __( 'Add new %s', 'storeengine' ), $label ), /* translators: %s: attribute name */ 'new_item_name' => sprintf( __( 'New %s', 'storeengine' ), $label ), /* translators: %s: attribute name */ 'not_found' => sprintf( __( 'No "%s" found', 'storeengine' ), $label ), /* translators: %s: attribute name */ 'back_to_items' => sprintf( __( '← Back to "%s" attributes', 'storeengine' ), $label ), ], 'hierarchical' => false, 'update_count_callback' => '_update_post_term_count', 'show_ui' => false, 'show_in_quick_edit' => false, 'show_in_menu' => false, 'meta_box_cb' => false, 'query_var' => $public, 'rewrite' => ! $public ? false : apply_filters( 'storeengine/attribute/rewrite_rule', [ 'slug' => Helper::get_attribute_taxonomy_name( $name ), 'with_front' => false, ] ), 'sort' => false, 'public' => $public, 'show_in_nav_menus' => $public && apply_filters( 'storeengine/attribute/show_in_nav_menus', false, $name ), 'capabilities' => [ 'manage_terms' => 'manage_post_tags', 'edit_terms' => 'edit_post_tags', 'delete_terms' => 'delete_post_tags', 'assign_terms' => 'assign_post_tags', ], 'show_admin_column' => false, 'show_in_rest' => true, 'rest_base' => Helper::get_attribute_taxonomy_name( $name ), 'rest_namespace' => STOREENGINE_PLUGIN_SLUG . '/v1', 'rest_controller_class' => 'WP_REST_Terms_Controller', ] ); } }