diff --git a/CHANGELOG.md b/CHANGELOG.md index 09be2c47..34617059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2026-05-09 +- **Billing docs + auto-provision/install reliability:** Updated Game Monitor and Support documentation links to always open `https://gameservers.world/docs/` (game-specific path when available) in a new tab, and hardened billing provisioning so existing `home_id` orders can retry install automatically via the shared gamemanager update trigger (no manual Update Server click required). Added structured provisioning logs (`modules/billing/logs/provisioning.log`) and enriched panel log context with order/invoice/user/home/home_cfg/mod/ip/port/mechanism/result/error fields; PayPal webhook renewals now auto-provision when an order still has no `home_id`. +- **Payment success provisioning visibility:** Expanded `modules/billing/payment_success.php` to show per-order provisioning state (install started/pending/failed) using live order/home/IP-port/mod consistency checks, so users/admins can immediately see provisioning outcomes instead of silent failures. + ## 2026-05-08 - **Auto-install trigger + monthly-only billing pricing:** Refactored Game Monitor update/install into a shared callable (`modules/gamemanager/update_actions.php`) reused by billing provisioning so new paid/free/admin-created homes auto-trigger the same install/update path used by `m=gamemanager&p=update&update=refresh` without manual clicks. Billing now treats monthly pricing (`price_monthly`) as canonical across admin service config, add-to-cart, free checkout, PayPal capture, and provisioning end-date math (31-day months), while preserving legacy daily/yearly columns for backward compatibility. - **LiteFM PHP 8.3 compatibility and install-pending UX:** Removed deprecated `${var}` interpolation usage, guarded missing `fm_cwd_*` session keys and `dirname()` null paths, and replaced directory-not-found warning output with a clear message when server files are not installed yet. diff --git a/docs/COPILOT_TODO.md b/docs/COPILOT_TODO.md index ba91d94f..3ab73879 100644 --- a/docs/COPILOT_TODO.md +++ b/docs/COPILOT_TODO.md @@ -10,3 +10,4 @@ - Complete a full pass over all `modules/billing/docs/*` game guides to standardize OS/Workshop/RCON capability statements against current XML-backed server support. - Add an automated billing provisioning integration test fixture that verifies arrange_ports exact/fallback allocation, duplicate-port protection, and home_id linkage after paid/free checkout. - Add a billing UI badge/filter that distinguishes "pending install" vs "installed" states directly in customer/server order views. +- Add an admin billing orders "provisioning details" drawer that reads `modules/billing/logs/provisioning.log` and shows the latest mechanism/result/error per order without leaving the panel. diff --git a/modules/billing/create_servers.php b/modules/billing/create_servers.php index 46c8a8c5..b0e6968f 100644 --- a/modules/billing/create_servers.php +++ b/modules/billing/create_servers.php @@ -3,6 +3,16 @@ require_once __DIR__ . '/../../includes/lib_remote.php'; require_once __DIR__ . '/../config_games/server_config_parser.php'; require_once __DIR__ . '/../gamemanager/update_actions.php'; +if (!defined('BILLING_INSTALL_MECHANISM')) { + define('BILLING_INSTALL_MECHANISM', 'gamemanager_trigger_update_install'); +} +if (!defined('BILLING_CPU_AFFINITY_NA')) { + define('BILLING_CPU_AFFINITY_NA', 'NA'); +} +if (!defined('BILLING_NICE_DEFAULT')) { + define('BILLING_NICE_DEFAULT', '0'); +} + if (!function_exists('billing_generate_provision_password')) { function billing_generate_provision_password(int $bytes = 12) { @@ -159,6 +169,47 @@ if (!function_exists('billing_resolve_mod_cfg_id')) { } } +if (!function_exists('billing_get_home_ip_port')) { + function billing_get_home_ip_port($db, string $db_prefix, int $home_id): array + { + $row = $db->resultQuery( + "SELECT ip_id, port + FROM `{$db_prefix}home_ip_ports` + WHERE home_id=" . $db->realEscapeSingle($home_id) . " + ORDER BY ip_id ASC, port ASC + LIMIT 1" + ); + if (!empty($row[0])) { + return array( + 'ok' => true, + 'ip_id' => intval($row[0]['ip_id'] ?? 0), + 'port' => intval($row[0]['port'] ?? 0), + ); + } + return array('ok' => false, 'ip_id' => 0, 'port' => 0); + } +} + +if (!function_exists('billing_write_provision_log')) { + /** + * Writes one JSON line per provisioning attempt to modules/billing/logs/provisioning.log. + * Fields include order/invoice/user/home/home_cfg/mod/ip/port/mechanism/install_result/error/message. + */ + function billing_write_provision_log(array $context): void + { + $logDir = __DIR__ . '/logs'; + if (!is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + $status = strtoupper((string)($context['install_result'] ?? 'INFO')); + $line = '[' . date('Y-m-d H:i:s') . '] [' . $status . '] ' . json_encode($context, JSON_UNESCAPED_SLASHES) . PHP_EOL; + $result = file_put_contents($logDir . '/provisioning.log', $line, FILE_APPEND | LOCK_EX); + if ($result === false) { + error_log('billing_write_provision_log: failed to append provisioning.log'); + } + } +} + function exec_ogp_module() { global $db,$view,$settings,$table_prefix; @@ -242,6 +293,26 @@ function exec_ogp_module() $user_id = $order['user_id']; $extended = isset($order['extended']) && $order['extended'] == "1" ? TRUE : FALSE; $alreadyProvisioned = !$extended && intval($order['home_id'] ?? 0) > 0; + $provision_invoice_id = 0; + $selected_ip_id = 0; + $selected_port = 0; + $selected_mod_id = 0; + $resolved_mod_cfg_id = 0; + $install_mechanism = BILLING_INSTALL_MECHANISM; + $install_result = 'pending'; + $install_message = ''; + $install_attempted = false; + $home_info = array(); + $invoiceRow = $db->resultQuery( + "SELECT invoice_id + FROM `{$db_prefix}billing_invoices` + WHERE order_id=" . $db->realEscapeSingle($order_id) . " + ORDER BY invoice_id DESC + LIMIT 1" + ); + if (!empty($invoiceRow[0]['invoice_id'])) { + $provision_invoice_id = intval($invoiceRow[0]['invoice_id']); + } //Query service info $service = $db->resultQuery( "SELECT * FROM `{$db_prefix}billing_services` @@ -269,6 +340,17 @@ function exec_ogp_module() if(!$order_failed && $alreadyProvisioned) { $home_id = intval($order['home_id']); + $home_info = $db->getGameHome($home_id); + if (empty($home_info)) { + $order_failed = true; + $order_failure_reason = "Order #{$order_id} references home_id {$home_id} but server_homes row is missing."; + $db->logger('BILLING PROVISION DATA INTEGRITY ERROR: ' . $order_failure_reason); + } + $existingIpPort = billing_get_home_ip_port($db, $db_prefix, intval($home_id)); + if (!empty($existingIpPort['ok'])) { + $selected_ip_id = intval($existingIpPort['ip_id']); + $selected_port = intval($existingIpPort['port']); + } } elseif(!$order_failed && $extended) { @@ -340,6 +422,11 @@ function exec_ogp_module() $order_failed = true; $order_failure_reason = (string)($allocatedPort['error'] ?? 'Port allocation failed.'); $db->logger("Provisioning pending install for order #{$order_id}: {$order_failure_reason}"); + $install_result = 'failed'; + $install_message = $order_failure_reason; + } else { + $selected_ip_id = intval($allocatedPort['ip_id'] ?? 0); + $selected_port = intval($allocatedPort['port'] ?? 0); } } @@ -350,6 +437,8 @@ function exec_ogp_module() if (empty($modResolution['ok'])) { $order_failed = true; $order_failure_reason = (string)($modResolution['error'] ?? 'No mod profile available for base install.'); + $install_result = 'failed'; + $install_message = $order_failure_reason; } else { $resolved_mod_cfg_id = intval($modResolution['mod_cfg_id']); } @@ -360,11 +449,14 @@ function exec_ogp_module() if ($mod_id === false) { $order_failed = true; $order_failure_reason = "Could not attach mod_cfg_id {$resolved_mod_cfg_id} to home #{$home_id}."; + $install_result = 'failed'; + $install_message = $order_failure_reason; } } if (!$order_failed) { $db->updateGameModParams( $max_players, $extra_params, $cpu_affinity, $nice, $home_id, $resolved_mod_cfg_id ); $db->assignHomeTo( "user", $user_id, $home_id, $access_rights ); + $selected_mod_id = intval($mod_id); } //Get The home info without mods in 1 array (Necesary for remote connection). @@ -373,6 +465,8 @@ function exec_ogp_module() if (empty($home_info)) { $order_failed = true; $order_failure_reason = "Could not load home info for home #{$home_id}."; + $install_result = 'failed'; + $install_message = $order_failure_reason; } } @@ -387,6 +481,8 @@ function exec_ogp_module() if (empty($home_info) || empty($home_info['mods'])) { $order_failed = true; $order_failure_reason = "Mods are not configured for home #{$home_id}; base install profile could not be resolved."; + $install_result = 'failed'; + $install_message = $order_failure_reason; } } @@ -398,6 +494,7 @@ function exec_ogp_module() } if (!$order_failed) { + $install_attempted = true; $autoInstall = gamemanager_trigger_update_install( $db, $home_info, @@ -405,9 +502,22 @@ function exec_ogp_module() array('settings' => $settings) ); $mod_id = intval($autoInstall['mod_id'] ?? $mod_id); + $selected_mod_id = intval($mod_id); + $install_message = (string)($autoInstall['message'] ?? ''); + if (!empty($autoInstall['already_running'])) { + $install_result = 'already_running'; + } elseif (!empty($autoInstall['started'])) { + $install_result = 'started'; + } elseif (!empty($autoInstall['completed'])) { + $install_result = 'completed'; + } else { + $install_result = 'pending'; + } if (empty($autoInstall['ok'])) { $order_failed = true; $order_failure_reason = "Server files have not been installed yet. " . ($autoInstall['message'] ?? 'Auto install could not be started.'); + $install_result = 'failed'; + $install_message = $order_failure_reason; } } if (!$order_failed) { @@ -443,6 +553,88 @@ function exec_ogp_module() } + + // Retry install for orders that already have home_id but never triggered installation. + if (!$order_failed && !$extended && !$install_attempted && intval($home_id) > 0) { + if ($selected_ip_id <= 0 || $selected_port <= 0) { + $existingIpPort = billing_get_home_ip_port($db, $db_prefix, intval($home_id)); + if (!empty($existingIpPort['ok'])) { + $selected_ip_id = intval($existingIpPort['ip_id']); + $selected_port = intval($existingIpPort['port']); + } else { + $allocatedPort = billing_allocate_home_port($db, $db_prefix, intval($home_id), intval($remote_server_id), intval($home_cfg_id)); + if (empty($allocatedPort['ok'])) { + $order_failed = true; + $order_failure_reason = (string)($allocatedPort['error'] ?? 'Port allocation failed for existing home.'); + $install_result = 'failed'; + $install_message = $order_failure_reason; + } else { + $selected_ip_id = intval($allocatedPort['ip_id'] ?? 0); + $selected_port = intval($allocatedPort['port'] ?? 0); + } + } + } + if (!$order_failed) { + if (empty($home_info)) { + $home_info = $db->getGameHome(intval($home_id)); + } + if (empty($home_info)) { + $order_failed = true; + $order_failure_reason = "Could not load home info for home #{$home_id}."; + $install_result = 'failed'; + $install_message = $order_failure_reason; + } + } + if (!$order_failed && empty($home_info['mods'])) { + $modResolution = billing_resolve_mod_cfg_id($db, intval($home_cfg_id), intval($mod_cfg_id)); + if (empty($modResolution['ok'])) { + $order_failed = true; + $order_failure_reason = (string)($modResolution['error'] ?? "Mods are not configured for home #{$home_id}; base install profile could not be resolved."); + $install_result = 'failed'; + $install_message = $order_failure_reason; + } else { + $resolved_mod_cfg_id = intval($modResolution['mod_cfg_id']); + $selected_mod_id = intval($db->addModToGameHome(intval($home_id), intval($resolved_mod_cfg_id))); + if ($selected_mod_id <= 0) { + $order_failed = true; + $order_failure_reason = "Could not attach mod_cfg_id {$resolved_mod_cfg_id} to home #{$home_id}."; + $install_result = 'failed'; + $install_message = $order_failure_reason; + } else { + $db->updateGameModParams($max_players, '', BILLING_CPU_AFFINITY_NA, BILLING_NICE_DEFAULT, intval($home_id), intval($resolved_mod_cfg_id)); + $db->assignHomeTo("user", $user_id, intval($home_id), $access_rights); + $home_info = $db->getGameHome(intval($home_id)); + } + } + } + if (!$order_failed) { + $selected_mod_id = intval(gamemanager_choose_mod_id((array)$home_info, intval($selected_mod_id))); + $install_attempted = true; + $autoInstall = gamemanager_trigger_update_install( + $db, + (array)$home_info, + intval($selected_mod_id), + array('settings' => $settings) + ); + $selected_mod_id = intval($autoInstall['mod_id'] ?? $selected_mod_id); + $install_message = (string)($autoInstall['message'] ?? ''); + if (!empty($autoInstall['already_running'])) { + $install_result = 'already_running'; + } elseif (!empty($autoInstall['started'])) { + $install_result = 'started'; + } elseif (!empty($autoInstall['completed'])) { + $install_result = 'completed'; + } else { + $install_result = 'pending'; + } + if (empty($autoInstall['ok'])) { + $order_failed = true; + $order_failure_reason = "Server files have not been installed yet. " . ($autoInstall['message'] ?? 'Auto install could not be started.'); + $install_result = 'failed'; + $install_message = $order_failure_reason; + } + } + } // Set expiration date in panel database // Status values: Active (provisioned & current), Invoiced (renewal invoice open), // Expired (past due and awaiting deletion) @@ -520,6 +712,35 @@ function exec_ogp_module() WHERE home_id = " . $db->realEscapeSingle($home_id)); } + $provisionContext = array( + 'order_id' => intval($order_id), + 'invoice_id' => intval($provision_invoice_id), + 'user_id' => intval($user_id), + 'home_id' => intval($home_id), + 'home_cfg_id' => intval($home_cfg_id ?? 0), + 'mod_id' => intval($selected_mod_id), + 'ip_id' => intval($selected_ip_id), + 'port' => intval($selected_port), + 'mechanism' => $install_mechanism, + 'install_result' => $order_failed ? 'failed' : (string)$install_result, + 'error' => $order_failed ? (string)$order_failure_reason : '', + 'message' => (string)$install_message, + ); + billing_write_provision_log($provisionContext); + $db->logger( + 'BILLING PROVISION RESULT order_id=' . intval($order_id) + . ' invoice_id=' . intval($provision_invoice_id) + . ' user_id=' . intval($user_id) + . ' home_id=' . intval($home_id) + . ' home_cfg_id=' . intval($home_cfg_id ?? 0) + . ' mod_id=' . intval($selected_mod_id) + . ' ip_id=' . intval($selected_ip_id) + . ' port=' . intval($selected_port) + . ' mechanism=' . $install_mechanism + . ' install_result=' . ($order_failed ? 'failed' : (string)$install_result) + . ($order_failed ? ' error=' . (string)$order_failure_reason : '') + ); + if ($order_failed) { $failed_count++; $failed_messages[] = "Order #{$order_id}: {$order_failure_reason}"; diff --git a/modules/billing/payment_success.php b/modules/billing/payment_success.php index 18ed9dce..1dcbb3fa 100644 --- a/modules/billing/payment_success.php +++ b/modules/billing/payment_success.php @@ -26,11 +26,35 @@ $db = mysqli_connect($db_host, $db_user, $db_pass, $db_name, isset($db_port) ? ( $orders = []; $total_paid = 0; +function billing_payment_success_provision_state(array $order): array +{ + $homeId = intval($order['home_id'] ?? 0); + $hasHome = intval($order['has_home'] ?? 0) > 0; + $ipPortCount = intval($order['ip_port_count'] ?? 0); + $modCount = intval($order['mod_count'] ?? 0); + + if ($homeId <= 0) { + return ['label' => 'PENDING', 'message' => 'Server record is queued for provisioning.', 'class' => 'status-badge status-pending']; + } + // home_id exists but server_homes row does not: orphaned consistency failure. + if (!$hasHome) { + return ['label' => 'FAILED', 'message' => 'Server setup failed. Please contact support with your order ID.', 'class' => 'status-badge status-failed']; + } + if ($ipPortCount <= 0 || $modCount <= 0) { + return ['label' => 'PENDING', 'message' => 'Server created; install is pending final IP/mod setup.', 'class' => 'status-badge status-pending']; + } + return ['label' => 'INSTALL STARTED', 'message' => 'Server created and install/update trigger has been started or queued.', 'class' => 'status-badge']; +} + if ($db && $user_id > 0) { // Get recent orders for this user (just paid) - $query = "SELECT o.*, s.service_name + $query = "SELECT o.*, s.service_name, + CASE WHEN sh.home_id IS NULL THEN 0 ELSE 1 END AS has_home, + (SELECT COUNT(*) FROM {$table_prefix}home_ip_ports hip WHERE hip.home_id = o.home_id) AS ip_port_count, + (SELECT COUNT(*) FROM {$table_prefix}game_mods gm WHERE gm.home_id = o.home_id) AS mod_count FROM {$table_prefix}billing_orders o LEFT JOIN {$table_prefix}billing_services s ON o.service_id = s.service_id + LEFT JOIN {$table_prefix}server_homes sh ON sh.home_id = o.home_id WHERE o.user_id = $user_id AND o.status = 'Active' ORDER BY o.order_date DESC @@ -39,6 +63,7 @@ if ($db && $user_id > 0) { $result = mysqli_query($db, $query); if ($result) { while ($row = mysqli_fetch_assoc($result)) { + $row['provision_state'] = billing_payment_success_provision_state($row); $orders[] = $row; $total_paid += floatval($row['price']); } @@ -132,6 +157,12 @@ if ($db && $user_id > 0) { background: #28a745; color: white; } + .status-pending { + background: #f0ad4e; + } + .status-failed { + background: #d9534f; + } .btn { display: inline-block; padding: 12px 24px; @@ -192,6 +223,7 @@ if ($db && $user_id > 0) { Game Duration Status + Provisioning Price @@ -203,6 +235,14 @@ if ($db && $user_id > 0) { x PAID + + + + +
+ +
+ $ diff --git a/modules/billing/paypal/webhook.php b/modules/billing/paypal/webhook.php index 883669f7..0fcab576 100644 --- a/modules/billing/paypal/webhook.php +++ b/modules/billing/paypal/webhook.php @@ -556,7 +556,12 @@ function wh_fulfill_payment(mysqli $db, string $pfx, array $payment, string $bil mysqli_stmt_close($stmt); } $last_order_id = $order_id; - wh_log('info', 'order_renewed', ['order_id' => $order_id, 'new_end' => $new_end]); + $existing_home_id = intval($row['home_id'] ?? 0); + wh_log('info', 'order_renewed', ['order_id' => $order_id, 'new_end' => $new_end, 'home_id' => $existing_home_id]); + if ($existing_home_id <= 0) { + $dir = ($billing_dir !== '') ? $billing_dir : dirname(__DIR__); + wh_try_provision($dir, $order_id, $user_id); + } } } else { // New order: create billing_orders row diff --git a/modules/gamemanager/server_monitor.php b/modules/gamemanager/server_monitor.php index 208b73e4..44270fa8 100644 --- a/modules/gamemanager/server_monitor.php +++ b/modules/gamemanager/server_monitor.php @@ -29,6 +29,10 @@ require_once("modules/config_games/server_config_parser.php"); require_once("includes/refreshed.php"); require_once('includes/lib_remote.php'); +if (!defined('GSP_WEBSITE_DOCS_BASE_URL')) { + define('GSP_WEBSITE_DOCS_BASE_URL', 'https://gameservers.world/docs/'); +} + function renderParam($param, $last_param, $param_access_enabled, $home_id) { global $db; @@ -156,14 +160,15 @@ function get_sync_name($server_xml) function gsp_docs_url_for_game_key($game_key) { + $baseDocsUrl = rtrim(GSP_WEBSITE_DOCS_BASE_URL, '/'); $game_key = trim((string)$game_key); if ($game_key !== '') { $docPath = __DIR__ . '/../billing/docs/' . $game_key . '/index.php'; if (is_file($docPath)) { - return '/docs.php?action=view&doc=' . rawurlencode($game_key); + return $baseDocsUrl . '/' . rawurlencode($game_key) . '/'; } } - return '/docs.php'; + return $baseDocsUrl . '/'; } function exec_ogp_module() { @@ -272,7 +277,7 @@ $home_info = $db->getGameHomeWithoutMods($home_id); $show_all = FALSE; } - $docsTarget = '/docs.php'; + $docsTarget = GSP_WEBSITE_DOCS_BASE_URL; if (is_array($home_info) && !empty($home_info['game_key'])) { $docsTarget = gsp_docs_url_for_game_key($home_info['game_key']); } diff --git a/modules/support/support.php b/modules/support/support.php index 4daa69df..13014c72 100644 --- a/modules/support/support.php +++ b/modules/support/support.php @@ -20,18 +20,23 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * - */ +*/ + +if (!defined('GSP_WEBSITE_DOCS_BASE_URL')) { + define('GSP_WEBSITE_DOCS_BASE_URL', 'https://gameservers.world/docs/'); +} function gsp_support_docs_url_for_game_key($game_key) { + $baseDocsUrl = rtrim(GSP_WEBSITE_DOCS_BASE_URL, '/'); $game_key = trim((string)$game_key); if ($game_key !== '') { $docPath = __DIR__ . '/../billing/docs/' . $game_key . '/index.php'; if (is_file($docPath)) { - return '/docs.php?action=view&doc=' . rawurlencode($game_key); + return $baseDocsUrl . '/' . rawurlencode($game_key) . '/'; } } - return '/docs.php'; + return $baseDocsUrl . '/'; } function exec_ogp_module() { @@ -93,7 +98,7 @@ if (!empty($webhook)) { } // end if submit echo ''; echo "

".get_lang('support')."

"; - $defaultDocsUrl = '/docs.php'; + $defaultDocsUrl = GSP_WEBSITE_DOCS_BASE_URL; if (!empty($server_homes) && is_array($server_homes)) { foreach ((array)$server_homes as $server_home_row) { if (!empty($server_home_row['game_key'])) { @@ -167,7 +172,7 @@ if (!empty($webhook)) { $(document).ready(function(){ function updateSupportDocLink(){ var selected = $('#gameserver option:selected'); - var url = selected.data('doc-url') || '/docs.php'; + var url = selected.data('doc-url') || ''; $('#support-doc-link').attr('href', url); } $('#gameserver').on('change', updateSupportDocLink);