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>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-06 16:14:47 +00:00 committed by GitHub
parent 0f4c4b3634
commit 41a812fdd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1351 additions and 109 deletions

View file

@ -108,11 +108,22 @@ function billing_admin_build_config(string $existingContent, array $vals): strin
return '"' . addslashes($v) . '"'; return '"' . addslashes($v) . '"';
}; };
$sandbox = (bool)$vals['paypal_sandbox']; $mode = (strtolower($vals['paypal_mode'] ?? 'sandbox') === 'live') ? 'live' : 'sandbox';
$retention = max(1, min(10, (int)($vals['backup_retention'] ?? 5))); $retention = max(1, min(10, (int)($vals['backup_retention'] ?? 5)));
$baseUrl = trim($vals['SITE_BASE_URL'] ?? ''); $baseUrl = rtrim(trim($vals['SITE_BASE_URL'] ?? ''), '/');
$bg = trim($vals['SITE_BACKGROUND'] ?? 'images/dark.jpg'); $bg = trim($vals['SITE_BACKGROUND'] ?? 'images/dark.jpg');
$dataDir = trim($vals['SITE_DATA_DIR'] ?? ''); $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 = ''; $dbBlock = '';
foreach (['db_host', 'db_port', 'db_user', 'db_pass', 'db_name', 'table_prefix', 'db_type'] as $var) { 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" . '###############################################' . "\n"
. $dbBlock . $dbBlock
. "\n" . "\n"
. '// Optional: base URL used by admin pages to build absolute image previews.' . "\n" . '// Optional: base URL without trailing slash (e.g. https://gameservers.world).' . "\n"
. '// Leave empty to prefer relative paths (local folder).' . "\n" . '// Leave empty to use relative paths.' . "\n"
. '$SITE_BASE_URL = ' . $q($baseUrl) . ';' . "\n" . '$SITE_BASE_URL = ' . $q($baseUrl) . ';' . "\n"
. "\n" . '$SITE_BASE_URL = rtrim(trim((string)$SITE_BASE_URL), \'/\');' . "\n"
. '// Normalize: ensure either empty or ends without trailing slash' . "\n"
. '$SITE_BASE_URL = trim((string)$SITE_BASE_URL);' . "\n"
. "\n" . "\n"
. '// Site-wide background image (relative to site root).' . "\n" . '// Site-wide background image (relative to site root).' . "\n"
. '$SITE_BACKGROUND = ' . $q($bg) . ';' . "\n" . '$SITE_BACKGROUND = ' . $q($bg) . ';' . "\n"
. '// Normalize' . "\n"
. '$SITE_BACKGROUND = trim((string)$SITE_BACKGROUND);' . "\n" . '$SITE_BACKGROUND = trim((string)$SITE_BACKGROUND);' . "\n"
. "\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" . $dataDirLine . "\n"
. "\n" . "\n"
. '// PayPal configuration — set credentials here, never in API files' . "\n" . '// ---------------------------------------------------------------------------' . "\n"
. '$paypal_sandbox = ' . ($sandbox ? 'true' : 'false') . '; // Set to false for live payments' . "\n" . '// PayPal configuration' . "\n"
. '$paypal_client_id = ' . $q($vals['paypal_client_id'] ?? '') . '; // Your PayPal Client ID' . "\n" . '// ---------------------------------------------------------------------------' . "\n"
. '$paypal_client_secret = ' . $q($vals['paypal_client_secret'] ?? '') . '; // Your PayPal Client Secret' . "\n" . '$paypal_mode = ' . $q($mode) . '; // \'sandbox\' or \'live\'' . "\n"
. '$paypal_webhook_id = ' . $q($vals['paypal_webhook_id'] ?? '') . '; // Your PayPal Webhook ID' . "\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" . "\n"
. '// Admin config backup retention: how many backups to keep (110). Default 5.' . "\n" . '// Admin config backup retention: how many backups to keep (110). Default 5.' . "\n"
. '$SITE_CONFIG_BACKUP_RETENTION = ' . $retention . ';' . "\n" . '$SITE_CONFIG_BACKUP_RETENTION = ' . $retention . ';' . "\n"
@ -167,14 +188,23 @@ function billing_admin_build_config(string $existingContent, array $vals): strin
$cfgVals = [ $cfgVals = [
'SITE_BASE_URL' => $SITE_BASE_URL ?? '', 'SITE_BASE_URL' => $SITE_BASE_URL ?? '',
'SITE_BACKGROUND' => $SITE_BACKGROUND ?? 'images/dark.jpg', 'SITE_BACKGROUND' => $SITE_BACKGROUND ?? 'images/dark.jpg',
'SITE_DATA_DIR' => isset($SITE_DATA_DIR) ? $SITE_DATA_DIR : '', 'SITE_DATA_DIR' => $SITE_DATA_DIR ?? '',
'paypal_sandbox' => $paypal_sandbox ?? true, 'paypal_mode' => $paypal_mode ?? 'sandbox',
'paypal_client_id' => $paypal_client_id ?? '', 'paypal_sandbox_client_id' => $paypal_sandbox_client_id ?? '',
'paypal_client_secret' => $paypal_client_secret ?? '', 'paypal_sandbox_client_secret' => $paypal_sandbox_client_secret ?? '',
'paypal_webhook_id' => $paypal_webhook_id ?? '', '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, '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) // Detect panel-mode (DB settings are managed by the panel)
$panelMode = defined('BILLING_PANEL_CONFIG_PATH'); $panelMode = defined('BILLING_PANEL_CONFIG_PATH');
$panelCfgPath = $panelMode ? BILLING_PANEL_CONFIG_PATH : null; $panelCfgPath = $panelMode ? BILLING_PANEL_CONFIG_PATH : null;
@ -199,13 +229,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_
'SITE_BASE_URL' => trim($_POST['SITE_BASE_URL'] ?? ''), 'SITE_BASE_URL' => trim($_POST['SITE_BASE_URL'] ?? ''),
'SITE_BACKGROUND' => trim($_POST['SITE_BACKGROUND'] ?? 'images/dark.jpg'), 'SITE_BACKGROUND' => trim($_POST['SITE_BACKGROUND'] ?? 'images/dark.jpg'),
'SITE_DATA_DIR' => trim($_POST['SITE_DATA_DIR'] ?? ''), 'SITE_DATA_DIR' => trim($_POST['SITE_DATA_DIR'] ?? ''),
'paypal_sandbox' => (($_POST['paypal_sandbox'] ?? 'true') === 'true'), 'paypal_mode' => (strtolower(trim($_POST['paypal_mode'] ?? 'sandbox')) === 'live') ? 'live' : 'sandbox',
'paypal_client_id' => trim($_POST['paypal_client_id'] ?? ''), 'paypal_sandbox_client_id' => trim($_POST['paypal_sandbox_client_id'] ?? ''),
'paypal_client_secret' => trim($_POST['paypal_client_secret'] ?? ''), 'paypal_live_client_id' => trim($_POST['paypal_live_client_id'] ?? ''),
'paypal_webhook_id' => trim($_POST['paypal_webhook_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), '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 // Validate
$validationError = ''; $validationError = '';
if ($formVals['backup_retention'] < 1 || $formVals['backup_retention'] > 10) { if ($formVals['backup_retention'] < 1 || $formVals['backup_retention'] > 10) {
@ -244,6 +283,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_
billing_admin_apply_retention($bakDir, $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); $status = 'Config saved successfully. Backup: ' . basename($bakName);
$statusType = 'success'; $statusType = 'success';
} }
@ -398,8 +438,8 @@ rsort($bakFiles); // newest first
<div class="field-group"> <div class="field-group">
<label for="cfg_base_url">Site Base URL</label> <label for="cfg_base_url">Site Base URL</label>
<div class="field-help"> <div class="field-help">
Full base URL without trailing slash (e.g. <code>https://gameservers.world</code>). Full base URL <strong>without trailing slash</strong> (e.g. <code>https://gameservers.world</code>).
Leave empty to use relative paths. Leave empty to use relative paths. Used to compute the full public PayPal webhook URL.
</div> </div>
<input type="text" id="cfg_base_url" name="SITE_BASE_URL" <input type="text" id="cfg_base_url" name="SITE_BASE_URL"
value="<?php echo h((string)$cfgVals['SITE_BASE_URL']); ?>" value="<?php echo h((string)$cfgVals['SITE_BASE_URL']); ?>"
@ -432,59 +472,130 @@ rsort($bakFiles); // newest first
<hr style="border:none;border-top:1px solid #eee;margin:24px 0;"> <hr style="border:none;border-top:1px solid #eee;margin:24px 0;">
<h3 style="margin-top:0;color:#333;">PayPal Configuration</h3> <h3 style="margin-top:0;color:#333;">PayPal Configuration</h3>
<!-- PayPal Sandbox --> <?php
<div class="field-group"> $isSandboxMode = ($cfgVals['paypal_mode'] ?? 'sandbox') !== 'live';
<label for="cfg_sandbox">PayPal Mode</label> $modeLabel = $isSandboxMode ? '🟡 Sandbox (test mode)' : '🟢 Live (real payments)';
<div class="field-help"> $modeBadgeClass = $isSandboxMode ? 'status-info' : 'status-success';
Use <strong>Sandbox</strong> for testing, <strong>Live</strong> for real payments. ?>
Make sure you use the matching Client ID and Secret for the selected mode. <div class="status-box <?php echo h($modeBadgeClass); ?>" style="margin-bottom:14px;font-size:0.95em;">
Currently active PayPal mode: <strong><?php echo h($modeLabel); ?></strong>
</div> </div>
<select id="cfg_sandbox" name="paypal_sandbox">
<option value="true" <?php echo $cfgVals['paypal_sandbox'] ? 'selected' : ''; ?>>Sandbox (test mode)</option> <!-- PayPal Mode -->
<option value="false" <?php echo !$cfgVals['paypal_sandbox'] ? 'selected' : ''; ?>>Live (real payments)</option> <div class="field-group">
<label for="cfg_mode">PayPal Mode</label>
<div class="field-help">
<strong>Sandbox</strong> uses test credentials and the PayPal sandbox API safe for development.
<strong>Live</strong> processes real payments. Switch only after configuring live credentials.
</div>
<select id="cfg_mode" name="paypal_mode">
<option value="sandbox" <?php echo $isSandboxMode ? 'selected' : ''; ?>>Sandbox (test mode)</option>
<option value="live" <?php echo !$isSandboxMode ? 'selected' : ''; ?>>Live (real payments)</option>
</select> </select>
</div> </div>
<!-- PayPal Client ID --> <!-- Sandbox credentials -->
<h4 style="color:#555;margin:20px 0 8px;">Sandbox Credentials</h4>
<div class="field-group"> <div class="field-group">
<label for="cfg_cid">PayPal Client ID</label> <label for="cfg_sb_id">Sandbox Client ID</label>
<div class="field-help"> <div class="field-help">Found in PayPal Developer Dashboard sandbox app. Safe to expose in browser JS.</div>
Your PayPal app Client ID. Safe to expose in browser JS. <input type="text" id="cfg_sb_id" name="paypal_sandbox_client_id"
Found in your PayPal Developer Dashboard under your app credentials. value="<?php echo h((string)$cfgVals['paypal_sandbox_client_id']); ?>"
placeholder="AfvY_... or sandbox client ID">
</div> </div>
<input type="text" id="cfg_cid" name="paypal_client_id"
value="<?php echo h((string)$cfgVals['paypal_client_id']); ?>"
placeholder="AY... or AZ...">
</div>
<!-- PayPal Client Secret -->
<div class="field-group"> <div class="field-group">
<label for="cfg_csecret">PayPal Client Secret</label> <label for="cfg_sb_sec">Sandbox Client Secret</label>
<div class="field-help"> <div class="field-help"><strong>Server-side only</strong> never sent to the browser. Leave blank to keep existing value.</div>
Your PayPal app Client Secret. <strong>Server-side only</strong> never sent to the browser.
</div>
<div class="pw-wrap"> <div class="pw-wrap">
<input type="password" id="cfg_csecret" name="paypal_client_secret" <input type="password" id="cfg_sb_sec" name="paypal_sandbox_client_secret"
value="<?php echo h((string)$cfgVals['paypal_client_secret']); ?>" placeholder="<?php echo $cfgVals['paypal_sandbox_client_secret'] !== '' ? '(set — leave blank to keep)' : '(not set)'; ?>"
autocomplete="new-password"> autocomplete="new-password">
<button type="button" class="btn-show" <button type="button" class="btn-show"
onclick="var f=document.getElementById('cfg_csecret');f.type=f.type==='password'?'text':'password';this.textContent=f.type==='password'?'Show':'Hide';"> onclick="var f=document.getElementById('cfg_sb_sec');f.type=f.type==='password'?'text':'password';this.textContent=f.type==='password'?'Show':'Hide';">Show</button>
Show
</button>
</div> </div>
</div> </div>
<div class="field-group">
<label for="cfg_sb_wh">Sandbox Webhook ID</label>
<div class="field-help">
Webhook ID from your PayPal sandbox app (for signature verification).
Leave empty to skip verification in sandbox mode (OK for initial setup).
</div>
<input type="text" id="cfg_sb_wh" name="paypal_sandbox_webhook_id"
value="<?php echo h((string)$cfgVals['paypal_sandbox_webhook_id']); ?>"
placeholder="Sandbox Webhook ID">
</div>
<!-- PayPal Webhook ID --> <!-- Live credentials -->
<h4 style="color:#555;margin:20px 0 8px;">Live Credentials</h4>
<div class="field-group"> <div class="field-group">
<label for="cfg_wh">PayPal Webhook ID</label> <label for="cfg_lv_id">Live Client ID</label>
<div class="field-help">From your PayPal live app. Leave blank until ready for production.</div>
<input type="text" id="cfg_lv_id" name="paypal_live_client_id"
value="<?php echo h((string)$cfgVals['paypal_live_client_id']); ?>"
placeholder="Live Client ID">
</div>
<div class="field-group">
<label for="cfg_lv_sec">Live Client Secret</label>
<div class="field-help"><strong>Server-side only.</strong> Leave blank to keep existing value.</div>
<div class="pw-wrap">
<input type="password" id="cfg_lv_sec" name="paypal_live_client_secret"
placeholder="<?php echo $cfgVals['paypal_live_client_secret'] !== '' ? '(set — leave blank to keep)' : '(not set)'; ?>"
autocomplete="new-password">
<button type="button" class="btn-show"
onclick="var f=document.getElementById('cfg_lv_sec');f.type=f.type==='password'?'text':'password';this.textContent=f.type==='password'?'Show':'Hide';">Show</button>
</div>
</div>
<div class="field-group">
<label for="cfg_lv_wh">Live Webhook ID</label>
<div class="field-help">Webhook ID from your PayPal live app (for signature verification).</div>
<input type="text" id="cfg_lv_wh" name="paypal_live_webhook_id"
value="<?php echo h((string)$cfgVals['paypal_live_webhook_id']); ?>"
placeholder="Live Webhook ID">
</div>
<!-- Webhook path + computed URL -->
<h4 style="color:#555;margin:20px 0 8px;">Webhook Endpoint</h4>
<div class="field-help" style="margin-bottom:10px;">
PayPal requires a <strong>full public HTTPS URL</strong> to deliver webhook events.
Set your Site Base URL above, then copy the computed URL below into your PayPal app's webhook configuration.
</div>
<div class="field-group">
<label for="cfg_wh_path">Webhook Path</label>
<div class="field-help">Path relative to the billing site root (must start with <code>/</code>). Default: <code>/paypal/webhook.php</code></div>
<input type="text" id="cfg_wh_path" name="paypal_webhook_path"
value="<?php echo h((string)$cfgVals['paypal_webhook_path']); ?>"
placeholder="/paypal/webhook.php"
oninput="updateWebhookUrl()">
</div>
<div class="field-group">
<label>Computed Full Webhook URL <small style="font-weight:normal;color:#888;">(read-only paste this into PayPal)</small></label>
<div class="field-help"> <div class="field-help">
Webhook ID from your PayPal app (used for webhook signature verification). This is the URL PayPal will POST webhook events to.
Leave empty to skip signature verification (not recommended for production). It must be publicly accessible over HTTPS before enabling live mode.
</div> </div>
<input type="text" id="cfg_wh" name="paypal_webhook_id" <input type="text" id="computed_webhook_url"
value="<?php echo h((string)$cfgVals['paypal_webhook_id']); ?>" class="readonly-field"
placeholder="Webhook ID"> value="<?php echo h($computedWebhookUrl); ?>"
readonly
style="font-family:monospace;color:#333;background:#f0f4ff;">
<button type="button" class="btn-show" style="margin-top:4px;"
onclick="navigator.clipboard.writeText(document.getElementById('computed_webhook_url').value).then(function(){this.textContent='Copied!';setTimeout(function(){document.querySelector('[onclick*=computed_webhook_url]').textContent='Copy';},2000);}.bind(this));">Copy</button>
</div> </div>
<script>
function updateWebhookUrl() {
var base = document.getElementById('cfg_base_url');
var path = document.getElementById('cfg_wh_path');
var out = document.getElementById('computed_webhook_url');
if (!base || !path || !out) return;
var b = base.value.replace(/\/+$/, '');
var p = path.value.replace(/^([^\/])/, '/$1');
out.value = b + p;
}
document.addEventListener('DOMContentLoaded', function() {
var base = document.getElementById('cfg_base_url');
if (base) base.addEventListener('input', updateWebhookUrl);
});
</script>
<hr style="border:none;border-top:1px solid #eee;margin:24px 0;"> <hr style="border:none;border-top:1px solid #eee;margin:24px 0;">
<h3 style="margin-top:0;color:#333;">Backup Settings</h3> <h3 style="margin-top:0;color:#333;">Backup Settings</h3>
@ -509,7 +620,94 @@ rsort($bakFiles); // newest first
</div> </div>
<!-- =================================================================== <!-- ===================================================================
SECTION B: Raw PHP editor SECTION B: PayPal Diagnostics
==================================================================== -->
<?php
// Gather diagnostics data
$diag_mode = $cfgVals['paypal_mode'] ?? 'sandbox';
$diag_is_sandbox = $diag_mode !== 'live';
$diag_sb_id_set = $cfgVals['paypal_sandbox_client_id'] !== '';
$diag_sb_sec_set = $cfgVals['paypal_sandbox_client_secret'] !== '';
$diag_sb_wh_set = $cfgVals['paypal_sandbox_webhook_id'] !== '';
$diag_lv_id_set = $cfgVals['paypal_live_client_id'] !== '';
$diag_lv_sec_set = $cfgVals['paypal_live_client_secret'] !== '';
$diag_lv_wh_set = $cfgVals['paypal_live_webhook_id'] !== '';
$diag_wh_path = $cfgVals['paypal_webhook_path'] ?? '/paypal/webhook.php';
$diag_wh_full_url = $computedWebhookUrl;
$diag_wh_file = __DIR__ . ltrim($diag_wh_path, '/');
$diag_wh_exists = file_exists($diag_wh_file);
// Active mode credential check
$diag_active_id_set = $diag_is_sandbox ? $diag_sb_id_set : $diag_lv_id_set;
$diag_active_sec_set = $diag_is_sandbox ? $diag_sb_sec_set : $diag_lv_sec_set;
$diag_active_wh_set = $diag_is_sandbox ? $diag_sb_wh_set : $diag_lv_wh_set;
function diag_badge(bool $ok, string $yes = 'Yes', string $no = 'No'): string {
$cls = $ok ? 'background:#d4edda;color:#155724;border:1px solid #c3e6cb;' : 'background:#f8d7da;color:#721c24;border:1px solid #f5c6cb;';
$label = $ok ? $yes : $no;
return '<span style="' . $cls . 'padding:2px 8px;border-radius:3px;font-size:0.85em;font-weight:600;">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>';
}
// 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
}
?>
<div class="cfg-section">
<h2>PayPal Diagnostics</h2>
<table style="border-collapse:collapse;width:100%;font-size:0.9em;">
<tr style="border-bottom:1px solid #eee;"><td style="padding:6px 8px;color:#555;width:260px;">Current mode</td><td style="padding:6px 8px;"><strong><?php echo h($diag_mode); ?></strong></td></tr>
<tr style="border-bottom:1px solid #eee;"><td style="padding:6px 8px;color:#555;">Active Client ID configured</td><td style="padding:6px 8px;"><?php echo diag_badge($diag_active_id_set); ?></td></tr>
<tr style="border-bottom:1px solid #eee;"><td style="padding:6px 8px;color:#555;">Active Client Secret configured</td><td style="padding:6px 8px;"><?php echo diag_badge($diag_active_sec_set); ?></td></tr>
<tr style="border-bottom:1px solid #eee;"><td style="padding:6px 8px;color:#555;">Active Webhook ID configured</td><td style="padding:6px 8px;"><?php echo diag_badge($diag_active_wh_set, 'Yes', 'No (signature verification skipped)'); ?></td></tr>
<tr style="border-bottom:1px solid #eee;"><td style="padding:6px 8px;color:#555;">Sandbox credentials</td><td style="padding:6px 8px;">ID: <?php echo diag_badge($diag_sb_id_set); ?> &nbsp; Secret: <?php echo diag_badge($diag_sb_sec_set); ?> &nbsp; Webhook ID: <?php echo diag_badge($diag_sb_wh_set); ?></td></tr>
<tr style="border-bottom:1px solid #eee;"><td style="padding:6px 8px;color:#555;">Live credentials</td><td style="padding:6px 8px;">ID: <?php echo diag_badge($diag_lv_id_set); ?> &nbsp; Secret: <?php echo diag_badge($diag_lv_sec_set); ?> &nbsp; Webhook ID: <?php echo diag_badge($diag_lv_wh_set); ?></td></tr>
<tr style="border-bottom:1px solid #eee;"><td style="padding:6px 8px;color:#555;">Webhook path</td><td style="padding:6px 8px;"><code><?php echo h($diag_wh_path); ?></code></td></tr>
<tr style="border-bottom:1px solid #eee;"><td style="padding:6px 8px;color:#555;">Full public webhook URL</td><td style="padding:6px 8px;"><code style="word-break:break-all;"><?php echo h($diag_wh_full_url ?: '(Site Base URL not set)'); ?></code></td></tr>
<tr><td style="padding:6px 8px;color:#555;">Webhook file exists on disk</td><td style="padding:6px 8px;"><?php echo diag_badge($diag_wh_exists, 'Yes — ' . h($diag_wh_file), 'No — ' . h($diag_wh_file) . ' not found'); ?></td></tr>
</table>
<?php if (!empty($diag_recent_events)): ?>
<h4 style="margin-top:18px;color:#555;">Recent Webhook Events</h4>
<table style="border-collapse:collapse;width:100%;font-size:0.85em;">
<thead><tr style="background:#f8f9fa;">
<th style="padding:6px 8px;text-align:left;border-bottom:2px solid #dee2e6;">PayPal Event ID</th>
<th style="padding:6px 8px;text-align:left;border-bottom:2px solid #dee2e6;">Type</th>
<th style="padding:6px 8px;text-align:left;border-bottom:2px solid #dee2e6;">Status</th>
<th style="padding:6px 8px;text-align:left;border-bottom:2px solid #dee2e6;">Received</th>
</tr></thead>
<tbody>
<?php foreach ($diag_recent_events as $ev): ?>
<tr style="border-bottom:1px solid #eee;">
<td style="padding:5px 8px;font-family:monospace;font-size:0.85em;"><?php echo h($ev['paypal_event_id'] ?: '—'); ?></td>
<td style="padding:5px 8px;"><?php echo h($ev['event_type']); ?></td>
<td style="padding:5px 8px;"><?php echo diag_badge($ev['processing_status'] === 'processed', $ev['processing_status'], $ev['processing_status']); ?></td>
<td style="padding:5px 8px;"><?php echo h($ev['created_at']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php elseif (empty($diag_recent_events)): ?>
<p style="color:#888;font-size:0.9em;margin-top:12px;">No webhook events recorded yet. Events will appear here after PayPal delivers the first webhook to <code><?php echo h($diag_wh_full_url ?: $diag_wh_path); ?></code>.</p>
<?php endif; ?>
</div>
<!-- ===================================================================
SECTION C: Raw PHP editor
==================================================================== --> ==================================================================== -->
<div class="cfg-section"> <div class="cfg-section">
<h2>Advanced: Raw Config Editor</h2> <h2>Advanced: Raw Config Editor</h2>

View file

@ -253,8 +253,8 @@ if ($applied_coupon && $coupon_discount_percent > 0) {
$final_amount = $total_amount - $discount_amount; $final_amount = $total_amount - $discount_amount;
// PayPal configuration (from config) // PayPal configuration (from config)
$sandbox = $paypal_sandbox ?? true; $client_id = function_exists('gsp_paypal_get_client_id') ? gsp_paypal_get_client_id() : ($paypal_client_id ?? '');
$client_id = $paypal_client_id ?? ''; $sandbox = function_exists('gsp_paypal_is_sandbox') ? gsp_paypal_is_sandbox() : ($paypal_sandbox ?? true);
// Prepare PayPal items // Prepare PayPal items
$paypal_items = []; $paypal_items = [];
@ -510,7 +510,7 @@ $siteBase = $protocol . $host;
<link rel="icon" href="images/logo-sm.png" type="image/png"> <link rel="icon" href="images/logo-sm.png" type="image/png">
<link rel="apple-touch-icon" href="images/logo-sm.png"> <link rel="apple-touch-icon" href="images/logo-sm.png">
<?php if (!$cart_empty && !empty($client_id)): ?> <?php if (!$cart_empty && !empty($client_id)): ?>
<script src="https://www.paypal.com/sdk/js?client-id=<?php echo htmlspecialchars($client_id); ?>&currency=USD&intent=capture"></script> <script src="https://www.paypal.com/sdk/js?client-id=<?php echo htmlspecialchars($client_id, ENT_QUOTES, 'UTF-8'); ?>&currency=USD&intent=capture<?php echo $sandbox ? '&debug=false' : ''; ?>"></script>
<?php endif; ?> <?php endif; ?>
</head> </head>
<body> <body>
@ -659,7 +659,7 @@ $siteBase = $protocol . $host;
} }
if ($cart_is_admin): if ($cart_is_admin):
?> ?>
<br><small><em>Admin: set <code>$paypal_client_id</code> in <a href="/admin_config.php" style="color:inherit;text-decoration:underline;">Site Config</a>.</em></small> <br><small><em>Admin: configure PayPal credentials in <a href="/admin_config.php" style="color:inherit;text-decoration:underline;">Site Config</a>.</em></small>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php else: ?> <?php else: ?>

View file

@ -20,13 +20,19 @@ class PayPalGateway implements PaymentGatewayInterface
/** /**
* Build a PayPalGateway instance from global config variables. * 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 public static function fromConfig(): self
{ {
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'] ?? ''; $clientId = $GLOBALS['paypal_client_id'] ?? '';
$clientSecret = $GLOBALS['paypal_client_secret'] ?? ''; $clientSecret = $GLOBALS['paypal_client_secret'] ?? '';
$sandbox = (bool)($GLOBALS['paypal_sandbox'] ?? true); $sandbox = (bool)($GLOBALS['paypal_sandbox'] ?? true);
}
return new self($clientId, $clientSecret, $sandbox); return new self($clientId, $clientSecret, $sandbox);
} }

View file

@ -22,9 +22,8 @@ $table_prefix = "gsp_"; // Table prefix used in the panel database
$db_type = "mysql"; $db_type = "mysql";
# --- Site base URL --- # --- Site base URL ---
# Leave empty to use relative paths (works for any install path). # Full base URL WITHOUT trailing slash. Leave empty to use relative paths.
# Set to your full base URL (without trailing slash) if you need absolute URLs: # Example: "https://gameservers.world" or "https://your-domain.com"
# e.g. "https://gameservers.world" or "http://173.208.136.11/testing/modules/billing"
$SITE_BASE_URL = ''; $SITE_BASE_URL = '';
# --- Background image --- # --- Background image ---
@ -37,10 +36,23 @@ $SITE_BACKGROUND = 'images/dark.jpg';
$SITE_DATA_DIR = realpath(__DIR__ . '/..') . DIRECTORY_SEPARATOR . 'data'; $SITE_DATA_DIR = realpath(__DIR__ . '/..') . DIRECTORY_SEPARATOR . 'data';
# --- PayPal settings --- # --- PayPal settings ---
$paypal_sandbox = true; // Set to false for live payments # Mode: 'sandbox' for testing, 'live' for real payments.
$paypal_client_id = ''; // Your PayPal Client ID $paypal_mode = 'sandbox';
$paypal_client_secret = ''; // Your PayPal Client Secret
$paypal_webhook_id = ''; // Your PayPal Webhook ID (for webhook signature verification) # 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 --- # --- Admin config backup retention ---
# Number of config backups to keep (110). Oldest backups beyond this limit are deleted. # Number of config backups to keep (110). Oldest backups beyond this limit are deleted.

View file

@ -14,14 +14,15 @@ $db_pass="Pkloyn7yvpht!";
$db_name="panel"; $db_name="panel";
$table_prefix="gsp_"; $table_prefix="gsp_";
$db_type="mysql"; $db_type="mysql";
// Optional: base URL used by admin pages to build absolute image previews. // Optional: base URL used by admin pages to build absolute image previews.
// Leave empty to prefer relative paths (local folder). // Leave empty to prefer relative paths (local folder).
// To enable production base URL, uncomment and set it to your site, e.g.: // 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 = ''; $SITE_BASE_URL = '';
// Normalize: ensure either empty or ends without trailing slash (we use join_base to handle joining) // Normalize: ensure either empty or ends without trailing slash
$SITE_BASE_URL = trim((string)$SITE_BASE_URL); $SITE_BASE_URL = rtrim(trim((string)$SITE_BASE_URL), '/');
// Site-wide background image (relative to site root). Change to your preferred background. // Site-wide background image (relative to site root). Change to your preferred background.
$SITE_BACKGROUND = 'images/dark.jpg'; $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) // Data directory for persisted payment webhook JSON files (relative to repo root)
$SITE_DATA_DIR = realpath(__DIR__ . '/..') . DIRECTORY_SEPARATOR . 'data'; $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 configuration
$paypal_client_id = ''; // Your PayPal Client ID // Set credentials here — never in API files or public pages.
$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' 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 (110). Default 5. // Admin config backup retention: how many backups to keep (110). Default 5.
$SITE_CONFIG_BACKUP_RETENTION = 5; $SITE_CONFIG_BACKUP_RETENTION = 5;

View file

@ -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 // 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). // 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)) { if (!isset($paypal_sandbox)) {
$paypal_sandbox = true; $paypal_sandbox = ($paypal_mode !== 'live');
} }
if (!isset($paypal_client_id)) { 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)) { 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)) { 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)) { if (!isset($SITE_BASE_URL)) {
$SITE_BASE_URL = ''; $SITE_BASE_URL = '';
} }
$SITE_BASE_URL = rtrim(trim((string)$SITE_BASE_URL), '/');
if (!isset($SITE_BACKGROUND)) { if (!isset($SITE_BACKGROUND)) {
$SITE_BACKGROUND = 'images/dark.jpg'; $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)); $SITE_CONFIG_BACKUP_RETENTION = max(1, min(10, (int)$SITE_CONFIG_BACKUP_RETENTION));
define('BILLING_CONFIG_LOADED', true); 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;
}
}

View file

@ -24,8 +24,8 @@
// Module general information // Module general information
$module_title = "billing"; $module_title = "billing";
$module_version = "3.2"; $module_version = "3.3";
$db_version = 2; $db_version = 3;
$module_required = FALSE; $module_required = FALSE;
// Module description // Module description
$module_description = "Billing storefront / provisioning integration. Public ordering runs as a standalone site; panel pages provide provisioning and admin order management."; $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;" ) 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;"
);
?> ?>

View file

@ -0,0 +1,711 @@
<?php
/**
* GSP PayPal Webhook Receiver
*
* Public URL: $SITE_BASE_URL + $paypal_webhook_path (e.g. https://gameservers.world/paypal/webhook.php)
*
* This endpoint:
* 1. Reads raw POST JSON from PayPal
* 2. Verifies the webhook signature using PayPal's verify-webhook-signature API
* 3. Checks for duplicate events (idempotency) via billing_paypal_webhook_events table
* 4. Processes supported event types and updates billing_orders / triggers provisioning
* 5. Returns appropriate HTTP status codes
*
* HTTP status codes returned:
* 200 success (or duplicate event safely ignored)
* 400 missing / invalid JSON body
* 401 PayPal signature verification failed or OAuth failed
* 500 internal error (DB unavailable, etc.)
*/
ini_set('display_errors', '0');
error_reporting(E_ALL);
// ---------------------------------------------------------------------------
// Bootstrap: load config and DB
// ---------------------------------------------------------------------------
$_billing_dir = dirname(__DIR__);
require_once $_billing_dir . '/includes/config_loader.php';
// Log helper — writes to logs/paypal_webhook.log; never logs secrets.
$_wh_log_file = $_billing_dir . '/logs/paypal_webhook.log';
@mkdir(dirname($_wh_log_file), 0755, true);
function wh_log(string $level, string $message, array $context = []): void
{
global $_wh_log_file;
$ctx = empty($context) ? '' : ' ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$line = '[' . date('c') . '] [' . strtoupper($level) . '] ' . $message . $ctx . "\n";
@file_put_contents($_wh_log_file, $line, FILE_APPEND | LOCK_EX);
}
// ---------------------------------------------------------------------------
// 1. Read raw input
// ---------------------------------------------------------------------------
$raw = (string)file_get_contents('php://input');
$headers = array_change_key_case((array)(getallheaders() ?: []), CASE_UPPER);
wh_log('info', 'webhook_received', ['ip' => $_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");
}

View file

@ -2,11 +2,18 @@
require_once(__DIR__ . '/includes/config_loader.php'); require_once(__DIR__ . '/includes/config_loader.php');
if (is_file(__DIR__ . '/includes/log.php')) require_once(__DIR__ . '/includes/log.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 = [ $config = [
'sandbox' => $paypal_sandbox ?? true, 'sandbox' => $_wh_sandbox,
'client_id' => $paypal_client_id ?? '', 'client_id' => $_wh_client_id,
'client_secret' => $paypal_client_secret ?? '', 'client_secret' => $_wh_client_secret,
'webhook_id' => $paypal_webhook_id ?? '', 'webhook_id' => $_wh_webhook_id,
'data_dir' => rtrim( 'data_dir' => rtrim(
(defined('SITE_DATA_DIR') ? SITE_DATA_DIR : '') ?: ($SITE_DATA_DIR ?? ''), (defined('SITE_DATA_DIR') ? SITE_DATA_DIR : '') ?: ($SITE_DATA_DIR ?? ''),
DIRECTORY_SEPARATOR DIRECTORY_SEPARATOR
@ -19,7 +26,149 @@ if (!$config['data_dir']) {
} }
function log_line($m){global $config; @file_put_contents($config['log_file'],'['.date('c')."] $m\n",FILE_APPEND);} 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 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); http_response_code(200);
@mkdir($config['data_dir'], 0775, true); @mkdir($config['data_dir'], 0775, true);