auth = Qixit_Auth::get_instance(); $this->logger = Qixit_Logger::get_instance(); $this->api_base_url = qixit_get_api_url(); } /** * Make authenticated API request * * @param string $endpoint API endpoint (e.g., 'api/products') * @param string $method HTTP method (GET, POST, PUT, DELETE) * @param array $body Request body * @return array Response with success, data, and error */ private function request($endpoint, $method = 'GET', $body = null) { $url = trailingslashit($this->api_base_url) . ltrim($endpoint, '/'); // Option B: Site API Key is the machine credential for plugin server calls. $api_key = get_option('qixit_site_api_key', ''); // Backwards compatibility: fall back to old option name if (empty($api_key)) { $api_key = get_option('qixit_developer_api_key', ''); } if (empty($api_key)) { return array( 'success' => false, 'error' => __('Site API Key is required. Add it in Settings to connect this site to Products Portal.', '2waymicropay'), 'status_code' => 401 ); } $headers = array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'X-API-Key' => $api_key ); // Optional legacy bearer token (kept for backward compatibility only). $token = get_option('qixit_jwt_token', ''); if (!empty($token)) { $headers['Authorization'] = 'Bearer ' . $token; } // Send fixed Application ID for this plugin (identifier only). $app_id = defined('QIXIT_APP_ID') ? QIXIT_APP_ID : 'wp-2waymicropay-plugin-v2'; $headers['X-App-ID'] = $app_id; $args = array( 'method' => $method, 'headers' => $headers, // Admin-initiated calls (product sync, authenticated requests). // 8s is generous for a publisher clicking a button; 30s was causing // long hangs when the VM was unreachable. 'timeout' => get_option('qixit_api_timeout', 8), 'sslverify' => true ); if ($body !== null && in_array($method, array('POST', 'PUT', 'PATCH'))) { $args['body'] = json_encode($body); $this->logger->log("API Request Body: " . $args['body']); } $this->logger->log("API Request: {$method} {$url}"); $response = wp_remote_request($url, $args); if (is_wp_error($response)) { $error_message = $response->get_error_message(); $this->logger->log("API Error: {$error_message}", 'error'); return array( 'success' => false, // translators: %s: API error message 'error' => sprintf(__('API request failed: %s', '2waymicropay'), $error_message) ); } $status_code = wp_remote_retrieve_response_code($response); $response_body = wp_remote_retrieve_body($response); $data = json_decode($response_body, true); $this->logger->log("API Response: Status {$status_code}"); // Handle different status codes if ($status_code >= 200 && $status_code < 300) { return array( 'success' => true, 'data' => $data, 'status_code' => $status_code ); } elseif ($status_code === 401) { return array( 'success' => false, 'error' => __('Authorization failed. Verify Site API Key and vendor access for this site.', '2waymicropay'), 'status_code' => 401 ); } else { $error_message = isset($data['message']) ? $data['message'] : __('API request failed', '2waymicropay'); $this->logger->log("API Error: {$error_message} (Status: {$status_code})", 'error'); return array( 'success' => false, 'error' => $error_message, 'status_code' => $status_code, 'data' => $data ); } } /** * Get all products for the authenticated vendor * * @return array Response with products */ public function get_products() { return $this->request('api/products', 'GET'); } /** * Get a single product by GUID (requires authentication) * * @param string $product_guid Product GUID * @return array Response with product data */ public function get_product($product_guid) { return $this->request('api/products/' . $product_guid, 'GET'); } /** * Get public product info by GUID (no authentication required) * Use this for fetching product price/info for display purposes * * @param string $product_guid Product GUID * @return array Response with product data (cost, description, accessHours, etc.) */ public function get_public_product($product_guid, $session = null) { // Request-level cache: the same product GUID is often fetched twice per // page (once in has_user_purchased, once in render_paywall). Avoid the // duplicate HTTP round-trip by caching within this PHP execution. // Include session so A/B assignment is not collapsed across viewers. $session_key = is_string($session) ? trim($session) : ''; $cache_key = $product_guid . '|' . $session_key; static $cache = array(); if (isset($cache[$cache_key])) { return $cache[$cache_key]; } $endpoint = 'api/products/public/' . $product_guid; if ($session_key !== '') { $endpoint .= '?session=' . rawurlencode($session_key); } $result = $this->request_public($endpoint); $cache[$cache_key] = $result; return $result; } /** * Get public payout/payment info by GUID (no authentication required) * Use this for fetching payout price/info for display purposes * * @param string $payout_guid Payout GUID * @return array Response with payout data (payAmount, description, etc.) */ public function get_public_payout($payout_guid) { // Use the public endpoint that doesn't require authentication // Note: Payment controller handles payout public access at /api/payment/public/ return $this->request_public('api/payment/public/' . $payout_guid); } /** * Check if a payout is available (has budget, not expired, vendor has funds) * Used to determine whether to show the payment overlay on images * * @param string $payout_guid Payout GUID * @return array Response with 'available' (bool), 'payAmount' (decimal), 'reason' (string) */ public function check_payout_availability($payout_guid) { return $this->request_public('api/payment/available/' . $payout_guid); } /** * Check whether a specific logged-in user is personally eligible to claim a payout. * Checks per-user constraints only (RepeatDays, IndividualRepeatMax). * Does NOT check global constraints (expiry, budget) — use check_payout_availability() for those. * * Returns array with 'eligible' (bool), 'reason' (string|null), 'deliveryUrl' (string). * On any API error, defaults to eligible=true to avoid false negatives. * * @param string $payout_guid Payout GUID * @param string $user_id User ID (GUID or username) from the qixit_auth cookie * @param string|null $auth_token Optional MailBox JWT from the qixit_auth cookie * @return array */ public function check_user_payout_eligibility($payout_guid, $user_id, $auth_token = null) { $products_api_url = qixit_get_products_api_url(); $url = rtrim($products_api_url, '/') . '/api/payment/user-eligible/' . $payout_guid; $app_id = defined('QIXIT_APP_ID') ? QIXIT_APP_ID : 'wp-2waymicropay-plugin-v2'; $eligibility_headers = array( 'X-User-Id' => $user_id, 'X-API-Key' => get_option('qixit_site_api_key', ''), 'X-App-ID' => $app_id, 'Accept' => 'application/json', ); if (!empty($auth_token)) { $eligibility_headers['Authorization'] = 'Bearer ' . $auth_token; } $response = wp_remote_get($url, array( 'timeout' => 5, 'headers' => $eligibility_headers, )); if (is_wp_error($response)) { return array('eligible' => true, 'reason' => null, 'deliveryUrl' => ''); } $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); if (!is_array($data)) { return array('eligible' => true, 'reason' => null, 'deliveryUrl' => ''); } return array( 'eligible' => isset($data['eligible']) ? (bool)$data['eligible'] : true, 'reason' => isset($data['reason']) ? $data['reason'] : null, 'deliveryUrl' => isset($data['deliveryUrl']) ? $data['deliveryUrl'] : '', ); } /** * Check if a user owns a product (for pay-once-view-forever products) * Used to verify ownership when local records might be missing * * @param string $product_guid Product GUID * @param string $user_id Qixit User ID (GUID) * @return array Response with 'owned' (bool), 'deliveryUrl' (string if owned) */ public function check_ownership($product_guid, $user_id) { $products_api_url = qixit_get_products_api_url(); $url = rtrim($products_api_url, '/') . '/api/purchase/check-ownership/' . $product_guid; $ownership_headers = array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'X-User-Id' => $user_id, ); $args = array( // Frontend page render — must not block visitor for more than 1.5s 'timeout' => 1.5, 'headers' => $ownership_headers, ); $response = wp_remote_get($url, $args); if (is_wp_error($response)) { return array( 'success' => false, 'message' => $response->get_error_message(), ); } $body = wp_remote_retrieve_body($response); $status_code = wp_remote_retrieve_response_code($response); if ($status_code !== 200) { return array( 'success' => false, 'message' => 'HTTP ' . $status_code, ); } $data = json_decode($body, true); return array( 'success' => true, 'data' => $data, ); } /** * Verify a completed purchase transaction (server-side, Site API Key). * * @param string $transaction_id Purchase history GUID. * @param string $product_guid Optional product GUID filter. * @return array */ public function verify_purchase($transaction_id, $product_guid = '') { $transaction_id = sanitize_text_field((string) $transaction_id); if ($transaction_id === '') { return array( 'success' => false, 'message' => 'Missing transaction ID', ); } $endpoint = 'api/purchase/verify/' . rawurlencode($transaction_id); if ($product_guid !== '') { $endpoint = add_query_arg('productGuid', sanitize_text_field($product_guid), $endpoint); } return $this->request($endpoint, 'GET'); } /** * Make a public API request (no authentication required) * * @param string $endpoint API endpoint * @return array Response array with success, data, message */ private function request_public($endpoint) { $products_api_url = qixit_get_products_api_url(); $url = rtrim($products_api_url, '/') . '/' . ltrim($endpoint, '/'); // Site API Key is required for production usage tracking and rate limiting $api_key = get_option('qixit_site_api_key', ''); // Backwards compatibility: fall back to old option name if (empty($api_key)) { $api_key = get_option('qixit_developer_api_key', ''); } $headers = array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ); // Add Site API Key header (required for production) if (!empty($api_key)) { $headers['X-API-Key'] = $api_key; } // Send fixed Application ID for this plugin (identifier only). $app_id = defined('QIXIT_APP_ID') ? QIXIT_APP_ID : 'wp-2waymicropay-plugin-v2'; $headers['X-App-ID'] = $app_id; $args = array( // Public product fetch — blocks frontend page render (paywall display, // has_user_purchased). Must not delay visitors if VM is unreachable. 'timeout' => 1.5, 'headers' => $headers, ); $response = wp_remote_get($url, $args); if (is_wp_error($response)) { return array( 'success' => false, 'message' => $response->get_error_message(), ); } $body = wp_remote_retrieve_body($response); $status_code = wp_remote_retrieve_response_code($response); if ($status_code !== 200) { return array( 'success' => false, 'message' => 'HTTP ' . $status_code, ); } $data = json_decode($body, true); return array( 'success' => true, 'data' => $data, ); } /** * Create a new product * * @param array $product_data Product data * @return array Response with created product */ public function create_product($product_data) { $data = array( 'description' => isset($product_data['description']) ? $product_data['description'] : '', 'cost' => isset($product_data['cost']) ? floatval($product_data['cost']) : 0.01, 'pitchUrl' => isset($product_data['pitchUrl']) ? $product_data['pitchUrl'] : '', 'deliveryUrl' => isset($product_data['deliveryUrl']) ? $product_data['deliveryUrl'] : '', 'commercialCode' => isset($product_data['commercialCode']) ? $product_data['commercialCode'] : 'CONTENT', 'sectionKey' => isset($product_data['sectionKey']) ? $product_data['sectionKey'] : null, 'sectionName' => isset($product_data['sectionName']) ? $product_data['sectionName'] : null, 'affiliateUserId' => isset($product_data['affiliateUserId']) ? $product_data['affiliateUserId'] : '', 'affiliatePercentage' => isset($product_data['affiliatePercentage']) ? intval($product_data['affiliatePercentage']) : 0, 'forwardUserId' => isset($product_data['forwardUserId']) ? $product_data['forwardUserId'] : '', 'forwardPercentage' => isset($product_data['forwardPercentage']) ? intval($product_data['forwardPercentage']) : 0, 'reportUrl' => isset($product_data['reportUrl']) ? $product_data['reportUrl'] : '', 'refundMessage' => isset($product_data['refundMessage']) ? $product_data['refundMessage'] : '', 'refundAddress' => isset($product_data['refundAddress']) ? $product_data['refundAddress'] : '', 'available' => isset($product_data['available']) ? intval($product_data['available']) : 1, 'addressRequired' => isset($product_data['addressRequired']) ? intval($product_data['addressRequired']) : 0, 'newBrowser' => isset($product_data['newBrowser']) ? intval($product_data['newBrowser']) : 0, // accessHours: -1 = forever (pay once), -2 = pay per view (each view), 0 = session only, N = hours 'accessHours' => isset($product_data['accessHours']) ? intval($product_data['accessHours']) : -1 ); // Drop null section fields so API defaulting still applies when unset. if ($data['sectionKey'] === null || $data['sectionKey'] === '') { unset($data['sectionKey']); } if ($data['sectionName'] === null || $data['sectionName'] === '') { unset($data['sectionName']); } return $this->request('api/products', 'POST', $data); } /** * Update an existing product * * @param string $product_guid Product GUID * @param array $product_data Product data * @return array Response with updated product */ public function update_product($product_guid, $product_data) { $data = array( 'description' => isset($product_data['description']) ? $product_data['description'] : '', 'cost' => isset($product_data['cost']) ? floatval($product_data['cost']) : 0.01, 'pitchUrl' => isset($product_data['pitchUrl']) ? $product_data['pitchUrl'] : '', 'deliveryUrl' => isset($product_data['deliveryUrl']) ? $product_data['deliveryUrl'] : '', 'commercialCode' => isset($product_data['commercialCode']) ? $product_data['commercialCode'] : 'CONTENT', 'sectionKey' => isset($product_data['sectionKey']) ? $product_data['sectionKey'] : null, 'sectionName' => isset($product_data['sectionName']) ? $product_data['sectionName'] : null, 'affiliateUserId' => isset($product_data['affiliateUserId']) ? $product_data['affiliateUserId'] : '', 'affiliatePercentage' => isset($product_data['affiliatePercentage']) ? intval($product_data['affiliatePercentage']) : 0, 'forwardUserId' => isset($product_data['forwardUserId']) ? $product_data['forwardUserId'] : '', 'forwardPercentage' => isset($product_data['forwardPercentage']) ? intval($product_data['forwardPercentage']) : 0, 'reportUrl' => isset($product_data['reportUrl']) ? $product_data['reportUrl'] : '', 'refundMessage' => isset($product_data['refundMessage']) ? $product_data['refundMessage'] : '', 'refundAddress' => isset($product_data['refundAddress']) ? $product_data['refundAddress'] : '', 'available' => isset($product_data['available']) ? intval($product_data['available']) : 1, 'addressRequired' => isset($product_data['addressRequired']) ? intval($product_data['addressRequired']) : 0, 'newBrowser' => isset($product_data['newBrowser']) ? intval($product_data['newBrowser']) : 0, // accessHours: -1 = forever (pay once), -2 = pay per view (each view), 0 = session only, N = hours 'accessHours' => isset($product_data['accessHours']) ? intval($product_data['accessHours']) : -1 ); if ($data['sectionKey'] === null || $data['sectionKey'] === '') { unset($data['sectionKey']); } if ($data['sectionName'] === null || $data['sectionName'] === '') { unset($data['sectionName']); } return $this->request('api/products/' . $product_guid, 'PUT', $data); } /** * Delete a product * * @param string $product_guid Product GUID * @return array Response */ public function delete_product($product_guid) { return $this->request('api/products/' . $product_guid, 'DELETE'); } /** * Get all payouts for the authenticated vendor * * @return array Response with payouts */ public function get_payouts() { return $this->request('api/payouts', 'GET'); } /** * Get a single payout by GUID * * @param string $payout_guid Payout GUID * @return array Response with payout data */ public function get_payout($payout_guid) { return $this->request('api/payouts/' . $payout_guid, 'GET'); } /** * Create a new payout * * @param array $payout_data Payout data * @return array Response with created payout */ public function create_payout($payout_data) { $data = array( 'description' => isset($payout_data['description']) ? $payout_data['description'] : '', 'payAmount' => isset($payout_data['payAmount']) ? floatval($payout_data['payAmount']) : 0.01, 'deliveryUrl' => isset($payout_data['deliveryUrl']) ? $payout_data['deliveryUrl'] : '', 'commercialCode' => isset($payout_data['commercialCode']) ? $payout_data['commercialCode'] : 'ADVERTISING', 'repeatDays' => isset($payout_data['repeatDays']) ? intval($payout_data['repeatDays']) : 0, 'expireDate' => isset($payout_data['expireDate']) ? $payout_data['expireDate'] : null, 'maxPay' => isset($payout_data['maxPay']) ? floatval($payout_data['maxPay']) : null, 'individualRepeatMax' => isset($payout_data['individualRepeatMax']) ? intval($payout_data['individualRepeatMax']) : null, 'emailVendor' => isset($payout_data['emailVendor']) ? intval($payout_data['emailVendor']) : 0, 'formPostUrl' => isset($payout_data['formPostUrl']) ? $payout_data['formPostUrl'] : '' ); return $this->request('api/payouts', 'POST', $data); } /** * Update an existing payout * * @param string $payout_guid Payout GUID * @param array $payout_data Payout data * @return array Response with updated payout */ public function update_payout($payout_guid, $payout_data) { $data = array( 'description' => isset($payout_data['description']) ? $payout_data['description'] : '', 'payAmount' => isset($payout_data['payAmount']) ? floatval($payout_data['payAmount']) : 0.01, 'deliveryUrl' => isset($payout_data['deliveryUrl']) ? $payout_data['deliveryUrl'] : '', 'commercialCode' => isset($payout_data['commercialCode']) ? $payout_data['commercialCode'] : 'ADVERTISING', 'repeatDays' => isset($payout_data['repeatDays']) ? intval($payout_data['repeatDays']) : 0, 'expireDate' => isset($payout_data['expireDate']) ? $payout_data['expireDate'] : null, 'maxPay' => isset($payout_data['maxPay']) ? floatval($payout_data['maxPay']) : null, 'individualRepeatMax' => isset($payout_data['individualRepeatMax']) ? intval($payout_data['individualRepeatMax']) : null, 'emailVendor' => isset($payout_data['emailVendor']) ? intval($payout_data['emailVendor']) : 0, 'formPostUrl' => isset($payout_data['formPostUrl']) ? $payout_data['formPostUrl'] : '' ); return $this->request('api/payouts/' . $payout_guid, 'PUT', $data); } /** * Delete a payout * * @param string $payout_guid Payout GUID * @return array Response */ public function delete_payout($payout_guid) { return $this->request('api/payouts/' . $payout_guid, 'DELETE'); } /** * Get all commercial categories from the Products Portal * This is a public endpoint (no authentication required) * * @return array Response with categories array containing 'code', 'categoryName', 'description' */ public function get_categories() { return $this->request_public('api/categories'); } /** * Get purchase URL for a product (PurchaseFast page) * * @param string $product_guid Product GUID * @param string $affiliate_id Optional affiliate ID * @param string $forwarder_id Optional forwarder ID * @return string Purchase URL */ public function get_purchase_url($product_guid, $affiliate_id = '', $forwarder_id = '') { // Get the products-web URL using the centralized function $products_web_url = qixit_get_products_web_url(); // Build PurchaseFast URL $url = trailingslashit($products_web_url) . 'purchasefast?PID=' . urlencode($product_guid); // Add optional parameters if (!empty($affiliate_id)) { $url .= '&aff=' . urlencode($affiliate_id); } $fwd_enabled = function_exists('qixit_is_forwarder_commission_enabled') && qixit_is_forwarder_commission_enabled(); if ($fwd_enabled) { $fwd = is_string($forwarder_id) ? $forwarder_id : ''; // Public attribution query arg on purchase links (not a privileged form action). // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- optional ?fwd= is public promoter attribution, sanitized below. if ($fwd === '' && isset($_GET['fwd'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- same public ?fwd= attribution read. $fwd = sanitize_text_field(wp_unslash($_GET['fwd'])); } // Always append &fwd= when enabled so promoters can copy the link and type an ID. $url .= '&fwd=' . rawurlencode($fwd); } elseif (!empty($forwarder_id)) { $url .= '&fwd=' . rawurlencode($forwarder_id); } return $url; } } // Initialize singleton Qixit_API::get_instance();