auth = Qixit_Auth::get_instance(); $this->logger = Qixit_Logger::get_instance(); // Add admin menu add_action('admin_menu', array($this, 'add_admin_menu')); // Register settings add_action('admin_init', array($this, 'register_settings')); // Enqueue admin styles and scripts add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_assets')); // Show/manage Qixit ID in WordPress Users screens. add_filter('manage_users_columns', array($this, 'add_qixit_user_column')); add_filter('manage_users_custom_column', array($this, 'render_qixit_user_column'), 10, 3); add_action('show_user_profile', array($this, 'render_qixit_user_profile_field')); add_action('edit_user_profile', array($this, 'render_qixit_user_profile_field')); add_action('personal_options_update', array($this, 'save_qixit_user_profile_field')); add_action('edit_user_profile_update', array($this, 'save_qixit_user_profile_field')); add_action('admin_init', array($this, 'handle_author_approval_actions')); add_action('admin_notices', array($this, 'render_author_approval_notice')); add_action('admin_notices', array($this, 'render_pending_submission_queue_notice')); add_action('admin_notices', array($this, 'render_global_paid_comments_sync_notice')); add_action('update_option_qixit_enable_paid_comments_global', array($this, 'handle_global_paid_comments_option_change'), 10, 2); add_action('update_option_qixit_global_paid_comments_price', array($this, 'handle_global_paid_comments_price_change'), 10, 2); } /** * Add admin menu */ public function add_admin_menu() { add_menu_page( __('2WayMicropay Settings', '2waymicropay'), __('2WayMicropay', '2waymicropay'), 'manage_options', 'qixit-settings', array($this, 'render_settings_page'), 'dashicons-money-alt', 80 ); add_submenu_page( 'qixit-settings', __('Settings', '2waymicropay'), __('Settings', '2waymicropay'), 'manage_options', 'qixit-settings', array($this, 'render_settings_page') ); add_submenu_page( 'qixit-settings', __('Customers', '2waymicropay'), __('๐Ÿ‘ฅ Customers', '2waymicropay'), 'manage_options', 'qixit-customers', array($this, 'render_customers_page') ); add_submenu_page( 'qixit-settings', __('Instructions', '2waymicropay'), __('๐Ÿ“– Instructions', '2waymicropay'), 'manage_options', 'qixit-instructions', array($this, 'render_instructions_page') ); } /** * Register settings */ public function register_settings() { // General Settings Section add_settings_section( 'qixit_general_section', __('General Settings', '2waymicropay'), array($this, 'render_general_section'), 'qixit-settings' ); // Default Commercial Category (required) register_setting('qixit_settings', 'qixit_default_commercial_category', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '' )); register_setting('qixit_settings', 'qixit_enable_logging', array( 'type' => 'boolean', 'sanitize_callback' => array($this, 'sanitize_checkbox'), 'default' => false )); // Site API Key (required for production - usage tracking and rate limiting) add_settings_section( 'qixit_developer_section', __('Site API Key', '2waymicropay'), array($this, 'render_developer_section'), 'qixit-settings' ); register_setting('qixit_settings', 'qixit_site_api_key', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '' )); // Keep old option for backwards compatibility register_setting('qixit_settings', 'qixit_developer_api_key', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '' )); // Author commerce settings (v4.0) register_setting('qixit_settings', 'qixit_enable_author_commerce', array( 'type' => 'boolean', 'sanitize_callback' => array($this, 'sanitize_checkbox'), 'default' => false )); register_setting('qixit_settings', 'qixit_author_onboarding_fee', array( 'type' => 'number', 'sanitize_callback' => array($this, 'sanitize_decimal'), 'default' => 0 )); register_setting('qixit_settings', 'qixit_author_publish_fee', array( 'type' => 'number', 'sanitize_callback' => array($this, 'sanitize_decimal'), 'default' => 0 )); register_setting('qixit_settings', 'qixit_author_default_split_percent', array( 'type' => 'integer', 'sanitize_callback' => array($this, 'sanitize_percentage'), 'default' => 0 )); register_setting('qixit_settings', 'qixit_author_require_moderation', array( 'type' => 'boolean', 'sanitize_callback' => array($this, 'sanitize_checkbox'), 'default' => true )); register_setting('qixit_settings', 'qixit_author_terms_text', array( 'type' => 'string', 'sanitize_callback' => array($this, 'sanitize_author_terms_html'), 'default' => '' )); register_setting('qixit_settings', 'qixit_author_signup_email_mode', array( 'type' => 'string', 'sanitize_callback' => array($this, 'sanitize_signup_email_mode'), 'default' => 'required' )); register_setting('qixit_settings', 'qixit_author_immediate_access_redirect_url', array( 'type' => 'string', 'sanitize_callback' => array($this, 'sanitize_pending_review_redirect_url'), 'default' => '' )); register_setting('qixit_settings', 'qixit_share_comment_revenue_with_authors', array( 'type' => 'boolean', 'sanitize_callback' => array($this, 'sanitize_checkbox'), 'default' => false )); register_setting('qixit_settings', 'qixit_comment_author_share_percent', array( 'type' => 'integer', 'sanitize_callback' => array($this, 'sanitize_percentage'), 'default' => 0 )); register_setting('qixit_settings', 'qixit_enable_paid_comments_global', array( 'type' => 'boolean', 'sanitize_callback' => array($this, 'sanitize_checkbox'), 'default' => false )); register_setting('qixit_settings', 'qixit_global_paid_comments_price', array( 'type' => 'number', 'sanitize_callback' => array($this, 'sanitize_decimal'), 'default' => 0.05 )); register_setting('qixit_settings', 'qixit_enable_forwarder_commissions', array( 'type' => 'boolean', 'sanitize_callback' => array($this, 'sanitize_checkbox'), 'default' => false )); register_setting('qixit_settings', 'qixit_forwarder_percent', array( 'type' => 'integer', 'sanitize_callback' => array($this, 'sanitize_percentage'), 'default' => 0 )); } /** * Render Site API key section description */ public function render_developer_section() { echo '

' . esc_html__('Required: Enter your Site API Key for production usage tracking, rate limiting, and analytics.', '2waymicropay') . '

'; echo '

' . esc_html__('Create a Site API Key in the Products Portal:', '2waymicropay') . ' products.qixit.com/api-keys

'; echo '

' . esc_html__('This key identifies your website to the 2WayMicropay platform and enables per-site usage tracking.', '2waymicropay') . '

'; } /** * Sanitize password (encrypt before storing) */ public function sanitize_password($password) { $current = get_option('qixit_vendor_password'); if (empty($password)) { // Preserve existing encrypted password when settings are saved // without typing a new password into the field. return !empty($current) ? $current : ''; } // Don't re-encrypt if it's already encrypted if ($password === $current) { return $current; } return $this->auth->encrypt_password($password); } /** * Sanitize checkbox settings using WordPress REST boolean sanitizer. */ public function sanitize_checkbox($value) { if (function_exists('rest_sanitize_boolean')) { return rest_sanitize_boolean($value); } if (is_bool($value)) { return $value; } $normalized = strtolower(trim((string) $value)); return in_array($normalized, array('1', 'true', 'yes', 'on'), true); } /** * Sanitize decimal currency field. */ public function sanitize_decimal($value) { return max(0, round(floatval($value), 2)); } /** * Sanitize percentage field (0-100). */ public function sanitize_percentage($value) { $value = intval($value); if ($value < 0) { return 0; } if ($value > 100) { return 100; } return $value; } /** * Sanitize author signup email mode. * * @param string $value * @return string */ public function sanitize_signup_email_mode($value) { $allowed = array('required', 'optional', 'hidden'); $value = sanitize_text_field($value); return in_array($value, $allowed, true) ? $value : 'required'; } /** * Sanitize pending-review redirect URL. * * @param string $value * @return string */ public function sanitize_pending_review_redirect_url($value) { return esc_url_raw($value); } /** * Sanitize author terms allowing safe hyperlink markup. * * @param string $value * @return string */ public function sanitize_author_terms_html($value) { // Allow standard post content HTML so publisher-entered formatting // and links are preserved in [qixit_author_rights]. return wp_kses_post($value); } /** * Add Qixit ID column to users list. * * @param array $columns * @return array */ public function add_qixit_user_column($columns) { $columns['qixit_id'] = __('Qixit ID', '2waymicropay'); $columns['qixit_author_approval'] = __('Author Access', '2waymicropay'); return $columns; } /** * Render Qixit ID in users list. * * @param string $value * @param string $column_name * @param int $user_id * @return string */ public function render_qixit_user_column($value, $column_name, $user_id) { if ($column_name === 'qixit_author_approval') { $user = get_user_by('id', intval($user_id)); if (!$user) { return '—'; } if (in_array('contributor', (array)$user->roles, true)) { return esc_html__('Contributor (editor/admin approval required to publish)', '2waymicropay'); } if (in_array('author', (array)$user->roles, true)) { return esc_html__('Author (can publish)', '2waymicropay'); } return esc_html__('N/A', '2waymicropay'); } if ($column_name !== 'qixit_id') { return $value; } $qixit_id = ''; if (class_exists('Qixit_Author_Commerce')) { $qixit_id = Qixit_Author_Commerce::get_instance()->get_author_qixit_id(intval($user_id)); } return !empty($qixit_id) ? esc_html($qixit_id) : '—'; } /** * Render editable Qixit ID on user profile pages. * * @param WP_User $user * @return void */ public function render_qixit_user_profile_field($user) { if (!current_user_can('edit_user', $user->ID)) { return; } $qixit_id = ''; if (class_exists('Qixit_Author_Commerce')) { $qixit_id = Qixit_Author_Commerce::get_instance()->get_author_qixit_id(intval($user->ID)); } ?>

sync_author_identity(intval($user_id), $qixit_id, false); return; } update_user_meta($user_id, 'qixit_id', $qixit_id); update_user_meta($user_id, 'qixit_user_id', $qixit_id); update_user_meta($user_id, 'qixit_username', $qixit_id); } /** * Approve pending new-author account from Users admin. * * @return void */ public function handle_author_approval_actions() { if (!is_admin() || !current_user_can('manage_options')) { return; } $qixit_action = isset($_GET['qixit_action']) ? sanitize_key(wp_unslash($_GET['qixit_action'])) : ''; if ($qixit_action !== 'approve_author') { return; } $user_id = isset($_GET['user_id']) ? intval(wp_unslash($_GET['user_id'])) : 0; if ($user_id <= 0) { return; } if (!isset($_GET['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'qixit_approve_author_' . $user_id)) { return; } $user = get_user_by('id', $user_id); if (!$user) { return; } $user->set_role('author'); delete_user_meta($user_id, 'qixit_author_approval_pending'); update_user_meta($user_id, 'qixit_author_approved_at', current_time('mysql')); update_user_meta($user_id, 'qixit_author_approved_by', get_current_user_id()); if (class_exists('Qixit_Author_Commerce')) { $commerce = Qixit_Author_Commerce::get_instance(); $qixit_id = $commerce->get_author_qixit_id($user_id); $commerce->sync_author_identity($user_id, $qixit_id, true); } wp_safe_redirect(add_query_arg('qixit_author_approved', '1', admin_url('users.php'))); exit; } /** * Render admin notice after approval action. * * @return void */ public function render_author_approval_notice() { if (!is_admin() || !current_user_can('manage_options')) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only success flag used only for admin notice rendering. $author_approved = isset($_GET['qixit_author_approved']) ? sanitize_text_field(wp_unslash($_GET['qixit_author_approved'])) : ''; if ($author_approved !== '1') { return; } echo '

' . esc_html__('Author approved successfully. The user can now create and publish content.', '2waymicropay') . '

'; } /** * Render editor/admin notice when contributor submissions are pending review. * * @return void */ public function render_pending_submission_queue_notice() { if (!is_admin() || !current_user_can('edit_others_posts')) { return; } if (!boolval(get_option('qixit_enable_author_commerce', false))) { return; } if (!boolval(get_option('qixit_author_require_moderation', true))) { return; } $pending_posts = wp_count_posts('post'); $pending_count = isset($pending_posts->pending) ? intval($pending_posts->pending) : 0; if ($pending_count <= 0) { return; } $review_url = admin_url('edit.php?post_status=pending&post_type=post'); $post_label = _n('post', 'posts', $pending_count, '2waymicropay'); $message = sprintf( /* translators: 1: count of pending posts, 2: singular/plural label */ __('Contributor release queue: %1$d pending %2$s waiting for editor/admin approval.', '2waymicropay'), $pending_count, $post_label ); echo '

' . esc_html($message) . ' ' . esc_html__('Review Pending Posts', '2waymicropay') . '

'; } /** * Apply global paid-comments defaults when option is turned on. * * @param mixed $old_value * @param mixed $new_value * @return void */ public function handle_global_paid_comments_option_change($old_value, $new_value) { $was_enabled = boolval($old_value); $is_enabled = boolval($new_value); if ($was_enabled || !$is_enabled || !class_exists('Qixit_Comments')) { return; } $result = Qixit_Comments::apply_global_paid_comments_defaults_to_existing_content(); set_transient('qixit_global_paid_comments_sync_notice', $result, 120); } /** * Refresh global paid-comments defaults when default price changes. * * @param mixed $old_value * @param mixed $new_value * @return void */ public function handle_global_paid_comments_price_change($old_value, $new_value) { if (floatval($old_value) === floatval($new_value)) { return; } if (!boolval(get_option('qixit_enable_paid_comments_global', false)) || !class_exists('Qixit_Comments')) { return; } $result = Qixit_Comments::apply_global_paid_comments_defaults_to_existing_content(); set_transient('qixit_global_paid_comments_sync_notice', $result, 120); } /** * Show one-time admin notice after global paid-comments backfill. * * @return void */ public function render_global_paid_comments_sync_notice() { if (!is_admin() || !current_user_can('manage_options')) { return; } $result = get_transient('qixit_global_paid_comments_sync_notice'); if (!is_array($result)) { return; } delete_transient('qixit_global_paid_comments_sync_notice'); $processed = isset($result['processed']) ? intval($result['processed']) : 0; $synced = isset($result['synced']) ? intval($result['synced']) : 0; $message = sprintf( /* translators: 1: number of posts/pages updated, 2: number of comments products synced */ __('Global paid comments applied to %1$d existing posts/pages. Shared comments product sync attempts: %2$d.', '2waymicropay'), $processed, $synced ); echo '

' . esc_html($message) . '

'; } /** * Get commercial categories from the API * Categories are cached for 1 hour to avoid excessive API calls * * @return array Associative array of code => display name */ public function get_commercial_categories() { // Check for cached categories $cached = get_transient('qixit_commercial_categories'); if ($cached !== false) { return $cached; } // Default categories in case API call fails $default_categories = array( '' => __('-- Select a Category (Required) --', '2waymicropay'), '015' => __('Entertainment', '2waymicropay'), '030' => __('Digital Goods', '2waymicropay'), '050' => __('Professional Services', '2waymicropay'), '060' => __('Charitable Donations', '2waymicropay'), '070' => __('Media & Content', '2waymicropay') ); try { // Fetch categories from the API $api = Qixit_API::get_instance(); $response = $api->get_categories(); if ($response['success'] && !empty($response['data'])) { $categories = array( '' => __('-- Select a Category (Required) --', '2waymicropay') ); foreach ($response['data'] as $category) { $code = isset($category['code']) ? $category['code'] : ''; $name = isset($category['categoryName']) ? $category['categoryName'] : ''; if (!empty($code)) { // Show code in parentheses for clarity $categories[$code] = !empty($name) ? "{$name} ({$code})" : "Category {$code}"; } } // Cache for 1 hour set_transient('qixit_commercial_categories', $categories, HOUR_IN_SECONDS); $this->logger->log("Fetched " . (count($categories) - 1) . " commercial categories from API"); return $categories; } else { $error_msg = isset($response['message']) ? $response['message'] : 'Unknown error'; $this->logger->log("Failed to fetch commercial categories: {$error_msg}", 'error'); } } catch (Exception $e) { $this->logger->log("Exception fetching commercial categories: " . $e->getMessage(), 'error'); } // Return defaults on failure, but don't cache them (try again next time) return $default_categories; } /** * Clear cached commercial categories * Called when a user clicks "Refresh Categories" button */ public function clear_categories_cache() { delete_transient('qixit_commercial_categories'); $this->logger->log("Commercial categories cache cleared"); } /** * Enqueue admin assets */ public function enqueue_admin_assets($hook) { // Load on Qixit pages, post editor, and post list (for bulk actions) $allowed_hooks = array('post.php', 'post-new.php', 'edit.php'); // WordPress hook names for this plugin are usually qixit-* (for example // "toplevel_page_qixit-settings"), not always "2WayMicropay". if (strpos($hook, 'qixit') === false && strpos($hook, '2waymicropay') === false && !in_array($hook, $allowed_hooks, true)) { return; } wp_enqueue_style('qixit-admin'); wp_enqueue_script('qixit-admin'); } /** * Render credentials section */ public function render_credentials_section() { echo '

' . esc_html__('Enter your 2WayMicropay vendor credentials to authenticate with the API.', '2waymicropay') . '

'; } /** * Render general section */ public function render_general_section() { echo '

' . esc_html__('General plugin settings.', '2waymicropay') . '

'; } /** * Render settings page */ public function render_settings_page() { if (!current_user_can('manage_options')) { return; } // Get token status $token_status = $this->auth->get_token_status(); // After settings save, proactively provision required author-commerce products. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- WordPress settings API owns nonce validation for options.php submissions. $settings_updated = isset($_GET['settings-updated']) ? sanitize_text_field(wp_unslash($_GET['settings-updated'])) : ''; if ($settings_updated === 'true' && class_exists('Qixit_Author_Commerce')) { $provision = Qixit_Author_Commerce::get_instance()->ensure_required_products(true); if (!empty($provision['messages']) && is_array($provision['messages'])) { foreach ($provision['messages'] as $message) { add_settings_error('qixit_messages', 'qixit_author_commerce_provision_ok', $message, 'success'); } } if (!empty($provision['errors']) && is_array($provision['errors'])) { foreach ($provision['errors'] as $error) { add_settings_error('qixit_messages', 'qixit_author_commerce_provision_error', $error, 'error'); } } } ?>

resync_all_products(); echo '

' . esc_html($resync_result['message']) . '

'; } ?>

get_commercial_categories(); $default_category = get_option('qixit_default_commercial_category', ''); ?>

get_immediate_access_destination_url(); } ?>

prefix . QIXIT_PAYMENT_DETAILS; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- analytics query against trusted plugin table name. $total_records = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- analytics query against trusted plugin table name. $oldest_record = $wpdb->get_var("SELECT MIN(date_purchased) FROM {$table_name}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- analytics query against trusted plugin table name. $newest_record = $wpdb->get_var("SELECT MAX(date_purchased) FROM {$table_name}"); ?>

prefix . QIXIT_PAYMENT_DETAILS); $table_name_display = $wpdb->prefix . QIXIT_PAYMENT_DETAILS; $logger = Qixit_Logger::get_instance(); // Handle rebuild tables action $rebuild_message = ''; if (isset($_POST['qixit_rebuild_tables']) && isset($_POST['qixit_rebuild_nonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['qixit_rebuild_nonce'])), 'qixit_rebuild_tables')) { $rebuild_message = $this->rebuild_database_tables(); $logger->log("Customers page: Rebuild tables action - " . $rebuild_message); } // DEBUG: Log table name and check if table exists $logger->log("Customers page: Using table {$table_name}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- schema existence check for trusted plugin table. $table_exists = $wpdb->get_var("SHOW TABLES LIKE '{$table_name}'"); $logger->log("Customers page: Table exists = " . ($table_exists ? 'YES' : 'NO')); if (!$table_exists) { $logger->log("Customers page: Table does not exist - attempting to create it now..."); // Create the table using direct SQL (dbDelta can be finicky) $charset_collate = $wpdb->get_charset_collate(); // Simplified CREATE TABLE without backticks (MySQL compatibility) $sql = "CREATE TABLE IF NOT EXISTS {$table_name} ( payment_id BIGINT NOT NULL AUTO_INCREMENT, product_id BIGINT NULL, type VARCHAR(1) NOT NULL DEFAULT 'P', qixit_PID VARCHAR(255) DEFAULT NULL, qixit_PID_guid CHAR(36) DEFAULT NULL, qixit_id VARCHAR(60) DEFAULT NULL, qixit_user_id VARCHAR(100) DEFAULT NULL, txn_id VARCHAR(100) DEFAULT NULL, total DECIMAL(15,4) NOT NULL DEFAULT 0, payment_for VARCHAR(50) NOT NULL DEFAULT 'view_post', wp_user_id BIGINT DEFAULT NULL, percent_to_author DECIMAL(15,2) DEFAULT NULL, date_purchased DATETIME NULL DEFAULT NULL, PRIMARY KEY (payment_id), KEY idx_guid (qixit_PID_guid), KEY idx_user (wp_user_id), KEY idx_qixit_user (qixit_user_id), KEY idx_txn (txn_id) ) {$charset_collate}"; $logger->log("Customers page: Executing SQL: " . substr($sql, 0, 200) . "..."); // Use direct query instead of dbDelta // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- CREATE TABLE statement is built from trusted constants/table names. $result = $wpdb->query($sql); $logger->log("Customers page: Query result = " . wp_json_encode($result)); if ($wpdb->last_error) { $logger->log("Customers page: SQL Error: " . $wpdb->last_error, 'error'); } // Check if creation succeeded // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- schema existence check for trusted plugin table. $table_exists = $wpdb->get_var("SHOW TABLES LIKE '{$table_name}'"); if ($table_exists) { $logger->log("Customers page: SUCCESS - Table created!"); } else { $logger->log("Customers page: FAILED to create table after query.", 'error'); // Try to get more info about what tables exist // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- troubleshooting-only schema listing. $all_tables = $wpdb->get_col("SHOW TABLES LIKE 'wpqi_%'"); $logger->log("Customers page: Existing wpqi_* tables: " . implode(', ', $all_tables)); } } // Detect all payment-detail table variants and select the most recently active one. // This helps older sites where historical rows may live in a different WP prefix. $report_table_name = $table_name_display; $payment_table_candidates = array(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- metadata query for diagnostics. $candidate_tables = $wpdb->get_col("SHOW TABLES LIKE '%qixit_payment_details%'"); if (is_array($candidate_tables) && !empty($candidate_tables)) { $chosen_latest = ''; $chosen_rows = -1; foreach ($candidate_tables as $candidate_table) { $candidate_table = sanitize_text_field((string) $candidate_table); if ($candidate_table === '' || !preg_match('/^[A-Za-z0-9_]+$/', $candidate_table) || strpos($candidate_table, 'qixit_payment_details') === false) { continue; } $candidate_table_sql = esc_sql($candidate_table); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name validated from SHOW TABLES and escaped with esc_sql(). $candidate_row_count = intval($wpdb->get_var("SELECT COUNT(*) FROM {$candidate_table_sql}")); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name validated from SHOW TABLES and escaped with esc_sql(). $candidate_latest = (string) $wpdb->get_var("SELECT MAX(date_purchased) FROM {$candidate_table_sql}"); $payment_table_candidates[] = array( 'table' => sanitize_text_field((string) $candidate_table), 'rows' => $candidate_row_count, 'latest' => $candidate_latest, ); if ($candidate_latest > $chosen_latest || ($candidate_latest === $chosen_latest && $candidate_row_count > $chosen_rows)) { $chosen_latest = $candidate_latest; $chosen_rows = $candidate_row_count; $report_table_name = sanitize_text_field((string) $candidate_table); } } } if ($report_table_name !== $table_name_display) { $logger->log("Customers page: Reporting from alternate table {$report_table_name} (current prefix table is {$table_name_display})"); } $table_name_display = $report_table_name; if ($report_table_name !== '' && preg_match('/^[A-Za-z0-9_]+$/', $report_table_name) && strpos($report_table_name, 'qixit_payment_details') !== false) { $table_name = esc_sql($report_table_name); } else { $table_name = esc_sql($wpdb->prefix . QIXIT_PAYMENT_DETAILS); $table_name_display = $wpdb->prefix . QIXIT_PAYMENT_DETAILS; } // DEBUG: Count total rows in table // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- diagnostics query against validated/escaped plugin table name. $total_rows = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}"); $logger->log("Customers page: Total rows in payment_details table = " . ($total_rows !== null ? $total_rows : 'ERROR')); $test_row_count = 0; $non_test_row_count = 0; // DEBUG: Show sample of raw data // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- diagnostics query against validated/escaped plugin table name. $sample_rows = $wpdb->get_results("SELECT payment_id, qixit_user_id, wp_user_id, total, date_purchased FROM {$table_name} ORDER BY date_purchased DESC LIMIT 5"); if ($sample_rows) { foreach ($sample_rows as $row) { $logger->log(" Sample row: id={$row->payment_id}, qixit_user='{$row->qixit_user_id}', wp_user={$row->wp_user_id}, total={$row->total}, date={$row->date_purchased}"); } } else { $logger->log(" No sample rows found or error: " . $wpdb->last_error); } // Keep plugin diagnostics rows out of customer analytics. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- diagnostics query against validated/escaped plugin table name. $test_row_count = intval($wpdb->get_var("SELECT COUNT(*) FROM {$table_name} WHERE qixit_user_id LIKE 'TEST_%' OR txn_id LIKE 'TEST_TXN_%'")); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- diagnostics query against validated/escaped plugin table name. $non_test_row_count = intval($wpdb->get_var("SELECT COUNT(*) FROM {$table_name} WHERE (qixit_user_id IS NULL OR qixit_user_id NOT LIKE 'TEST_%') AND (txn_id IS NULL OR txn_id NOT LIKE 'TEST_TXN_%')")); // Get customer statistics from the payment details table. // Keep all real identities (including guest-like and UUID-like values) and exclude // only explicit test rows inserted by plugin diagnostics. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $customers = $wpdb->get_results(" SELECT COALESCE(NULLIF(qixit_user_id, ''), NULLIF(qixit_id, '')) AS qixit_user_id, wp_user_id, COUNT(*) as purchase_count, SUM(total) as total_spent, MAX(date_purchased) as last_purchase, MIN(date_purchased) as first_purchase FROM {$table_name} WHERE COALESCE(NULLIF(qixit_user_id, ''), NULLIF(qixit_id, '')) IS NOT NULL AND COALESCE(NULLIF(qixit_user_id, ''), NULLIF(qixit_id, '')) NOT LIKE 'TEST_%' AND (txn_id IS NULL OR txn_id NOT LIKE 'TEST_TXN_%') GROUP BY COALESCE(NULLIF(qixit_user_id, ''), NULLIF(qixit_id, '')), wp_user_id ORDER BY purchase_count DESC "); $logger->log("Customers page: Qixit customers query returned " . count($customers) . " rows"); if ($wpdb->last_error) { $logger->log("Customers page: Query error: " . $wpdb->last_error, 'error'); } // Also get WordPress users who made purchases $wp_customers = $wpdb->get_results(" SELECT wp_user_id, COUNT(*) as purchase_count, SUM(total) as total_spent, MAX(date_purchased) as last_purchase, MIN(date_purchased) as first_purchase FROM {$table_name} WHERE wp_user_id IS NOT NULL AND wp_user_id > 0 AND (qixit_user_id IS NULL OR qixit_user_id = '') AND (txn_id IS NULL OR txn_id NOT LIKE 'TEST_TXN_%') GROUP BY wp_user_id ORDER BY purchase_count DESC "); $logger->log("Customers page: WP customers query returned " . count($wp_customers) . " rows"); // Date context for quick "current month" validation from real payment rows. $payment_date_context = $wpdb->get_row(" SELECT MIN(date_purchased) AS earliest_purchase, MAX(date_purchased) AS latest_purchase FROM {$table_name} WHERE (qixit_user_id IS NULL OR qixit_user_id NOT LIKE 'TEST_%') AND (txn_id IS NULL OR txn_id NOT LIKE 'TEST_TXN_%') "); // Lightweight diagnostics for rows that cannot be attributed to a user identity bucket. $unknown_identity = $wpdb->get_row(" SELECT COUNT(*) AS row_count, COALESCE(SUM(total), 0) AS total_amount FROM {$table_name} WHERE (qixit_user_id IS NULL OR qixit_user_id = '') AND (wp_user_id IS NULL OR wp_user_id = 0) AND (txn_id IS NULL OR txn_id NOT LIKE 'TEST_TXN_%') "); // Author/contributor fee totals (onboarding + publish fees) mapped to current WP roles. $author_fee_rows = $wpdb->get_results(" SELECT wp_user_id, SUM(CASE WHEN payment_for = 'author_registration' THEN total ELSE 0 END) AS onboarding_fees, SUM(CASE WHEN payment_for = 'add_post' THEN total ELSE 0 END) AS publish_fees, SUM(total) AS total_paid_fees, COUNT(*) AS fee_count, MIN(date_purchased) AS first_fee_date, MAX(date_purchased) AS last_fee_date FROM {$table_name} WHERE wp_user_id IS NOT NULL AND wp_user_id > 0 AND payment_for IN ('author_registration', 'add_post') AND (txn_id IS NULL OR txn_id NOT LIKE 'TEST_TXN_%') GROUP BY wp_user_id ORDER BY total_paid_fees DESC "); $author_fee_rows = is_array($author_fee_rows) ? $author_fee_rows : array(); foreach ($author_fee_rows as $idx => $row) { $wp_user = get_userdata(intval($row->wp_user_id)); if (!$wp_user) { continue; } $user_roles = (array) $wp_user->roles; if (!in_array('author', $user_roles, true) && !in_array('contributor', $user_roles, true)) { unset($author_fee_rows[$idx]); continue; } $roles = array_map('ucfirst', $user_roles); $author_fee_rows[$idx]->role_label = implode(', ', $roles); $author_fee_rows[$idx]->display_name = $wp_user->display_name; $author_fee_rows[$idx]->user_login = $wp_user->user_login; } $author_fee_rows = array_values($author_fee_rows); // Comment payment reporting via comments product GUID relationships. $comment_guid_map = array(); $products_table = esc_sql($wpdb->prefix . QIXIT_PRODUCTS); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- products table name derived from trusted prefix constant. $comment_product_guids = $wpdb->get_col(" SELECT DISTINCT comments_qixit_PID_guid FROM {$products_table} WHERE comments_qixit_PID_guid IS NOT NULL AND comments_qixit_PID_guid != '' "); foreach ((array) $comment_product_guids as $guid) { $guid = trim((string) $guid); if ($guid !== '') { $comment_guid_map[$guid] = true; } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- reporting query uses trusted wp_options table. $comment_guid_options = $wpdb->get_col(" SELECT option_value FROM {$wpdb->options} WHERE option_name = 'qixit_comments_product_guid_global' OR option_name LIKE 'qixit_comments_product_guid_author_%' "); foreach ((array) $comment_guid_options as $guid) { $guid = trim((string) $guid); if ($guid !== '') { $comment_guid_map[$guid] = true; } } $comment_report = array(); $comment_total = 0.0; $all_non_test_rows = $wpdb->get_results(" SELECT qixit_user_id, wp_user_id, qixit_PID_guid, total, payment_for, date_purchased FROM {$table_name} WHERE (qixit_user_id IS NULL OR qixit_user_id NOT LIKE 'TEST_%') AND (txn_id IS NULL OR txn_id NOT LIKE 'TEST_TXN_%') "); foreach ((array) $all_non_test_rows as $payment_row) { $payment_guid = trim((string) $payment_row->qixit_PID_guid); $payment_for = trim((string) $payment_row->payment_for); $is_comment_payment = ($payment_for === 'comments') || ($payment_guid !== '' && isset($comment_guid_map[$payment_guid])); if (!$is_comment_payment) { continue; } $amount = floatval($payment_row->total); $comment_total += $amount; $qixit_user_id = trim((string) $payment_row->qixit_user_id); $wp_user_id = intval($payment_row->wp_user_id); if ($qixit_user_id !== '') { $bucket_key = 'q:' . $qixit_user_id; $label = $qixit_user_id; $identity_type = __('Qixit Username', '2waymicropay'); } elseif ($wp_user_id > 0) { $bucket_key = 'w:' . $wp_user_id; $wp_user = get_userdata($wp_user_id); $label = $wp_user ? $wp_user->display_name : ('User #' . $wp_user_id); $identity_type = __('WordPress User', '2waymicropay'); } else { $bucket_key = 'u:unknown'; $label = __('Unknown identity', '2waymicropay'); $identity_type = __('Unknown', '2waymicropay'); } if (!isset($comment_report[$bucket_key])) { $comment_report[$bucket_key] = (object) array( 'identity_label' => $label, 'identity_type' => $identity_type, 'payment_count' => 0, 'total_paid' => 0.0, 'first_payment' => $payment_row->date_purchased, 'last_payment' => $payment_row->date_purchased, ); } $comment_report[$bucket_key]->payment_count++; $comment_report[$bucket_key]->total_paid += $amount; if (strtotime((string) $payment_row->date_purchased) < strtotime((string) $comment_report[$bucket_key]->first_payment)) { $comment_report[$bucket_key]->first_payment = $payment_row->date_purchased; } if (strtotime((string) $payment_row->date_purchased) > strtotime((string) $comment_report[$bucket_key]->last_payment)) { $comment_report[$bucket_key]->last_payment = $payment_row->date_purchased; } } // Monthly rollup to quickly validate "where did May go?" questions. $monthly_totals = $wpdb->get_results(" SELECT DATE_FORMAT(date_purchased, '%Y-%m') AS year_month, COUNT(*) AS payment_count, COALESCE(SUM(total), 0) AS total_amount FROM {$table_name} WHERE date_purchased IS NOT NULL AND (qixit_user_id IS NULL OR qixit_user_id NOT LIKE 'TEST_%') AND (txn_id IS NULL OR txn_id NOT LIKE 'TEST_TXN_%') GROUP BY DATE_FORMAT(date_purchased, '%Y-%m') ORDER BY year_month DESC LIMIT 12 "); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // Calculate totals $total_customers = count($customers) + count($wp_customers); $total_purchases = array_sum(array_column($customers, 'purchase_count')) + array_sum(array_column($wp_customers, 'purchase_count')); $total_revenue = array_sum(array_column($customers, 'total_spent')) + array_sum(array_column($wp_customers, 'total_spent')); $author_fee_total = array_sum(array_map(static function ($row) { return floatval($row->total_paid_fees); }, $author_fee_rows)); ?>

๐Ÿ“Š Build Your Customer Database

This page shows customers who have purchased content on your website. Every time someone uses a purchase link generated by your site, their username is captured here. Use this data to build marketing lists and send follow-up offers to your paying customers. : products.qixit.com.

๐Ÿ”ง Database Status: prefix . QIXIT_PRODUCTS; $payment_table = $wpdb->prefix . QIXIT_PAYMENT_DETAILS; $author_table = $wpdb->prefix . QIXIT_AUTHOR_SETTINGS; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- schema checks for trusted plugin tables. $products_exists = $wpdb->get_var("SHOW TABLES LIKE '{$products_table}'") ? 'โœ…' : 'โŒ'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- schema checks for trusted plugin tables. $payment_exists = $wpdb->get_var("SHOW TABLES LIKE '{$payment_table}'") ? 'โœ…' : 'โŒ'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- schema checks for trusted plugin tables. $author_exists = $wpdb->get_var("SHOW TABLES LIKE '{$author_table}'") ? 'โœ…' : 'โŒ'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- summary count for trusted plugin table. $payment_count = $wpdb->get_var("SELECT COUNT(*) FROM {$payment_table}"); ?> Products | Payments ( records) | Authors

earliest_purchase : ''; $latest_purchase = $payment_date_context ? $payment_date_context->latest_purchase : ''; if (!empty($earliest_purchase) && !empty($latest_purchase)) { echo esc_html(date_i18n('M j, Y', strtotime($earliest_purchase)) . ' - ' . date_i18n('M j, Y', strtotime($latest_purchase))); } else { esc_html_e('No non-test payments found yet.', '2waymicropay'); } ?> row_count : 0), number_format(floatval($unknown_identity ? $unknown_identity->total_amount : 0), 2) )); ?> 0): ?>

๐Ÿ’ก How to get started

  1. Create premium content (posts, pages, games, videos)
  2. Set a price using the 2WayMicropay settings box
  3. Save/Publish โ€” the product syncs to the API automatically
  4. When customers pay, they appear in this list!

qixit_user_id; ?>
purchase_count); ?> $total_spent), 2); ?> first_purchase))); ?> last_purchase))); ?>
$

wp_user_id); $display_name = $wp_user ? $wp_user->display_name : 'User #' . $customer->wp_user_id; $user_login = $wp_user ? $wp_user->user_login : ''; ?>

purchase_count); ?> $total_spent), 2); ?> first_purchase))); ?> last_purchase))); ?>
$

display_name) ? $fee_row->display_name : ('User #' . intval($fee_row->wp_user_id))); ?> user_login)): ?>
user_login); ?>
role_label) ? $fee_row->role_label : __('Unknown', '2waymicropay')); ?> $onboarding_fees), 2)); ?> $publish_fees), 2)); ?> $total_paid_fees), 2)); ?> fee_count)); ?>
$

identity_label); ?> identity_type); ?> payment_count)); ?> $total_paid), 2)); ?> first_payment))); ?> last_payment))); ?>
$

year_month); ?> payment_count)); ?> $total_amount), 2)); ?>

$
$
$

๐Ÿ“ฌ Marketing Your Customer List

These are your customers - people who have already paid for content on your site. They're valuable because:

Coming soon: Export this list and upload it to the Marketing Portal to send targeted offers directly to your paying customers!

0) { rows.push(headers); } $table.find('tbody tr').each(function() { var row = []; $(this).find('td').each(function() { row.push($(this).text().replace(/\s+/g, ' ').trim()); }); if (row.length > 0) { rows.push(row); } }); rows.push([]); }); // Create CSV content var csvContent = rows.map(function(row) { return row.map(function(cell) { // Escape quotes and wrap in quotes if contains comma var str = String(cell); if (str.indexOf(',') > -1 || str.indexOf('"') > -1) { return '"' + str.replace(/"/g, '""') + '"'; } return str; }).join(','); }).join('\n'); // Download var blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); var link = document.createElement('a'); var url = URL.createObjectURL(blob); link.setAttribute('href', url); link.setAttribute('download', 'customers-' + new Date().toISOString().split('T')[0] + '.csv'); link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); document.body.removeChild(link); }); }); JS ); ?> get_charset_collate(); $results = array(); // Table 1: Products $table_name = $wpdb->prefix . QIXIT_PRODUCTS; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- schema existence check for trusted plugin table. if (!$wpdb->get_var("SHOW TABLES LIKE '{$table_name}'")) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- CREATE TABLE template for plugin-owned schema. $sql = "CREATE TABLE IF NOT EXISTS {$table_name} ( product_id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NULL, post_qixit_PID VARCHAR(255) DEFAULT NULL, post_qixit_PID_guid CHAR(36) DEFAULT NULL, comments_qixit_PID_guid CHAR(36) DEFAULT NULL, premium_post_type VARCHAR(50) DEFAULT 'premium', premium_post_cost DECIMAL(15,4) NOT NULL DEFAULT 0, comments_cost DECIMAL(15,4) DEFAULT 0.05, access_hours INT DEFAULT -1, active TINYINT(1) DEFAULT 1, date_created DATETIME NULL DEFAULT NULL, date_updated DATETIME NULL DEFAULT NULL, PRIMARY KEY (product_id), UNIQUE KEY post_qixit_PID (post_qixit_PID), KEY idx_guid (post_qixit_PID_guid), KEY idx_post (post_id) ) {$charset_collate}"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- CREATE TABLE statement is built from trusted constants/table names. $result = $wpdb->query($sql); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- schema existence check for trusted plugin table. if ($wpdb->get_var("SHOW TABLES LIKE '{$table_name}'")) { $results[] = "Products table created โœ…"; $logger->log("Rebuild: Products table created successfully"); } else { $results[] = "Products table FAILED: " . $wpdb->last_error; $logger->log("Rebuild: Products table failed - " . $wpdb->last_error, 'error'); } } else { $results[] = "Products table already exists โœ…"; } // Table 2: Payment Details $table_name = $wpdb->prefix . QIXIT_PAYMENT_DETAILS; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- schema existence check for trusted plugin table. if (!$wpdb->get_var("SHOW TABLES LIKE '{$table_name}'")) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- CREATE TABLE template for plugin-owned schema. $sql = "CREATE TABLE IF NOT EXISTS {$table_name} ( payment_id BIGINT NOT NULL AUTO_INCREMENT, product_id BIGINT NULL, type VARCHAR(1) NOT NULL DEFAULT 'P', qixit_PID VARCHAR(255) DEFAULT NULL, qixit_PID_guid CHAR(36) DEFAULT NULL, qixit_id VARCHAR(60) DEFAULT NULL, qixit_user_id VARCHAR(100) DEFAULT NULL, txn_id VARCHAR(100) DEFAULT NULL, total DECIMAL(15,4) NOT NULL DEFAULT 0, payment_for VARCHAR(50) NOT NULL DEFAULT 'view_post', wp_user_id BIGINT DEFAULT NULL, percent_to_author DECIMAL(15,2) DEFAULT NULL, date_purchased DATETIME NULL DEFAULT NULL, PRIMARY KEY (payment_id), KEY idx_guid (qixit_PID_guid), KEY idx_user (wp_user_id), KEY idx_qixit_user (qixit_user_id), KEY idx_txn (txn_id) ) {$charset_collate}"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- CREATE TABLE statement is built from trusted constants/table names. $result = $wpdb->query($sql); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- schema existence check for trusted plugin table. if ($wpdb->get_var("SHOW TABLES LIKE '{$table_name}'")) { $results[] = "Payment Details table created โœ…"; $logger->log("Rebuild: Payment Details table created successfully"); } else { $results[] = "Payment Details table FAILED: " . $wpdb->last_error; $logger->log("Rebuild: Payment Details table failed - " . $wpdb->last_error, 'error'); } } else { $results[] = "Payment Details table already exists โœ…"; } // Table 3: Author Settings $table_name = $wpdb->prefix . QIXIT_AUTHOR_SETTINGS; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- schema existence check for trusted plugin table. if (!$wpdb->get_var("SHOW TABLES LIKE '{$table_name}'")) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- CREATE TABLE template for plugin-owned schema. $sql = "CREATE TABLE IF NOT EXISTS {$table_name} ( id BIGINT NOT NULL AUTO_INCREMENT, wp_user_id INT NOT NULL, qixit_id VARCHAR(255) DEFAULT NULL, entitlement_status VARCHAR(30) DEFAULT 'inactive', entitlement_purchased_at DATETIME DEFAULT NULL, entitlement_expires_at DATETIME DEFAULT NULL, date_created DATETIME DEFAULT NULL, date_updated DATETIME DEFAULT NULL, PRIMARY KEY (id), KEY idx_user (wp_user_id) ) {$charset_collate}"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- CREATE TABLE statement is built from trusted constants/table names. $result = $wpdb->query($sql); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- schema existence check for trusted plugin table. if ($wpdb->get_var("SHOW TABLES LIKE '{$table_name}'")) { $results[] = "Author Settings table created โœ…"; $logger->log("Rebuild: Author Settings table created successfully"); } else { $results[] = "Author Settings table FAILED: " . $wpdb->last_error; $logger->log("Rebuild: Author Settings table failed - " . $wpdb->last_error, 'error'); } } else { $results[] = "Author Settings table already exists โœ…"; } return implode(' | ', $results); } /** * Resync all products to the Products Portal API * Updates reportUrl for all products and paid comments * * @return array ['success' => bool, 'message' => string] */ private function resync_all_products() { global $wpdb; $logger = Qixit_Logger::get_instance(); $product_manager = Qixit_Product::get_instance(); $logger->log("Resync All: Starting bulk resync of all products"); // Ensure Site API Key validation is healthy before running a long resync. // This avoids false "success" messages when every API update would fail. $token_status = $this->auth->get_token_status(); if (!is_array($token_status) || ($token_status['status'] ?? '') !== 'valid') { $status_message = is_array($token_status) && !empty($token_status['message']) ? $token_status['message'] : __('Site API Key validation failed. Please verify your Site API Key first.', '2waymicropay'); $logger->log("Resync All: Aborted due to failed key validation: " . $status_message, 'error'); return array( 'success' => false, 'message' => sprintf( /* translators: %s: validation failure message */ __('Resync aborted: %s', '2waymicropay'), $status_message ) ); } $table_name = $wpdb->prefix . QIXIT_PRODUCTS; // First, log what's in the products table for debugging // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- debug count against trusted plugin table. $total_count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}"); $logger->log("Resync All: Total products in table: {$total_count}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- debug sample query against trusted plugin table. $all_products = $wpdb->get_results("SELECT product_id, post_id, qixit_post_type, enable_paid_comments, active, post_qixit_PID_guid, comments_qixit_PID_guid FROM {$table_name} LIMIT 20"); if ($all_products) { foreach ($all_products as $p) { $has_guid = !empty($p->post_qixit_PID_guid) ? 'Y' : 'N'; $has_comment_guid = !empty($p->comments_qixit_PID_guid) ? 'Y' : 'N'; $logger->log(" Product ID={$p->product_id}, post_id={$p->post_id}, type='{$p->qixit_post_type}', paid_comments={$p->enable_paid_comments}, active={$p->active}, has_guid={$has_guid}, has_comment_guid={$has_comment_guid}"); } } else { $logger->log(" No products found in table at all!"); } // Get all products from local database that need syncing // Include products that have: premium content, paid comments, or existing API guid $reg_type = QIXIT_REG; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- resync selection query uses trusted plugin table name. $products = $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$table_name} WHERE post_id IS NOT NULL AND post_id > 0 AND ( (qixit_post_type IS NOT NULL AND qixit_post_type != '' AND qixit_post_type != %s) OR enable_paid_comments = 1 OR post_qixit_PID_guid IS NOT NULL OR comments_qixit_PID_guid IS NOT NULL )", $reg_type )); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $logger->log("Resync All: Query for syncable products returned " . ($products ? count($products) : 0) . " rows"); if ($wpdb->last_error) { $logger->log("Resync All: Query error: " . $wpdb->last_error, 'error'); } if (!$products || count($products) === 0) { $logger->log("Resync All: No products matched the sync criteria"); return array('success' => true, 'message' => __('No products to sync. Check that you have premium content or paid comments enabled on some posts.', '2waymicropay')); } $total = count($products); $synced = 0; $errors = 0; $comments_synced = 0; $logger->log("Resync All: Found {$total} products to sync"); foreach ($products as $product) { if (empty($product->post_id)) { continue; } // Check if post still exists $post = get_post($product->post_id); if (!$post) { $logger->log("Resync All: Post {$product->post_id} no longer exists, skipping"); continue; } // Check if this is a premium product (not just paid comments) $is_premium = !empty($product->qixit_post_type) && $product->qixit_post_type !== QIXIT_REG; if ($is_premium) { // Sync the main product $logger->log("Resync All: Syncing product for post {$product->post_id}"); $result = $product_manager->sync_product_to_api($product->post_id); if ($result && isset($result['success']) && $result['success']) { $synced++; $logger->log("Resync All: Successfully synced product for post {$product->post_id}"); } else { $errors++; $logger->log("Resync All: Failed to sync product for post {$product->post_id}: " . json_encode($result), 'error'); } } // Check for paid comments if (!empty($product->enable_paid_comments)) { $logger->log("Resync All: Syncing comments product for post {$product->post_id}"); $result = $product_manager->sync_comments_product_to_api($product->post_id, $product); if ($result && isset($result['success']) && $result['success']) { $comments_synced++; $logger->log("Resync All: Successfully synced comments product for post {$product->post_id}"); } else { $errors++; $logger->log("Resync All: Failed to sync comments product for post {$product->post_id}: " . json_encode($result), 'error'); } } } // Re-provision author-rights / publication / revision products so Categories update too. if (class_exists('Qixit_Author_Commerce')) { $logger->log('Resync All: Ensuring author commerce products (incl. Price Optimization Categories)'); try { Qixit_Author_Commerce::get_instance()->ensure_required_products(true); } catch (Exception $e) { $errors++; $logger->log('Resync All: Author commerce ensure failed: ' . $e->getMessage(), 'error'); } } // translators: 1: synced products count, 2: synced comments count, 3: error count $resync_message_template = __('Resync complete! Products: %1$d synced. Paid Comments: %2$d synced. Errors: %3$d. Author-rights products refreshed when configured.', '2waymicropay'); $message = sprintf( $resync_message_template, $synced, $comments_synced, $errors ); $logger->log("Resync All: Complete - {$message}"); return array( 'success' => ($errors === 0), 'message' => $message ); } /** * Insert a test record into the payment details table * * @return string Result message */ private function insert_test_record() { global $wpdb; $logger = Qixit_Logger::get_instance(); $table_name = $wpdb->prefix . QIXIT_PAYMENT_DETAILS; $test_data = array( 'product_id' => 0, 'type' => 'P', 'qixit_PID' => 'TEST_' . time(), 'qixit_PID_guid' => null, 'qixit_id' => '', 'qixit_user_id' => 'TEST_USER_' . gmdate('His'), 'txn_id' => 'TEST_TXN_' . time(), 'total' => 0.01, 'payment_for' => 'view_post', 'wp_user_id' => get_current_user_id(), 'percent_to_author' => 0, 'date_purchased' => current_time('mysql') ); $logger->log("Test insert: Attempting to insert test record into {$table_name}"); $logger->log("Test insert: Data = " . json_encode($test_data)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- direct write to plugin-owned test table. $result = $wpdb->insert($table_name, $test_data); if ($result === false) { $error_msg = "FAILED to insert test record: " . $wpdb->last_error; $logger->log("Test insert: " . $error_msg, 'error'); return $error_msg; } $insert_id = $wpdb->insert_id; $success_msg = "SUCCESS! Test record inserted with ID: {$insert_id} into {$table_name}. Note: TEST rows are intentionally hidden from customer analytics and only appear in the table diagnostics counts."; $logger->log("Test insert: " . $success_msg); return $success_msg; } /** * Render logs page */ public function render_logs_page() { if (!current_user_can('manage_options')) { return; } $logs = $this->logger->get_recent_logs(100); ?>

    ๐Ÿš€ Getting Started

    1. Configure API Connection - Go to Settings and enter your vendor credentials and Site API Key.
    2. Connect - Save a valid Site API Key to connect this site to the Products API.
    3. Set Up Premium Content - Edit any post or page and use the 2WayMicropay Settings box.

    ๐ŸŒ Products Portal Direct Access

    You can also manage your products directly through the Products Portal: https://products.qixit.com

    Log in with the same vendor credentials you use here. The Products Portal allows you to:

    • View all your products and their sales history
    • Create and manage products manually
    • View transaction reports and payouts
    • Manage your account settings
    • Price Optimization โ€” priced-render metrics, demand curves, and A/B price tests at products.qixit.com/pricing (not shown in this plugin)

    Commercial Code (postage) is set in plugin Settings / the post metabox. Price Optimization Category follows each post or pageโ€™s WordPress categories (or defaults to WP Post / WP Page when none). Paid Comments and Author Rights use those named Categories. Use Resync All to push Categories to existing products.

    ๐Ÿ’ฐ Setting Prices on Posts/Pages

    Method 1: Individual Post/Page Editor

    1. Edit any post or page
    2. Find the "2WayMicropay Settings" box in the sidebar
    3. Select "Premium - Pay Once, View Forever" or "Premium - Pay Per View"
    4. Set your price (min $0.01, max $100.00)
    5. Optionally check "Use Read More as paywall" to show free preview up to the <!--more--> tag
    6. Publish/Update your post โ€” the product is automatically synced to the API

    Method 2: Bulk Actions (Multiple Posts/Pages)

    1. Go to Posts โ†’ All Posts or Pages โ†’ All Pages
    2. Check the boxes next to the posts/pages you want to monetize
    3. From the Bulk Actions dropdown, select:
      • 2WayMicropay: Set Premium (Forever Access) - One-time purchase
      • 2WayMicropay: Set Premium (Pay Per View) - Pay each time
      • 2WayMicropay: Set as Free - Remove paywall
      • 2WayMicropay: Sync All to API - Register all selected products
    4. Click Apply

    ๐Ÿ“ Using the "Read More" Paywall

    The Read More paywall lets you show a free preview of your content:

    1. In your post editor, add the <!--more--> tag (click "Read More" button in editor)
    2. Content before the tag = FREE preview visible to all
    3. Content after the tag = PREMIUM content behind paywall
    4. Enable "Use Read More as paywall" in 2WayMicropay settings
    Example Post Content:
    This is the free teaser that everyone can read.
    It hooks the reader and makes them want more!
    
    <!--more-->
    
    This is the premium content that readers
    must pay to access. Include your best 
    insights and exclusive content here!

    ๐Ÿ”— Getting Payment Links

    You can get a direct payment link for any premium content:

    1. Edit the post/page
    2. Products sync automatically when you save. Look for the green "โœ… Synced to API" badge.
    3. Click "๐Ÿ”— Get Payment Link"
    4. Copy the link and share it anywhere (email, social media, etc.)

    ๐Ÿ“‹ Available Shortcodes

    Shortcode Description Example
    [qixit_buy_button] Display a buy button with micro-frame popup [qixit_buy_button text="Buy Now" class="my-button"]
    [qixit_readmore] Inline "Read more..." link with tooltip and micro-frame [qixit_readmore text="Continue reading..."]
    [qixit_paywall] Display the full paywall box for the current post [qixit_paywall]
    [qixit_price] Display the price of the current post [qixit_price] โ†’ "$0.99"
    [qixit_status] Show purchase status for current user [qixit_status] โ†’ "โœ“ Purchased" or "๐Ÿ”’ $0.99"
    [qixit_premium_content] Wrap content to only show if purchased (alias: [qixit_premium]) [qixit_premium_content]Secret content here[/qixit_premium_content]
    [qixit_image] Image with purchase or payout overlay (alias: [2waymicropay_image]) [qixit_image src="..." link="https://products.qixit.com/purchasefast?PID=..."]
    [qixit_payperplay] Pay-Per-Play wrapper for games, songs, videos (alias: [qixit_pay_per_play]; see below) [qixit_payperplay pid="GUID" image="screenshot.jpg" price="0.05"]Game embed[/qixit_payperplay]
    [qixit_author_rights] Displays configured Author Terms text only (informational; no buy button) [qixit_author_rights]
    [qixit_become_an_author] Signup form + purchase button for Author or Contributor rights (based on Moderation Policy) [qixit_become_an_author]

    ๐Ÿ”— Forwarder Referral Commissions

    Forwarder commissions let promoters earn a percentage when they share your purchase links. Enable this under Settings โ†’ Forwarder Referral Commissions and set the forwarder percentage (same model as the Vendors Portal product forwarder split).

    1. Enable Allow Forwarder Split and set Forwarder Commission (%).
    2. Save settings, then update/re-sync affected premium posts so Products Portal products receive the new forwardPercentage.
    3. Purchase links generated by this site include an empty &fwd= suffix when forwarder commissions are enabled.
    4. Promoters copy the link, paste it, and type their Qixit username/ID after fwd= (example: ...purchasefast?PID=โ€ฆ&fwd=promotername).
    5. If fwd is missing or the ID is invalid/inactive, that commission percentage stays with the vendor โ€” the purchase still completes normally.
    6. Author/affiliate share plus forwarder share must not exceed 100%.

    ๐Ÿ‘ฅ Author Commerce

    Author Commerce lets site owners monetize contributor access and share revenue with post authors. The workflow includes: paid onboarding rights, optional per-publish fees, and post-level revenue share.

    Owner Setup Checklist

    1. Go to Settings and open Author Commerce Settings.
    2. Enable Author Commerce.
    3. Moderation Policy. Choose Contributor Rights or Author Rights (mutually exclusive checkboxes).
      • Contributor: Contributions must be approved by Editor or Admin before publication.
      • Author: Authors can publish posts at their discretion.
    4. Set Author Rights Fee (one-time onboarding payment).
    5. Optional: Set Per Publish Fee (charged only on first publication of a new post).
    6. Set Default Author Revenue Share (%) for new premium content.
    7. Optional: Enable Share Revenue from Comments and set Comment Revenue Share (%).
    8. Enter Author Terms text shown on the author-rights purchase page.
    9. Set Author Instructions URL as the post-payment destination page for all successful author-rights purchases.
    10. Save settings.

    Create the Author-Rights Purchase Page

    1. Create a new WordPress page (for example: Become an Author).
    2. Add [qixit_become_an_author] to collect applicant details (username, optional/required email based on settings, and new WordPress password). The purchase button label is Purchase Author Rights or Purchase Contributor Rights based on Moderation Policy.
    3. Optionally add [qixit_author_rights] to display your Author Terms text (informational only โ€” no buy button).
    4. Publish the page and add it to site navigation or onboarding emails.
    5. Do not format these shortcodes as Code / inline code in the block editor.
    6. Purchase โ€ฆ Rights validates required signup fields, then launches checkout. Checkout login uses the applicantโ€™s existing Qixit/mailbox password (not the new WordPress password).
    7. The plugin creates/updates reusable site products named for Author or Contributor rights and publication fees.
    8. After successful payment callback, the new WordPress account is created and linked for payout mapping.

    The plugin automatically creates a default Author Instructions page (Logging into your author account) and sets it as the Author Instructions URL when that setting is empty. Site owners should edit that page as needed for their own onboarding terms and workflow.

    The public author-rights purchase/signup page (with [qixit_become_an_author]) is still owner-controlled content, so you can tailor messaging and placement.

    The contributor's Qixit identity is captured from the successful payment callback and linked to their WordPress account for revenue-sharing payouts.

    Users Admin: Qixit ID Mapping

    • Go to Users to see a Qixit ID column for all users.
    • Use the Author Access column to confirm whether each user is Contributor (approval required to publish) or Author (can publish).
    • Edit any user profile to set/update Qixit ID / Username.
    • This mapping is required for author revenue-sharing payouts.

    How Publish Gating Works

    • If onboarding fee is enabled and a contributor has no active entitlement, publish is blocked and they get a payment link.
    • If publication fee is enabled, first publish is blocked until publication fee is paid.
    • After publication fee payment, the editor unlock window is 15 minutes for that post.
    • Admins and editors are never blocked by author-commerce gating.
    • Signup form requires a WordPress password separate from the contributor's 2WayMicropay password.

    Owner Management of Author Share

    1. Open a post/page and find the 2WayMicropay Settings metabox.
    2. Set Author Share (%) per post (or keep default from settings).
    3. Ensure the post author has a mapped Qixit ID (profile/user-meta or author settings table).
    4. Save/update the post so the API payload includes affiliate split headers for that product.

    If author share is set but no author Qixit ID is mapped, the metabox shows a warning and split payload is not sent until mapping is fixed.

    Approval Toggle (On/Off)

    • In Settings, Moderation Policy controls the WordPress role assigned after successful payment.
    • In Settings, Author Instructions URL controls where all paid applicants land after checkout.
    • In Settings, Signup Email Field controls whether email is required, optional, or hidden on the signup form.
    • When OFF: after payment, account is created as Author and redirected to the Author Instructions URL.
    • When ON: after payment, account is created as Contributor and redirected to the Author Instructions URL. Contributor posts require Editor/Admin approval to publish.

    ๐Ÿ–ผ๏ธ Payout Image Links (Pay Visitors to Visit)

    The payout image link is a special link you attach to an image on your site. When a visitor clicks the image, the 2WayMicropay platform pays them a small amount of real money or vendor credits for visiting the destination page. This is perfect for sponsored content, advertisements, or any scenario where you want to reward visitors.

    How to Set Up a Payout Image Link

    1. Log in to the Products Portal as a vendor
    2. Create a new Payout Link (under the Payouts section)
    3. Set the Pay Amount (what each visitor earns), Delivery URL (where the visitor goes), budget, and expiration date
    4. Copy the generated payout link (it contains a PYID= parameter)
    5. In your WordPress page/post, use the [qixit_image] shortcode, setting the link attribute to the payout URL:
    [qixit_image src="" link="https://products.qixit.com/paymentfast?PYID=your-payout-guid-here" width="600" height="200"]

    Visitors will see a price overlay on the image showing how much they will earn (for example + $1.00 vendor credits). Hovering the image or the related hyperlink shows the same amount in a tooltip. Clicking the image opens the payment page where they log in (or create a free account) and receive the payment.

    Correct Link Setup (Avoid Broken Overlays)

    • Use a Products Portal URL for the micropay / shortcode link attribute: https://products.qixit.com/paymentfast?PYID=โ€ฆ or โ€ฆ/purchasefast?PID=โ€ฆ. Do not paste a Media Library or CDN image URL into that field.
    • Block editor: Enable micropay in the Image block’s 2WayMicropay sidebar panel and paste the Portal link there. Prefer that over wrapping the image in a separate custom hyperlink (nested links break overlays).
    • Visual reference: 2waymicropay.com โ€” Images pay shows the intended badge (compact logo mark + amount on a small white badge).

    Payout Types

    TypeWhat Visitor ReceivesBudget Field
    Real Funds Actual money credited to their account (withdrawable) Max Pay (total dollar budget across all recipients)
    Vendor Credits (Promo) Credits redeemable for purchases on this vendor’s site Promo Budget (total credit budget across all recipients)

    Active vs. Inactive Payout Links

    A payout link is active as long as:

    • The expiration date has not passed (or no expiration is set), and
    • The total budget has not been exhausted, and
    • The vendor has sufficient funds in their account

    When a payout link becomes inactive, the plugin automatically changes its behavior:

    StatePrice OverlayClick Destination
    Active Shown (e.g., “+ 5¢” or “+ $1.00 vendor credits”) Payout page → visitor earns payment → redirected to Delivery URL
    Inactive (expired or budget exhausted) Hidden โ€” no price overlay is shown Delivery URL directly (plain link, no payment process)
    Key benefit: Once you attach a payout link to an image, you never need to update the WordPress post again. The image will always link to the destination page. When the payout is active, visitors earn money. When it expires or the budget runs out, visitors still reach the page — they just don’t earn anything. If you later extend the expiration date or add more budget in the Products Portal, the payout link automatically becomes active again.

    Block editor: Use the standard Image block alignment controls (left, center, right). Enable micropay in the 2WayMicropay sidebar panel โ€” alignment is preserved on the frontend. Classic editor: Insert an image with Add Media, set alignment, then use the 2WayMicropay Image toolbar button (or set align on the shortcode).

    Shortcode Attributes

    AttributeRequiredDescription
    srcYesURL of the image to display
    linkYesThe payout URL (contains PYID=) or a plain URL
    widthNoImage width in pixels or CSS value (e.g., 600 or 300px)
    heightNoImage height in pixels
    max_widthNoMaximum width of the micropay container (e.g., 500px)
    alignNoImage alignment: left, right, or center (text wraps around left/right floats)
    altNoAlt text for the image

    Troubleshooting Payout Links

    No price overlay showing on the image
    The payout is most likely inactive (expired or budget exhausted). Check the payout settings in the Products Portal. Extending the expiration date or adding budget will restore the overlay automatically on next page load.
    Overlay shows “+ click to earn” without an amount, or hover says “Offer details unavailable”
    Confirm the image micropay link is a Products Portal paymentfast?PYID= URL (not a media file URL). Also wait if the public Products API recently returned “Too many requests” for your IP โ€” that block clears automatically within about an hour.
    Green “2WayMicropay” oval appears on top of the price badge
    Update to plugin 4.1.6 or later. Overlays use the compact branding logo mark plus the amount; a full wordmark pill is not used on image badges.
    Image clicks go directly to the destination without the payout page
    This is normal behavior when the payout is inactive. The plugin falls back to the Delivery URL so visitors still reach the destination.
    Payout says “Promo budget exhausted” or “Promotion has expired”
    The payout link has run out of budget or passed its expiration date. Increase the budget or extend the date in the Products Portal to reactivate it.

    ๐ŸŽฎ Pay-Per-Play (Games, Songs, Videos)

    Use the [qixit_payperplay] shortcode to require payment for each play of a game, song, or video. Unlike session-based content, users must pay again every time they want to play.

    How It Works

    1. Create a product in the Products Portal (Delivery URL is optional for Pay Per Play)
    2. Copy the Product GUID (from the product's edit page or URL)
    3. Create/edit a WordPress page and add the shortcode with your game embedded inside
    4. Visitors see a screenshot/placeholder with a "Play Now" button
    5. Clicking triggers the payment micro-frame
    6. After payment, the content loads — audio and video begin playing automatically
    7. To play again, they must pay again (no session cookies)

    Shortcode Attributes

    Attribute Required Description
    pid โœ… Yes Product GUID from the Products Portal (e.g., "28b5242d-deef-40fa-a3e6-e41f8c3da3e8")
    image โœ… Yes URL to placeholder image/screenshot (shows before payment)
    price No Price to display on button (default: "0.01"). For display only - actual price is set in Products Portal
    button_text No Text on the play button (default: "Play Now")
    button_again_text No Button text after first play (default: "Play Again")
    width No Container width (default: "100%")
    height No Container height (default: "400px")
    title No Title displayed on the placeholder overlay
    โš ๏ธ Important: WordPress does not handle multi-line shortcodes well. All shortcode parameters must be on one line (no line breaks between attributes).

    Example: Pay-Per-Play Game

    This example embeds self-hosted game HTML that requires 5¢ per play. Upload your game to the Media Library or theme and reference it locally:

    [qixit_payperplay pid="your-product-guid-here" image="" price="0.05" button_text="Play Game" title="Snake Game" width="800px" height="600px"]
    <iframe src="/wp-content/uploads/your-game.html" width="100%" height="100%" frameborder="0"></iframe>
    [/qixit_payperplay]

    Example: Pay-Per-Play Video

    Embed self-hosted video content (upload to your Media Library or theme) that requires payment to watch:

    [qixit_payperplay pid="your-product-guid-here" image="" price="0.05" button_text="โ–ถ Watch Video" title="Premium Video" width="560px" height="315px"]
    <video controls autoplay style="width:100%">
        <source src="/wp-content/uploads/your-video.mp4" type="video/mp4">
    </video>
    [/qixit_payperplay]

    Example: Pay-Per-Play Audio (Song or Speech)

    Embed self-hosted audio (upload to your Media Library) that requires payment to listen:

    [qixit_payperplay pid="your-product-guid-here" image="" price="0.02" button_text="โ–ถ Listen Now" title="Premium Audio" width="400px" height="200px"]
    <audio controls autoplay style="width:100%">
        <source src="/wp-content/uploads/your-audio.mp3" type="audio/mpeg">
    </audio>
    [/qixit_payperplay]

    Product Portal Setup

    Before using Pay-Per-Play, you must create the product in the Products Portal:

    1. Log in to the Products Portal
    2. Create a new product or edit an existing one
    3. Set the price (e.g., $0.05)
    4. Important: Set Access Type to “Pay Per Play (Single Use Only)”
    5. Delivery URL is optional. Leave it blank when the content is embedded via shortcode (the shortcode content IS the delivery). If you provide a Delivery URL, it will also open in a new window after payment.
    6. Copy the Product GUID and use it in the pid attribute
    Delivery URL behavior:
    • Shortcode + no Delivery URL: Embedded content (game, audio, video) reveals after payment. This is the typical setup.
    • Shortcode + Delivery URL: Embedded content reveals AND the Delivery URL opens in a new window.
    • Standalone link + Delivery URL: The Delivery URL opens in a new window (the URL is what the buyer is paying for).
    • Standalone link + no Delivery URL: Error — there is nothing to deliver. Always set a Delivery URL when not using the shortcode.

    JavaScript API for Game Developers

    If you're embedding a custom game, you can call QixitFrontend.resetPayPerPlay(containerId) when the game ends to show the placeholder again and require payment for the next play.

    // Call this from your game when play ends:
    window.parent.QixitFrontend.resetPayPerPlay('qixit-payperplay-1');

    ๐ŸŽจ Branding Integration (Developer Standard)

    To keep all sites in sync with platform branding updates, integrations should load branding assets from the manifest endpoint and avoid hardcoded colors in UI overlays/paywalls.

    1. Fetch GET /api/branding/config from the Products API host.
    2. Load urls.css and urls.js from that manifest (versioned assets).
    3. Use branding-provided logo URLs (urls.logoPng / urls.logoSvg). For image purchase/payout badges, prefer the compact logoPng mark next to the price โ€” not a full wordmark.
    4. In local CSS, use tokenized values with fallbacks (for example var(--qixit-brand-start, #52ac5b)) instead of fixed colors.
    5. Keep plugin-specific CSS limited to layout/spacing/behavior, not hardcoded brand palettes.

    This plugin now follows that rule for paywalls, comment purchase tooltips, image link badges, and pay-per-play overlays. If central branding changes later, those surfaces can update across customer sites without per-site code edits.

    ๐Ÿ–ผ๏ธ Micro-Frame Purchase Flow

    When visitors click a "Buy" button or "Read more..." link on premium content, a compact purchase frame (about 380ร—180 pixels when already logged in; taller when login is required) appears directly on the pageโ€”no redirects or popups needed.

    How It Works

    1. Visitor clicks a purchase link/button
    2. A small iframe appears near the click point
    3. The PurchaseFast page loads inside the frame
    4. If not logged in, a compact login form appears
    5. After login, one-click purchase confirmation
    6. Content unlocks automatically upon successful payment

    Read More Style

    Enable the "Use Read More tag" option in the post editor to show free content above the <!--more--> tag, with an inline "Read more..." link that opens the micro-frame. This creates a natural paywall break point.

    Tooltip Preview

    "Read more..." links include a hover tooltip showing the price before clicking, e.g., "Pay 5 cents to continue reading".

    Products Portal

    The micro-frame loads from the Products Portal at https://products.qixit.com.

    ๐Ÿ’ฌ Paid Comments

    Require users to pay before commenting on your posts/pages. When enabled, the comment form is locked until payment is complete.

    How It Works

    1. You enable paid comments on a post/page and set a price
    2. Visitors see the comment section but must pay to unlock it
    3. They click "Pay to Comment" and complete checkout in the purchase frame
    4. They log in (or create a free account) and pay
    5. After payment, they're returned to your post with the comment form unlocked
    6. Their comment is auto-approved (no moderation needed)

    How to Enable Paid Comments

    1. Edit a post or page
    2. In the Qixit Micropayment Settings box, check "Require payment for comments"
    3. Set the Comment Price (e.g., $0.05)
    4. Save the post โ€” the comment product is automatically synced to the API

    Global Paid Comments Fallback (Site-Wide)

    1. Go to 2WayMicropay Settings and enable "Global Paid Comments".
    2. Set a Default Comment Price used when a post/page has no per-post comment price.
    3. When enabled, existing posts/pages are backfilled and future posts/pages inherit paid comments automatically.
    4. The plugin uses a shared comments product as a fallback path (not a unique product for every page by default).

    Priority order: explicit per-post settings and author-share comment products are used first; the global shared comments product is fallback only.

    Per-post override: owners/editors can still change comment price per post, enable a per-post paid-comment product, or allow unpaid comments on a specific post/page.

    What Users See

    Before Payment After Payment
    • Comment form is hidden
    • "Pay $X to Comment" button shown
    • Existing comments visible
    • โœ… "Payment Confirmed!" message
    • Comment form unlocked
    • Comment is auto-approved

    Benefits for You

    • ๐Ÿ’ฐ Revenue - Monetize your comment sections
    • ๐Ÿ›ก๏ธ Spam Prevention - Payment barrier stops spam bots
    • โšก Less Moderation - Paid comments auto-approve
    • ๐Ÿ“ง Quality Engagement - Paying users post thoughtful comments

    โš™๏ธ Content Types Explained

    Type Description Best For
    Regular (Free) No paywall, content is freely accessible Blog posts, teasers, general content
    Pay Once, View Forever User pays once and can access anytime Articles, tutorials, downloads, ebooks
    Pay Per View User pays each time they view Time-sensitive content, live streams, events

    โ“ Troubleshooting

    "Not Connected" error
    The Settings page shows Connected when the Site API Key validates for this account context. Vendor username/password are no longer probed during status checks, preventing IP/email step-up challenges for plugin traffic. For troubleshooting only, use "Test API Connection" in Advanced Diagnostics.
    "Not authenticated" after entering credentials
    Save settings after updating Site API Key so the connection status can revalidate immediately.
    Paywall not showing
    Make sure the post type is set to Premium (not Regular). Products sync automatically when you save; look for the green "โœ… Synced to API" badge.
    Payment link not working
    Verify the product is synced (green badge in the Qixit Micropayment Settings box). Re-saving the post triggers a fresh sync.
    Payout image link shows no overlay and goes straight to destination
    The payout is inactive: either the expiration date has passed, the budget has been exhausted, or the vendor has insufficient funds. Check the payout settings in the Products Portal (products.qixit.com). Extending the date or adding budget reactivates the payout automatically.
    Payout image link shows no overlay and the image is not clickable
    If no Delivery URL was set on the payout link, the link becomes unclickable when inactive. Set a Delivery URL in the Products Portal to ensure the image always links somewhere.
    Image badge missing the dollar/cent amount, or a green “2WayMicropay” oval appears
    Use plugin 4.1.6+. Set the micropay link to a Products Portal purchase/payment URL (not a media URL). See Payout Image Links โ†’ Correct Link Setup above. Demo: Images pay.
    Read More not working
    Make sure you added the <!--more--> tag in your post AND enabled "Use Read More as paywall" in settings.

    ๐Ÿ“ž Support

    Need help? Contact us: