From 41a812fdd612c9976ae0e70fe7924cda89924612 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 16:14:47 +0000 Subject: [PATCH] feat: add PayPal sandbox/live credentials, webhook endpoint, and admin diagnostics - config.inc.php: new sandbox/live credential structure with paypal_mode, separate sandbox/live client_id, client_secret, webhook_id, and webhook_path - config.example.php: updated to match new structure - config_loader.php: adds defaults and backward compat mapping from old $paypal_sandbox/$paypal_client_id variables; adds gsp_paypal_* helper functions - PayPalGateway.php: fromConfig() uses gsp_paypal_* helpers with fallback - cart.php: uses gsp_paypal_get_client_id()/gsp_paypal_is_sandbox() helpers - webhook.php: updated to use gsp_paypal_* helpers for credentials/API base - paypal/webhook.php: new full-featured webhook receiver with signature verification, idempotency log, event processing, provisioning trigger - admin_config.php: expanded to separate sandbox/live fields, computed webhook URL, diagnostics panel showing credential status and recent webhook events - module.php: bumped to v3.3/db_version 3, adds billing_paypal_webhook_events table Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/f974e469-8562-41df-ba37-bc340f5a154c Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- modules/billing/admin_config.php | 344 ++++++++-- modules/billing/cart.php | 8 +- modules/billing/classes/PayPalGateway.php | 14 +- modules/billing/includes/config.example.php | 26 +- modules/billing/includes/config.inc.php | 36 +- modules/billing/includes/config_loader.php | 132 +++- modules/billing/module.php | 28 +- modules/billing/paypal/webhook.php | 711 ++++++++++++++++++++ modules/billing/webhook.php | 161 ++++- 9 files changed, 1351 insertions(+), 109 deletions(-) create mode 100644 modules/billing/paypal/webhook.php diff --git a/modules/billing/admin_config.php b/modules/billing/admin_config.php index 515ef13c..fe0233b1 100644 --- a/modules/billing/admin_config.php +++ b/modules/billing/admin_config.php @@ -108,11 +108,22 @@ function billing_admin_build_config(string $existingContent, array $vals): strin return '"' . addslashes($v) . '"'; }; - $sandbox = (bool)$vals['paypal_sandbox']; - $retention = max(1, min(10, (int)($vals['backup_retention'] ?? 5))); - $baseUrl = trim($vals['SITE_BASE_URL'] ?? ''); - $bg = trim($vals['SITE_BACKGROUND'] ?? 'images/dark.jpg'); - $dataDir = trim($vals['SITE_DATA_DIR'] ?? ''); + $mode = (strtolower($vals['paypal_mode'] ?? 'sandbox') === 'live') ? 'live' : 'sandbox'; + $retention = max(1, min(10, (int)($vals['backup_retention'] ?? 5))); + $baseUrl = rtrim(trim($vals['SITE_BASE_URL'] ?? ''), '/'); + $bg = trim($vals['SITE_BACKGROUND'] ?? 'images/dark.jpg'); + $dataDir = trim($vals['SITE_DATA_DIR'] ?? ''); + $wh_path = '/' . ltrim(trim($vals['paypal_webhook_path'] ?? '/paypal/webhook.php'), '/'); + + // Sandbox credentials — never erase existing secret if field was left blank + $sb_id = trim($vals['paypal_sandbox_client_id'] ?? ''); + $sb_sec = trim($vals['paypal_sandbox_client_secret'] ?? ''); + $sb_wh = trim($vals['paypal_sandbox_webhook_id'] ?? ''); + + // Live credentials — never erase existing secret if field was left blank + $lv_id = trim($vals['paypal_live_client_id'] ?? ''); + $lv_sec = trim($vals['paypal_live_client_secret'] ?? ''); + $lv_wh = trim($vals['paypal_live_webhook_id'] ?? ''); $dbBlock = ''; foreach (['db_host', 'db_port', 'db_user', 'db_pass', 'db_name', 'table_prefix', 'db_type'] as $var) { @@ -135,26 +146,36 @@ function billing_admin_build_config(string $existingContent, array $vals): strin . '###############################################' . "\n" . $dbBlock . "\n" - . '// Optional: base URL used by admin pages to build absolute image previews.' . "\n" - . '// Leave empty to prefer relative paths (local folder).' . "\n" + . '// Optional: base URL without trailing slash (e.g. https://gameservers.world).' . "\n" + . '// Leave empty to use relative paths.' . "\n" . '$SITE_BASE_URL = ' . $q($baseUrl) . ';' . "\n" - . "\n" - . '// Normalize: ensure either empty or ends without trailing slash' . "\n" - . '$SITE_BASE_URL = trim((string)$SITE_BASE_URL);' . "\n" + . '$SITE_BASE_URL = rtrim(trim((string)$SITE_BASE_URL), \'/\');' . "\n" . "\n" . '// Site-wide background image (relative to site root).' . "\n" . '$SITE_BACKGROUND = ' . $q($bg) . ';' . "\n" - . '// Normalize' . "\n" . '$SITE_BACKGROUND = trim((string)$SITE_BACKGROUND);' . "\n" . "\n" - . '// Data directory for persisted payment webhook JSON files (relative to repo root)' . "\n" + . '// Data directory for persisted payment webhook JSON files.' . "\n" . $dataDirLine . "\n" . "\n" - . '// PayPal configuration — set credentials here, never in API files' . "\n" - . '$paypal_sandbox = ' . ($sandbox ? 'true' : 'false') . '; // Set to false for live payments' . "\n" - . '$paypal_client_id = ' . $q($vals['paypal_client_id'] ?? '') . '; // Your PayPal Client ID' . "\n" - . '$paypal_client_secret = ' . $q($vals['paypal_client_secret'] ?? '') . '; // Your PayPal Client Secret' . "\n" - . '$paypal_webhook_id = ' . $q($vals['paypal_webhook_id'] ?? '') . '; // Your PayPal Webhook ID' . "\n" + . '// ---------------------------------------------------------------------------' . "\n" + . '// PayPal configuration' . "\n" + . '// ---------------------------------------------------------------------------' . "\n" + . '$paypal_mode = ' . $q($mode) . '; // \'sandbox\' or \'live\'' . "\n" + . "\n" + . '// Sandbox credentials (PayPal Developer Dashboard → sandbox app)' . "\n" + . '$paypal_sandbox_client_id = ' . $q($sb_id) . ';' . "\n" + . '$paypal_sandbox_client_secret = ' . $q($sb_sec) . ';' . "\n" + . '$paypal_sandbox_webhook_id = ' . $q($sb_wh) . ';' . "\n" + . "\n" + . '// Live credentials (leave blank until ready for production)' . "\n" + . '$paypal_live_client_id = ' . $q($lv_id) . ';' . "\n" + . '$paypal_live_client_secret = ' . $q($lv_sec) . ';' . "\n" + . '$paypal_live_webhook_id = ' . $q($lv_wh) . ';' . "\n" + . "\n" + . '// Webhook path (relative to billing site root, must start with /)' . "\n" + . '// Full public URL = $SITE_BASE_URL + $paypal_webhook_path' . "\n" + . '$paypal_webhook_path = ' . $q($wh_path) . ';' . "\n" . "\n" . '// Admin config backup retention: how many backups to keep (1–10). Default 5.' . "\n" . '$SITE_CONFIG_BACKUP_RETENTION = ' . $retention . ';' . "\n" @@ -165,16 +186,25 @@ function billing_admin_build_config(string $existingContent, array $vals): strin // Read current values from config (already loaded by config_loader above). // --------------------------------------------------------------------------- $cfgVals = [ - 'SITE_BASE_URL' => $SITE_BASE_URL ?? '', - 'SITE_BACKGROUND' => $SITE_BACKGROUND ?? 'images/dark.jpg', - 'SITE_DATA_DIR' => isset($SITE_DATA_DIR) ? $SITE_DATA_DIR : '', - 'paypal_sandbox' => $paypal_sandbox ?? true, - 'paypal_client_id' => $paypal_client_id ?? '', - 'paypal_client_secret' => $paypal_client_secret ?? '', - 'paypal_webhook_id' => $paypal_webhook_id ?? '', - 'backup_retention' => $SITE_CONFIG_BACKUP_RETENTION ?? 5, + 'SITE_BASE_URL' => $SITE_BASE_URL ?? '', + 'SITE_BACKGROUND' => $SITE_BACKGROUND ?? 'images/dark.jpg', + 'SITE_DATA_DIR' => $SITE_DATA_DIR ?? '', + 'paypal_mode' => $paypal_mode ?? 'sandbox', + 'paypal_sandbox_client_id' => $paypal_sandbox_client_id ?? '', + 'paypal_sandbox_client_secret' => $paypal_sandbox_client_secret ?? '', + 'paypal_sandbox_webhook_id' => $paypal_sandbox_webhook_id ?? '', + 'paypal_live_client_id' => $paypal_live_client_id ?? '', + 'paypal_live_client_secret' => $paypal_live_client_secret ?? '', + 'paypal_live_webhook_id' => $paypal_live_webhook_id ?? '', + 'paypal_webhook_path' => $paypal_webhook_path ?? '/paypal/webhook.php', + 'backup_retention' => $SITE_CONFIG_BACKUP_RETENTION ?? 5, ]; +// Computed full webhook URL for display +$computedWebhookUrl = function_exists('gsp_paypal_get_full_webhook_url') + ? gsp_paypal_get_full_webhook_url() + : rtrim($cfgVals['SITE_BASE_URL'], '/') . $cfgVals['paypal_webhook_path']; + // Detect panel-mode (DB settings are managed by the panel) $panelMode = defined('BILLING_PANEL_CONFIG_PATH'); $panelCfgPath = $panelMode ? BILLING_PANEL_CONFIG_PATH : null; @@ -196,16 +226,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_ } else { // Collect and validate form values $formVals = [ - 'SITE_BASE_URL' => trim($_POST['SITE_BASE_URL'] ?? ''), - 'SITE_BACKGROUND' => trim($_POST['SITE_BACKGROUND'] ?? 'images/dark.jpg'), - 'SITE_DATA_DIR' => trim($_POST['SITE_DATA_DIR'] ?? ''), - 'paypal_sandbox' => (($_POST['paypal_sandbox'] ?? 'true') === 'true'), - 'paypal_client_id' => trim($_POST['paypal_client_id'] ?? ''), - 'paypal_client_secret' => trim($_POST['paypal_client_secret'] ?? ''), - 'paypal_webhook_id' => trim($_POST['paypal_webhook_id'] ?? ''), - 'backup_retention' => (int)($_POST['backup_retention'] ?? 5), + 'SITE_BASE_URL' => trim($_POST['SITE_BASE_URL'] ?? ''), + 'SITE_BACKGROUND' => trim($_POST['SITE_BACKGROUND'] ?? 'images/dark.jpg'), + 'SITE_DATA_DIR' => trim($_POST['SITE_DATA_DIR'] ?? ''), + 'paypal_mode' => (strtolower(trim($_POST['paypal_mode'] ?? 'sandbox')) === 'live') ? 'live' : 'sandbox', + 'paypal_sandbox_client_id' => trim($_POST['paypal_sandbox_client_id'] ?? ''), + 'paypal_live_client_id' => trim($_POST['paypal_live_client_id'] ?? ''), + 'paypal_sandbox_webhook_id' => trim($_POST['paypal_sandbox_webhook_id'] ?? ''), + 'paypal_live_webhook_id' => trim($_POST['paypal_live_webhook_id'] ?? ''), + 'paypal_webhook_path' => trim($_POST['paypal_webhook_path'] ?? '/paypal/webhook.php'), + 'backup_retention' => (int)($_POST['backup_retention'] ?? 5), ]; + // Client secrets: only update if a non-blank value was submitted (never erase existing). + $sbSecPost = trim($_POST['paypal_sandbox_client_secret'] ?? ''); + $formVals['paypal_sandbox_client_secret'] = ($sbSecPost !== '') ? $sbSecPost : ($cfgVals['paypal_sandbox_client_secret'] ?? ''); + + $lvSecPost = trim($_POST['paypal_live_client_secret'] ?? ''); + $formVals['paypal_live_client_secret'] = ($lvSecPost !== '') ? $lvSecPost : ($cfgVals['paypal_live_client_secret'] ?? ''); + // Validate $validationError = ''; if ($formVals['backup_retention'] < 1 || $formVals['backup_retention'] > 10) { @@ -243,7 +282,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_ $retention = max(1, min(10, $formVals['backup_retention'])); billing_admin_apply_retention($bakDir, $retention); - $cfgVals = $formVals; // update displayed values + $cfgVals = $formVals; // update displayed values + $computedWebhookUrl = rtrim($formVals['SITE_BASE_URL'], '/') . ('/' . ltrim($formVals['paypal_webhook_path'] ?? '/paypal/webhook.php', '/')); $status = 'Config saved successfully. Backup: ' . basename($bakName); $statusType = 'success'; } @@ -398,8 +438,8 @@ rsort($bakFiles); // newest first
- Full base URL without trailing slash (e.g. https://gameservers.world). - Leave empty to use relative paths. + Full base URL without trailing slash (e.g. https://gameservers.world). + Leave empty to use relative paths. Used to compute the full public PayPal webhook URL.

PayPal Configuration

- + +
+ Currently active PayPal mode: +
+ +
- +
- Use Sandbox for testing, Live for real payments. - Make sure you use the matching Client ID and Secret for the selected mode. + Sandbox uses test credentials and the PayPal sandbox API — safe for development. + Live processes real payments. Switch only after configuring live credentials.
- + +
- + +

Sandbox Credentials

- -
- Your PayPal app Client ID. Safe to expose in browser JS. - Found in your PayPal Developer Dashboard under your app credentials. -
- + +
Found in PayPal Developer Dashboard → sandbox app. Safe to expose in browser JS.
+
- -
- -
- Your PayPal app Client Secret. Server-side only — never sent to the browser. -
+ +
Server-side only — never sent to the browser. Leave blank to keep existing value.
- + onclick="var f=document.getElementById('cfg_sb_sec');f.type=f.type==='password'?'text':'password';this.textContent=f.type==='password'?'Show':'Hide';">Show
+
+ +
+ Webhook ID from your PayPal sandbox app (for signature verification). + Leave empty to skip verification in sandbox mode (OK for initial setup). +
+ +
- + +

Live Credentials

- -
- Webhook ID from your PayPal app (used for webhook signature verification). - Leave empty to skip signature verification (not recommended for production). -
- + +
From your PayPal live app. Leave blank until ready for production.
+
+
+ +
Server-side only. Leave blank to keep existing value.
+
+ + +
+
+
+ +
Webhook ID from your PayPal live app (for signature verification).
+ +
+ + +

Webhook Endpoint

+
+ PayPal requires a full public HTTPS URL to deliver webhook events. + Set your Site Base URL above, then copy the computed URL below into your PayPal app's webhook configuration. +
+
+ +
Path relative to the billing site root (must start with /). Default: /paypal/webhook.php
+ +
+
+ +
+ This is the URL PayPal will POST webhook events to. + It must be publicly accessible over HTTPS before enabling live mode. +
+ + +
+

Backup Settings

@@ -509,7 +620,94 @@ rsort($bakFiles); // newest first
+ ' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ''; + } + + // Last webhook events + $diag_recent_events = []; + try { + $port_int = intval($db_port ?? 3306) ?: 3306; + $diag_db = @mysqli_connect($db_host ?? 'localhost', $db_user ?? '', $db_pass ?? '', $db_name ?? '', $port_int); + if ($diag_db) { + $pfx_diag = $table_prefix ?? 'gsp_'; + $res = @mysqli_query($diag_db, "SELECT paypal_event_id, event_type, processing_status, created_at FROM `{$pfx_diag}billing_paypal_webhook_events` ORDER BY id DESC LIMIT 5"); + if ($res) { + while ($row = mysqli_fetch_assoc($res)) { + $diag_recent_events[] = $row; + } + } + mysqli_close($diag_db); + } + } catch (Throwable $e) { + // non-fatal + } + ?> +
+

PayPal Diagnostics

+ + + + + + + + + + +
Current mode
Active Client ID configured
Active Client Secret configured
Active Webhook ID configured
Sandbox credentialsID:   Secret:   Webhook ID:
Live credentialsID:   Secret:   Webhook ID:
Webhook path
Full public webhook URL
Webhook file exists on disk
+ + +

Recent Webhook Events

+ + + + + + + + + + + + + + + + + +
PayPal Event IDTypeStatusReceived
+ +

No webhook events recorded yet. Events will appear here after PayPal delivers the first webhook to .

+ +
+ +

Advanced: Raw Config Editor

diff --git a/modules/billing/cart.php b/modules/billing/cart.php index a9b05516..64a94935 100644 --- a/modules/billing/cart.php +++ b/modules/billing/cart.php @@ -253,8 +253,8 @@ if ($applied_coupon && $coupon_discount_percent > 0) { $final_amount = $total_amount - $discount_amount; // PayPal configuration (from config) -$sandbox = $paypal_sandbox ?? true; -$client_id = $paypal_client_id ?? ''; +$client_id = function_exists('gsp_paypal_get_client_id') ? gsp_paypal_get_client_id() : ($paypal_client_id ?? ''); +$sandbox = function_exists('gsp_paypal_is_sandbox') ? gsp_paypal_is_sandbox() : ($paypal_sandbox ?? true); // Prepare PayPal items $paypal_items = []; @@ -510,7 +510,7 @@ $siteBase = $protocol . $host; - + @@ -659,7 +659,7 @@ $siteBase = $protocol . $host; } if ($cart_is_admin): ?> -
Admin: set $paypal_client_id in Site Config. +
Admin: configure PayPal credentials in Site Config.
diff --git a/modules/billing/classes/PayPalGateway.php b/modules/billing/classes/PayPalGateway.php index 5aeb27aa..fd7b5b26 100644 --- a/modules/billing/classes/PayPalGateway.php +++ b/modules/billing/classes/PayPalGateway.php @@ -20,13 +20,19 @@ class PayPalGateway implements PaymentGatewayInterface /** * Build a PayPalGateway instance from global config variables. - * Expects $paypal_client_id, $paypal_client_secret, $paypal_sandbox in scope. + * Prefers the new gsp_paypal_* helper functions; falls back to legacy globals. */ public static function fromConfig(): self { - $clientId = $GLOBALS['paypal_client_id'] ?? ''; - $clientSecret = $GLOBALS['paypal_client_secret'] ?? ''; - $sandbox = (bool)($GLOBALS['paypal_sandbox'] ?? true); + if (function_exists('gsp_paypal_get_client_id')) { + $clientId = gsp_paypal_get_client_id(); + $clientSecret = gsp_paypal_get_client_secret(); + $sandbox = gsp_paypal_is_sandbox(); + } else { + $clientId = $GLOBALS['paypal_client_id'] ?? ''; + $clientSecret = $GLOBALS['paypal_client_secret'] ?? ''; + $sandbox = (bool)($GLOBALS['paypal_sandbox'] ?? true); + } return new self($clientId, $clientSecret, $sandbox); } diff --git a/modules/billing/includes/config.example.php b/modules/billing/includes/config.example.php index dbee9b4f..31f9843c 100644 --- a/modules/billing/includes/config.example.php +++ b/modules/billing/includes/config.example.php @@ -22,9 +22,8 @@ $table_prefix = "gsp_"; // Table prefix used in the panel database $db_type = "mysql"; # --- Site base URL --- -# Leave empty to use relative paths (works for any install path). -# Set to your full base URL (without trailing slash) if you need absolute URLs: -# e.g. "https://gameservers.world" or "http://173.208.136.11/testing/modules/billing" +# Full base URL WITHOUT trailing slash. Leave empty to use relative paths. +# Example: "https://gameservers.world" or "https://your-domain.com" $SITE_BASE_URL = ''; # --- Background image --- @@ -37,10 +36,23 @@ $SITE_BACKGROUND = 'images/dark.jpg'; $SITE_DATA_DIR = realpath(__DIR__ . '/..') . DIRECTORY_SEPARATOR . 'data'; # --- PayPal settings --- -$paypal_sandbox = true; // Set to false for live payments -$paypal_client_id = ''; // Your PayPal Client ID -$paypal_client_secret = ''; // Your PayPal Client Secret -$paypal_webhook_id = ''; // Your PayPal Webhook ID (for webhook signature verification) +# Mode: 'sandbox' for testing, 'live' for real payments. +$paypal_mode = 'sandbox'; + +# Sandbox credentials (PayPal Developer Dashboard → sandbox app) +$paypal_sandbox_client_id = ''; // e.g. AfvY_... +$paypal_sandbox_client_secret = ''; // Keep server-side only +$paypal_sandbox_webhook_id = ''; // Set after registering webhook in PayPal + +# Live credentials (leave blank until ready for production) +$paypal_live_client_id = ''; +$paypal_live_client_secret = ''; +$paypal_live_webhook_id = ''; + +# Webhook path (relative to billing site root, must start with /) +# Full public URL = $SITE_BASE_URL + $paypal_webhook_path +# Example full URL: https://gameservers.world/paypal/webhook.php +$paypal_webhook_path = '/paypal/webhook.php'; # --- Admin config backup retention --- # Number of config backups to keep (1–10). Oldest backups beyond this limit are deleted. diff --git a/modules/billing/includes/config.inc.php b/modules/billing/includes/config.inc.php index b389e452..3f014096 100644 --- a/modules/billing/includes/config.inc.php +++ b/modules/billing/includes/config.inc.php @@ -3,7 +3,7 @@ # Website Database Configuration # This file contains the database connection # settings for the _website standalone site. -# +# # These settings should match the panel's # database configuration in includes/config.inc.php ############################################### @@ -14,14 +14,15 @@ $db_pass="Pkloyn7yvpht!"; $db_name="panel"; $table_prefix="gsp_"; $db_type="mysql"; + // Optional: base URL used by admin pages to build absolute image previews. // Leave empty to prefer relative paths (local folder). // To enable production base URL, uncomment and set it to your site, e.g.: -// $SITE_BASE_URL = 'https://gameservers.world/'; +// $SITE_BASE_URL = 'https://gameservers.world'; $SITE_BASE_URL = ''; -// Normalize: ensure either empty or ends without trailing slash (we use join_base to handle joining) -$SITE_BASE_URL = trim((string)$SITE_BASE_URL); +// Normalize: ensure either empty or ends without trailing slash +$SITE_BASE_URL = rtrim(trim((string)$SITE_BASE_URL), '/'); // Site-wide background image (relative to site root). Change to your preferred background. $SITE_BACKGROUND = 'images/dark.jpg'; @@ -31,11 +32,28 @@ $SITE_BACKGROUND = trim((string)$SITE_BACKGROUND); // Data directory for persisted payment webhook JSON files (relative to repo root) $SITE_DATA_DIR = realpath(__DIR__ . '/..') . DIRECTORY_SEPARATOR . 'data'; -// PayPal configuration — set credentials here, never in API files -$paypal_sandbox = true; // Set to false for live payments -$paypal_client_id = ''; // Your PayPal Client ID -$paypal_client_secret = ''; // Your PayPal Client Secret -$paypal_webhook_id = ''; // Your PayPal Webhook ID (for webhook signature verification) +// --------------------------------------------------------------------------- +// PayPal configuration +// Set credentials here — never in API files or public pages. +// +// Mode: 'sandbox' for testing, 'live' for real payments. +// --------------------------------------------------------------------------- +$paypal_mode = 'sandbox'; // 'sandbox' or 'live' + +// Sandbox credentials (use for testing — safe to share with the dev team) +$paypal_sandbox_client_id = 'AfvY_C2zA_hTHxHq7TIhtOeub4xBdySYrt_Hjj3d_WYQwjWI9NfOAVOTeResx2rgZ_nP5tOoxQSAHw8c'; +$paypal_sandbox_client_secret = 'EJ216np9cAj9n7KSddez3fLVxGe-zi4oKKKl1YGqPp88XIikr4Qzbxh0XW2as-V6LgdX-upjtQAg9dC0'; +$paypal_sandbox_webhook_id = ''; // Set after registering the webhook in PayPal sandbox dashboard + +// Live credentials (leave blank until ready for production) +$paypal_live_client_id = ''; +$paypal_live_client_secret = ''; +$paypal_live_webhook_id = ''; + +// Webhook path (relative to billing site root, must start with /) +// Full public URL = $SITE_BASE_URL + $paypal_webhook_path +// e.g. https://gameservers.world/paypal/webhook.php +$paypal_webhook_path = '/paypal/webhook.php'; // Admin config backup retention: how many backups to keep (1–10). Default 5. $SITE_CONFIG_BACKUP_RETENTION = 5; diff --git a/modules/billing/includes/config_loader.php b/modules/billing/includes/config_loader.php index 3f0699dc..d72d15a2 100644 --- a/modules/billing/includes/config_loader.php +++ b/modules/billing/includes/config_loader.php @@ -207,21 +207,71 @@ unset($_billing_panel_config, $panelDbVars, $diskVars, $needsSync, $k, $v); // Step 4: Apply safe defaults for billing-specific variables that may be absent // in older config files (never overwrite values already set by the config). // --------------------------------------------------------------------------- + +// --- PayPal mode (new-style) --- +// Backward compat: if $paypal_sandbox was set (old config) and $paypal_mode is absent, +// derive $paypal_mode from $paypal_sandbox. +if (!isset($paypal_mode)) { + if (isset($paypal_sandbox)) { + $paypal_mode = $paypal_sandbox ? 'sandbox' : 'live'; + } else { + $paypal_mode = 'sandbox'; + } +} +$paypal_mode = (strtolower((string)$paypal_mode) === 'live') ? 'live' : 'sandbox'; + +// --- Sandbox credentials --- +if (!isset($paypal_sandbox_client_id)) { + // Backward compat: if old $paypal_client_id was set while in sandbox mode, use it + $paypal_sandbox_client_id = (isset($paypal_client_id) && $paypal_mode === 'sandbox') ? $paypal_client_id : ''; +} +if (!isset($paypal_sandbox_client_secret)) { + $paypal_sandbox_client_secret = (isset($paypal_client_secret) && $paypal_mode === 'sandbox') ? $paypal_client_secret : ''; +} +if (!isset($paypal_sandbox_webhook_id)) { + $paypal_sandbox_webhook_id = (isset($paypal_webhook_id) && $paypal_mode === 'sandbox') ? $paypal_webhook_id : ''; +} + +// --- Live credentials --- +if (!isset($paypal_live_client_id)) { + $paypal_live_client_id = (isset($paypal_client_id) && $paypal_mode === 'live') ? $paypal_client_id : ''; +} +if (!isset($paypal_live_client_secret)) { + $paypal_live_client_secret = (isset($paypal_client_secret) && $paypal_mode === 'live') ? $paypal_client_secret : ''; +} +if (!isset($paypal_live_webhook_id)) { + $paypal_live_webhook_id = (isset($paypal_webhook_id) && $paypal_mode === 'live') ? $paypal_webhook_id : ''; +} + +// --- Legacy compatibility shims (read-only derived values) --- +// Keep old variable names populated so any code that still reads $paypal_sandbox, +// $paypal_client_id, $paypal_client_secret, $paypal_webhook_id keeps working. if (!isset($paypal_sandbox)) { - $paypal_sandbox = true; + $paypal_sandbox = ($paypal_mode !== 'live'); } if (!isset($paypal_client_id)) { - $paypal_client_id = ''; + $paypal_client_id = ($paypal_mode === 'live') ? $paypal_live_client_id : $paypal_sandbox_client_id; } if (!isset($paypal_client_secret)) { - $paypal_client_secret = ''; + $paypal_client_secret = ($paypal_mode === 'live') ? $paypal_live_client_secret : $paypal_sandbox_client_secret; } if (!isset($paypal_webhook_id)) { - $paypal_webhook_id = ''; + $paypal_webhook_id = ($paypal_mode === 'live') ? $paypal_live_webhook_id : $paypal_sandbox_webhook_id; } + +// --- Webhook path --- +if (!isset($paypal_webhook_path) || (string)$paypal_webhook_path === '') { + $paypal_webhook_path = '/paypal/webhook.php'; +} +// Ensure webhook path starts with / +$paypal_webhook_path = '/' . ltrim((string)$paypal_webhook_path, '/'); + +// --- Site settings --- if (!isset($SITE_BASE_URL)) { $SITE_BASE_URL = ''; } +$SITE_BASE_URL = rtrim(trim((string)$SITE_BASE_URL), '/'); + if (!isset($SITE_BACKGROUND)) { $SITE_BACKGROUND = 'images/dark.jpg'; } @@ -234,3 +284,77 @@ if (!isset($SITE_CONFIG_BACKUP_RETENTION) || !is_int($SITE_CONFIG_BACKUP_RETENTI $SITE_CONFIG_BACKUP_RETENTION = max(1, min(10, (int)$SITE_CONFIG_BACKUP_RETENTION)); define('BILLING_CONFIG_LOADED', true); + +// --------------------------------------------------------------------------- +// PayPal helper functions — use these everywhere instead of reading globals. +// --------------------------------------------------------------------------- + +if (!function_exists('gsp_paypal_get_mode')) { + function gsp_paypal_get_mode(): string + { + return $GLOBALS['paypal_mode'] === 'live' ? 'live' : 'sandbox'; + } +} + +if (!function_exists('gsp_paypal_is_sandbox')) { + function gsp_paypal_is_sandbox(): bool + { + return gsp_paypal_get_mode() !== 'live'; + } +} + +if (!function_exists('gsp_paypal_get_client_id')) { + function gsp_paypal_get_client_id(): string + { + if (gsp_paypal_is_sandbox()) { + return (string)($GLOBALS['paypal_sandbox_client_id'] ?? ''); + } + return (string)($GLOBALS['paypal_live_client_id'] ?? ''); + } +} + +if (!function_exists('gsp_paypal_get_client_secret')) { + function gsp_paypal_get_client_secret(): string + { + if (gsp_paypal_is_sandbox()) { + return (string)($GLOBALS['paypal_sandbox_client_secret'] ?? ''); + } + return (string)($GLOBALS['paypal_live_client_secret'] ?? ''); + } +} + +if (!function_exists('gsp_paypal_get_webhook_id')) { + function gsp_paypal_get_webhook_id(): string + { + if (gsp_paypal_is_sandbox()) { + return (string)($GLOBALS['paypal_sandbox_webhook_id'] ?? ''); + } + return (string)($GLOBALS['paypal_live_webhook_id'] ?? ''); + } +} + +if (!function_exists('gsp_paypal_get_api_base')) { + function gsp_paypal_get_api_base(): string + { + return gsp_paypal_is_sandbox() + ? 'https://api-m.sandbox.paypal.com' + : 'https://api-m.paypal.com'; + } +} + +if (!function_exists('gsp_paypal_get_webhook_path')) { + function gsp_paypal_get_webhook_path(): string + { + $path = (string)($GLOBALS['paypal_webhook_path'] ?? '/paypal/webhook.php'); + return '/' . ltrim($path, '/'); + } +} + +if (!function_exists('gsp_paypal_get_full_webhook_url')) { + function gsp_paypal_get_full_webhook_url(): string + { + $base = rtrim((string)($GLOBALS['SITE_BASE_URL'] ?? ''), '/'); + $path = gsp_paypal_get_webhook_path(); + return $base . $path; + } +} diff --git a/modules/billing/module.php b/modules/billing/module.php index 60fc28f1..e3bb7615 100644 --- a/modules/billing/module.php +++ b/modules/billing/module.php @@ -24,8 +24,8 @@ // Module general information $module_title = "billing"; -$module_version = "3.2"; -$db_version = 2; +$module_version = "3.3"; +$db_version = 3; $module_required = FALSE; // Module description $module_description = "Billing storefront / provisioning integration. Public ordering runs as a standalone site; panel pages provide provisioning and admin order management."; @@ -322,4 +322,28 @@ $install_queries[2] = array( ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;" ); +// ----------------------------------------------------------------------- +// db_version 3 — Add billing_paypal_webhook_events table for idempotent +// webhook event processing. +// ----------------------------------------------------------------------- +$install_queries[3] = array( + "CREATE TABLE IF NOT EXISTS `OGP_DB_PREFIXbilling_paypal_webhook_events` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `paypal_event_id` VARCHAR(100) NOT NULL DEFAULT '', + `event_type` VARCHAR(100) NOT NULL DEFAULT '', + `resource_id` VARCHAR(100) NOT NULL DEFAULT '', + `order_id` VARCHAR(100) NOT NULL DEFAULT '', + `capture_id` VARCHAR(100) NOT NULL DEFAULT '', + `billing_order_id` INT(11) NOT NULL DEFAULT 0, + `processing_status` VARCHAR(50) NOT NULL DEFAULT 'received', + `raw_json` MEDIUMTEXT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `processed_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uidx_paypal_event_id` (`paypal_event_id`), + KEY `idx_event_type` (`event_type`), + KEY `idx_billing_order_id` (`billing_order_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;" +); + ?> diff --git a/modules/billing/paypal/webhook.php b/modules/billing/paypal/webhook.php new file mode 100644 index 00000000..3ba762d5 --- /dev/null +++ b/modules/billing/paypal/webhook.php @@ -0,0 +1,711 @@ + $_SERVER['REMOTE_ADDR'] ?? '', 'bytes' => strlen($raw)]); + +if ($raw === '') { + wh_log('warn', 'empty_body'); + http_response_code(400); + echo json_encode(['error' => 'empty_body']); + exit; +} + +$evt = json_decode($raw, true); +if (json_last_error() !== JSON_ERROR_NONE || !is_array($evt)) { + wh_log('warn', 'invalid_json', ['json_error' => json_last_error_msg()]); + http_response_code(400); + echo json_encode(['error' => 'invalid_json']); + exit; +} + +// --------------------------------------------------------------------------- +// 2. DB connection (needed for idempotency log and order updates) +// --------------------------------------------------------------------------- +$db_port_int = intval($db_port ?? 3306) ?: 3306; +$wh_db = @mysqli_connect($db_host ?? 'localhost', $db_user ?? '', $db_pass ?? '', $db_name ?? '', $db_port_int); +if (!$wh_db) { + wh_log('error', 'db_connect_failed', ['error' => mysqli_connect_error()]); + http_response_code(500); + echo json_encode(['error' => 'db_unavailable']); + exit; +} +mysqli_set_charset($wh_db, 'utf8mb4'); + +$pfx = $table_prefix ?? 'gsp_'; + +// --------------------------------------------------------------------------- +// 2a. Ensure the webhook event log table exists (idempotent DDL) +// --------------------------------------------------------------------------- +wh_ensure_event_table($wh_db, $pfx); + +// --------------------------------------------------------------------------- +// 3. PayPal OAuth token +// --------------------------------------------------------------------------- +$api_base = gsp_paypal_get_api_base(); +$client_id = gsp_paypal_get_client_id(); +$client_secret = gsp_paypal_get_client_secret(); +$webhook_id = gsp_paypal_get_webhook_id(); + +if (empty($client_id) || empty($client_secret)) { + wh_log('error', 'paypal_not_configured'); + http_response_code(500); + echo json_encode(['error' => 'paypal_not_configured']); + mysqli_close($wh_db); + exit; +} + +$access_token = wh_get_access_token($api_base, $client_id, $client_secret); +if (!$access_token) { + wh_log('warn', 'oauth_failed'); + http_response_code(401); + echo json_encode(['error' => 'oauth_failed']); + mysqli_close($wh_db); + exit; +} + +// --------------------------------------------------------------------------- +// 4. Verify webhook signature (skip only if webhook_id is empty) +// --------------------------------------------------------------------------- +if (!empty($webhook_id)) { + $verified = wh_verify_signature($api_base, $access_token, $webhook_id, $headers, $evt); + if (!$verified) { + wh_log('warn', 'signature_invalid', [ + 'transmission_id' => $headers['PAYPAL-TRANSMISSION-ID'] ?? '', + 'event_type' => $evt['event_type'] ?? '', + ]); + http_response_code(401); + echo json_encode(['error' => 'signature_invalid']); + mysqli_close($wh_db); + exit; + } + wh_log('info', 'signature_ok'); +} else { + wh_log('warn', 'signature_skipped_no_webhook_id'); +} + +// --------------------------------------------------------------------------- +// 5. Idempotency check +// --------------------------------------------------------------------------- +$paypal_event_id = $evt['id'] ?? ''; +$event_type = $evt['event_type'] ?? ''; +$resource = $evt['resource'] ?? []; + +if ($paypal_event_id !== '') { + $existing = wh_get_event($wh_db, $pfx, $paypal_event_id); + if ($existing && $existing['processing_status'] === 'processed') { + wh_log('info', 'duplicate_event_ignored', ['paypal_event_id' => $paypal_event_id, 'event_type' => $event_type]); + http_response_code(200); + echo json_encode(['status' => 'duplicate_ignored']); + mysqli_close($wh_db); + exit; + } +} + +// Log the event as received (upsert — so retries update the record) +$log_id = wh_log_event($wh_db, $pfx, [ + 'paypal_event_id' => $paypal_event_id, + 'event_type' => $event_type, + 'resource_id' => $resource['id'] ?? '', + 'order_id' => '', + 'capture_id' => '', + 'billing_order_id' => 0, + 'processing_status' => 'received', + 'raw_json' => $raw, +]); + +// --------------------------------------------------------------------------- +// 6. Process event +// --------------------------------------------------------------------------- +$result = wh_process_event($wh_db, $pfx, $event_type, $resource, $evt, $access_token, $api_base, $raw); + +// Update log entry with final status +if ($log_id > 0) { + wh_update_event_status($wh_db, $pfx, $log_id, $result['status'], $result['billing_order_id'] ?? 0); +} + +wh_log('info', 'event_processed', [ + 'event_type' => $event_type, + 'status' => $result['status'], + 'billing_order_id' => $result['billing_order_id'] ?? 0, +]); + +http_response_code(200); +echo json_encode(['status' => $result['status']]); +mysqli_close($wh_db); +exit; + +// ============================================================================ +// Helper functions +// ============================================================================ + +/** + * Get OAuth access token from PayPal. + */ +function wh_get_access_token(string $api_base, string $client_id, string $client_secret): ?string +{ + $ch = curl_init($api_base . '/v1/oauth2/token'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => 'grant_type=client_credentials', + CURLOPT_HTTPHEADER => ['Accept: application/json'], + CURLOPT_USERPWD => $client_id . ':' . $client_secret, + CURLOPT_TIMEOUT => 15, + ]); + $body = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code !== 200 || !$body) { + return null; + } + $data = json_decode($body, true); + return $data['access_token'] ?? null; +} + +/** + * Verify PayPal webhook signature. + * Returns true only when PayPal confirms verification_status = SUCCESS. + */ +function wh_verify_signature( + string $api_base, + string $access_token, + string $webhook_id, + array $headers, + array $evt +): bool { + $payload = [ + 'auth_algo' => $headers['PAYPAL-AUTH-ALGO'] ?? '', + 'cert_url' => $headers['PAYPAL-CERT-URL'] ?? '', + 'transmission_id' => $headers['PAYPAL-TRANSMISSION-ID'] ?? '', + 'transmission_sig' => $headers['PAYPAL-TRANSMISSION-SIG'] ?? '', + 'transmission_time'=> $headers['PAYPAL-TRANSMISSION-TIME'] ?? '', + 'webhook_id' => $webhook_id, + 'webhook_event' => $evt, + ]; + $ch = curl_init($api_base . '/v1/notifications/verify-webhook-signature'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $access_token, + ], + CURLOPT_TIMEOUT => 15, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code !== 200 || !$resp) { + return false; + } + $data = json_decode($resp, true); + return ($data['verification_status'] ?? '') === 'SUCCESS'; +} + +/** + * Process a single webhook event. Returns ['status' => string, 'billing_order_id' => int]. + */ +function wh_process_event( + mysqli $db, + string $pfx, + string $event_type, + array $resource, + array $evt, + string $access_token, + string $api_base, + string $raw_json +): array { + switch ($event_type) { + case 'CHECKOUT.ORDER.APPROVED': + return wh_handle_order_approved($db, $pfx, $resource, $evt); + + case 'PAYMENT.CAPTURE.COMPLETED': + case 'PAYMENT.SALE.COMPLETED': + return wh_handle_capture_completed($db, $pfx, $resource, $evt, $access_token, $api_base); + + case 'PAYMENT.CAPTURE.DENIED': + case 'PAYMENT.SALE.DENIED': + return wh_handle_capture_denied($db, $pfx, $resource, $evt); + + case 'PAYMENT.CAPTURE.REFUNDED': + case 'PAYMENT.SALE.REFUNDED': + return wh_handle_capture_refunded($db, $pfx, $resource, $evt); + + default: + wh_log('info', 'unhandled_event_type', ['event_type' => $event_type]); + return ['status' => 'ignored_unhandled', 'billing_order_id' => 0]; + } +} + +/** + * CHECKOUT.ORDER.APPROVED — buyer approved the order but capture not yet done. + * We log this for auditing; the actual fulfillment happens on CAPTURE.COMPLETED. + */ +function wh_handle_order_approved(mysqli $db, string $pfx, array $resource, array $evt): array +{ + $paypal_order_id = $resource['id'] ?? ($evt['resource']['id'] ?? ''); + wh_log('info', 'order_approved', ['paypal_order_id' => $paypal_order_id]); + return ['status' => 'approved_logged', 'billing_order_id' => 0]; +} + +/** + * PAYMENT.CAPTURE.COMPLETED — payment fully captured; provision the server. + */ +function wh_handle_capture_completed( + mysqli $db, + string $pfx, + array $resource, + array $evt, + string $access_token, + string $api_base +): array { + $capture_id = $resource['id'] ?? ''; + $amount = $resource['amount']['value'] ?? null; + $currency = $resource['amount']['currency_code'] ?? 'USD'; + + // Extract PayPal order ID from supplementary_data or links + $paypal_order_id = $resource['supplementary_data']['related_ids']['order_id'] ?? ''; + if (empty($paypal_order_id) && isset($resource['links']) && is_array($resource['links'])) { + foreach ($resource['links'] as $lnk) { + if (!empty($lnk['href']) && stripos($lnk['href'], '/v2/checkout/orders/') !== false) { + $paypal_order_id = basename(parse_url($lnk['href'], PHP_URL_PATH)); + break; + } + } + } + + // Extract invoice/custom from resource or fetch the full order + $invoice_ref = $resource['invoice_id'] ?? ($resource['invoice_number'] ?? null); + $custom_id = $resource['custom_id'] ?? ($resource['custom'] ?? null); + + // If we have a PayPal order ID, fetch the order to get invoice/custom IDs + if (!empty($paypal_order_id) && (empty($invoice_ref) || empty($custom_id))) { + $order_data = wh_fetch_paypal_order($api_base, $access_token, $paypal_order_id); + if ($order_data) { + $pu = $order_data['purchase_units'][0] ?? []; + if (empty($invoice_ref)) $invoice_ref = $pu['invoice_id'] ?? null; + if (empty($custom_id)) $custom_id = $pu['custom_id'] ?? null; + } + } + + wh_log('info', 'capture_completed', [ + 'capture_id' => $capture_id, + 'paypal_order_id' => $paypal_order_id, + 'invoice_ref' => $invoice_ref, + 'custom_id' => $custom_id, + 'amount' => $amount, + ]); + + // Find matching billing invoice(s) and process payment + $billing_order_id = wh_fulfill_payment($db, $pfx, [ + 'capture_id' => $capture_id, + 'paypal_order_id' => $paypal_order_id, + 'invoice_ref' => $invoice_ref, + 'custom_id' => $custom_id, + 'amount' => $amount, + 'currency' => $currency, + ]); + + return ['status' => 'processed', 'billing_order_id' => $billing_order_id]; +} + +/** + * PAYMENT.CAPTURE.DENIED — capture was denied (e.g. failed fraud check). + */ +function wh_handle_capture_denied(mysqli $db, string $pfx, array $resource, array $evt): array +{ + $capture_id = $resource['id'] ?? ''; + wh_log('warn', 'capture_denied', ['capture_id' => $capture_id]); + + // Find the billing order for this capture and mark it denied, if still pending + if ($capture_id !== '') { + $esc = mysqli_real_escape_string($db, $capture_id); + $sql = "UPDATE `{$pfx}billing_orders` + SET status = 'payment_denied' + WHERE payment_txid = '{$esc}' + AND status NOT IN ('Active','cancelled') + LIMIT 1"; + mysqli_query($db, $sql); + } + + return ['status' => 'denied_logged', 'billing_order_id' => 0]; +} + +/** + * PAYMENT.CAPTURE.REFUNDED — payment was refunded. + */ +function wh_handle_capture_refunded(mysqli $db, string $pfx, array $resource, array $evt): array +{ + $refund_id = $resource['id'] ?? ''; + $capture_id = $resource['links'] ? (function () use ($resource) { + foreach ($resource['links'] as $l) { + if (($l['rel'] ?? '') === 'up' && stripos($l['href'] ?? '', '/captures/') !== false) { + return basename(parse_url($l['href'], PHP_URL_PATH)); + } + } + return ''; + })() : ''; + + wh_log('info', 'capture_refunded', ['refund_id' => $refund_id, 'capture_id' => $capture_id]); + + // Log the refund; do not automatically cancel the server unless the billing lifecycle supports it. + return ['status' => 'refunded_logged', 'billing_order_id' => 0]; +} + +/** + * Fetch a PayPal order by ID. Returns decoded array or null. + */ +function wh_fetch_paypal_order(string $api_base, string $access_token, string $order_id): ?array +{ + $ch = curl_init($api_base . '/v2/checkout/orders/' . urlencode($order_id)); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $access_token, + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 15, + ]); + $body = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code !== 200 || !$body) { + wh_log('warn', 'order_fetch_failed', ['order_id' => $order_id, 'http' => $code]); + return null; + } + $data = json_decode($body, true); + return is_array($data) ? $data : null; +} + +/** + * Match the PayPal capture to a billing invoice, mark it paid, create/extend billing_orders, + * and trigger server provisioning. Returns the billing_order_id or 0. + */ +function wh_fulfill_payment(mysqli $db, string $pfx, array $payment): int +{ + $txid = $payment['capture_id'] ?? ''; + $custom_id = $payment['custom_id'] ?? null; + $invoice_ref = $payment['invoice_ref'] ?? null; + $amount = isset($payment['amount']) ? floatval($payment['amount']) : null; + $now = date('Y-m-d H:i:s'); + $esc_txid = mysqli_real_escape_string($db, (string)$txid); + + // Find matching invoices + $invoices = []; + + // 1) Match by numeric custom_id (which we set to invoice_id when creating the PayPal order) + if (!empty($custom_id) && ctype_digit((string)$custom_id)) { + $inv_id = intval($custom_id); + $res = mysqli_query($db, "SELECT * FROM `{$pfx}billing_invoices` WHERE invoice_id = {$inv_id} AND status = 'due' LIMIT 1"); + if ($res && $row = mysqli_fetch_assoc($res)) { + $invoices[] = $row; + } + } + + // 2) Match by invoice reference in description + if (empty($invoices) && !empty($invoice_ref)) { + $esc_ref = mysqli_real_escape_string($db, (string)$invoice_ref); + $res = mysqli_query($db, "SELECT * FROM `{$pfx}billing_invoices` WHERE status = 'due' AND description LIKE '%{$esc_ref}%'"); + if ($res) { + while ($row = mysqli_fetch_assoc($res)) { + $invoices[] = $row; + } + } + } + + // 3) Fallback: match by exact amount + if (empty($invoices) && $amount !== null) { + $esc_amount = number_format($amount, 2, '.', ''); + $res = mysqli_query($db, "SELECT * FROM `{$pfx}billing_invoices` WHERE status = 'due' AND amount = {$esc_amount}"); + if ($res) { + while ($row = mysqli_fetch_assoc($res)) { + $invoices[] = $row; + } + } + } + + if (empty($invoices)) { + wh_log('warn', 'no_matching_invoices', ['custom_id' => $custom_id, 'invoice_ref' => $invoice_ref, 'amount' => $amount]); + return 0; + } + + $last_order_id = 0; + + foreach ($invoices as $inv) { + $invoice_id = intval($inv['invoice_id']); + $order_id = intval($inv['order_id'] ?? 0); + $user_id = intval($inv['user_id']); + $service_id = intval($inv['service_id'] ?? 0); + $duration = $inv['invoice_duration'] ?? 'month'; + $qty = max(1, intval($inv['qty'] ?? 1)); + + // Mark invoice paid + $stmt = mysqli_prepare($db, "UPDATE `{$pfx}billing_invoices` SET status='paid', payment_status='paid', paid_date=?, payment_txid=?, payment_method='paypal' WHERE invoice_id=? LIMIT 1"); + if ($stmt) { + mysqli_stmt_bind_param($stmt, 'ssi', $now, $esc_txid, $invoice_id); + mysqli_stmt_execute($stmt); + mysqli_stmt_close($stmt); + } + + // Increment coupon usage if applicable + $coupon_id = intval($inv['coupon_id'] ?? 0); + if ($coupon_id > 0) { + mysqli_query($db, "UPDATE `{$pfx}billing_coupons` SET current_uses = current_uses + 1 WHERE coupon_id = {$coupon_id}"); + } + + // Duration → months + $months = 1; + if (stripos($duration, 'year') !== false) { + $months = $qty * 12; + } else { + $months = $qty; + } + + if ($order_id > 0) { + // Renewal: extend existing order + $res = mysqli_query($db, "SELECT end_date, home_id FROM `{$pfx}billing_orders` WHERE order_id = {$order_id} LIMIT 1"); + if ($res && $row = mysqli_fetch_assoc($res)) { + $current_end = $row['end_date'] ?? $now; + $extend_from = (strtotime($current_end) > time()) ? $current_end : $now; + $dt = new DateTime($extend_from); + $dt->modify('+' . $months . ' months'); + $new_end = $dt->format('Y-m-d H:i:s'); + + $stmt = mysqli_prepare($db, "UPDATE `{$pfx}billing_orders` SET end_date=?, status='Active', payment_txid=?, paid_ts=? WHERE order_id=? LIMIT 1"); + if ($stmt) { + mysqli_stmt_bind_param($stmt, 'sssi', $new_end, $esc_txid, $now, $order_id); + mysqli_stmt_execute($stmt); + mysqli_stmt_close($stmt); + } + $last_order_id = $order_id; + wh_log('info', 'order_renewed', ['order_id' => $order_id, 'new_end' => $new_end]); + } + } else { + // New order: create billing_orders row + $dt = new DateTime($now); + $dt->modify('+' . $months . ' months'); + $end_date = $dt->format('Y-m-d H:i:s'); + $invoice_amount = floatval($inv['amount'] ?? $inv['total_due'] ?? 0); + $price = number_format($invoice_amount, 2, '.', ''); + $esc_home = mysqli_real_escape_string($db, $inv['home_name'] ?? ''); + $esc_dur = mysqli_real_escape_string($db, $duration); + $esc_rcon = mysqli_real_escape_string($db, $inv['remote_control_password'] ?? ''); + $esc_ftp = mysqli_real_escape_string($db, $inv['ftp_password'] ?? ''); + $ip_val = intval($inv['ip'] ?? 0); + $max_pl = intval($inv['max_players'] ?? 0); + + $sql = sprintf( + "INSERT INTO `%sbilling_orders` (user_id, service_id, home_name, ip, max_players, qty, invoice_duration, price, remote_control_password, ftp_password, status, order_date, end_date, payment_txid, paid_ts) + VALUES (%d, %d, '%s', %d, %d, %d, '%s', %s, '%s', '%s', 'Active', '%s', '%s', '%s', '%s')", + $pfx, + $user_id, $service_id, $esc_home, $ip_val, $max_pl, $qty, + $esc_dur, $price, $esc_rcon, $esc_ftp, $now, $end_date, $esc_txid, $now + ); + + if (mysqli_query($db, $sql)) { + $new_order_id = (int)mysqli_insert_id($db); + + // Link invoice → order + $stmt = mysqli_prepare($db, "UPDATE `{$pfx}billing_invoices` SET order_id=? WHERE invoice_id=? LIMIT 1"); + if ($stmt) { + mysqli_stmt_bind_param($stmt, 'ii', $new_order_id, $invoice_id); + mysqli_stmt_execute($stmt); + mysqli_stmt_close($stmt); + } + + $last_order_id = $new_order_id; + wh_log('info', 'order_created', ['order_id' => $new_order_id, 'invoice_id' => $invoice_id]); + + // Attempt provisioning via panel bridge + wh_try_provision($_billing_dir ?? dirname(__DIR__), $new_order_id, $user_id); + } else { + wh_log('error', 'order_insert_failed', ['db_error' => mysqli_error($db), 'invoice_id' => $invoice_id]); + } + } + } + + return $last_order_id; +} + +/** + * Attempt to provision a newly created server via the panel bridge. + * Non-fatal: logs warnings on failure. + */ +function wh_try_provision(string $billing_dir, int $order_id, int $user_id): void +{ + $bridge = $billing_dir . '/includes/panel_bridge.php'; + $create = $billing_dir . '/create_servers.php'; + if (!is_file($bridge) || !is_file($create)) { + wh_log('info', 'provision_skipped_no_bridge', ['order_id' => $order_id]); + return; + } + try { + require_once $bridge; + if (!function_exists('billing_panel_bootstrap')) { + wh_log('warn', 'provision_no_bootstrap_fn', ['order_id' => $order_id]); + return; + } + $ctx = billing_panel_bootstrap(); + if (!$ctx || empty($ctx['db'])) { + wh_log('warn', 'provision_panel_bootstrap_failed', ['order_id' => $order_id]); + return; + } + $GLOBALS['db'] = $ctx['db']; + $GLOBALS['settings'] = $ctx['settings'] ?? []; + require_once $create; + if (function_exists('billing_invoke_provision')) { + $r = billing_invoke_provision(['order_ids' => [$order_id], 'user_id' => $user_id, 'is_admin' => true]); + wh_log('info', 'provision_result', ['order_id' => $order_id, 'result' => $r]); + } + } catch (Throwable $e) { + wh_log('error', 'provision_exception', ['order_id' => $order_id, 'error' => $e->getMessage()]); + } +} + +// ============================================================================ +// Webhook event log table helpers +// ============================================================================ + +/** + * Ensure billing_paypal_webhook_events table exists (idempotent, no ALTER on existing tables). + */ +function wh_ensure_event_table(mysqli $db, string $pfx): void +{ + $table = $pfx . 'billing_paypal_webhook_events'; + $res = mysqli_query($db, "SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}'"); + if ($res && $row = mysqli_fetch_assoc($res)) { + if (intval($row['cnt']) > 0) { + return; // table exists + } + } + $sql = "CREATE TABLE IF NOT EXISTS `{$table}` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `paypal_event_id` VARCHAR(100) NOT NULL DEFAULT '', + `event_type` VARCHAR(100) NOT NULL DEFAULT '', + `resource_id` VARCHAR(100) NOT NULL DEFAULT '', + `order_id` VARCHAR(100) NOT NULL DEFAULT '', + `capture_id` VARCHAR(100) NOT NULL DEFAULT '', + `billing_order_id` INT(11) NOT NULL DEFAULT 0, + `processing_status` VARCHAR(50) NOT NULL DEFAULT 'received', + `raw_json` MEDIUMTEXT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `processed_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uidx_paypal_event_id` (`paypal_event_id`), + KEY `idx_event_type` (`event_type`), + KEY `idx_billing_order_id` (`billing_order_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"; + mysqli_query($db, $sql); +} + +/** + * Retrieve an existing webhook event log row by paypal_event_id. + */ +function wh_get_event(mysqli $db, string $pfx, string $paypal_event_id): ?array +{ + if ($paypal_event_id === '') return null; + $esc = mysqli_real_escape_string($db, $paypal_event_id); + $res = mysqli_query($db, "SELECT * FROM `{$pfx}billing_paypal_webhook_events` WHERE paypal_event_id = '{$esc}' LIMIT 1"); + if (!$res) return null; + $row = mysqli_fetch_assoc($res); + return $row ?: null; +} + +/** + * Insert or update a webhook event log row. Returns the row id. + */ +function wh_log_event(mysqli $db, string $pfx, array $data): int +{ + $paypal_event_id = mysqli_real_escape_string($db, $data['paypal_event_id'] ?? ''); + $event_type = mysqli_real_escape_string($db, $data['event_type'] ?? ''); + $resource_id = mysqli_real_escape_string($db, $data['resource_id'] ?? ''); + $order_id_str = mysqli_real_escape_string($db, $data['order_id'] ?? ''); + $capture_id = mysqli_real_escape_string($db, $data['capture_id'] ?? ''); + $billing_order_id = intval($data['billing_order_id'] ?? 0); + $processing_status = mysqli_real_escape_string($db, $data['processing_status'] ?? 'received'); + $raw_json = mysqli_real_escape_string($db, $data['raw_json'] ?? ''); + $now = date('Y-m-d H:i:s'); + + if ($paypal_event_id === '') { + // No stable event ID — always insert + $sql = "INSERT INTO `{$pfx}billing_paypal_webhook_events` + (paypal_event_id, event_type, resource_id, order_id, capture_id, billing_order_id, processing_status, raw_json, created_at) + VALUES ('{$paypal_event_id}', '{$event_type}', '{$resource_id}', '{$order_id_str}', '{$capture_id}', {$billing_order_id}, '{$processing_status}', '{$raw_json}', '{$now}')"; + mysqli_query($db, $sql); + return (int)mysqli_insert_id($db); + } + + // Upsert: insert or update existing row + $sql = "INSERT INTO `{$pfx}billing_paypal_webhook_events` + (paypal_event_id, event_type, resource_id, order_id, capture_id, billing_order_id, processing_status, raw_json, created_at) + VALUES ('{$paypal_event_id}', '{$event_type}', '{$resource_id}', '{$order_id_str}', '{$capture_id}', {$billing_order_id}, '{$processing_status}', '{$raw_json}', '{$now}') + ON DUPLICATE KEY UPDATE + processing_status = VALUES(processing_status), + billing_order_id = VALUES(billing_order_id)"; + mysqli_query($db, $sql); + $insert_id = (int)mysqli_insert_id($db); + if ($insert_id > 0) { + return $insert_id; + } + // Row already existed — fetch its id + $existing = wh_get_event($db, $pfx, $data['paypal_event_id']); + return $existing ? intval($existing['id']) : 0; +} + +/** + * Update processing_status and processed_at on an event log row. + */ +function wh_update_event_status(mysqli $db, string $pfx, int $log_id, string $status, int $billing_order_id): void +{ + $esc_status = mysqli_real_escape_string($db, $status); + $now = date('Y-m-d H:i:s'); + $bod = intval($billing_order_id); + mysqli_query($db, "UPDATE `{$pfx}billing_paypal_webhook_events` + SET processing_status = '{$esc_status}', billing_order_id = {$bod}, processed_at = '{$now}' + WHERE id = {$log_id} LIMIT 1"); +} diff --git a/modules/billing/webhook.php b/modules/billing/webhook.php index bed7d136..734cbe4c 100644 --- a/modules/billing/webhook.php +++ b/modules/billing/webhook.php @@ -2,11 +2,18 @@ require_once(__DIR__ . '/includes/config_loader.php'); if (is_file(__DIR__ . '/includes/log.php')) require_once(__DIR__ . '/includes/log.php'); +// Derive active credentials via helper functions (falls back to old globals gracefully) +$_wh_sandbox = function_exists('gsp_paypal_is_sandbox') ? gsp_paypal_is_sandbox() : ($paypal_sandbox ?? true); +$_wh_client_id = function_exists('gsp_paypal_get_client_id') ? gsp_paypal_get_client_id() : ($paypal_client_id ?? ''); +$_wh_client_secret = function_exists('gsp_paypal_get_client_secret') ? gsp_paypal_get_client_secret() : ($paypal_client_secret ?? ''); +$_wh_webhook_id = function_exists('gsp_paypal_get_webhook_id') ? gsp_paypal_get_webhook_id() : ($paypal_webhook_id ?? ''); +$_wh_api_base = function_exists('gsp_paypal_get_api_base') ? gsp_paypal_get_api_base() : ($_wh_sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'); + $config = [ - 'sandbox' => $paypal_sandbox ?? true, - 'client_id' => $paypal_client_id ?? '', - 'client_secret' => $paypal_client_secret ?? '', - 'webhook_id' => $paypal_webhook_id ?? '', + 'sandbox' => $_wh_sandbox, + 'client_id' => $_wh_client_id, + 'client_secret' => $_wh_client_secret, + 'webhook_id' => $_wh_webhook_id, 'data_dir' => rtrim( (defined('SITE_DATA_DIR') ? SITE_DATA_DIR : '') ?: ($SITE_DATA_DIR ?? ''), DIRECTORY_SEPARATOR @@ -18,8 +25,150 @@ if (!$config['data_dir']) { $config['data_dir'] = realpath(__DIR__ . '/') . DIRECTORY_SEPARATOR . 'data'; } -function log_line($m){global $config; @file_put_contents($config['log_file'],'['.date('c')."] $m\n",FILE_APPEND);} -function api_base(){global $config; return $config['sandbox'] ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';} +function log_line($m){global $config; @file_put_contents($config['log_file'],'['.date('c')."] $m\n",FILE_APPEND);} +function api_base(){global $_wh_api_base; return $_wh_api_base;} + +http_response_code(200); +@mkdir($config['data_dir'], 0775, true); + +$raw = file_get_contents('php://input'); +$headers = array_change_key_case(getallheaders() ?: [], CASE_UPPER); +if (function_exists('site_log_info')) site_log_info('webhook_hit', ['ip'=>($_SERVER['REMOTE_ADDR']??''),'bytes'=>strlen($raw)]); +else log_line("HIT ip=".($_SERVER['REMOTE_ADDR']??'') ." bytes=".strlen($raw)); +if (!$raw) { log_line("NO_BODY"); exit; } + +// 1) OAuth2 +$ch = curl_init(api_base().'/v1/oauth2/token'); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER=>true, + CURLOPT_POST=>true, + CURLOPT_POSTFIELDS=>'grant_type=client_credentials', + CURLOPT_HTTPHEADER=>['Accept: application/json'], + CURLOPT_USERPWD=>$config['client_id'].':'.$config['client_secret'], +]); +$tokenResp = curl_exec($ch); +$http = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); +$o = ['http'=>$http,'resp'=>substr($tokenResp,0,400)]; +if ($http!==200){ if (function_exists('site_log_warn')) site_log_warn('oauth_fail',$o); else log_line("OAUTH_FAIL http=$http resp=$tokenResp"); exit; } +$access_token = json_decode($tokenResp, true)['access_token'] ?? null; +if (!$access_token){ if (function_exists('site_log_warn')) site_log_warn('oauth_no_token', []); else log_line("OAUTH_NO_TOKEN"); exit; } + +// 2) Verify webhook signature +$verifyPayload = [ + 'transmission_id' => $headers['PAYPAL-TRANSMISSION-ID'] ?? '', + 'transmission_time' => $headers['PAYPAL-TRANSMISSION-TIME'] ?? '', + 'cert_url' => $headers['PAYPAL-CERT-URL'] ?? '', + 'auth_algo' => $headers['PAYPAL-AUTH-ALGO'] ?? '', + 'transmission_sig' => $headers['PAYPAL-TRANSMISSION-SIG'] ?? '', + 'webhook_id' => $config['webhook_id'], + 'webhook_event' => json_decode($raw, true), +]; +$ch = curl_init(api_base().'/v1/notifications/verify-webhook-signature'); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER=>true, + CURLOPT_POST=>true, + CURLOPT_POSTFIELDS=>json_encode($verifyPayload), + CURLOPT_HTTPHEADER=>[ + 'Content-Type: application/json', + 'Authorization: Bearer '.$access_token + ], +]); +$verifyResp = curl_exec($ch); +$http = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); +$verifyJson = json_decode($verifyResp, true); +if ($http!==200 || ($verifyJson['verification_status'] ?? '') !== 'SUCCESS'){ + if (function_exists('site_log_warn')) site_log_warn('verify_fail', ['http'=>$http,'status'=>($verifyJson['verification_status']??'NONE')]); + else log_line("VERIFY_FAIL http=$http status=".($verifyJson['verification_status']??'NONE')); + exit; +} +if (function_exists('site_log_info')) site_log_info('verify_ok', ['http'=>$http]); +else log_line("VERIFY_OK"); + +// 3) Parse and persist +$evt = json_decode($raw, true); +$type = $evt['event_type'] ?? ''; +$res = $evt['resource'] ?? []; + +$invoice = $res['invoice_id'] ?? ($res['invoice_number'] ?? null); +$custom = $res['custom_id'] ?? ($res['custom'] ?? null); + +$amount = $res['amount']['value'] ?? ($res['amount']['total'] ?? null); +$currency = $res['amount']['currency_code'] ?? ($res['amount']['currency'] ?? null); +$payer = $res['payer']['email_address'] ?? ($res['payer']['payer_info']['email'] ?? null); + +$items = []; +if (isset($res['purchase_units'][0]['items']) && is_array($res['purchase_units'][0]['items'])) { + $items = $res['purchase_units'][0]['items']; +} + +if (!$items && $type === 'PAYMENT.CAPTURE.COMPLETED') { + $orderId = $res['supplementary_data']['related_ids']['order_id'] ?? null; + if (!$orderId && isset($res['links']) && is_array($res['links'])) { + foreach ((array)$res['links'] as $lnk) { + if (!empty($lnk['href']) && !empty($lnk['rel']) && stripos($lnk['href'], '/v2/checkout/orders/') !== false) { + $orderId = basename(parse_url($lnk['href'], PHP_URL_PATH)); + break; + } + } + } + if ($orderId) { + $ch = curl_init(api_base()."/v2/checkout/orders/".urlencode($orderId)); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ 'Authorization: Bearer '.$access_token, 'Content-Type: application/json' ], + ]); + $orderJson = curl_exec($ch); + $httpOrder = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($httpOrder === 200) { + $order = json_decode($orderJson, true); + if (isset($order['purchase_units'][0]['items']) && is_array($order['purchase_units'][0]['items'])) { + $items = $order['purchase_units'][0]['items']; + } + if (!$invoice) { $invoice = $order['purchase_units'][0]['invoice_id'] ?? $invoice; } + if (!$custom) { $custom = $order['purchase_units'][0]['custom_id'] ?? $custom; } + } else { + if (function_exists('site_log_warn')) site_log_warn('order_fetch_fail', ['orderId'=>$orderId,'http'=>$httpOrder]); + else log_line("ORDER_FETCH_FAIL id=$orderId http=$httpOrder"); + } + } +} + +$status = 'IGNORED'; +if (in_array($type, ['PAYMENT.CAPTURE.COMPLETED','PAYMENT.SALE.COMPLETED'], true)) { + $record = [ + 'event_type' => $type, + 'status' => 'PAID', + 'amount' => $amount, + 'currency' => $currency, + 'payer' => $payer, + 'invoice' => $invoice, + 'custom' => $custom, + 'resource_id' => $res['id'] ?? null, + 'items' => $items, + 'ts' => date('c'), + ]; + $name = $invoice ?: 'NO-INVOICE-'.bin2hex(random_bytes(4)); + @file_put_contents($config['data_dir']."/".$name.".json", json_encode($record, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)); + $status = 'WROTE_FILE'; + + // Attempt to mark order paid in DB + require_once(__DIR__ . '/includes/payment_processor.php'); + try { + process_payment_record($record); + } catch (Exception $e) { + if (function_exists('site_log_error')) site_log_error('process_payment_fail',['err'=>$e->getMessage()]); + else log_line('PROC_FAIL '.$e->getMessage()); + } +} + +if (function_exists('site_log_info')) site_log_info('webhook_event',['type'=>$type,'invoice'=>($invoice ?: 'none'),'items_count'=>count((array)$items),'status'=>$status]); +else log_line("EVENT $type invoice=".($invoice ?: 'none')." items_count=".count((array)$items)." status=$status"); + +?> + http_response_code(200); @mkdir($config['data_dir'], 0775, true);