';
}
/**
* 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']) . '
';
}
?>
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.
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
Edit any post or page
Find the "2WayMicropay Settings" box in the sidebar
Select "Premium - Pay Once, View Forever" or "Premium - Pay Per View"
Set your price (min $0.01, max $100.00)
Optionally check "Use Read More as paywall" to show free preview up to the <!--more--> tag
Publish/Update your post โ the product is automatically synced to the API
Method 2: Bulk Actions (Multiple Posts/Pages)
Go to Posts โ All Posts or Pages โ All Pages
Check the boxes next to the posts/pages you want to monetize
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
Click Apply
๐ Using the "Read More" Paywall
The Read More paywall lets you show a free preview of your content:
In your post editor, add the <!--more--> tag (click "Read More" button in editor)
Content before the tag = FREE preview visible to all
Content after the tag = PREMIUM content behind paywall
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:
Edit the post/page
Products sync automatically when you save. Look for the green "โ Synced to API" badge.
Click "๐ Get Payment Link"
Copy the link and share it anywhere (email, social media, etc.)
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).
Enable Allow Forwarder Split and set Forwarder Commission (%).
Save settings, then update/re-sync affected premium posts so Products Portal products receive the new forwardPercentage.
Purchase links generated by this site include an empty &fwd= suffix when forwarder commissions are enabled.
Promoters copy the link, paste it, and type their Qixit username/ID after fwd= (example: ...purchasefast?PID=โฆ&fwd=promotername).
If fwd is missing or the ID is invalid/inactive, that commission percentage stays with the vendor โ the purchase still completes normally.
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.
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.
Set Author Rights Fee (one-time onboarding payment).
Optional: Set Per Publish Fee (charged only on first publication of a new post).
Set Default Author Revenue Share (%) for new premium content.
Optional: Enable Share Revenue from Comments and set Comment Revenue Share (%).
Enter Author Terms text shown on the author-rights purchase page.
Set Author Instructions URL as the post-payment destination page for all successful author-rights purchases.
Save settings.
Create the Author-Rights Purchase Page
Create a new WordPress page (for example: Become an Author).
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.
Optionally add [qixit_author_rights] to display your Author Terms text (informational only โ no buy button).
Publish the page and add it to site navigation or onboarding emails.
Do not format these shortcodes as Code / inline code in the block editor.
Purchase โฆ Rights validates required signup fields, then launches checkout. Checkout login uses the applicantโs existing Qixit/mailbox password (not the new WordPress password).
The plugin creates/updates reusable site products named for Author or Contributor rights and publication fees.
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
Open a post/page and find the 2WayMicropay Settings metabox.
Set Author Share (%) per post (or keep default from settings).
Ensure the post author has a mapped Qixit ID (profile/user-meta or author settings table).
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.
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
Type
What Visitor Receives
Budget 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:
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
Attribute
Required
Description
src
Yes
URL of the image to display
link
Yes
The payout URL (contains PYID=) or a plain URL
width
No
Image width in pixels or CSS value (e.g., 600 or 300px)
height
No
Image height in pixels
max_width
No
Maximum width of the micropay container (e.g., 500px)
align
No
Image alignment: left, right, or center (text wraps around left/right floats)
alt
No
Alt 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
Create a product in the Products Portal (Delivery URL is optional for Pay Per Play)
Copy the Product GUID (from the product's edit page or URL)
Create/edit a WordPress page and add the shortcode with your game embedded inside
Visitors see a screenshot/placeholder with a "Play Now" button
Clicking triggers the payment micro-frame
After payment, the content loads — audio and video begin playing automatically
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:
Before using Pay-Per-Play, you must create the product in the Products Portal:
Log in to the Products Portal
Create a new product or edit an existing one
Set the price (e.g., $0.05)
Important: Set Access Type to “Pay Per Play (Single Use Only)”
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.
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.
Fetch GET /api/branding/config from the Products API host.
Load urls.css and urls.js from that manifest (versioned assets).
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.
In local CSS, use tokenized values with fallbacks (for example var(--qixit-brand-start, #52ac5b)) instead of fixed colors.
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
Visitor clicks a purchase link/button
A small iframe appears near the click point
The PurchaseFast page loads inside the frame
If not logged in, a compact login form appears
After login, one-click purchase confirmation
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".
Require users to pay before commenting on your posts/pages. When enabled, the comment form is locked until payment is complete.
How It Works
You enable paid comments on a post/page and set a price
Visitors see the comment section but must pay to unlock it
They click "Pay to Comment" and complete checkout in the purchase frame
They log in (or create a free account) and pay
After payment, they're returned to your post with the comment form unlocked
Their comment is auto-approved (no moderation needed)
How to Enable Paid Comments
Edit a post or page
In the Qixit Micropayment Settings box, check "Require payment for comments"
Set the Comment Price (e.g., $0.05)
Save the post โ the comment product is automatically synced to the API
Global Paid Comments Fallback (Site-Wide)
Go to 2WayMicropay Settings and enable "Global Paid Comments".
Set a Default Comment Price used when a post/page has no per-post comment price.
When enabled, existing posts/pages are backfilled and future posts/pages inherit paid comments automatically.
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.
๐ง 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.