capability, $this->menu_slug, array( $this, 'render_dashboard_page' ) ); } /** * Enqueue assets only on plugin admin page. * * @param string $hook_suffix Current admin screen hook. * @return void */ public function enqueue_assets( $hook_suffix = '' ) { if ( 'plugins.php' === $hook_suffix ) { $this->enqueue_deactivation_prompt(); return; } if ( ! is_admin() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin screen check. Assets load only on this plugin page. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( $this->menu_slug !== $page ) { return; } wp_enqueue_style( 'wcutp-admin', WCUTP_PLUGIN_URL . 'assets/css/wcutp-admin.css', array(), WCUTP_VERSION ); $current_tab = $this->get_current_tab(); if ( 'campaigns' === $current_tab ) { $google_fonts_url = WCUTP_Constants::top_bar_google_fonts_url(); if ( '' !== $google_fonts_url ) { wp_enqueue_style( 'wcutp-top-bar-fonts', $google_fonts_url, array(), WCUTP_VERSION ); } } $admin_deps = array( 'jquery', 'wc-enhanced-select' ); if ( 'dashboard' === $current_tab ) { wp_enqueue_script( 'chartjs', WCUTP_PLUGIN_URL . 'assets/chartjs/chart.umd.min.js', array(), '4.6.1', true ); $admin_deps[] = 'chartjs'; } // Use WooCommerce enhanced selects for searchable admin pickers. wp_enqueue_style( 'woocommerce_admin_styles' ); wp_enqueue_script( 'wc-enhanced-select' ); wp_enqueue_script( 'wcutp-admin', WCUTP_PLUGIN_URL . 'assets/js/wcutp-admin.js', $admin_deps, WCUTP_VERSION, true ); wp_localize_script( 'wcutp-admin', 'wcutpAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'recalculateAllCustomersNonce' => wp_create_nonce( 'wcutp_recalculate_all_customers_batch' ), 'recalculateBatchSize' => 25, 'recalculateBatchDelayMs' => 120, 'recalculateMaxRetries' => 6, 'recalculateRetryBaseMs' => 500, 'i18n' => array( 'recalculateRunning' => esc_html__( 'Recalculation in progress...', 'codem-rewards-engine' ), 'recalculateDone' => esc_html__( 'Recalculation completed successfully.', 'codem-rewards-engine' ), 'recalculateFailed' => esc_html__( 'Recalculation failed. Please try again.', 'codem-rewards-engine' ), 'recalculateRetrying' => esc_html__( 'Server is busy (503). Retrying current batch...', 'codem-rewards-engine' ), ), ) ); } /** * Ask whether deactivation should also delete plugin data. * * @return void */ private function enqueue_deactivation_prompt() { if ( ! current_user_can( $this->capability ) ) { return; } wp_enqueue_script( 'wcutp-deactivation-prompt', WCUTP_PLUGIN_URL . 'assets/js/wcutp-deactivation-prompt.js', array( 'jquery' ), WCUTP_VERSION, true ); wp_localize_script( 'wcutp-deactivation-prompt', 'wcutpDeactivation', array( 'pluginBasename' => plugin_basename( WCUTP_PLUGIN_FILE ), 'promptMessage' => __( 'Do you want to clean Codem Rewards Engine data as well? Click OK to clean plugin data on deactivation, or Cancel to keep data.', 'codem-rewards-engine' ), ) ); } /** * Handle page form submits. * * @return void */ public function handle_form_submissions() { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Each save handler below checks its own nonce. This only routes the request. if ( ! is_admin() || empty( $_POST['wcutp_action'] ) ) { return; } if ( ! current_user_can( $this->capability ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Each save handler below checks its own nonce. This only routes the request. $action = sanitize_key( wp_unslash( $_POST['wcutp_action'] ) ); if ( 'save_tiers' === $action ) { $this->handle_save_tiers(); } if ( 'save_global_settings' === $action ) { $this->handle_save_global_settings(); } if ( 'save_tier_discounts' === $action ) { $this->handle_save_tier_discounts(); } if ( 'save_discount_exclusions' === $action ) { $this->handle_save_discount_exclusions(); } if ( 'save_access_restrictions' === $action ) { $this->handle_save_access_restrictions(); } if ( 'save_birthday_coupons' === $action ) { $this->handle_save_birthday_coupons(); } if ( 'save_campaigns' === $action || 'save_campaign' === $action ) { $this->handle_save_campaigns(); } if ( 'delete_campaign' === $action ) { $this->handle_delete_campaign(); } if ( 'recalculate_lifetime_spend_user' === $action ) { $this->handle_recalculate_lifetime_spend_user(); } if ( 'recalculate_lifetime_spend_all' === $action ) { $this->handle_recalculate_lifetime_spend_all(); } if ( 'delete_audit_logs' === $action ) { $this->handle_delete_audit_logs(); } if ( 'export_audit_logs_csv' === $action ) { $this->handle_export_audit_logs_csv(); } } /** * Save tier data with validation. * * @return void */ private function handle_save_tiers() { check_admin_referer( 'wcutp_save_tiers', 'wcutp_nonce' ); $previous = get_option( WCUTP_Constants::OPTION_TIERS, WCUTP_Constants::default_tiers() ); $previous = is_array( $previous ) ? $previous : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nested tier rows are sanitized by sanitize_tiers() on the next line. $raw_tiers = isset( $_POST['tiers'] ) ? wp_unslash( $_POST['tiers'] ) : array(); $tiers = $this->sanitize_tiers( $raw_tiers ); $validation_result = $this->validate_tier_ranges( $tiers ); if ( true !== $validation_result ) { set_transient( 'wcutp_tiers_error', $validation_result, 60 ); $this->redirect_with_notice( 'tier_range_error', 'customer-tiers' ); return; } $status_changed = $this->has_tier_status_toggle( $previous, $tiers ); update_option( WCUTP_Constants::OPTION_TIERS, $tiers ); if ( $status_changed ) { update_option( WCUTP_Constants::OPTION_RECALC_PENDING, 1, false ); } $this->record_audit_log_entries( 'Customer Tiers', 'Save Tiers', $this->build_audit_diff_entries( $previous, $tiers, 'tiers' ) ); $this->redirect_with_notice( 'tiers_saved', 'customer-tiers' ); } /** * Save global settings with validation. * * @return void */ private function handle_save_global_settings() { check_admin_referer( 'wcutp_save_global_settings', 'wcutp_nonce' ); $previous = get_option( WCUTP_Constants::OPTION_SETTINGS, WCUTP_Constants::default_settings() ); $previous = is_array( $previous ) ? $previous : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Each settings field is cast to a boolean below. $raw_settings = isset( $_POST['settings'] ) ? wp_unslash( $_POST['settings'] ) : array(); $raw_settings = is_array( $raw_settings ) ? $raw_settings : array(); $settings = array( 'include_taxes' => ! empty( $raw_settings['include_taxes'] ), 'include_shipping' => ! empty( $raw_settings['include_shipping'] ), 'allow_downgrade' => ! empty( $raw_settings['allow_downgrade'] ), ); update_option( WCUTP_Constants::OPTION_SETTINGS, $settings ); $this->record_audit_log_entries( 'Global Settings', 'Save Settings', $this->build_audit_diff_entries( $previous, $settings, 'settings' ) ); $this->redirect_with_notice( 'settings_saved', 'global-settings' ); } /** * Save tier discount rules with validation. * * @return void */ private function handle_save_tier_discounts() { check_admin_referer( 'wcutp_save_tier_discounts', 'wcutp_nonce' ); $previous = get_option( WCUTP_Constants::OPTION_TIER_DISCOUNTS, array() ); $previous = is_array( $previous ) ? $previous : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nested discount rows are sanitized by sanitize_tier_discounts() on the next line. $raw_discounts = isset( $_POST['tier_discounts'] ) ? wp_unslash( $_POST['tier_discounts'] ) : array(); $discounts = $this->sanitize_tier_discounts( $raw_discounts ); update_option( WCUTP_Constants::OPTION_TIER_DISCOUNTS, $discounts ); $this->record_audit_log_entries( 'Tier Discounts', 'Save Tier Discounts', $this->build_audit_diff_entries( $previous, $discounts, 'tier_discounts' ) ); $this->redirect_with_notice( 'tier_discounts_saved', 'tier-discounts' ); } /** * Save discount exclusion settings. * * @return void */ private function handle_save_discount_exclusions() { check_admin_referer( 'wcutp_save_discount_exclusions', 'wcutp_nonce' ); $previous = get_option( WCUTP_Constants::OPTION_DISCOUNT_EXCLUSIONS, array() ); $previous = is_array( $previous ) ? $previous : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Product, category, and tier fields are sanitized below. $raw = isset( $_POST['discount_exclusions'] ) ? wp_unslash( $_POST['discount_exclusions'] ) : array(); $raw = is_array( $raw ) ? $raw : array(); $product_ids = isset( $raw['product_ids'] ) ? $this->sanitize_id_input( $raw['product_ids'] ) : array(); $category_ids = isset( $raw['category_ids'] ) ? $this->sanitize_id_input( $raw['category_ids'] ) : array(); $tier_slugs = array(); if ( isset( $raw['tier_slugs'] ) && is_array( $raw['tier_slugs'] ) ) { $tier_slugs = array_map( 'sanitize_key', $raw['tier_slugs'] ); $tier_slugs = array_values( array_filter( array_unique( $tier_slugs ) ) ); } $next = array( 'product_ids' => $product_ids, 'category_ids' => $category_ids, 'tier_slugs' => $tier_slugs, ); update_option( WCUTP_Constants::OPTION_DISCOUNT_EXCLUSIONS, $next ); $this->record_audit_log_entries( 'Discount Exclusions', 'Save Discount Exclusions', $this->build_audit_diff_entries( $previous, $next, 'discount_exclusions' ) ); $this->redirect_with_notice( 'discount_exclusions_saved', 'discount-exclusions' ); } /** * Save tier access restriction settings. * * @return void */ private function handle_save_access_restrictions() { check_admin_referer( 'wcutp_save_access_restrictions', 'wcutp_nonce' ); $previous = get_option( WCUTP_Constants::OPTION_ACCESS_RESTRICTIONS, array() ); $previous = is_array( $previous ) ? $previous : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Restriction fields and notices are sanitized below. $raw = isset( $_POST['access_restrictions'] ) ? wp_unslash( $_POST['access_restrictions'] ) : array(); $raw = is_array( $raw ) ? $raw : array(); $guest_tier_slug = isset( $raw['guest_tier_slug'] ) ? sanitize_key( (string) $raw['guest_tier_slug'] ) : ''; $disable_notice = isset( $raw['disable_notice'] ) ? sanitize_textarea_field( (string) $raw['disable_notice'] ) : ''; $block_notice = isset( $raw['block_notice'] ) ? sanitize_textarea_field( (string) $raw['block_notice'] ) : ''; $hide_notice = isset( $raw['hide_notice'] ) ? sanitize_textarea_field( (string) $raw['hide_notice'] ) : ''; $product_rules = array(); if ( isset( $raw['product_rules'] ) && is_array( $raw['product_rules'] ) ) { foreach ( $raw['product_rules'] as $rule ) { if ( ! is_array( $rule ) ) { continue; } $product_ids = isset( $rule['product_ids'] ) ? $this->sanitize_id_input( $rule['product_ids'] ) : array(); $behavior = isset( $rule['behavior'] ) ? sanitize_key( (string) $rule['behavior'] ) : 'block'; $tiers = isset( $rule['allowed_tiers'] ) && is_array( $rule['allowed_tiers'] ) ? array_map( 'sanitize_key', $rule['allowed_tiers'] ) : array(); $tiers = array_values( array_filter( array_unique( $tiers ) ) ); if ( empty( $product_ids ) || empty( $tiers ) ) { continue; } if ( ! in_array( $behavior, array( 'hide', 'disable', 'block' ), true ) ) { $behavior = 'block'; } $product_rules[] = array( 'product_ids' => $product_ids, 'allowed_tiers' => $tiers, 'behavior' => $behavior, ); } } $category_rules = array(); if ( isset( $raw['category_rules'] ) && is_array( $raw['category_rules'] ) ) { foreach ( $raw['category_rules'] as $rule ) { if ( ! is_array( $rule ) ) { continue; } $category_ids = isset( $rule['category_ids'] ) ? $this->sanitize_id_input( $rule['category_ids'] ) : array(); $behavior = isset( $rule['behavior'] ) ? sanitize_key( (string) $rule['behavior'] ) : 'block'; $tiers = isset( $rule['allowed_tiers'] ) && is_array( $rule['allowed_tiers'] ) ? array_map( 'sanitize_key', $rule['allowed_tiers'] ) : array(); $tiers = array_values( array_filter( array_unique( $tiers ) ) ); if ( empty( $category_ids ) || empty( $tiers ) ) { continue; } if ( ! in_array( $behavior, array( 'hide', 'disable', 'block' ), true ) ) { $behavior = 'block'; } $category_rules[] = array( 'category_ids' => $category_ids, 'allowed_tiers' => $tiers, 'behavior' => $behavior, ); } } $next = array( 'guest_tier_slug' => $guest_tier_slug, 'disable_notice' => $disable_notice, 'block_notice' => $block_notice, 'hide_notice' => $hide_notice, 'product_rules' => $product_rules, 'category_rules' => $category_rules, ); update_option( WCUTP_Constants::OPTION_ACCESS_RESTRICTIONS, $next ); $this->record_audit_log_entries( 'Tier Access Restrictions', 'Save Access Restrictions', $this->build_audit_diff_entries( $previous, $next, 'access_restrictions' ) ); $this->redirect_with_notice( 'access_restrictions_saved', 'tier-access-restrictions' ); } // ==================== BIRTHDAY COUPON UPGRADE: multi-rule save handler (start) ==================== // handle_save_birthday_coupons() below now reads birthday_coupons[rules] as an array (was one flat // birthday_coupons[...] object) and delegates per-row sanitizing to the new sanitize_birthday_coupon_rule(). /** * Save birthday coupon settings (one or more rules). * * @return void */ private function handle_save_birthday_coupons() { check_admin_referer( 'wcutp_save_birthday_coupons', 'wcutp_nonce' ); $previous = get_option( WCUTP_Constants::OPTION_BIRTHDAY_COUPON_SETTINGS, WCUTP_Constants::default_birthday_coupon_settings() ); $previous = is_array( $previous ) ? $previous : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Each birthday rule is sanitized by sanitize_birthday_coupon_rule(). $raw = isset( $_POST['birthday_coupons'] ) ? wp_unslash( $_POST['birthday_coupons'] ) : array(); $raw = is_array( $raw ) ? $raw : array(); $raw_rules = isset( $raw['rules'] ) && is_array( $raw['rules'] ) ? $raw['rules'] : array(); $rules = array(); foreach ( $raw_rules as $raw_rule ) { if ( ! is_array( $raw_rule ) ) { continue; } $rule = $this->sanitize_birthday_coupon_rule( $raw_rule ); if ( null === $rule ) { continue; } $rules[] = $rule; } if ( empty( $rules ) ) { $rules[] = WCUTP_Constants::default_birthday_coupon_rule( wp_generate_uuid4() ); } $settings = array( 'rules' => $rules ); update_option( WCUTP_Constants::OPTION_BIRTHDAY_COUPON_SETTINGS, $settings ); $this->record_audit_log_entries( 'Birthday Coupons', 'Save Birthday Coupon Settings', $this->build_audit_diff_entries( $previous, $settings, 'birthday_coupons' ) ); $this->redirect_with_notice( 'birthday_coupons_saved', 'birthday-coupons' ); } /** * Sanitize one raw birthday coupon rule row from $_POST. * * @param array $raw_rule Raw rule row. * @return array|null */ private function sanitize_birthday_coupon_rule( $raw_rule ) { $id = isset( $raw_rule['id'] ) ? sanitize_key( (string) $raw_rule['id'] ) : ''; if ( '' === $id ) { $id = sanitize_key( 'rule-' . wp_generate_uuid4() ); } $rule = WCUTP_Constants::default_birthday_coupon_rule( $id ); $rule['discount_type'] = ( isset( $raw_rule['discount_type'] ) && 'fixed_cart' === sanitize_key( (string) $raw_rule['discount_type'] ) ) ? 'fixed_cart' : 'percent'; $rule['discount_value'] = max( 0.01, (float) wc_format_decimal( isset( $raw_rule['discount_value'] ) ? $raw_rule['discount_value'] : 0, 2 ) ); $rule['validity_period'] = ( isset( $raw_rule['validity_period'] ) && 'week' === sanitize_key( (string) $raw_rule['validity_period'] ) ) ? 'week' : 'month'; $rule['apply_to_all_tiers'] = ! empty( $raw_rule['apply_to_all_tiers'] ); $rule['feb29_use_feb28'] = ! empty( $raw_rule['feb29_use_feb28'] ); $rule['usage_limit_per_user'] = max( 1, absint( isset( $raw_rule['usage_limit_per_user'] ) ? $raw_rule['usage_limit_per_user'] : 1 ) ); $rule['status'] = ( isset( $raw_rule['status'] ) && 'inactive' === sanitize_key( (string) $raw_rule['status'] ) ) ? 'inactive' : 'active'; if ( 'percent' === $rule['discount_type'] ) { $rule['discount_value'] = min( 100.0, $rule['discount_value'] ); } $applicable_tiers = isset( $raw_rule['applicable_tiers'] ) && is_array( $raw_rule['applicable_tiers'] ) ? array_map( 'sanitize_key', $raw_rule['applicable_tiers'] ) : array(); $rule['applicable_tiers'] = array_values( array_filter( array_unique( $applicable_tiers ) ) ); return $rule; } /** * Save campaign settings. * * @return void */ private function handle_save_campaigns() { check_admin_referer( 'wcutp_save_campaigns', 'wcutp_nonce' ); $previous_campaigns = get_option( WCUTP_Constants::OPTION_CAMPAIGNS, array() ); $previous_campaigns = is_array( $previous_campaigns ) ? $previous_campaigns : array(); $raw_campaigns = array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Campaign fields are sanitized before the option is saved. if ( isset( $_POST['campaign'] ) && is_array( $_POST['campaign'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Campaign fields are sanitized before the option is saved. $raw_campaigns[] = wp_unslash( $_POST['campaign'] ); } else { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Campaign fields are sanitized before the option is saved. $raw_campaigns = isset( $_POST['campaigns'] ) ? wp_unslash( $_POST['campaigns'] ) : array(); } $raw_campaigns = is_array( $raw_campaigns ) ? $raw_campaigns : array(); $existing_campaigns = get_option( WCUTP_Constants::OPTION_CAMPAIGNS, array() ); $existing_campaigns = is_array( $existing_campaigns ) ? $existing_campaigns : array(); $existing_map = array(); foreach ( $existing_campaigns as $row ) { if ( ! is_array( $row ) ) { continue; } $id = isset( $row['id'] ) ? sanitize_key( (string) $row['id'] ) : ''; if ( '' !== $id ) { $existing_map[ $id ] = $row; } } $campaigns = array(); foreach ( $raw_campaigns as $row ) { if ( ! is_array( $row ) ) { continue; } $campaign = $this->sanitize_campaign_row( $row ); if ( empty( $campaign ) ) { continue; } $existing = isset( $existing_map[ $campaign['id'] ] ) ? $existing_map[ $campaign['id'] ] : array(); $campaign = $this->hydrate_campaign_runtime_state( $campaign, $existing ); $campaign = $this->sync_campaign_temp_category( $campaign, $existing ); $campaigns[] = $campaign; } // Single-entry edit mode should preserve untouched campaigns. if ( isset( $_POST['campaign'] ) && is_array( $_POST['campaign'] ) ) { $campaigns = $existing_campaigns; if ( ! empty( $raw_campaigns[0] ) && is_array( $raw_campaigns[0] ) ) { $campaign = $this->sanitize_campaign_row( $raw_campaigns[0] ); if ( ! empty( $campaign ) ) { $existing = isset( $existing_map[ $campaign['id'] ] ) ? $existing_map[ $campaign['id'] ] : array(); $campaign = $this->hydrate_campaign_runtime_state( $campaign, $existing ); $campaign = $this->sync_campaign_temp_category( $campaign, $existing ); $updated = false; foreach ( $campaigns as $index => $row ) { if ( ! is_array( $row ) ) { continue; } $row_id = isset( $row['id'] ) ? sanitize_key( (string) $row['id'] ) : ''; if ( $row_id === $campaign['id'] ) { $campaigns[ $index ] = $campaign; $updated = true; break; } } if ( ! $updated ) { $campaigns[] = $campaign; } } } } $next_campaigns = array_values( $campaigns ); update_option( WCUTP_Constants::OPTION_CAMPAIGNS, $next_campaigns ); $this->record_audit_log_entries( 'Campaigns', 'Save Campaigns', $this->build_audit_diff_entries( $previous_campaigns, $next_campaigns, 'campaigns' ) ); $this->redirect_with_notice( 'campaigns_saved', 'campaigns' ); } /** * Delete a campaign row and temporary term if created. * * @return void */ private function handle_delete_campaign() { check_admin_referer( 'wcutp_delete_campaign', 'wcutp_nonce' ); $campaign_id = isset( $_POST['campaign_id'] ) ? sanitize_key( (string) wp_unslash( $_POST['campaign_id'] ) ) : ''; if ( '' === $campaign_id ) { $this->redirect_with_notice( 'campaigns_saved', 'campaigns' ); } $campaigns = get_option( WCUTP_Constants::OPTION_CAMPAIGNS, array() ); $campaigns = is_array( $campaigns ) ? $campaigns : array(); $next = array(); foreach ( $campaigns as $campaign ) { if ( ! is_array( $campaign ) ) { continue; } $id = isset( $campaign['id'] ) ? sanitize_key( (string) $campaign['id'] ) : ''; if ( $id === $campaign_id ) { $deleted_name = isset( $campaign['name'] ) ? sanitize_text_field( (string) $campaign['name'] ) : ''; $this->cleanup_campaign_temp_category( $campaign ); continue; } $next[] = $campaign; } update_option( WCUTP_Constants::OPTION_CAMPAIGNS, $next ); $this->record_audit_log_entries( 'Campaigns', 'Delete Campaign', array( array( 'field' => 'campaign_id', 'old' => $campaign_id, 'new' => 'deleted', ), array( 'field' => 'campaign_name', 'old' => isset( $deleted_name ) ? $deleted_name : '', 'new' => 'deleted', ), ) ); $this->redirect_with_notice( 'campaigns_saved', 'campaigns' ); } /** * Recalculate lifetime spend for a specific customer. * * @return void */ private function handle_recalculate_lifetime_spend_user() { check_admin_referer( 'wcutp_recalculate_lifetime_spend_user', 'wcutp_nonce' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The customer picker may submit an array. The id is cast with absint() below. $raw_user_id = isset( $_POST['user_id'] ) ? wp_unslash( $_POST['user_id'] ) : 0; if ( is_array( $raw_user_id ) ) { $raw_user_id = reset( $raw_user_id ); } $user_id = absint( $raw_user_id ); $user = $user_id > 0 ? get_user_by( 'id', $user_id ) : false; if ( ! ( $user instanceof WP_User ) ) { $this->redirect_with_notice( 'lifetime_spend_user_not_found', 'customer-tiers' ); } if ( ! in_array( 'customer', (array) $user->roles, true ) ) { $this->redirect_with_notice( 'lifetime_spend_user_invalid_customer', 'customer-tiers' ); } $tier_service = new WCUTP_Tier_Service(); $tier_service->recalculate_user_tier( $user_id ); $this->record_audit_log_entries( 'Customer Tiers', 'Recalculate Customer Lifetime Spend', array( array( 'field' => 'user_id', 'old' => '', 'new' => (string) $user_id, ), ) ); $this->redirect_with_notice( 'lifetime_spend_user_recalculated', 'customer-tiers' ); } /** * Recalculate lifetime spend for all customer users. * * @return void */ private function handle_recalculate_lifetime_spend_all() { check_admin_referer( 'wcutp_recalculate_lifetime_spend_all', 'wcutp_nonce' ); $tier_service = new WCUTP_Tier_Service(); $processed = $tier_service->recalculate_all_user_tiers(); delete_option( WCUTP_Constants::OPTION_RECALC_PENDING ); $this->record_audit_log_entries( 'Customer Tiers', 'Recalculate All Customers Lifetime Spend', array( array( 'field' => 'processed_customers', 'old' => '', 'new' => (string) absint( $processed ), ), ) ); $this->redirect_with_notice( ( $processed > 0 ) ? 'lifetime_spend_all_recalculated' : 'lifetime_spend_all_recalculated_empty', 'customer-tiers' ); } /** * Process one AJAX batch for all-customer lifetime recalculation. * * @return void */ public function ajax_recalculate_all_customers_batch() { if ( ! current_user_can( $this->capability ) ) { wp_send_json_error( array( 'message' => esc_html__( 'Unauthorized request.', 'codem-rewards-engine' ), ), 403 ); } check_ajax_referer( 'wcutp_recalculate_all_customers_batch', 'nonce' ); $offset = isset( $_POST['offset'] ) ? absint( wp_unslash( $_POST['offset'] ) ) : 0; $limit = isset( $_POST['limit'] ) ? absint( wp_unslash( $_POST['limit'] ) ) : 100; $tier_service = new WCUTP_Tier_Service(); $total = $tier_service->get_total_customer_count(); $result = $tier_service->recalculate_user_tiers_batch( $offset, $limit ); $processed = isset( $result['processed'] ) ? absint( $result['processed'] ) : 0; $next_offset = isset( $result['next_offset'] ) ? absint( $result['next_offset'] ) : $offset; $done = ! empty( $result['done'] ); if ( $done ) { delete_option( WCUTP_Constants::OPTION_RECALC_PENDING ); $this->record_audit_log_entries( 'Customer Tiers', 'Recalculate All Customers Lifetime Spend', array( array( 'field' => 'processed_customers', 'old' => '', 'new' => (string) $next_offset, ), ) ); } wp_send_json_success( array( 'processed' => $processed, 'next_offset' => $next_offset, 'done' => $done, 'total' => $total, ) ); } /** * Delete audit logs within provided date range. * * @return void */ private function handle_delete_audit_logs() { check_admin_referer( 'wcutp_delete_audit_logs', 'wcutp_nonce' ); $start = isset( $_POST['audit_start_date'] ) ? sanitize_text_field( (string) wp_unslash( $_POST['audit_start_date'] ) ) : ''; $end = isset( $_POST['audit_end_date'] ) ? sanitize_text_field( (string) wp_unslash( $_POST['audit_end_date'] ) ) : ''; $start_ts = $this->parse_audit_filter_date_start( $start ); $end_ts = $this->parse_audit_filter_date_end( $end ); $deleted_cnt = $this->delete_audit_logs_in_range_from_files( $start_ts, $end_ts ); $this->redirect_with_notice( ( $deleted_cnt > 0 ) ? 'audit_logs_deleted' : 'audit_logs_delete_empty', 'audit-logs' ); } /** * Export filtered audit logs as CSV. * * @return void */ private function handle_export_audit_logs_csv() { check_admin_referer( 'wcutp_export_audit_logs_csv', 'wcutp_nonce' ); $start = isset( $_POST['audit_start_date'] ) ? sanitize_text_field( (string) wp_unslash( $_POST['audit_start_date'] ) ) : ''; $end = isset( $_POST['audit_end_date'] ) ? sanitize_text_field( (string) wp_unslash( $_POST['audit_end_date'] ) ) : ''; $start = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $start ) ? $start : wp_date( 'Y-m-01', current_time( 'timestamp' ) ); $end = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $end ) ? $end : wp_date( 'Y-m-t', current_time( 'timestamp' ) ); $start_ts = $this->parse_audit_filter_date_start( $start ); $end_ts = $this->parse_audit_filter_date_end( $end ); $logs = $this->read_audit_logs_from_files_in_range( $start_ts, $end_ts ); $filename = sprintf( 'wcutp-audit-logs-%s-to-%s.csv', str_replace( '-', '', $start ), str_replace( '-', '', $end ) ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=' . $filename ); $output = fopen( 'php://output', 'w' ); if ( false === $output ) { exit; } fputcsv( $output, array( 'Date/Time', 'User ID', 'User Login', 'User Email', 'Module', 'Action', 'Field', 'Old Value', 'New Value', ) ); foreach ( $logs as $row ) { if ( ! is_array( $row ) ) { continue; } $time_str = isset( $row['time'] ) ? sanitize_text_field( (string) $row['time'] ) : ''; fputcsv( $output, array( $this->format_audit_log_datetime_for_display( $time_str ), isset( $row['user_id'] ) ? absint( $row['user_id'] ) : 0, isset( $row['user_login'] ) ? sanitize_text_field( (string) $row['user_login'] ) : '', isset( $row['user_email'] ) ? sanitize_email( (string) $row['user_email'] ) : '', isset( $row['module'] ) ? sanitize_text_field( (string) $row['module'] ) : '', isset( $row['action'] ) ? sanitize_text_field( (string) $row['action'] ) : '', isset( $row['field'] ) ? sanitize_text_field( (string) $row['field'] ) : '', isset( $row['old_value'] ) ? sanitize_text_field( (string) $row['old_value'] ) : '', isset( $row['new_value'] ) ? sanitize_text_field( (string) $row['new_value'] ) : '', ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the CSV stream opened on php://output. fclose( $output ); exit; } /** * Render the main dashboard page with tabs. * * @return void */ public function render_dashboard_page() { if ( ! current_user_can( $this->capability ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'codem-rewards-engine' ) ); } $tabs = $this->get_tabs(); $current_tab = $this->get_current_tab(); ?>

render_admin_notice(); ?> render_recalculate_pending_notice(); ?>
render_dashboard_tab(); break; case 'customer-tiers': $this->render_customer_tiers_tab(); break; case 'global-settings': $this->render_global_settings_tab(); break; case 'audit-logs': $this->render_audit_logs_tab(); break; case 'tier-discounts': $this->render_tier_discounts_tab(); break; case 'discount-exclusions': $this->render_discount_exclusions_tab(); break; case 'tier-access-restrictions': $this->render_access_restrictions_tab(); break; case 'birthday-coupons': $this->render_birthday_coupons_tab(); break; case 'campaigns': $this->render_campaigns_tab(); break; case 'faq': $this->render_faq_tab(); break; default: $this->render_dashboard_tab(); break; } ?>
get_tier_customer_distribution( $tier_slugs ); $counts = array(); foreach ( $tier_slugs as $slug ) { $counts[] = isset( $distribution[ $slug ] ) ? absint( $distribution[ $slug ] ) : 0; } $campaign_rows = $this->get_dashboard_campaign_rows( $campaigns ); $campaign_total = count( $campaign_rows ); $status_counts = array( 'active' => 0, 'pending' => 0, 'ended' => 0, ); foreach ( $campaign_rows as $row ) { $key = isset( $row['status_key'] ) ? $row['status_key'] : 'pending'; if ( isset( $status_counts[ $key ] ) ) { $status_counts[ $key ]++; } } $create_campaign_url = add_query_arg( array( 'page' => $this->menu_slug, 'tab' => 'campaigns', 'campaign_action' => 'new', ), admin_url( 'admin.php' ) ); $chart_data = array( 'tierLabels' => $tier_labels, 'tierCounts' => $counts, 'campaignStatus' => array( 'labels' => array( __( 'Live', 'codem-rewards-engine' ), __( 'Scheduled', 'codem-rewards-engine' ), __( 'Ended', 'codem-rewards-engine' ), ), 'keys' => array( 'active', 'pending', 'ended' ), 'counts' => array( $status_counts['active'], $status_counts['pending'], $status_counts['ended'], ), 'colors' => array( '#0f6b37', '#5e6675', '#7f2530' ), ), 'i18n' => array( 'customers' => __( 'Customers', 'codem-rewards-engine' ), 'all' => __( 'All statuses', 'codem-rewards-engine' ), ), ); ?>

0 ) : ?>


$tier_slugs Known tier slugs. * @return array */ private function get_tier_customer_distribution( $tier_slugs ) { $tier_slugs = array_values( array_filter( array_map( 'sanitize_key', is_array( $tier_slugs ) ? $tier_slugs : array() ) ) ); $counts = array(); foreach ( $tier_slugs as $slug ) { $counts[ $slug ] = 0; } if ( empty( $tier_slugs ) ) { return $counts; } global $wpdb; $meta_key = WCUTP_Constants::META_CUSTOMER_TIER; $placeholders = implode( ',', array_fill( 0, count( $tier_slugs ), '%s' ) ); $sql = "SELECT meta_value AS tier_slug, COUNT(*) AS customer_count FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value IN ($placeholders) GROUP BY meta_value"; $args = array_merge( array( $meta_key ), $tier_slugs ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Placeholders are built from sanitized tier slugs. Values are passed as prepare arguments. $prepared = $wpdb->prepare( $sql, ...$args ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Query was prepared above. The dashboard count is not cached so the chart stays current. $rows = $prepared ? $wpdb->get_results( $prepared, ARRAY_A ) : array(); $rows = is_array( $rows ) ? $rows : array(); foreach ( $rows as $row ) { $slug = isset( $row['tier_slug'] ) ? sanitize_key( (string) $row['tier_slug'] ) : ''; if ( '' === $slug || ! isset( $counts[ $slug ] ) ) { continue; } $counts[ $slug ] = isset( $row['customer_count'] ) ? absint( $row['customer_count'] ) : 0; } return $counts; } /** * Build campaign rows for the dashboard table/pie. * * @param array $campaigns Campaign option rows. * @return array> */ private function get_dashboard_campaign_rows( $campaigns ) { $campaigns = is_array( $campaigns ) ? $campaigns : array(); $rows = array(); foreach ( $campaigns as $campaign ) { if ( ! is_array( $campaign ) ) { continue; } $name = isset( $campaign['name'] ) ? sanitize_text_field( (string) $campaign['name'] ) : ''; if ( '' === $name ) { continue; } $start_at = isset( $campaign['start_at'] ) ? sanitize_text_field( (string) $campaign['start_at'] ) : ''; $end_at = isset( $campaign['end_at'] ) ? sanitize_text_field( (string) $campaign['end_at'] ) : ''; $timezone = isset( $campaign['timezone'] ) ? sanitize_text_field( (string) $campaign['timezone'] ) : wp_timezone_string(); $discount_type = isset( $campaign['discount_type'] ) ? sanitize_key( (string) $campaign['discount_type'] ) : 'percentage'; $discount_value = isset( $campaign['discount_value'] ) ? (string) wc_format_decimal( (float) $campaign['discount_value'], 2 ) : '0.00'; $status_key = $this->calculate_campaign_time_state( $campaign ); $status_data = $this->get_campaign_status_data( $status_key ); $rows[] = array( 'name' => $name, 'start_display' => $this->format_campaign_window_datetime( $start_at, $timezone ), 'end_display' => $this->format_campaign_window_datetime( $end_at, $timezone ), 'discount' => ( 'fixed' === $discount_type ? '$' . $discount_value : $discount_value . '%' ), 'status_key' => $status_key, 'status_label' => $status_data['label'], 'status_class' => $status_data['class'], ); } return $rows; } /** * Render customer tiers tab. * * @return void */ private function render_customer_tiers_tab() { $tiers = get_option( WCUTP_Constants::OPTION_TIERS, WCUTP_Constants::default_tiers() ); $tiers = is_array( $tiers ) ? $tiers : WCUTP_Constants::default_tiers(); $tiers[] = array( 'slug' => '', 'name' => '', 'min' => '', 'max' => '', 'status' => 'active', ); ?>

$tier ) : ?>

get_total_customer_count(); ?>

$tier ) : ?>
'product_cat', 'hide_empty' => false, 'orderby' => 'name', 'order' => 'ASC', ) ); ?>

'product_cat', 'hide_empty' => false, 'orderby' => 'name', 'order' => 'ASC', ) ); ?>

$rule ) : ?> 0 ) { $product_ids = array( $legacy_product_id ); } } $behavior = isset( $rule['behavior'] ) ? sanitize_key( (string) $rule['behavior'] ) : 'block'; $allowed = isset( $rule['allowed_tiers'] ) && is_array( $rule['allowed_tiers'] ) ? array_map( 'sanitize_key', $rule['allowed_tiers'] ) : array(); ?>

$rule ) : ?> 0 ) { $category_ids = array( $legacy_category_id ); } } $behavior = isset( $rule['behavior'] ) ? sanitize_key( (string) $rule['behavior'] ) : 'block'; $allowed = isset( $rule['allowed_tiers'] ) && is_array( $rule['allowed_tiers'] ) ? array_map( 'sanitize_key', $rule['allowed_tiers'] ) : array(); ?>

*/ private function get_birthday_coupon_tier_choices() { $tiers = get_option( WCUTP_Constants::OPTION_TIERS, WCUTP_Constants::default_tiers() ); $tiers = is_array( $tiers ) ? $tiers : WCUTP_Constants::default_tiers(); $choices = array(); foreach ( $tiers as $tier ) { if ( ! is_array( $tier ) ) { continue; } $slug = isset( $tier['slug'] ) ? sanitize_key( (string) $tier['slug'] ) : ''; if ( '' === $slug ) { continue; } $choices[] = array( 'slug' => $slug, 'name' => isset( $tier['name'] ) ? sanitize_text_field( (string) $tier['name'] ) : ucfirst( $slug ), ); } return $choices; } /** * Render birthday coupons tab: multiple independent rules, each its own card. * * @return void */ private function render_birthday_coupons_tab() { $raw = get_option( WCUTP_Constants::OPTION_BIRTHDAY_COUPON_SETTINGS, array() ); $rules = WCUTP_Constants::normalize_birthday_coupon_rules( $raw ); $tier_choices = $this->get_birthday_coupon_tier_choices(); $active_count = count( array_filter( $rules, static function( $rule ) { return 'active' === $rule['status']; } ) ); ob_start(); $this->render_birthday_coupon_rule_card( '__INDEX__', WCUTP_Constants::default_birthday_coupon_rule( '' ), $tier_choices ); $template_html = ob_get_clean(); ?>

$rule ) : ?> render_birthday_coupon_rule_card( $index, $rule, $tier_choices ); ?>
$rule Rule data. * @param array $tier_choices Tier slug/name choices. * @return void */ private function render_birthday_coupon_rule_card( $index, $rule, $tier_choices ) { $name_prefix = 'birthday_coupons[rules][' . $index . ']'; $applicable_tiers = isset( $rule['applicable_tiers'] ) && is_array( $rule['applicable_tiers'] ) ? $rule['applicable_tiers'] : array(); $apply_to_all_tiers = ! empty( $rule['apply_to_all_tiers'] ); ?>
'product_cat', 'hide_empty' => false, 'orderby' => 'name', 'order' => 'ASC', ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only campaign screen link. It chooses New or Edit and does not save data. $action = isset( $_GET['campaign_action'] ) ? sanitize_key( (string) wp_unslash( $_GET['campaign_action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only campaign screen link. It selects which campaign to display. $campaign_id = isset( $_GET['campaign_id'] ) ? sanitize_key( (string) wp_unslash( $_GET['campaign_id'] ) ) : ''; $editing = in_array( $action, array( 'new', 'edit' ), true ); $form_campaign = $this->default_campaign_row(); if ( 'edit' === $action && '' !== $campaign_id ) { foreach ( $campaigns as $campaign ) { if ( ! is_array( $campaign ) ) { continue; } $id = isset( $campaign['id'] ) ? sanitize_key( (string) $campaign['id'] ) : ''; if ( $id === $campaign_id ) { $form_campaign = wp_parse_args( $campaign, $this->default_campaign_row() ); break; } } } if ( 'new' === $action ) { $form_campaign['id'] = 'campaign-' . wp_generate_password( 8, false, false ); } $form_campaign['id'] = isset( $form_campaign['id'] ) ? sanitize_key( (string) $form_campaign['id'] ) : 'campaign-' . wp_generate_password( 8, false, false ); $form_campaign['name'] = isset( $form_campaign['name'] ) ? sanitize_text_field( (string) $form_campaign['name'] ) : ''; $form_campaign['start_at'] = isset( $form_campaign['start_at'] ) ? sanitize_text_field( (string) $form_campaign['start_at'] ) : ''; $form_campaign['end_at'] = isset( $form_campaign['end_at'] ) ? sanitize_text_field( (string) $form_campaign['end_at'] ) : ''; $form_campaign['timezone'] = isset( $form_campaign['timezone'] ) ? sanitize_text_field( (string) $form_campaign['timezone'] ) : wp_timezone_string(); $form_campaign['tier_slugs'] = isset( $form_campaign['tier_slugs'] ) && is_array( $form_campaign['tier_slugs'] ) ? array_map( 'sanitize_key', $form_campaign['tier_slugs'] ) : array(); $form_campaign['discount_type'] = isset( $form_campaign['discount_type'] ) ? sanitize_key( (string) $form_campaign['discount_type'] ) : 'percentage'; $form_campaign['discount_value'] = isset( $form_campaign['discount_value'] ) ? (string) wc_format_decimal( (float) $form_campaign['discount_value'], 2 ) : ''; $form_campaign['discount_mode'] = isset( $form_campaign['discount_mode'] ) ? sanitize_key( (string) $form_campaign['discount_mode'] ) : 'higher'; $form_campaign['enable_categories'] = ! empty( $form_campaign['enable_categories'] ); $form_campaign['enable_products'] = ! empty( $form_campaign['enable_products'] ); $form_campaign['category_ids'] = isset( $form_campaign['category_ids'] ) && is_array( $form_campaign['category_ids'] ) ? array_map( 'absint', $form_campaign['category_ids'] ) : array(); $form_campaign['product_ids'] = isset( $form_campaign['product_ids'] ) && is_array( $form_campaign['product_ids'] ) ? array_map( 'absint', $form_campaign['product_ids'] ) : array(); $form_campaign['top_bar_text'] = isset( $form_campaign['top_bar_text'] ) ? WCUTP_Constants::sanitize_top_bar_text( $form_campaign['top_bar_text'] ) : ''; $form_campaign['top_bar_bg_color'] = isset( $form_campaign['top_bar_bg_color'] ) ? WCUTP_Constants::sanitize_top_bar_color( $form_campaign['top_bar_bg_color'], '#e63333' ) : '#e63333'; $form_campaign['top_bar_text_color'] = isset( $form_campaign['top_bar_text_color'] ) ? WCUTP_Constants::sanitize_top_bar_color( $form_campaign['top_bar_text_color'], '#ededed' ) : '#ededed'; $form_campaign['top_bar_font_family'] = isset( $form_campaign['top_bar_font_family'] ) ? WCUTP_Constants::sanitize_top_bar_font( $form_campaign['top_bar_font_family'] ) : 'Lato'; $form_campaign['top_bar_font_size'] = isset( $form_campaign['top_bar_font_size'] ) ? WCUTP_Constants::sanitize_top_bar_font_size( $form_campaign['top_bar_font_size'] ) : 18; $form_campaign['temp_category_id'] = isset( $form_campaign['temp_category_id'] ) ? absint( $form_campaign['temp_category_id'] ) : 0; if ( strpos( $form_campaign['start_at'], ' ' ) !== false ) { $form_campaign['start_at'] = str_replace( ' ', 'T', $form_campaign['start_at'] ); } if ( strpos( $form_campaign['end_at'], ' ' ) !== false ) { $form_campaign['end_at'] = str_replace( ' ', 'T', $form_campaign['end_at'] ); } if ( 'edit' === $action && absint( $form_campaign['temp_category_id'] ) <= 0 ) { $fallback_term_id = $this->find_campaign_temp_category_id( $form_campaign ); if ( $fallback_term_id > 0 ) { $form_campaign['temp_category_id'] = $fallback_term_id; $this->upsert_campaign_option_row( $form_campaign ); } } $create_url = add_query_arg( array( 'page' => $this->menu_slug, 'tab' => 'campaigns', 'campaign_action' => 'new', ), admin_url( 'admin.php' ) ); $list_url = add_query_arg( array( 'page' => $this->menu_slug, 'tab' => 'campaigns', ), admin_url( 'admin.php' ) ); ?>

format_campaign_window_datetime( $start_at, $timezone ); $end_display = $this->format_campaign_window_datetime( $end_at, $timezone ); $tier_slugs = isset( $campaign['tier_slugs'] ) && is_array( $campaign['tier_slugs'] ) ? array_map( 'sanitize_key', $campaign['tier_slugs'] ) : array(); $discount_type = isset( $campaign['discount_type'] ) ? sanitize_key( (string) $campaign['discount_type'] ) : 'percentage'; $discount_value = isset( $campaign['discount_value'] ) ? (string) wc_format_decimal( (float) $campaign['discount_value'], 2 ) : '0.00'; $enable_categories = ! empty( $campaign['enable_categories'] ); $enable_products = ! empty( $campaign['enable_products'] ); $top_bar_text = isset( $campaign['top_bar_text'] ) ? WCUTP_Constants::sanitize_top_bar_text( $campaign['top_bar_text'] ) : ''; $status_key = $this->calculate_campaign_time_state( $campaign ); $status_data = $this->get_campaign_status_data( $status_key ); $edit_url = add_query_arg( array( 'page' => $this->menu_slug, 'tab' => 'campaigns', 'campaign_action' => 'edit', 'campaign_id' => $id, ), admin_url( 'admin.php' ) ); $scope = array(); if ( $enable_categories ) { $scope[] = esc_html__( 'Categories', 'codem-rewards-engine' ); } if ( $enable_products ) { $scope[] = esc_html__( 'Products', 'codem-rewards-engine' ); } if ( empty( $scope ) ) { $scope[] = esc_html__( 'Storewide', 'codem-rewards-engine' ); } ?>


/100

campaign_has_draft_products( $form_campaign['product_ids'] ); ?>
find_campaign_temp_category_id( $form_campaign ); } $temp_term = ( $temp_category_id > 0 ) ? get_term( $temp_category_id, 'product_cat' ) : null; $temp_link = ( $temp_term instanceof WP_Term ) ? get_term_link( $temp_term ) : ''; ?> = 2 ) : ?>
'', 'name' => '', 'start_at' => '', 'end_at' => '', 'timezone' => wp_timezone_string(), 'tier_slugs' => array(), 'discount_type' => 'percentage', 'discount_value' => '', 'discount_mode' => 'higher', 'enable_categories' => false, 'enable_products' => false, 'category_ids' => array(), 'product_ids' => array(), 'top_bar_text' => '', 'top_bar_bg_color' => '#e63333', 'top_bar_text_color' => '#ededed', 'top_bar_font_family' => 'Lato', 'top_bar_font_size' => 18, 'revert_to_draft_after_end' => false, 'auto_published_product_ids' => array(), 'lifecycle_state' => 'pending', 'temp_category_id' => 0, ); } /** * Calculate current campaign time state from start/end and timezone. * * @param array $campaign Campaign data. * @return string */ private function calculate_campaign_time_state( $campaign ) { $campaign = is_array( $campaign ) ? $campaign : array(); $start_at = isset( $campaign['start_at'] ) ? sanitize_text_field( (string) $campaign['start_at'] ) : ''; $end_at = isset( $campaign['end_at'] ) ? sanitize_text_field( (string) $campaign['end_at'] ) : ''; $timezone = isset( $campaign['timezone'] ) ? sanitize_text_field( (string) $campaign['timezone'] ) : ''; if ( '' === $start_at || '' === $end_at || '' === $timezone ) { return 'pending'; } $start_at = str_replace( 'T', ' ', $start_at ); $end_at = str_replace( 'T', ' ', $end_at ); try { $tz = new DateTimeZone( $timezone ); $start = DateTimeImmutable::createFromFormat( 'Y-m-d H:i', $start_at, $tz ); $end = DateTimeImmutable::createFromFormat( 'Y-m-d H:i', $end_at, $tz ); $now = new DateTimeImmutable( 'now', $tz ); if ( ! $start || ! $end ) { return 'pending'; } if ( $now < $start ) { return 'pending'; } if ( $now > $end ) { return 'ended'; } } catch ( Exception $e ) { return 'pending'; } return 'active'; } /** * Format campaign date-time for readable window display. * * @param string $value Campaign datetime (Y-m-d H:i or datetime-local style). * @param string $timezone Timezone identifier. * @return string */ private function format_campaign_window_datetime( $value, $timezone ) { $value = sanitize_text_field( (string) $value ); if ( '' === $value ) { return '—'; } $value = str_replace( 'T', ' ', $value ); try { $tz = new DateTimeZone( sanitize_text_field( (string) $timezone ) ); } catch ( Exception $e ) { $tz = wp_timezone(); } $date = DateTimeImmutable::createFromFormat( 'Y-m-d H:i', $value, $tz ); if ( ! $date ) { return $value; } $format = function_exists( 'wc_date_format' ) && function_exists( 'wc_time_format' ) ? wc_date_format() . ' ' . wc_time_format() : 'M j, Y g:i a'; return wp_date( $format, $date->getTimestamp(), $tz ); } /** * Resolve campaign status label and css class. * * @param string $status_key Lifecycle key. * @return array */ private function get_campaign_status_data( $status_key ) { $status_key = sanitize_key( (string) $status_key ); $map = array( 'pending' => array( 'label' => esc_html__( 'Scheduled', 'codem-rewards-engine' ), 'class' => 'scheduled', ), 'active' => array( 'label' => esc_html__( 'Live', 'codem-rewards-engine' ), 'class' => 'live', ), 'ended' => array( 'label' => esc_html__( 'Ended', 'codem-rewards-engine' ), 'class' => 'ended', ), ); return isset( $map[ $status_key ] ) ? $map[ $status_key ] : $map['pending']; } /** * Render audit logs tab. * * @return void */ private function render_audit_logs_tab() { $default_start = wp_date( 'Y-m-01', current_time( 'timestamp' ) ); $default_end = wp_date( 'Y-m-t', current_time( 'timestamp' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only audit log filter. The date is sanitized and does not change stored logs. $start_date = isset( $_GET['audit_start_date'] ) ? sanitize_text_field( (string) wp_unslash( $_GET['audit_start_date'] ) ) : $default_start; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only audit log filter. The date is sanitized and does not change stored logs. $end_date = isset( $_GET['audit_end_date'] ) ? sanitize_text_field( (string) wp_unslash( $_GET['audit_end_date'] ) ) : $default_end; $start_ts = $this->parse_audit_filter_date_start( $start_date ); $end_ts = $this->parse_audit_filter_date_end( $end_date ); $filtered_logs = $this->read_audit_logs_from_files_in_range( $start_ts, $end_ts ); ?>

format_audit_log_datetime_for_display( $time ); $user_login = isset( $row['user_login'] ) ? sanitize_text_field( (string) $row['user_login'] ) : ''; $user_email = isset( $row['user_email'] ) ? sanitize_text_field( (string) $row['user_email'] ) : ''; $module = isset( $row['module'] ) ? sanitize_text_field( (string) $row['module'] ) : ''; $action = isset( $row['action'] ) ? sanitize_text_field( (string) $row['action'] ) : ''; $field = isset( $row['field'] ) ? sanitize_text_field( (string) $row['field'] ) : ''; $old = isset( $row['old_value'] ) ? sanitize_text_field( (string) $row['old_value'] ) : ''; $new = isset( $row['new_value'] ) ? sanitize_text_field( (string) $row['new_value'] ) : ''; $user_label = '' !== $user_login ? $user_login : esc_html__( 'System', 'codem-rewards-engine' ); if ( '' !== $user_email ) { $user_label .= ' (' . $user_email . ')'; } ?>
esc_html__( 'Customer Tiers', 'codem-rewards-engine' ), 'items' => array( array( 'q' => esc_html__( 'How are tiers assigned and updated?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Tier assignment is based on lifetime spend from completed orders, with refunds deducted.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Global Settings control whether taxes and shipping are included in lifetime spend.', 'codem-rewards-engine' ), esc_html__( 'Tier recalculation runs automatically on completed-order status changes and refunds.', 'codem-rewards-engine' ), ), 'tip' => esc_html__( 'For existing stores, run "Recalculate All Customers" once after configuring tiers.', 'codem-rewards-engine' ), ), array( 'q' => esc_html__( 'How exactly must an admin configure Customer Tiers for flawless operation?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Tiers must start at spend 0, have continuous non-overlapping ranges, and leave Max Spend blank ONLY on the highest tier.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Rule 1: Lowest tier (Guest/Bronze) must start at Minimum Spend = 0.', 'codem-rewards-engine' ), esc_html__( 'Rule 2: Use continuous integer ranges (e.g. 0-299, 300-999, 1000-2499, 2500+). Lower Max = N requires Next Min >= N + 1.', 'codem-rewards-engine' ), esc_html__( 'Rule 3: Intermediate tiers must set a Max Spend; only the single highest tier leaves Max Spend blank.', 'codem-rewards-engine' ), esc_html__( 'Rule 4: Ensure all tiers have unique Names and Slugs.', 'codem-rewards-engine' ), esc_html__( 'Rule 5: Run "Recalculate All Customers" whenever you add, edit, or toggle tier status.', 'codem-rewards-engine' ), ), 'tip' => esc_html__( 'Real-time validation highlights errors in red and prevents saving until spend ranges are valid.', 'codem-rewards-engine' ), ), array( 'q' => esc_html__( 'What happens if I set a tier to Inactive?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Inactive tiers are ignored by runtime logic, but users already assigned to that tier are not reassigned instantly.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'A confirmation popup is shown when changing a tier status to Inactive.', 'codem-rewards-engine' ), esc_html__( 'Run "Recalculate All Customers" to reassign affected users immediately.', 'codem-rewards-engine' ), ), 'tip' => esc_html__( 'Keep at least one active tier at all times to avoid fallback ambiguity.', 'codem-rewards-engine' ), ), array( 'q' => esc_html__( 'Where can I see customer tier data?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Tier and Lifetime Spend are visible in Users list columns and user profile.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Users list supports filtering and sorting by tier.', 'codem-rewards-engine' ), esc_html__( 'Customer profile includes tier summary values for quick verification.', 'codem-rewards-engine' ), ), ), ), ), array( 'title' => esc_html__( 'Global Settings', 'codem-rewards-engine' ), 'items' => array( array( 'q' => esc_html__( 'How do Include Taxes and Include Shipping affect spend?', 'codem-rewards-engine' ), 'key' => esc_html__( 'They only affect lifetime spend calculation, not live cart totals.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'When unchecked, order tax/shipping components are excluded from lifetime spend.', 'codem-rewards-engine' ), esc_html__( 'Changes apply to future recalculations and should be followed by manual recalc for consistency.', 'codem-rewards-engine' ), ), ), array( 'q' => esc_html__( 'What does Allow Downgrade do?', 'codem-rewards-engine' ), 'key' => esc_html__( 'When enabled, customers can move to lower tiers if spend decreases; when disabled, highest achieved tier is kept.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Highest tier is tracked separately and used when downgrade is locked.', 'codem-rewards-engine' ), ), 'tip' => esc_html__( 'Disable downgrades for loyalty programs where tier status should be retained.', 'codem-rewards-engine' ), ), ), ), array( 'title' => esc_html__( 'Tier Discounts', 'codem-rewards-engine' ), 'items' => array( array( 'q' => esc_html__( 'How are tier discounts calculated?', 'codem-rewards-engine' ), 'key' => esc_html__( 'One active discount rule is resolved for the current tier and applied as a negative fee in cart/checkout.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Percentage discounts use eligible line subtotal.', 'codem-rewards-engine' ), esc_html__( 'Fixed discounts are capped so they never exceed eligible subtotal.', 'codem-rewards-engine' ), ), 'tip' => esc_html__( 'If campaigns are active, final discount depends on campaign priority mode.', 'codem-rewards-engine' ), ), array( 'q' => esc_html__( 'Do inactive tiers get discounts?', 'codem-rewards-engine' ), 'key' => esc_html__( 'No. Inactive tiers are ignored at runtime.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'If a user still has an inactive tier in meta, runtime falls back to lowest active tier.', 'codem-rewards-engine' ), ), ), array( 'q' => esc_html__( 'Why is discount not applied in cart?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Most cases are caused by exclusions, inactive tier/rule, or tier mismatch.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Check Discount Exclusions for product/category/tier exclusions.', 'codem-rewards-engine' ), esc_html__( 'Confirm current user tier is active and has an active rule with value > 0.', 'codem-rewards-engine' ), ), ), ), ), array( 'title' => esc_html__( 'Discount Exclusions', 'codem-rewards-engine' ), 'items' => array( array( 'q' => esc_html__( 'Difference between exclusions and access restrictions?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Discount Exclusions only affect discounting; Access Restrictions affect purchasing eligibility.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Excluded products/categories remain purchasable but receive no discount.', 'codem-rewards-engine' ), esc_html__( 'Restricted products/categories can be hidden, disabled, or server-side blocked.', 'codem-rewards-engine' ), ), ), array( 'q' => esc_html__( 'If no tier is selected under "Exclude Entire Tiers", is discount blocked?', 'codem-rewards-engine' ), 'key' => esc_html__( 'No. Discount is allowed for all tiers unless specific tiers are checked there.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Category/product exclusions still apply independently.', 'codem-rewards-engine' ), ), ), ), ), array( 'title' => esc_html__( 'Tier Access Restrictions', 'codem-rewards-engine' ), 'items' => array( array( 'q' => esc_html__( 'Product rule or category rule: which wins?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Product-level rule overrides category-level rule for that product.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Use product-level rules for exceptions inside restricted categories.', 'codem-rewards-engine' ), ), ), array( 'q' => esc_html__( 'What do Hide, Disable, and Block do?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Hide: hidden from loops. Disable: visible but not purchasable. Block: visible, but add-to-cart is blocked server-side.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Server-side cart checks remove unauthorized items even if URL is manipulated.', 'codem-rewards-engine' ), ), 'tip' => esc_html__( 'For stronger catalog control, use Hide; for merchandising visibility, use Disable or Block.', 'codem-rewards-engine' ), ), array( 'q' => esc_html__( 'How does AJAX Add to Cart interact with Block behavior?', 'codem-rewards-engine' ), 'key' => esc_html__( 'When WooCommerce AJAX add-to-cart on archives is enabled, blocked add-to-cart keeps users on the current page with error notice.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'When AJAX is disabled, WooCommerce default redirect behavior is preserved.', 'codem-rewards-engine' ), ), ), array( 'q' => esc_html__( 'How does guest tier fallback work?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Guests are evaluated as the configured Guest Tier.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'If Guest Tier is not set, lowest active tier is used.', 'codem-rewards-engine' ), ), 'tip' => esc_html__( 'Set Guest tier explicitly to avoid unexpected public catalog visibility.', 'codem-rewards-engine' ), ), array( 'q' => esc_html__( 'Why does a restricted category show no products?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Current tier is unauthorized for that category rule, so products are filtered/blocked.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'A restriction notice is shown on unauthorized category archives.', 'codem-rewards-engine' ), ), ), ), ), array( 'title' => esc_html__( 'Campaigns', 'codem-rewards-engine' ), 'items' => array( array( 'q' => esc_html__( 'How do campaign discounts work with tier discounts?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Campaign discounts are separate from tier discounts and are evaluated by campaign priority mode.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Auto Apply Higher Discount: best discount wins among competitive discounts.', 'codem-rewards-engine' ), esc_html__( 'Multiple Discount: campaign discounts can stack per configuration.', 'codem-rewards-engine' ), ), ), array( 'q' => esc_html__( 'When is temporary campaign category created?', 'codem-rewards-engine' ), 'key' => esc_html__( 'It is created when campaign becomes Live and product scope is enabled with selected products.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Category name matches campaign name.', 'codem-rewards-engine' ), esc_html__( 'Selected products are assigned into that temporary category by lifecycle sync.', 'codem-rewards-engine' ), ), ), array( 'q' => esc_html__( 'What does "Send to draft again after campaign is ended?" do?', 'codem-rewards-engine' ), 'key' => esc_html__( 'It appears only when selected products include at least one draft product, and controls draft restoration after campaign end.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Draft products can be auto-published when campaign starts.', 'codem-rewards-engine' ), esc_html__( 'If enabled, those products are returned to draft after campaign ends.', 'codem-rewards-engine' ), ), ), array( 'q' => esc_html__( 'What do campaign statuses mean?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Campaign table status shows Pending, Scheduled, Live, or Ended based on start/end window and runtime lifecycle.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Window timestamps are displayed in readable format using campaign timezone.', 'codem-rewards-engine' ), ), ), array( 'q' => esc_html__( 'Top bar campaign messages are not rotating. Why?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Only active/live campaigns with non-empty banner text participate in message rotation. Each rotating message also applies its own background color, text color, font family, and font size.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Check campaign time window, tier eligibility, and Top Bar Style Configuration values.', 'codem-rewards-engine' ), ), ), ), ), array( 'title' => esc_html__( 'Birthday Coupons', 'codem-rewards-engine' ), 'items' => array( array( 'q' => esc_html__( 'When are birthday coupons sent?', 'codem-rewards-engine' ), 'key' => esc_html__( 'A daily WP-Cron task checks birthdays and sends eligible coupons.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Coupon is linked to customer email and guarded to once per user per year.', 'codem-rewards-engine' ), esc_html__( 'Email template/content is managed in WooCommerce -> Settings -> Emails -> Birthday Coupon.', 'codem-rewards-engine' ), ), 'tip' => esc_html__( 'WP-Cron requires site traffic; use a real cron hit in production for reliability.', 'codem-rewards-engine' ), ), array( 'q' => esc_html__( 'How to test birthday coupon quickly?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Set test user birthday to any date within the current month, keep Birthday Coupons active, then run cron event `wcutp_birthday_coupon_daily` on the 1st of that month (or temporarily adjust the site date to the 1st for testing).', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Verify coupon creation under coupons and confirm email delivery.', 'codem-rewards-engine' ), esc_html__( 'WooCommerce "Send a test email" uses demo values for placeholders like {coupon_code} and {customer_name}.', 'codem-rewards-engine' ), ), ), ), ), array( 'title' => esc_html__( 'Audit Logs', 'codem-rewards-engine' ), 'items' => array( array( 'q' => esc_html__( 'What changes are tracked in audit logs?', 'codem-rewards-engine' ), 'key' => esc_html__( 'All tab settings saves and key actions are stored with Date/Time, User, Module, Action, Field, Old Value, and New Value.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Logs are field-level diffs so you can identify exactly what changed.', 'codem-rewards-engine' ), ), ), array( 'q' => esc_html__( 'How can I export or cleanup logs?', 'codem-rewards-engine' ), 'key' => esc_html__( 'Use date-range filters, then Export CSV or Delete Logs In Filter Range from the Audit Logs tab.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Date/time display follows WordPress date and time format settings.', 'codem-rewards-engine' ), ), ), ), ), array( 'title' => esc_html__( 'General Troubleshooting', 'codem-rewards-engine' ), 'items' => array( array( 'q' => esc_html__( 'Storefront works for admin but not customers/guests. Why?', 'codem-rewards-engine' ), 'key' => esc_html__( 'First check WooCommerce Site Visibility and plugin access rules.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Coming Soon mode can hide products for non-admin users.', 'codem-rewards-engine' ), esc_html__( 'Also verify customer tier assignment and active statuses.', 'codem-rewards-engine' ), ), ), array( 'q' => esc_html__( 'How do I fully reset plugin data?', 'codem-rewards-engine' ), 'key' => esc_html__( 'On plugin deactivation, confirm cleanup prompt to remove plugin options/meta and start fresh.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Cancel cleanup on deactivation if you want to keep existing configuration/data.', 'codem-rewards-engine' ), ), ), array( 'q' => esc_html__( 'Recommended setup order', 'codem-rewards-engine' ), 'key' => esc_html__( 'Dashboard -> Customer Tiers -> Global Settings -> Recalculate Lifetime Spend -> Tier Discounts -> Exclusions -> Access Restrictions -> Campaigns -> Birthday Coupons.', 'codem-rewards-engine' ), 'points' => array( esc_html__( 'Validate with guest and tiered customer accounts before going live.', 'codem-rewards-engine' ), ), ), ), ), ); ?>

$item ) : ?>
>

*/ private function get_tabs() { return array( 'dashboard' => array( 'label' => esc_html__( 'Dashboard', 'codem-rewards-engine' ), 'description' => esc_html__( 'Overview of tiers and campaigns', 'codem-rewards-engine' ), 'icon' => 'dashicons-dashboard', ), 'customer-tiers' => array( 'label' => esc_html__( 'Customer Tiers', 'codem-rewards-engine' ), 'description' => esc_html__( 'Tier thresholds and status', 'codem-rewards-engine' ), 'icon' => 'dashicons-groups', ), 'tier-discounts' => array( 'label' => esc_html__( 'Tier Discounts', 'codem-rewards-engine' ), 'description' => esc_html__( 'Automatic discounts by tier', 'codem-rewards-engine' ), 'icon' => 'dashicons-tickets-alt', ), 'discount-exclusions' => array( 'label' => esc_html__( 'Discount Exclusions', 'codem-rewards-engine' ), 'description' => esc_html__( 'Exclude products and categories', 'codem-rewards-engine' ), 'icon' => 'dashicons-hidden', ), 'tier-access-restrictions' => array( 'label' => esc_html__( 'Tier Access Restrictions', 'codem-rewards-engine' ), 'description' => esc_html__( 'Purchase eligibility controls', 'codem-rewards-engine' ), 'icon' => 'dashicons-lock', ), 'campaigns' => array( 'label' => esc_html__( 'Campaigns', 'codem-rewards-engine' ), 'description' => esc_html__( 'Time-bound campaign discounts', 'codem-rewards-engine' ), 'icon' => 'dashicons-megaphone', ), 'birthday-coupons' => array( 'label' => esc_html__( 'Birthday Coupons', 'codem-rewards-engine' ), 'description' => esc_html__( 'Automated birthday coupon flow', 'codem-rewards-engine' ), 'icon' => 'dashicons-awards', ), 'global-settings' => array( 'label' => esc_html__( 'Global Settings', 'codem-rewards-engine' ), 'description' => esc_html__( 'Spend calculation and downgrade', 'codem-rewards-engine' ), 'icon' => 'dashicons-admin-generic', ), 'audit-logs' => array( 'label' => esc_html__( 'Audit Logs', 'codem-rewards-engine' ), 'description' => esc_html__( 'Track all configuration changes', 'codem-rewards-engine' ), 'icon' => 'dashicons-list-view', ), 'faq' => array( 'label' => esc_html__( 'FAQ', 'codem-rewards-engine' ), 'description' => esc_html__( 'Questions and setup guide', 'codem-rewards-engine' ), 'icon' => 'dashicons-editor-help', ), ); } /** * Resolve current active tab. * * @return string */ private function get_current_tab() { $tabs = $this->get_tabs(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only tab link. Unknown values fall back to the dashboard. $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'dashboard'; if ( ! isset( $tabs[ $tab ] ) ) { $tab = 'dashboard'; } return $tab; } /** * Sanitize and normalize tier rows. * * @param mixed $raw_tiers Raw rows. * @return array> */ private function sanitize_tiers( $raw_tiers ) { $raw_tiers = is_array( $raw_tiers ) ? $raw_tiers : array(); $tiers = array(); foreach ( $raw_tiers as $index => $raw_tier ) { if ( ! is_array( $raw_tier ) ) { continue; } $name = isset( $raw_tier['name'] ) ? sanitize_text_field( $raw_tier['name'] ) : ''; $slug = isset( $raw_tier['slug'] ) ? sanitize_key( $raw_tier['slug'] ) : ''; if ( '' === $slug && '' !== $name ) { $slug = sanitize_key( sanitize_title( $name ) ); } if ( '' === $slug && '' !== $name ) { $slug = 'tier-' . absint( $index + 1 ); } if ( '' === $name && '' === $slug ) { continue; } $min = isset( $raw_tier['min'] ) ? (float) $raw_tier['min'] : 0.0; $min = max( 0.0, $min ); $max = null; $max_raw = isset( $raw_tier['max'] ) ? trim( (string) $raw_tier['max'] ) : ''; if ( '' !== $max_raw ) { $max = (float) $max_raw; if ( $max < $min ) { $max = $min; } } $status = isset( $raw_tier['status'] ) ? sanitize_key( $raw_tier['status'] ) : 'inactive'; $status = in_array( $status, array( 'active', 'inactive' ), true ) ? $status : 'inactive'; $tiers[] = array( 'slug' => $slug, 'name' => ( '' !== $name ) ? $name : ucfirst( $slug ), 'min' => (float) wc_format_decimal( $min, 2 ), 'max' => ( null === $max ) ? null : (float) wc_format_decimal( $max, 2 ), 'status' => $status, ); } if ( empty( $tiers ) ) { return WCUTP_Constants::default_tiers(); } usort( $tiers, static function( $a, $b ) { $min_a = isset( $a['min'] ) ? (float) $a['min'] : 0; $min_b = isset( $b['min'] ) ? (float) $b['min'] : 0; if ( $min_a === $min_b ) { return 0; } return ( $min_a < $min_b ) ? -1 : 1; } ); $seen_slugs = array(); foreach ( $tiers as $idx => $tier ) { $base_slug = $tier['slug']; $slug = $base_slug; $suffix = 2; while ( isset( $seen_slugs[ $slug ] ) ) { $slug = $base_slug . '-' . $suffix; ++$suffix; } $tiers[ $idx ]['slug'] = $slug; $seen_slugs[ $slug ] = true; } return $tiers; } /** * Validate tier spend ranges for overlaps and inconsistencies. * * @param array> $tiers Sanitized tiers list. * @return true|string True if valid, or localized error message if invalid. */ public function validate_tier_ranges( $tiers ) { if ( ! is_array( $tiers ) || empty( $tiers ) ) { return true; } $sorted = $tiers; usort( $sorted, static function( $a, $b ) { $min_a = isset( $a['min'] ) ? (float) $a['min'] : 0.0; $min_b = isset( $b['min'] ) ? (float) $b['min'] : 0.0; if ( $min_a === $min_b ) { return 0; } return ( $min_a < $min_b ) ? -1 : 1; } ); $count = count( $sorted ); for ( $i = 0; $i < $count; $i++ ) { $name_i = isset( $sorted[ $i ]['name'] ) && '' !== trim( (string) $sorted[ $i ]['name'] ) ? (string) $sorted[ $i ]['name'] : 'Tier #' . ( $i + 1 ); $min_i = isset( $sorted[ $i ]['min'] ) ? (float) $sorted[ $i ]['min'] : 0.0; $max_i = ( isset( $sorted[ $i ]['max'] ) && null !== $sorted[ $i ]['max'] && '' !== $sorted[ $i ]['max'] ) ? (float) $sorted[ $i ]['max'] : null; if ( null !== $max_i && $min_i > $max_i ) { return sprintf( /* translators: 1: Tier name, 2: Min spend, 3: Max spend */ __( 'Tier "%1$s" has Minimum Spend (%2$s) greater than Maximum Spend (%3$s).', 'codem-rewards-engine' ), $name_i, $min_i, $max_i ); } if ( null === $max_i && $i < $count - 1 ) { return sprintf( /* translators: %s: Tier name */ __( 'Tier "%s" has Maximum Spend left blank (unlimited), but higher tiers exist. Only the highest tier can have an unlimited Maximum Spend.', 'codem-rewards-engine' ), $name_i ); } if ( $i < $count - 1 ) { $next = $sorted[ $i + 1 ]; $name_next = isset( $next['name'] ) && '' !== trim( (string) $next['name'] ) ? (string) $next['name'] : 'Tier #' . ( $i + 2 ); $min_next = isset( $next['min'] ) ? (float) $next['min'] : 0.0; if ( null === $max_i ) { return sprintf( /* translators: 1: Higher tier name, 2: Lower tier name */ __( 'Tier "%1$s" overlaps with Tier "%2$s" because "%2$s" has an unlimited Maximum Spend.', 'codem-rewards-engine' ), $name_next, $name_i ); } $req_min = $max_i + 1.0; if ( $min_next < $req_min ) { return sprintf( /* translators: 1: Higher tier name, 2: Higher tier min spend, 3: Lower tier name, 4: Lower tier min, 5: Lower tier max, 6: Required minimum spend */ __( 'Tier "%1$s" minimum spend (%2$s) overlaps with Tier "%3$s" range [%4$s - %5$s]. Minimum spend for "%1$s" must be at least %6$s.', 'codem-rewards-engine' ), $name_next, $min_next, $name_i, $min_i, $max_i, $req_min ); } } } return true; } /** * Sanitize tier discount rows. * * @param mixed $raw_discounts Raw discount rows. * @return array> */ private function sanitize_tier_discounts( $raw_discounts ) { $raw_discounts = is_array( $raw_discounts ) ? $raw_discounts : array(); $sanitized = array(); $seen_tiers = array(); foreach ( $raw_discounts as $row ) { if ( ! is_array( $row ) ) { continue; } $tier_slug = isset( $row['tier_slug'] ) ? sanitize_key( (string) $row['tier_slug'] ) : ''; $type = isset( $row['type'] ) ? sanitize_key( (string) $row['type'] ) : 'percentage'; $status = isset( $row['status'] ) ? sanitize_key( (string) $row['status'] ) : 'inactive'; $value = isset( $row['value'] ) ? (float) $row['value'] : 0.0; if ( '' === $tier_slug || isset( $seen_tiers[ $tier_slug ] ) ) { continue; } $type = in_array( $type, array( 'percentage', 'fixed' ), true ) ? $type : 'percentage'; $status = in_array( $status, array( 'active', 'inactive' ), true ) ? $status : 'inactive'; $value = max( 0.0, (float) wc_format_decimal( $value, 2 ) ); if ( 'percentage' === $type ) { $value = min( 100.0, $value ); } $sanitized[] = array( 'tier_slug' => $tier_slug, 'type' => $type, 'value' => $value, 'status' => $status, ); $seen_tiers[ $tier_slug ] = true; } return $sanitized; } /** * Sanitize a single campaign row. * * @param array $row Raw campaign row. * @return array */ private function sanitize_campaign_row( $row ) { $name = isset( $row['name'] ) ? sanitize_text_field( (string) $row['name'] ) : ''; if ( '' === $name ) { return array(); } $id = isset( $row['id'] ) ? sanitize_key( (string) $row['id'] ) : ''; if ( '' === $id ) { $id = 'campaign-' . wp_generate_password( 8, false, false ); } $timezone = isset( $row['timezone'] ) ? sanitize_text_field( (string) $row['timezone'] ) : ''; if ( '' === $timezone || ! in_array( $timezone, timezone_identifiers_list(), true ) ) { $timezone = wp_timezone_string(); } if ( '' === $timezone ) { $timezone = 'UTC'; } $start_at = isset( $row['start_at'] ) ? sanitize_text_field( (string) $row['start_at'] ) : ''; $end_at = isset( $row['end_at'] ) ? sanitize_text_field( (string) $row['end_at'] ) : ''; $start_at = str_replace( 'T', ' ', $start_at ); $end_at = str_replace( 'T', ' ', $end_at ); if ( ! $this->is_valid_datetime_ymd_hi( $start_at ) || ! $this->is_valid_datetime_ymd_hi( $end_at ) ) { return array(); } $tier_slugs = isset( $row['tier_slugs'] ) && is_array( $row['tier_slugs'] ) ? array_map( 'sanitize_key', $row['tier_slugs'] ) : array(); $tier_slugs = array_values( array_filter( array_unique( $tier_slugs ) ) ); if ( empty( $tier_slugs ) ) { return array(); } $discount_type = isset( $row['discount_type'] ) ? sanitize_key( (string) $row['discount_type'] ) : 'percentage'; $discount_type = in_array( $discount_type, array( 'percentage', 'fixed' ), true ) ? $discount_type : 'percentage'; $discount_value = isset( $row['discount_value'] ) ? (float) wc_format_decimal( $row['discount_value'], 2 ) : 0.0; $discount_value = max( 0.0, $discount_value ); if ( 'percentage' === $discount_type ) { $discount_value = min( 100.0, $discount_value ); } if ( $discount_value <= 0 ) { return array(); } $discount_mode = isset( $row['discount_mode'] ) ? sanitize_key( (string) $row['discount_mode'] ) : 'higher'; $discount_mode = in_array( $discount_mode, array( 'higher', 'multiple' ), true ) ? $discount_mode : 'higher'; $enable_categories = ! empty( $row['enable_categories'] ); $enable_products = ! empty( $row['enable_products'] ); $category_ids = $enable_categories && isset( $row['category_ids'] ) ? $this->sanitize_id_input( $row['category_ids'] ) : array(); $product_ids = $enable_products && isset( $row['product_ids'] ) ? $this->sanitize_id_input( $row['product_ids'] ) : array(); $top_bar_text = isset( $row['top_bar_text'] ) ? WCUTP_Constants::sanitize_top_bar_text( $row['top_bar_text'] ) : ''; $top_bar_bg_color = isset( $row['top_bar_bg_color'] ) ? WCUTP_Constants::sanitize_top_bar_color( $row['top_bar_bg_color'], '#e63333' ) : '#e63333'; $top_bar_text_color = isset( $row['top_bar_text_color'] ) ? WCUTP_Constants::sanitize_top_bar_color( $row['top_bar_text_color'], '#ededed' ) : '#ededed'; $top_bar_font_family = isset( $row['top_bar_font_family'] ) ? WCUTP_Constants::sanitize_top_bar_font( $row['top_bar_font_family'] ) : 'Lato'; $top_bar_font_size = isset( $row['top_bar_font_size'] ) ? WCUTP_Constants::sanitize_top_bar_font_size( $row['top_bar_font_size'] ) : 18; $revert_to_draft_after_end = ! empty( $row['revert_to_draft_after_end'] ); if ( $revert_to_draft_after_end && ! $this->campaign_has_draft_products( $product_ids ) ) { $revert_to_draft_after_end = false; } $auto_published_product_ids = isset( $row['auto_published_product_ids'] ) ? $this->sanitize_id_input( $row['auto_published_product_ids'] ) : array(); $lifecycle_state = isset( $row['lifecycle_state'] ) ? sanitize_key( (string) $row['lifecycle_state'] ) : 'pending'; if ( ! in_array( $lifecycle_state, array( 'pending', 'active', 'ended' ), true ) ) { $lifecycle_state = 'pending'; } return array( 'id' => $id, 'name' => $name, 'start_at' => $start_at, 'end_at' => $end_at, 'timezone' => $timezone, 'tier_slugs' => $tier_slugs, 'discount_type' => $discount_type, 'discount_value' => $discount_value, 'discount_mode' => $discount_mode, 'enable_categories' => $enable_categories, 'enable_products' => $enable_products, 'category_ids' => $category_ids, 'product_ids' => $product_ids, 'top_bar_text' => $top_bar_text, 'top_bar_bg_color' => $top_bar_bg_color, 'top_bar_text_color' => $top_bar_text_color, 'top_bar_font_family' => $top_bar_font_family, 'top_bar_font_size' => $top_bar_font_size, 'revert_to_draft_after_end' => $revert_to_draft_after_end, 'auto_published_product_ids' => $auto_published_product_ids, 'lifecycle_state' => $lifecycle_state, 'temp_category_id' => isset( $row['temp_category_id'] ) ? absint( $row['temp_category_id'] ) : 0, ); } /** * Preserve runtime campaign fields when editing from admin form. * * @param array $campaign Sanitized campaign row. * @param array $existing Existing campaign row. * @return array */ private function hydrate_campaign_runtime_state( $campaign, $existing ) { $campaign = is_array( $campaign ) ? $campaign : array(); $existing = is_array( $existing ) ? $existing : array(); if ( empty( $campaign ) ) { return $campaign; } if ( ! isset( $campaign['auto_published_product_ids'] ) && isset( $existing['auto_published_product_ids'] ) ) { $campaign['auto_published_product_ids'] = $this->sanitize_id_input( $existing['auto_published_product_ids'] ); } if ( ! isset( $campaign['lifecycle_state'] ) && isset( $existing['lifecycle_state'] ) ) { $campaign['lifecycle_state'] = sanitize_key( (string) $existing['lifecycle_state'] ); } if ( ! isset( $campaign['revert_to_draft_after_end'] ) && isset( $existing['revert_to_draft_after_end'] ) ) { $campaign['revert_to_draft_after_end'] = ! empty( $existing['revert_to_draft_after_end'] ); } if ( empty( $campaign['auto_published_product_ids'] ) || ! is_array( $campaign['auto_published_product_ids'] ) ) { $campaign['auto_published_product_ids'] = array(); } if ( empty( $campaign['lifecycle_state'] ) || ! in_array( $campaign['lifecycle_state'], array( 'pending', 'active', 'ended' ), true ) ) { $campaign['lifecycle_state'] = 'pending'; } return $campaign; } /** * Product label for campaign product selectors. * * @param WC_Product $product Product object. * @return string */ private function get_campaign_product_option_label( $product ) { if ( ! $product instanceof WC_Product ) { return ''; } $label = $product->get_formatted_name(); $status = get_post_status( $product->get_id() ); if ( 'draft' === $status ) { $label .= ' (' . esc_html__( 'In-Draft', 'codem-rewards-engine' ) . ')'; } return $label; } /** * Whether selected campaign products include at least one draft product. * * @param array $product_ids Product IDs. * @return bool */ private function campaign_has_draft_products( $product_ids ) { $product_ids = $this->normalize_campaign_product_ids_for_categories( $product_ids ); foreach ( $product_ids as $product_id ) { if ( 'draft' === get_post_status( $product_id ) ) { return true; } } return false; } /** * Mark draft products in WooCommerce product search JSON response. * * @param array $products Product result map. * @return array */ public function append_draft_badge_to_search_products( $products ) { $products = is_array( $products ) ? $products : array(); foreach ( $products as $product_id => $label ) { $resolved_id = absint( is_numeric( $product_id ) ? $product_id : 0 ); if ( $resolved_id <= 0 ) { continue; } $status = ''; $product = wc_get_product( $resolved_id ); if ( $product instanceof WC_Product ) { $status = (string) $product->get_status(); } if ( '' === $status ) { $status = (string) get_post_status( $resolved_id ); } if ( 'draft' === $status && false === strpos( (string) $label, '(In-Draft)' ) ) { $products[ $product_id ] = sprintf( '%s (%s)', wp_strip_all_tags( (string) $label ), esc_html__( 'In-Draft', 'codem-rewards-engine' ) ); } } return $products; } /** * Sync temporary campaign category for multi-product campaigns. * * @param array $campaign Campaign row. * @param array $existing Existing saved campaign row. * @return array */ private function sync_campaign_temp_category( $campaign, $existing ) { $campaign = is_array( $campaign ) ? $campaign : array(); $existing = is_array( $existing ) ? $existing : array(); $existing_term = isset( $existing['temp_category_id'] ) ? absint( $existing['temp_category_id'] ) : 0; $existing_products = isset( $existing['product_ids'] ) && is_array( $existing['product_ids'] ) ? array_map( 'absint', $existing['product_ids'] ) : array(); $product_ids = isset( $campaign['product_ids'] ) && is_array( $campaign['product_ids'] ) ? array_map( 'absint', $campaign['product_ids'] ) : array(); $existing_products = $this->normalize_campaign_product_ids_for_categories( $existing_products ); $product_ids = $this->normalize_campaign_product_ids_for_categories( $product_ids ); $enable_products = ! empty( $campaign['enable_products'] ); if ( ! $enable_products || count( $product_ids ) < 2 ) { if ( $existing_term > 0 ) { foreach ( $existing_products as $old_product_id ) { wp_remove_object_terms( $old_product_id, array( $existing_term ), 'product_cat' ); } wp_delete_term( $existing_term, 'product_cat' ); } $campaign['temp_category_id'] = 0; return $campaign; } $term_id = $existing_term; $slug = sanitize_title( $campaign['name'] . '-' . $campaign['id'] ); $name = sanitize_text_field( (string) $campaign['name'] ); if ( $term_id > 0 ) { $updated = wp_update_term( $term_id, 'product_cat', array( 'name' => $name, 'slug' => $slug, ) ); if ( is_wp_error( $updated ) ) { $term_id = 0; } } if ( $term_id <= 0 ) { // Temporary category is created by lifecycle cron at campaign start. $campaign['temp_category_id'] = 0; return $campaign; } if ( $term_id > 0 && ! empty( $product_ids ) ) { $removed_products = array_diff( $existing_products, $product_ids ); foreach ( $removed_products as $removed_product_id ) { wp_remove_object_terms( $removed_product_id, array( $term_id ), 'product_cat' ); } foreach ( $product_ids as $product_id ) { wp_set_object_terms( $product_id, array( $term_id ), 'product_cat', true ); } } $campaign['temp_category_id'] = $term_id; return $campaign; } /** * Normalize campaign product IDs for taxonomy assignment. * Variations are converted to parent product IDs. * * @param array $product_ids Product/variation IDs. * @return array */ private function normalize_campaign_product_ids_for_categories( $product_ids ) { $product_ids = is_array( $product_ids ) ? array_map( 'absint', $product_ids ) : array(); $product_ids = array_values( array_filter( $product_ids ) ); $normalized = array(); foreach ( $product_ids as $product_id ) { $post_type = get_post_type( $product_id ); if ( 'product_variation' === $post_type ) { $parent_id = wp_get_post_parent_id( $product_id ); if ( $parent_id > 0 ) { $normalized[] = absint( $parent_id ); } continue; } if ( 'product' === $post_type ) { $normalized[] = absint( $product_id ); } } return array_values( array_unique( $normalized ) ); } /** * Delete temporary campaign category if it exists. * * @param array $campaign Campaign data. * @return void */ private function cleanup_campaign_temp_category( $campaign ) { if ( ! is_array( $campaign ) ) { return; } $term_id = isset( $campaign['temp_category_id'] ) ? absint( $campaign['temp_category_id'] ) : 0; if ( $term_id <= 0 ) { return; } wp_delete_term( $term_id, 'product_cat' ); } /** * Update or append campaign row in option storage. * * @param array $campaign Campaign row. * @return void */ private function upsert_campaign_option_row( $campaign ) { if ( ! is_array( $campaign ) ) { return; } $campaign_id = isset( $campaign['id'] ) ? sanitize_key( (string) $campaign['id'] ) : ''; if ( '' === $campaign_id ) { return; } $campaigns = get_option( WCUTP_Constants::OPTION_CAMPAIGNS, array() ); $campaigns = is_array( $campaigns ) ? $campaigns : array(); $updated = false; foreach ( $campaigns as $index => $row ) { if ( ! is_array( $row ) ) { continue; } $row_id = isset( $row['id'] ) ? sanitize_key( (string) $row['id'] ) : ''; if ( $row_id === $campaign_id ) { $campaigns[ $index ] = $campaign; $updated = true; break; } } if ( ! $updated ) { $campaigns[] = $campaign; } update_option( WCUTP_Constants::OPTION_CAMPAIGNS, array_values( $campaigns ) ); } /** * Find campaign temporary category by deterministic slug. * * @param array $campaign Campaign row. * @return int */ private function find_campaign_temp_category_id( $campaign ) { if ( ! is_array( $campaign ) ) { return 0; } $name = isset( $campaign['name'] ) ? sanitize_text_field( (string) $campaign['name'] ) : ''; $id = isset( $campaign['id'] ) ? sanitize_key( (string) $campaign['id'] ) : ''; if ( '' === $name || '' === $id ) { return 0; } $slug = sanitize_title( $name . '-' . $id ); if ( '' === $slug ) { return 0; } $term = get_term_by( 'slug', $slug, 'product_cat' ); if ( ! ( $term instanceof WP_Term ) ) { return 0; } return absint( $term->term_id ); } /** * Validate datetime in Y-m-d H:i format. * * @param string $value Datetime value. * @return bool */ private function is_valid_datetime_ymd_hi( $value ) { $value = (string) $value; $dt = DateTimeImmutable::createFromFormat( 'Y-m-d H:i', $value ); return $dt instanceof DateTimeImmutable; } /** * Convert input into unique integer IDs. * * @param mixed $input Array or CSV input. * @return array */ private function sanitize_id_input( $input ) { $values = array(); if ( is_array( $input ) ) { $values = $input; } elseif ( is_string( $input ) ) { $parts = preg_split( '/[\\s,]+/', $input ); $values = is_array( $parts ) ? $parts : array(); } $ids = array_map( 'absint', $values ); $ids = array_values( array_filter( $ids ) ); return array_values( array_unique( $ids ) ); } /** * Record audit log entries with actor and timestamp. * * @param string $module Plugin module/tab. * @param string $action Action label. * @param array $entries Diff entry rows. * @return void */ private function record_audit_log_entries( $module, $action, $entries ) { $module = sanitize_text_field( (string) $module ); $action = sanitize_text_field( (string) $action ); $entries = is_array( $entries ) ? $entries : array(); if ( empty( $entries ) ) { $entries = array( array( 'field' => 'system', 'old' => '', 'new' => 'no_field_change', ), ); } $current_user = wp_get_current_user(); $user_id = (int) get_current_user_id(); $user_login = ( $current_user instanceof WP_User && ! empty( $current_user->user_login ) ) ? sanitize_text_field( (string) $current_user->user_login ) : ''; $user_email = ( $current_user instanceof WP_User && ! empty( $current_user->user_email ) ) ? sanitize_email( (string) $current_user->user_email ) : ''; $timestamp = current_time( 'mysql' ); $rows = array(); foreach ( $entries as $entry ) { if ( ! is_array( $entry ) ) { continue; } $field = isset( $entry['field'] ) ? sanitize_text_field( (string) $entry['field'] ) : ''; $old = isset( $entry['old'] ) ? $this->audit_value_to_string( $entry['old'] ) : ''; $new = isset( $entry['new'] ) ? $this->audit_value_to_string( $entry['new'] ) : ''; $rows[] = array( 'time' => $timestamp, 'user_id' => $user_id, 'user_login' => $user_login, 'user_email' => $user_email, 'module' => $module, 'action' => $action, 'field' => $field, 'old_value' => $old, 'new_value' => $new, ); } if ( empty( $rows ) ) { return; } $this->migrate_legacy_option_audit_logs_to_files(); $this->append_audit_logs_to_files( $rows ); } /** * Append audit rows into monthly JSONL files. * * @param array> $rows Audit rows. * @return void */ private function append_audit_logs_to_files( $rows ) { $rows = is_array( $rows ) ? $rows : array(); if ( empty( $rows ) ) { return; } $dir = $this->get_audit_log_directory_path(); if ( '' === $dir ) { return; } foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $time_str = isset( $row['time'] ) ? sanitize_text_field( (string) $row['time'] ) : ''; $file = $this->get_audit_log_file_path_for_datetime( $time_str ); if ( '' === $file ) { $file = trailingslashit( $dir ) . 'audit-' . wp_date( 'Y-m', current_time( 'timestamp' ) ) . '.log'; } $payload = wp_json_encode( $row ); if ( ! is_string( $payload ) || '' === $payload ) { continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Appends one audit row inside the protected uploads directory. WP_Filesystem can request FTP credentials during a settings save. $written = @file_put_contents( $file, $payload . PHP_EOL, FILE_APPEND | LOCK_EX ); if ( false === $written ) { if ( function_exists( 'wc_get_logger' ) ) { $logger = wc_get_logger(); $logger->error( 'Failed to write audit log file: ' . $file, array( 'source' => 'wcutp' ) ); } } } } /** * Load audit rows from files within selected timestamp range. * * @param int $start_ts Start timestamp. * @param int $end_ts End timestamp. * @return array> */ private function read_audit_logs_from_files_in_range( $start_ts, $end_ts ) { $start_ts = absint( $start_ts ); $end_ts = absint( $end_ts ); if ( $start_ts <= 0 || $end_ts <= 0 || $end_ts < $start_ts ) { return array(); } $this->migrate_legacy_option_audit_logs_to_files(); $files = $this->get_audit_log_files_in_range( $start_ts, $end_ts ); if ( empty( $files ) ) { return array(); } $rows = array(); foreach ( $files as $file ) { if ( ! is_string( $file ) || '' === $file || ! file_exists( $file ) ) { continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reads one local audit-log file a line at a time. $handle = @fopen( $file, 'r' ); if ( false === $handle ) { continue; } while ( ! feof( $handle ) ) { $line = fgets( $handle ); if ( false === $line ) { continue; } $line = trim( (string) $line ); if ( '' === $line ) { continue; } $row = json_decode( $line, true ); if ( ! is_array( $row ) ) { continue; } $time_str = isset( $row['time'] ) ? sanitize_text_field( (string) $row['time'] ) : ''; $time_ts = $this->parse_audit_log_time_to_timestamp( $time_str ); if ( false === $time_ts || $time_ts < $start_ts || $time_ts > $end_ts ) { continue; } $rows[] = $row; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the local audit-log file opened above. fclose( $handle ); } usort( $rows, array( $this, 'sort_audit_log_rows_desc' ) ); return $rows; } /** * Delete audit rows from files within selected range. * * @param int $start_ts Start timestamp. * @param int $end_ts End timestamp. * @return int */ private function delete_audit_logs_in_range_from_files( $start_ts, $end_ts ) { $start_ts = absint( $start_ts ); $end_ts = absint( $end_ts ); if ( $start_ts <= 0 || $end_ts <= 0 || $end_ts < $start_ts ) { return 0; } $this->migrate_legacy_option_audit_logs_to_files(); $files = $this->get_audit_log_files_in_range( $start_ts, $end_ts ); $deleted = 0; foreach ( $files as $file ) { $deleted += $this->rewrite_audit_log_file_excluding_range( $file, $start_ts, $end_ts ); } return $deleted; } /** * Rewrite one log file and drop lines inside timestamp window. * * @param string $file File path. * @param int $start_ts Start timestamp. * @param int $end_ts End timestamp. * @return int */ private function rewrite_audit_log_file_excluding_range( $file, $start_ts, $end_ts ) { $file = is_string( $file ) ? $file : ''; if ( '' === $file || ! file_exists( $file ) ) { return 0; } $kept_lines = array(); $deleted = 0; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reads one local audit-log file so rows in the selected range can be removed. $handle = @fopen( $file, 'r' ); if ( false === $handle ) { return 0; } while ( ! feof( $handle ) ) { $line = fgets( $handle ); if ( false === $line ) { continue; } $raw = trim( (string) $line ); if ( '' === $raw ) { continue; } $row = json_decode( $raw, true ); if ( ! is_array( $row ) ) { $kept_lines[] = $raw; continue; } $time_str = isset( $row['time'] ) ? sanitize_text_field( (string) $row['time'] ) : ''; $time_ts = $this->parse_audit_log_time_to_timestamp( $time_str ); if ( false !== $time_ts && $time_ts >= $start_ts && $time_ts <= $end_ts ) { ++$deleted; continue; } $kept_lines[] = $raw; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the local audit-log file opened above. fclose( $handle ); if ( empty( $kept_lines ) ) { wp_delete_file( $file ); return $deleted; } $payload = implode( PHP_EOL, $kept_lines ) . PHP_EOL; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Rewrites one audit file inside the protected uploads directory. WP_Filesystem can request FTP credentials during a settings save. @file_put_contents( $file, $payload, LOCK_EX ); return $deleted; } /** * Return file paths for months intersecting selected range. * * @param int $start_ts Start timestamp. * @param int $end_ts End timestamp. * @return array */ private function get_audit_log_files_in_range( $start_ts, $end_ts ) { $dir = $this->get_audit_log_directory_path(); if ( '' === $dir || ! is_dir( $dir ) ) { return array(); } try { $cursor = new DateTimeImmutable( '@' . $start_ts ); $cursor = $cursor->setTimezone( wp_timezone() )->modify( 'first day of this month 00:00:00' ); $end = new DateTimeImmutable( '@' . $end_ts ); $end = $end->setTimezone( wp_timezone() )->modify( 'first day of this month 00:00:00' ); } catch ( Exception $e ) { return array(); } $files = array(); while ( $cursor <= $end ) { $file = trailingslashit( $dir ) . 'audit-' . $cursor->format( 'Y-m' ) . '.log'; if ( file_exists( $file ) ) { $files[] = $file; } $cursor = $cursor->modify( '+1 month' ); } return $files; } /** * Get monthly file path based on audit row datetime. * * @param string $time_str Mysql datetime. * @return string */ private function get_audit_log_file_path_for_datetime( $time_str ) { $dir = $this->get_audit_log_directory_path(); if ( '' === $dir ) { return ''; } $time_ts = $this->parse_audit_log_time_to_timestamp( $time_str ); if ( false === $time_ts ) { $time_ts = (int) current_time( 'timestamp' ); } return trailingslashit( $dir ) . 'audit-' . wp_date( 'Y-m', $time_ts, wp_timezone() ) . '.log'; } /** * Get and ensure the protected audit log directory. * * Logs stay in uploads/{plugin-slug}, which WordPress allows, and the * directory is closed to direct web access. * * @return string */ private function get_audit_log_directory_path() { $uploads = wp_upload_dir(); if ( ! is_array( $uploads ) || empty( $uploads['basedir'] ) ) { return ''; } $base = trailingslashit( (string) $uploads['basedir'] ) . 'codem-rewards-engine'; if ( ! is_dir( $base ) && ! wp_mkdir_p( $base ) ) { return ''; } $this->protect_audit_log_directory( $base ); $this->migrate_legacy_audit_log_directory( $base, (string) $uploads['basedir'] ); return $base; } /** * Block direct HTTP access to audit logs. * * Apache uses .htaccess, IIS uses web.config, and index.php stops * directory listings where those files are ignored. * * @param string $dir Audit log directory. * @return void */ private function protect_audit_log_directory( $dir ) { $dir = trailingslashit( (string) $dir ); if ( '' === $dir || ! is_dir( untrailingslashit( $dir ) ) ) { return; } $guards = array( 'index.php' => " '', '.htaccess' => "Options -Indexes\n\n\tRequire all denied\n\n\n\tDeny from all\n\n", 'web.config' => "\n\n\t\n\t\t\n\t\t\t\n\t\t\n\t\n\n", ); foreach ( $guards as $name => $contents ) { $file = $dir . $name; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Compares an access-guard file before rewriting it. $current = file_exists( $file ) ? file_get_contents( $file ) : false; if ( is_string( $current ) && $current === $contents ) { continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Writes access guards into the plugin uploads directory. WP_Filesystem can request FTP credentials during a settings save. @file_put_contents( $file, $contents, LOCK_EX ); } } /** * Move logs out of the old public uploads/wcutp-logs directory. * * @param string $new_dir Protected directory. * @param string $uploads_dir Uploads basedir. * @return void */ private function migrate_legacy_audit_log_directory( $new_dir, $uploads_dir ) { $legacy = trailingslashit( (string) $uploads_dir ) . 'wcutp-logs'; $new_dir = trailingslashit( (string) $new_dir ); if ( ! is_dir( $legacy ) || $legacy === $new_dir ) { return; } $files = glob( $legacy . '/audit-*.log' ); $files = is_array( $files ) ? $files : array(); foreach ( $files as $file ) { $name = basename( (string) $file ); if ( ! preg_match( '/^audit-\d{4}-\d{2}\.log$/', $name ) ) { continue; } $this->import_plaintext_audit_log( $file, $new_dir . $name ); } $leftovers = glob( trailingslashit( $legacy ) . '*' ); $leftovers = is_array( $leftovers ) ? $leftovers : array(); foreach ( $leftovers as $leftover ) { if ( is_file( $leftover ) ) { wp_delete_file( $leftover ); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removes the old public audit directory after its files are moved. @rmdir( $legacy ); } /** * Copy JSONL rows into the protected audit directory. * * @param string $source Legacy log path. * @param string $dest Protected log path. * @return void */ private function import_plaintext_audit_log( $source, $dest ) { if ( $source === $dest ) { return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads one legacy audit file so it can be moved into the protected directory. $body = file_get_contents( $source ); if ( is_string( $body ) && '' !== trim( $body ) ) { $chunk = rtrim( $body ) . PHP_EOL; $flags = file_exists( $dest ) ? FILE_APPEND | LOCK_EX : LOCK_EX; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Writes migrated audit rows into the protected directory. @file_put_contents( $dest, $chunk, $flags ); } wp_delete_file( $source ); } /** * Sort callback for audit rows by newest first. * * @param array $a Row A. * @param array $b Row B. * @return int */ private function sort_audit_log_rows_desc( $a, $b ) { $time_a = isset( $a['time'] ) ? $this->parse_audit_log_time_to_timestamp( (string) $a['time'] ) : false; $time_b = isset( $b['time'] ) ? $this->parse_audit_log_time_to_timestamp( (string) $b['time'] ) : false; $time_a = false === $time_a ? 0 : (int) $time_a; $time_b = false === $time_b ? 0 : (int) $time_b; if ( $time_a === $time_b ) { return 0; } return ( $time_a > $time_b ) ? -1 : 1; } /** * One-time migration from legacy DB option logs to files. * * @return void */ private function migrate_legacy_option_audit_logs_to_files() { $migrated_flag = get_option( 'wcutp_audit_logs_migrated_to_files', false ); if ( $migrated_flag ) { return; } $legacy = get_option( WCUTP_Constants::OPTION_AUDIT_LOGS, array() ); $legacy = is_array( $legacy ) ? $legacy : array(); if ( ! empty( $legacy ) ) { $this->append_audit_logs_to_files( $legacy ); } delete_option( WCUTP_Constants::OPTION_AUDIT_LOGS ); update_option( 'wcutp_audit_logs_migrated_to_files', 1, false ); } /** * Build field-level diff entries for audit logging. * * @param mixed $old_value Old value. * @param mixed $new_value New value. * @param string $path Field path. * @return array> */ private function build_audit_diff_entries( $old_value, $new_value, $path = '' ) { $path = (string) $path; if ( is_array( $old_value ) && is_array( $new_value ) ) { if ( ! $this->is_assoc_array( $old_value ) && ! $this->is_assoc_array( $new_value ) ) { if ( $this->audit_value_to_string( $old_value ) === $this->audit_value_to_string( $new_value ) ) { return array(); } return array( array( 'field' => $path, 'old' => $old_value, 'new' => $new_value, ), ); } $entries = array(); $all_keys = array_unique( array_merge( array_keys( $old_value ), array_keys( $new_value ) ) ); sort( $all_keys ); foreach ( $all_keys as $key ) { $sub_old = array_key_exists( $key, $old_value ) ? $old_value[ $key ] : null; $sub_new = array_key_exists( $key, $new_value ) ? $new_value[ $key ] : null; $sub_path = '' !== $path ? $path . '.' . (string) $key : (string) $key; $entries = array_merge( $entries, $this->build_audit_diff_entries( $sub_old, $sub_new, $sub_path ) ); } return $entries; } $old_normalized = $this->audit_value_to_string( $old_value ); $new_normalized = $this->audit_value_to_string( $new_value ); if ( $old_normalized === $new_normalized ) { return array(); } return array( array( 'field' => $path, 'old' => $old_value, 'new' => $new_value, ), ); } /** * Convert value to compact audit-safe string. * * @param mixed $value Value. * @return string */ private function audit_value_to_string( $value ) { if ( null === $value ) { return 'null'; } if ( is_bool( $value ) ) { return $value ? 'true' : 'false'; } if ( is_array( $value ) || is_object( $value ) ) { $encoded = wp_json_encode( $value ); $encoded = is_string( $encoded ) ? $encoded : ''; return $this->truncate_audit_value( $encoded ); } $string = is_scalar( $value ) ? (string) $value : ''; return $this->truncate_audit_value( wp_strip_all_tags( $string ) ); } /** * Limit audit value length. * * @param string $value Value string. * @return string */ private function truncate_audit_value( $value ) { $value = sanitize_text_field( (string) $value ); if ( strlen( $value ) > 500 ) { return substr( $value, 0, 497 ) . '...'; } return $value; } /** * Check if array is associative. * * @param array $array Input array. * @return bool */ private function is_assoc_array( $array ) { if ( ! is_array( $array ) ) { return false; } return array_keys( $array ) !== range( 0, count( $array ) - 1 ); } /** * Check if any existing tier status toggled between active/inactive. * * @param array $previous Previous tiers. * @param array $current Current tiers. * @return bool */ private function has_tier_status_toggle( $previous, $current ) { $previous = is_array( $previous ) ? $previous : array(); $current = is_array( $current ) ? $current : array(); $prev_map = array(); foreach ( $previous as $row ) { if ( ! is_array( $row ) ) { continue; } $slug = isset( $row['slug'] ) ? sanitize_key( (string) $row['slug'] ) : ''; if ( '' === $slug ) { continue; } $status = isset( $row['status'] ) ? sanitize_key( (string) $row['status'] ) : 'inactive'; $prev_map[ $slug ] = in_array( $status, array( 'active', 'inactive' ), true ) ? $status : 'inactive'; } foreach ( $current as $row ) { if ( ! is_array( $row ) ) { continue; } $slug = isset( $row['slug'] ) ? sanitize_key( (string) $row['slug'] ) : ''; if ( '' === $slug || ! isset( $prev_map[ $slug ] ) ) { continue; } $status = isset( $row['status'] ) ? sanitize_key( (string) $row['status'] ) : 'inactive'; $status = in_array( $status, array( 'active', 'inactive' ), true ) ? $status : 'inactive'; if ( $status !== $prev_map[ $slug ] ) { return true; } } return false; } /** * Parse audit start date to timestamp (inclusive day start). * * @param string $date Date in Y-m-d. * @return int */ private function parse_audit_filter_date_start( $date ) { $date = sanitize_text_field( (string) $date ); $date = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ? $date : wp_date( 'Y-m-01', current_time( 'timestamp' ) ); try { $dt = new DateTimeImmutable( $date . ' 00:00:00', wp_timezone() ); return $dt->getTimestamp(); } catch ( Exception $e ) { return (int) current_time( 'timestamp' ); } } /** * Parse audit end date to timestamp (inclusive day end). * * @param string $date Date in Y-m-d. * @return int */ private function parse_audit_filter_date_end( $date ) { $date = sanitize_text_field( (string) $date ); $date = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ? $date : wp_date( 'Y-m-t', current_time( 'timestamp' ) ); try { $dt = new DateTimeImmutable( $date . ' 23:59:59', wp_timezone() ); return $dt->getTimestamp(); } catch ( Exception $e ) { return (int) current_time( 'timestamp' ); } } /** * Parse stored audit time into timestamp in site timezone. * * @param string $time_str Mysql datetime string. * @return int|false */ private function parse_audit_log_time_to_timestamp( $time_str ) { $time_str = sanitize_text_field( (string) $time_str ); if ( '' === $time_str ) { return false; } $dt = DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', $time_str, wp_timezone() ); if ( false === $dt ) { return false; } return $dt->getTimestamp(); } /** * Format audit date/time using WordPress admin-selected formats. * * @param string $time_str Mysql datetime string. * @return string */ private function format_audit_log_datetime_for_display( $time_str ) { $timestamp = $this->parse_audit_log_time_to_timestamp( $time_str ); if ( false === $timestamp ) { return '—'; } $date_format = get_option( 'date_format', 'F j, Y' ); $time_format = get_option( 'time_format', 'g:i a' ); $format = trim( $date_format . ' ' . $time_format ); return wp_date( $format, $timestamp, wp_timezone() ); } /** * Render settings saved notice. * * @return void */ private function render_admin_notice() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only notice code added by this plugin after a redirect. $notice = isset( $_GET['wcutp_notice'] ) ? sanitize_key( wp_unslash( $_GET['wcutp_notice'] ) ) : ''; if ( '' === $notice ) { return; } if ( 'tier_range_error' === $notice ) { $error_message = get_transient( 'wcutp_tiers_error' ); delete_transient( 'wcutp_tiers_error' ); $error_message = is_string( $error_message ) && '' !== $error_message ? $error_message : __( 'Tier spend ranges must not overlap.', 'codem-rewards-engine' ); ?>

esc_html__( 'Customer tiers updated successfully.', 'codem-rewards-engine' ), 'settings_saved' => esc_html__( 'Global settings updated successfully.', 'codem-rewards-engine' ), 'tier_discounts_saved' => esc_html__( 'Tier discounts updated successfully.', 'codem-rewards-engine' ), 'discount_exclusions_saved' => esc_html__( 'Discount exclusions updated successfully.', 'codem-rewards-engine' ), 'access_restrictions_saved' => esc_html__( 'Access restrictions updated successfully.', 'codem-rewards-engine' ), 'birthday_coupons_saved' => esc_html__( 'Birthday coupon settings updated successfully.', 'codem-rewards-engine' ), 'campaigns_saved' => esc_html__( 'Campaign settings updated successfully.', 'codem-rewards-engine' ), 'lifetime_spend_user_not_found' => esc_html__( 'Customer not found. Please select a customer.', 'codem-rewards-engine' ), 'lifetime_spend_user_invalid_customer' => esc_html__( 'Selected user is not a customer.', 'codem-rewards-engine' ), 'lifetime_spend_user_recalculated' => esc_html__( 'Lifetime spend recalculated for the selected customer.', 'codem-rewards-engine' ), 'lifetime_spend_all_recalculated' => esc_html__( 'Lifetime spend recalculated for all customers.', 'codem-rewards-engine' ), 'lifetime_spend_all_recalculated_empty' => esc_html__( 'No customer users found to recalculate.', 'codem-rewards-engine' ), 'audit_logs_deleted' => esc_html__( 'Audit logs in the selected date range were deleted.', 'codem-rewards-engine' ), 'audit_logs_delete_empty' => esc_html__( 'No audit logs were found in the selected date range.', 'codem-rewards-engine' ), ); if ( ! isset( $messages[ $notice ] ) ) { return; } ?>

$this->menu_slug, 'tab' => sanitize_key( $tab ), 'wcutp_notice' => sanitize_key( $notice ), ), admin_url( 'admin.php' ) ); wp_safe_redirect( $url ); exit; } }