From b03d9b21712ae998bc7047b591bec3f04c726e19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 22:17:22 +0000 Subject: [PATCH 1/2] feat: simplify billing status to Active/Invoiced/Expired with new SQL migration and cron rewrite Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/5dbd58e1-7aa0-41e2-8dd3-c56b69ede05e Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- modules/billing/admin_invoices.php | 18 +- modules/billing/admin_orders.php | 38 +- modules/billing/api/capture_order.php | 30 +- modules/billing/create_servers.php | 43 +- modules/billing/cron-shop.php | 798 +++++++++--------- .../billing/includes/payment_processor.php | 4 +- modules/billing/my_orders_panel.php | 8 +- modules/billing/my_servers.php | 32 +- modules/billing/payment_success.php | 2 +- modules/billing/renew_server.php | 4 +- ...billing_status_active_invoiced_expired.sql | 260 ++++++ 11 files changed, 738 insertions(+), 499 deletions(-) create mode 100644 sql/update_billing_status_active_invoiced_expired.sql diff --git a/modules/billing/admin_invoices.php b/modules/billing/admin_invoices.php index a2dd8865..bf0bb887 100644 --- a/modules/billing/admin_invoices.php +++ b/modules/billing/admin_invoices.php @@ -44,12 +44,9 @@ function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } .btn-save { background: #28a745; color: white; border: none; padding: 5px 12px; border-radius: 3px; cursor: pointer; } .btn-save:hover { background: #218838; } .status-badge { display: inline-block; padding: 3px 8px; border-radius: 3px; font-size: 12px; font-weight: 600; } - .status-paid { background: #d4edda; color: #155724; } - .status-pending { background: #fff3cd; color: #856404; } - .status-in-cart { background: #d1ecf1; color: #0c5460; } - .status-expired { background: #f8d7da; color: #721c24; } - .status-renew { background: #cce5ff; color: #004085; } - .status-installed { background: #d4edda; color: #155724; } + .status-Active { background: #d4edda; color: #155724; } + .status-Invoiced { background: #fff3cd; color: #856404; } + .status-Expired { background: #f8d7da; color: #721c24; } @@ -128,12 +125,9 @@ function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } diff --git a/modules/billing/admin_orders.php b/modules/billing/admin_orders.php index 041be69a..c502565f 100644 --- a/modules/billing/admin_orders.php +++ b/modules/billing/admin_orders.php @@ -29,14 +29,14 @@ function exec_ogp_module() header("Location: home.php?m=billing&p=provision_servers&order_id=".$order_id); exit; break; - case 'suspend': - $db->query("UPDATE OGP_DB_PREFIXbilling_orders SET status='suspended' WHERE order_id=".$order_id); + case 'expire': + $db->query("UPDATE OGP_DB_PREFIXbilling_orders SET status='Expired' WHERE order_id=".$order_id); break; case 'activate': - $db->query("UPDATE OGP_DB_PREFIXbilling_orders SET status='paid' WHERE order_id=".$order_id); + $db->query("UPDATE OGP_DB_PREFIXbilling_orders SET status='Active' WHERE order_id=".$order_id); break; - case 'delete': - $db->query("UPDATE OGP_DB_PREFIXbilling_orders SET status='deleted' WHERE order_id=".$order_id); + case 'invoice': + $db->query("UPDATE OGP_DB_PREFIXbilling_orders SET status='Invoiced' WHERE order_id=".$order_id); break; } } @@ -56,12 +56,9 @@ function exec_ogp_module() echo ""; echo "Status: "; echo "Search: "; echo ""; @@ -101,9 +98,9 @@ function exec_ogp_module() echo " "; echo ""; echo ""; @@ -128,11 +125,10 @@ function exec_ogp_module() foreach ((array)$orders as $order) { $status_class = ''; switch ($order['status']) { - case 'paid': $status_class = 'label-warning'; break; - case 'installed': $status_class = 'label-success'; break; - case 'suspended': $status_class = 'label-danger'; break; - case 'deleted': $status_class = 'label-default'; break; - default: $status_class = 'label-info'; + case 'Active': $status_class = 'label-success'; break; + case 'Invoiced': $status_class = 'label-warning'; break; + case 'Expired': $status_class = 'label-danger'; break; + default: $status_class = 'label-info'; } echo ""; @@ -150,11 +146,11 @@ function exec_ogp_module() echo "".($order['home_id'] ? $order['home_id'] : 'N/A').""; echo ""; - if ($order['status'] == 'paid') { + if ($order['status'] == 'Active' && !$order['home_id']) { echo "Provision "; } - if ($order['status'] == 'installed' && $order['home_id']) { + if ($order['status'] == 'Active' && $order['home_id']) { echo "View Server "; } diff --git a/modules/billing/api/capture_order.php b/modules/billing/api/capture_order.php index 3ab5f447..1468e4d3 100644 --- a/modules/billing/api/capture_order.php +++ b/modules/billing/api/capture_order.php @@ -214,8 +214,6 @@ if ($coupon_id > 0) { $updateInvoicesSql = "UPDATE {$table_prefix}billing_invoices SET status='paid', paid_date='$now', payment_txid='$esc_txid', payment_method='paypal' WHERE user_id=$user_id AND status='due'"; - -log_payment('UPDATE_INVOICES_SQL', $updateInvoicesSql); $updateResult = mysqli_query($mysqli, $updateInvoicesSql); if (!$updateResult) { @@ -265,7 +263,7 @@ while ($inv = mysqli_fetch_assoc($invoicesResult)) { } $newEndDate = date('Y-m-d H:i:s', strtotime("+$qty $durationUnit", $baseTs)); $renewSql = "UPDATE {$table_prefix}billing_orders - SET status='installed', end_date='$newEndDate', paid_ts='$now', payment_txid='$esc_txid' + SET status='Active', end_date='$newEndDate', paid_ts='$now', payment_txid='$esc_txid' WHERE order_id=$existing_order_id LIMIT 1"; if (mysqli_query($mysqli, $renewSql)) { $renewedOrders++; @@ -274,6 +272,28 @@ while ($inv = mysqli_fetch_assoc($invoicesResult)) { 'invoice_id' => $invoice_id, 'new_end_date' => $newEndDate ]); + + // Also update server_homes.billing_status and next_invoice_date + $homeIdRow = mysqli_query($mysqli, "SELECT home_id FROM {$table_prefix}billing_orders WHERE order_id=$existing_order_id LIMIT 1"); + if ($homeIdRow && mysqli_num_rows($homeIdRow) === 1) { + $homeData = mysqli_fetch_assoc($homeIdRow); + $home_id_upd = intval($homeData['home_id'] ?? 0); + if ($home_id_upd > 0) { + $next_inv_date = mysqli_real_escape_string($mysqli, $newEndDate); + mysqli_query($mysqli, "UPDATE {$table_prefix}server_homes + SET billing_status = 'Active', + next_invoice_date = '$next_inv_date', + server_expiration_date = NULL + WHERE home_id = $home_id_upd"); + // Mark the matching gsp_invoices renewal invoice as Active + mysqli_query($mysqli, "UPDATE {$table_prefix}invoices + SET billing_status = 'Active', + paid_at = '$now', + payment_id = '$esc_txid' + WHERE home_id = $home_id_upd AND billing_status = 'Invoiced'"); + log_payment('SERVER_HOME_ACTIVATED', ['home_id' => $home_id_upd]); + } + } } else { log_payment('ORDER_RENEWAL_FAILED', [ 'order_id' => $existing_order_id, @@ -298,14 +318,14 @@ while ($inv = mysqli_fetch_assoc($invoicesResult)) { // Calculate end_date $end_date = date('Y-m-d H:i:s', strtotime("+$qty $duration")); - // Insert order with status='paid' (panel will provision and change to 'active') + // Insert order with status='Active' (server will be provisioned automatically) $insertOrderSql = "INSERT INTO {$table_prefix}billing_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, paypal_data ) VALUES ( $user_id, $service_id, '$home_name', $ip, $max_players, $qty, '$duration', - $amount, '$rcon_pw', '$ftp_pw', 'paid', '$now', '$end_date', + $amount, '$rcon_pw', '$ftp_pw', 'Active', '$now', '$end_date', '$esc_txid', '$now', '$esc_paypal_json' )"; diff --git a/modules/billing/create_servers.php b/modules/billing/create_servers.php index cc66a614..0840990c 100644 --- a/modules/billing/create_servers.php +++ b/modules/billing/create_servers.php @@ -45,12 +45,12 @@ function exec_ogp_module() } } - // Handle provision_all request - provision all paid orders for this user + // Handle provision_all request - provision all Active (paid) orders for this user if ($provision_all) { if ( $isAdmin ){ - $orders = $db->resultQuery( "SELECT * FROM OGP_DB_PREFIXbilling_orders WHERE status='paid' ORDER BY order_id" ); + $orders = $db->resultQuery( "SELECT * FROM OGP_DB_PREFIXbilling_orders WHERE status='Active' AND (home_id='0' OR home_id='') ORDER BY order_id" ); } else { - $orders = $db->resultQuery( "SELECT * FROM OGP_DB_PREFIXbilling_orders WHERE user_id=".$db->realEscapeSingle($user_id)." AND status='paid' ORDER BY order_id" ); + $orders = $db->resultQuery( "SELECT * FROM OGP_DB_PREFIXbilling_orders WHERE user_id=".$db->realEscapeSingle($user_id)." AND status='Active' AND (home_id='0' OR home_id='') ORDER BY order_id" ); } } // Handle provision_single or order_id parameter - provision specific order @@ -62,9 +62,9 @@ function exec_ogp_module() } $idList = implode(',', array_map('intval', $orderIds)); if ( $isAdmin ){ - $orders = $db->resultQuery( "SELECT * FROM OGP_DB_PREFIXbilling_orders WHERE order_id IN ($idList) AND status='paid'" ); + $orders = $db->resultQuery( "SELECT * FROM OGP_DB_PREFIXbilling_orders WHERE order_id IN ($idList) AND status='Active'" ); } else { - $orders = $db->resultQuery( "SELECT * FROM OGP_DB_PREFIXbilling_orders WHERE order_id IN ($idList) AND user_id=".$db->realEscapeSingle($user_id)." AND status='paid'" ); + $orders = $db->resultQuery( "SELECT * FROM OGP_DB_PREFIXbilling_orders WHERE order_id IN ($idList) AND user_id=".$db->realEscapeSingle($user_id)." AND status='Active'" ); } } $processed_orders = array(); @@ -338,16 +338,10 @@ function exec_ogp_module() } - // Set expiration date in ogp database - //status is: in-cart, paid, installed, invoiced, suspended, deleted - // 'paid' - order has been paid but server not yet created - // 'installed' - server created and active - // 'invoiced' - invoice created for renewal - // 'suspended' - server suspended for non-payment - // 'deleted' - server deleted after extended suspension - //end_date the server will be suspended - //in cron_shop the end_date is used to delete the server - //several days after being suspended + // Set expiration date in panel database + // Status values: Active (provisioned & current), Invoiced (renewal invoice open), + // Expired (past due and awaiting deletion) + // end_date / next_invoice_date: when the next renewal invoice should be generated if ($order['invoice_duration'] == "day") { @@ -397,19 +391,28 @@ function exec_ogp_module() } } - // set order status to 'installed' to indicate server has been provisioned + $end_date_str = date('Y-m-d H:i:s', $end_date); + + // Set order status to 'Active' (server provisioned and current) $db->query("UPDATE OGP_DB_PREFIXbilling_orders - SET status='installed' + SET status='Active' WHERE order_id=".$db->realEscapeSingle($order_id)); - // set the order expiration + // Set the order expiration / next renewal date $db->query("UPDATE OGP_DB_PREFIXbilling_orders - SET end_date='" . $db->realEscapeSingle($end_date) . "' + SET end_date='" . $db->realEscapeSingle($end_date_str) . "' WHERE order_id=".$db->realEscapeSingle($order_id)); - // Save home id created by this order + // Save home_id created by this order $db->query("UPDATE OGP_DB_PREFIXbilling_orders SET home_id='" . $db->realEscapeSingle($home_id) . "' WHERE order_id=".$db->realEscapeSingle($order_id)); + + // Set billing_status and next_invoice_date on server_homes + $db->query("UPDATE OGP_DB_PREFIXserver_homes + SET billing_status = 'Active', + next_invoice_date = '" . $db->realEscapeSingle($end_date_str) . "', + billing_enabled = 1 + WHERE home_id = " . $db->realEscapeSingle($home_id)); $provisioned_count++; diff --git a/modules/billing/cron-shop.php b/modules/billing/cron-shop.php index 62d2afdf..18aedb12 100644 --- a/modules/billing/cron-shop.php +++ b/modules/billing/cron-shop.php @@ -1,7 +1,7 @@ Invoiced : next_invoice_date has arrived -> create {prefix}invoices record. + * B. Invoiced -> Expired : server_expiration_date passed and invoice unpaid. + * C. Expired -> Deleted : past delete_after_expired_days grace window -> remove server. + * D. Paid invoices (safety net): set server and invoice back to Active. + * + * Prerequisites (run once): + * sql/update_billing_status_active_invoiced_expired.sql */ -chdir(realpath(dirname(__FILE__))); /* Change to the current file path */ -chdir("../.."); /* Base path to ogp web files */ -// Report all PHP errors +chdir(realpath(dirname(__FILE__))); /* Change to the billing module directory */ +chdir("../.."); /* Step back to the OGP/GSP web root */ + error_reporting(E_ALL); -// Path definitions -define("CONFIG_FILE","includes/config.inc.php"); -//Require +ini_set('display_errors', '1'); + +define("CONFIG_FILE", "includes/config.inc.php"); require_once("includes/functions.php"); require_once("includes/helpers.php"); require_once("includes/html_functions.php"); require_once("modules/config_games/server_config_parser.php"); require_once("includes/lib_remote.php"); -require_once CONFIG_FILE; -// Connect to the database server and select database. -$db = createDatabaseConnection($db_type, $db_host, $db_user, $db_pass, $db_name, $table_prefix, isset($db_port) ? $db_port : NULL); +require_once(CONFIG_FILE); + +// Connect using the panel's DB helper (provides $db with logger(), resultQuery(), etc.) +$db = createDatabaseConnection( + $db_type, $db_host, $db_user, $db_pass, $db_name, $table_prefix, + isset($db_port) ? $db_port : null +); $panel_settings = $db->getSettings(); -if( isset($panel_settings['time_zone']) && $panel_settings['time_zone'] != "" ) - date_default_timezone_set($panel_settings['time_zone']); - -// Date calculations -$today = time(); -$invoice_date = strtotime('+ 7 days'); // Create invoice 7 days before expiration -$suspend_date = $today; // Suspend immediately when overdue -$removal_date = strtotime('- 7 days'); // Remove 7 days after suspension -$rundate = date('Y-m-d H:i:s', is_numeric($today) ? (int)$today : strtotime($today)); - -$db->logger("BILLING-CRON: Server lifecycle automation running at " . $rundate); - -// ================================================================================== -// STEP 1: CREATE RENEWAL INVOICES FOR SERVERS EXPIRING IN 7 DAYS -// ================================================================================== -// Find all ACTIVE servers (installed) that expire within 7 days and don't have an unpaid invoice -$upcoming_expirations = $db->resultQuery(" - SELECT o.*, u.users_email, u.users_fname, u.users_lname - FROM " . $table_prefix . "billing_orders o - LEFT JOIN " . $table_prefix . "users u ON o.user_id = u.user_id - WHERE o.status = 'installed' - AND o.end_date IS NOT NULL - AND UNIX_TIMESTAMP(o.end_date) < {$invoice_date} - AND UNIX_TIMESTAMP(o.end_date) > {$today} - AND NOT EXISTS ( - SELECT 1 FROM " . $table_prefix . "billing_invoices i - WHERE i.order_id = o.order_id AND i.status = 'unpaid' - ) -"); - -if (is_array($upcoming_expirations)) { - foreach ((array)$upcoming_expirations as $order) { - $user_id = $order['user_id']; - $order_id = $order['order_id']; - $home_id = $order['home_id']; - $customer_name = trim(($order['users_fname'] ?? '') . ' ' . ($order['users_lname'] ?? '')); - $customer_email = $order['users_email'] ?? ''; - - // Create renewal invoice - $invoice_desc = "Renewal for " . $order['home_name']; - $due_date = date('Y-m-d H:i:s', strtotime($order['end_date'])); - - $db->query("INSERT INTO " . $table_prefix . "billing_invoices - (order_id, user_id, customer_name, customer_email, amount, currency, status, - invoice_date, due_date, description, invoice_duration, qty) - VALUES ( - {$order_id}, - {$user_id}, - '" . $db->realEscapeSingle($customer_name) . "', - '" . $db->realEscapeSingle($customer_email) . "', - " . floatval($order['price']) . ", - 'USD', - 'unpaid', - NOW(), - '" . $db->realEscapeSingle($due_date) . "', - '" . $db->realEscapeSingle($invoice_desc) . "', - '" . $db->realEscapeSingle($order['invoice_duration']) . "', - " . intval($order['qty']) . " - )"); - - // Mark order status as 'renew' to indicate renewal invoice was created - $db->query("UPDATE " . $table_prefix . "billing_orders - SET status='renew' - WHERE order_id={$order_id}"); - - // Send renewal notice email - $settings = $db->getSettings(); - $subject = "Renewal Invoice for " . $order['home_name'] . " - " . $panel_settings['panel_name']; - $message = "Your server '" . $order['home_name'] . "' (ID: {$home_id}) will expire on " . - date('F j, Y', strtotime($order['end_date'])) . - ".

A renewal invoice has been created. Please log in to your account and pay the invoice to continue your service." . - "

Amount Due: $" . number_format($order['price'], 2) . - "
Due Date: " . date('F j, Y', strtotime($order['end_date'])) . - "

Thank you for your business!
"; - - $mail = mymail($customer_email, $subject, $message, $settings); - - $db->logger("BILLING-CRON: Created renewal invoice for order {$order_id}, home {$home_id}"); - - if (!$mail) { - $db->logger("BILLING-CRON: Email FAILED - Renewal invoice for order {$order_id}"); - } - } +if (!empty($panel_settings['time_zone'])) { + date_default_timezone_set($panel_settings['time_zone']); } -// ================================================================================== -// STEP 2: SUSPEND SERVERS THAT ARE EXPIRED AND HAVE UNPAID INVOICES -// ================================================================================== -// Find servers that: -// - Are currently installed or renew (active) -// - Have passed their end_date -// - Have at least one unpaid invoice -$servers_to_suspend = $db->resultQuery(" - SELECT DISTINCT o.*, u.users_email - FROM " . $table_prefix . "billing_orders o - LEFT JOIN " . $table_prefix . "users u ON o.user_id = u.user_id - INNER JOIN " . $table_prefix . "billing_invoices i ON o.order_id = i.order_id - WHERE o.status IN ('installed', 'renew') - AND o.end_date IS NOT NULL - AND UNIX_TIMESTAMP(o.end_date) < {$suspend_date} - AND i.status = 'unpaid' +$rundate = date('Y-m-d H:i:s'); +$db->logger("BILLING-CRON: ===== Lifecycle automation started at {$rundate} ====="); + +// ---------------------------------------------------------------- +// Load global billing config (grace_days, delete_after_expired_days) +// Falls back to safe defaults when {prefix}billing_config is empty. +// ---------------------------------------------------------------- +$cfg_rows = $db->resultQuery( + "SELECT * FROM {$table_prefix}billing_config WHERE game_key IS NULL AND enabled = 1 ORDER BY config_id ASC LIMIT 1" +); +$global_cfg = is_array($cfg_rows) && !empty($cfg_rows) ? $cfg_rows[0] : []; +$grace_days = intval($global_cfg['grace_days'] ?? 0); +$delete_after_days = intval($global_cfg['delete_after_expired_days'] ?? 7); +$default_rate_type = $global_cfg['rate_type'] ?? 'monthly'; +$default_price_player = floatval($global_cfg['price_per_player'] ?? 0.00); + +$db->logger("BILLING-CRON: Config => grace_days={$grace_days}, delete_after={$delete_after_days}, rate={$default_rate_type}"); + +// ====================================================================== +// STEP A - Active -> Invoiced +// Find billing-enabled servers whose next_invoice_date has arrived +// and that do not already have an open 'Invoiced' renewal invoice. +// ====================================================================== +$db->logger("BILLING-CRON: --- Step A: Active -> Invoiced ---"); + +$due_for_invoice = $db->resultQuery(" + SELECT sh.home_id, sh.home_name, sh.user_id_main AS user_id, + sh.next_invoice_date, sh.server_expiration_date, + bo.price, bo.invoice_duration, bo.qty, bo.order_id, + COALESCE(bs.price_monthly, 0) AS svc_price_monthly, + u.users_email, + CONCAT(COALESCE(u.users_fname,''), ' ', COALESCE(u.users_lname,'')) AS customer_name + FROM {$table_prefix}server_homes sh + LEFT JOIN {$table_prefix}users u ON u.user_id = sh.user_id_main + LEFT JOIN {$table_prefix}billing_orders bo + ON bo.home_id = sh.home_id AND bo.status = 'Active' + LEFT JOIN {$table_prefix}billing_services bs ON bs.service_id = bo.service_id + WHERE sh.billing_enabled = 1 + AND sh.billing_status = 'Active' + AND sh.next_invoice_date IS NOT NULL + AND sh.next_invoice_date <= NOW() + AND NOT EXISTS ( + SELECT 1 FROM {$table_prefix}invoices inv + WHERE inv.home_id = sh.home_id AND inv.billing_status = 'Invoiced' + ) + ORDER BY sh.home_id ASC "); -if (is_array($servers_to_suspend)) { - foreach ((array)$servers_to_suspend as $order) { - $user_id = $order['user_id']; - $home_id = $order['home_id']; - $order_id = $order['order_id']; - - // Get home and server info - $home_info = $db->getGameHomeWithoutMods($home_id); - if (!$home_info) { - $db->logger("BILLING-CRON: WARNING - Home {$home_id} not found for order {$order_id}, marking suspended anyway"); - $db->query("UPDATE " . $table_prefix . "billing_orders SET status='suspended' WHERE order_id={$order_id}"); +if (is_array($due_for_invoice)) { + foreach ($due_for_invoice as $srv) { + $home_id = intval($srv['home_id']); + $user_id = intval($srv['user_id']); + $home_name = $srv['home_name'] ?? 'Server #' . $home_id; + $qty = max(1, intval($srv['qty'] ?? 1)); + + // Normalise rate_type to the ENUM values used in {prefix}invoices + $raw_rate = strtolower($srv['invoice_duration'] ?? $default_rate_type); + $rate_map = ['day' => 'daily', 'daily' => 'daily', + 'month' => 'monthly', 'monthly' => 'monthly', + 'year' => 'yearly', 'yearly' => 'yearly']; + $rate_type = $rate_map[$raw_rate] ?? 'monthly'; + + // Pricing: billing_config > billing_orders flat price + $price_per_player = $default_price_player; + $player_slots = max(0, intval($srv['qty'] ?? 0)); + $subtotal = $price_per_player * max(1, $player_slots); + if ($subtotal == 0.00 && floatval($srv['price'] ?? 0) > 0) { + $subtotal = floatval($srv['price']); + } + $total_due = $subtotal; + + // Calculate due_date: now + 1 billing period + $period_map = ['daily' => '+1 day', 'monthly' => '+1 month', 'yearly' => '+1 year']; + $due_date_ts = strtotime($period_map[$rate_type], time()); + $due_date = date('Y-m-d H:i:s', $due_date_ts); + + // Guard: skip if an invoice for this exact period already exists + $exists = $db->resultQuery(" + SELECT invoice_id FROM {$table_prefix}invoices + WHERE home_id = {$home_id} + AND due_date = '" . $db->realEscapeSingle($due_date) . "' + LIMIT 1 + "); + if (is_array($exists) && !empty($exists)) { + $db->logger("BILLING-CRON: Step A - SKIP home {$home_id}: invoice for this period already exists"); continue; } - - $server_info = $db->getRemoteServerById($home_info['remote_server_id']); - $remote = new OGPRemoteLibrary($server_info['agent_ip'], $server_info['agent_port'], - $server_info['encryption_key'], $server_info['timeout']); - - // Disable FTP - $ftp_login = isset($home_info['ftp_login']) ? $home_info['ftp_login'] : $home_id; - $remote->ftp_mgr("userdel", $ftp_login); - $db->changeFtpStatus('disabled', $home_id); - - // Stop the server - $server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']); - $control_type = isset($server_xml->control_protocol_type) ? $server_xml->control_protocol_type : ""; - $addresses = $db->getHomeIpPorts($home_id); - - foreach ((array)$addresses as $address) { - $remote->remote_stop_server($home_id, $address['ip'], $address['port'], - $server_xml->control_protocol, $home_info['control_password'], - $control_type, $home_info['home_path']); - } - - // Unassign from user - $db->unassignHomeFrom("user", $user_id, $home_id); - - // Update order status - $db->query("UPDATE " . $table_prefix . "billing_orders SET status='suspended' WHERE order_id={$order_id}"); - - $db->logger("BILLING-CRON: SUSPENDED server {$home_id} for order {$order_id} due to unpaid invoice"); - - // Send suspension email - $settings = $db->getSettings(); - $subject = "Server Suspended - " . $order['home_name'] . " - " . $panel_settings['panel_name']; - $message = "Your server '" . $order['home_name'] . "' (ID: {$home_id}) has been suspended due to non-payment." . - "

Your server has been stopped and will be permanently deleted in 7 days if payment is not received." . - "

Please log in to your account and pay your outstanding invoice to restore your server." . - "

Thank you."; - - $mail = mymail($order['users_email'], $subject, $message, $settings); - - if (!$mail) { - $db->logger("BILLING-CRON: Email FAILED - Suspension notice for order {$order_id}"); + + // Create renewal invoice in {prefix}invoices + $db->query(" + INSERT INTO {$table_prefix}invoices + (home_id, user_id, due_date, billing_status, rate_type, + price_per_player, player_slots, quantity, subtotal, total_due) + VALUES ( + {$home_id}, {$user_id}, + '" . $db->realEscapeSingle($due_date) . "', + 'Invoiced', + '" . $db->realEscapeSingle($rate_type) . "', + " . number_format($price_per_player, 2, '.', '') . ", + {$player_slots}, + {$qty}, + " . number_format($subtotal, 2, '.', '') . ", + " . number_format($total_due, 2, '.', '') . " + ) + "); + $new_invoice_id = $db->lastInsertId(); + + // Update server_homes: set Invoiced, store invoice id and expiration date + $db->query(" + UPDATE {$table_prefix}server_homes + SET billing_status = 'Invoiced', + server_expiration_date = '" . $db->realEscapeSingle($due_date) . "', + last_invoice_id = " . intval($new_invoice_id) . " + WHERE home_id = {$home_id} + "); + + $db->logger("BILLING-CRON: Step A - INVOICED home {$home_id} (invoice #{$new_invoice_id}, due {$due_date})"); + + // Send renewal notice + if (!empty($srv['users_email'])) { + $settings = $db->getSettings(); + $subject = "Renewal Invoice for {$home_name} - " . ($panel_settings['panel_name'] ?? 'Game Server Panel'); + $message = "Your server '{$home_name}' (ID: {$home_id}) has a renewal invoice due on " + . date('F j, Y', $due_date_ts) . "." + . "

Amount Due: \$" . number_format($total_due, 2) + . "
Due Date: " . date('F j, Y', $due_date_ts) + . "

Please log in to pay your invoice and keep your server active." + . "

Thank you!"; + if (!mymail($srv['users_email'], $subject, $message, $settings)) { + $db->logger("BILLING-CRON: Step A - Email FAILED for home {$home_id}"); + } } } } -// ================================================================================== -// STEP 3: DELETE SERVERS THAT HAVE BEEN SUSPENDED FOR 7+ DAYS -// ================================================================================== -// Find servers that: -// - Are currently suspended -// - Have been suspended for at least 7 days (end_date + 7 days has passed) -// - Still have unpaid invoices -$servers_to_delete = $db->resultQuery(" - SELECT DISTINCT o.*, u.users_email - FROM " . $table_prefix . "billing_orders o - LEFT JOIN " . $table_prefix . "users u ON o.user_id = u.user_id - INNER JOIN " . $table_prefix . "billing_invoices i ON o.order_id = i.order_id - WHERE o.status = 'suspended' - AND o.end_date IS NOT NULL - AND UNIX_TIMESTAMP(o.end_date) < {$removal_date} - AND i.status = 'unpaid' +// ====================================================================== +// STEP B - Invoiced -> Expired +// Servers whose expiration date has passed and whose last invoice +// is still unpaid. +// ====================================================================== +$db->logger("BILLING-CRON: --- Step B: Invoiced -> Expired ---"); + +$past_due = $db->resultQuery(" + SELECT sh.home_id, sh.home_name, sh.user_id_main AS user_id, + sh.last_invoice_id, sh.server_expiration_date, + u.users_email + FROM {$table_prefix}server_homes sh + LEFT JOIN {$table_prefix}users u ON u.user_id = sh.user_id_main + WHERE sh.billing_enabled = 1 + AND sh.billing_status = 'Invoiced' + AND sh.server_expiration_date IS NOT NULL + AND sh.server_expiration_date < NOW() + AND ( + sh.last_invoice_id IS NULL + OR EXISTS ( + SELECT 1 FROM {$table_prefix}invoices inv + WHERE inv.invoice_id = sh.last_invoice_id + AND inv.billing_status = 'Invoiced' + AND inv.paid_at IS NULL + ) + ) + ORDER BY sh.home_id ASC "); -if (is_array($servers_to_delete)) { - foreach ((array)$servers_to_delete as $order) { - $user_id = $order['user_id']; - $home_id = $order['home_id']; - $order_id = $order['order_id']; - - // Get home and server info +if (is_array($past_due)) { + foreach ($past_due as $srv) { + $home_id = intval($srv['home_id']); + $last_invoice_id = intval($srv['last_invoice_id'] ?? 0); + + // Mark server Expired + $db->query(" + UPDATE {$table_prefix}server_homes + SET billing_status = 'Expired' + WHERE home_id = {$home_id} + "); + + // Mark matching invoice Expired (if still unpaid) + if ($last_invoice_id > 0) { + $db->query(" + UPDATE {$table_prefix}invoices + SET billing_status = 'Expired' + WHERE invoice_id = {$last_invoice_id} + AND billing_status = 'Invoiced' + AND paid_at IS NULL + "); + } + + $db->logger("BILLING-CRON: Step B - EXPIRED home {$home_id}"); + + // Notify user + if (!empty($srv['users_email'])) { + $settings = $db->getSettings(); + $home_name = $srv['home_name'] ?? 'Server #' . $home_id; + $subject = "Server Expired - {$home_name} - " . ($panel_settings['panel_name'] ?? 'Game Server Panel'); + $message = "Your server '{$home_name}' (ID: {$home_id}) has expired due to non-payment." + . "

The server will be permanently deleted in {$delete_after_days} day(s) if payment is not received." + . "

Please log in and pay your outstanding invoice to restore service." + . "

Thank you."; + if (!mymail($srv['users_email'], $subject, $message, $settings)) { + $db->logger("BILLING-CRON: Step B - Email FAILED for home {$home_id}"); + } + } + } +} + +// ====================================================================== +// STEP C - Expired -> Deleted +// Servers that have been Expired longer than delete_after_expired_days. +// ====================================================================== +$db->logger("BILLING-CRON: --- Step C: Expired -> Deleted (window={$delete_after_days}d) ---"); + +$to_delete = $db->resultQuery(" + SELECT sh.home_id, sh.home_name, sh.user_id_main AS user_id, + sh.server_expiration_date, + u.users_email + FROM {$table_prefix}server_homes sh + LEFT JOIN {$table_prefix}users u ON u.user_id = sh.user_id_main + WHERE sh.billing_enabled = 1 + AND sh.billing_status = 'Expired' + AND sh.server_expiration_date IS NOT NULL + AND sh.server_expiration_date < DATE_SUB(NOW(), INTERVAL {$delete_after_days} DAY) + ORDER BY sh.home_id ASC +"); + +if (is_array($to_delete)) { + foreach ($to_delete as $srv) { + $home_id = intval($srv['home_id']); + $user_id = intval($srv['user_id']); + $home_name = $srv['home_name'] ?? 'Server #' . $home_id; + + // Fetch home info for remote deletion $home_info = $db->getGameHomeWithoutMods($home_id); if ($home_info) { $server_info = $db->getRemoteServerById($home_info['remote_server_id']); - $remote = new OGPRemoteLibrary($server_info['agent_ip'], $server_info['agent_port'], - $server_info['encryption_key'], $server_info['timeout']); - - // Remove the game home from db - $db->deleteGameHome($home_id); - - // Remove the game home files from remote server - $remote->remove_home($home_info['home_path']); - - // Drop database and user if they exist (both user_#### and server_#### formats) - @$db->query("DROP USER 'user_" . $home_id . "'@'%'"); - @$db->query("DROP USER 'user_" . $home_id . "'@'localhost'"); - @$db->query("DROP USER 'server_" . $home_id . "'@'%'"); - @$db->query("DROP USER 'server_" . $home_id . "'@'localhost'"); - @$db->query("DROP DATABASE IF EXISTS user_" . $home_id); - @$db->query("DROP DATABASE IF EXISTS server_" . $home_id); + if ($server_info) { + $remote = new OGPRemoteLibrary( + $server_info['agent_ip'], + $server_info['agent_port'], + $server_info['encryption_key'], + $server_info['timeout'] + ); + + // Stop the running server process + $server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']); + $control_type = isset($server_xml->control_protocol_type) + ? (string)$server_xml->control_protocol_type : ""; + $addresses = $db->getHomeIpPorts($home_id); + foreach ((array)$addresses as $addr) { + $remote->remote_stop_server( + $home_id, $addr['ip'], $addr['port'], + $server_xml->control_protocol, + $home_info['control_password'], + $control_type, + $home_info['home_path'] + ); + } + + // Disable FTP + $ftp_login = !empty($home_info['ftp_login']) ? $home_info['ftp_login'] : $home_id; + $remote->ftp_mgr("userdel", $ftp_login); + $db->changeFtpStatus('disabled', $home_id); + + // Unassign from user + $db->unassignHomeFrom("user", $user_id, $home_id); + + // Delete home record from panel DB + $db->deleteGameHome($home_id); + + // Remove server files on remote agent + $remote->remove_home($home_info['home_path']); + + // Drop any per-server database/user accounts + @$db->query("DROP USER 'user_{$home_id}'@'%'"); + @$db->query("DROP USER 'user_{$home_id}'@'localhost'"); + @$db->query("DROP USER 'server_{$home_id}'@'%'"); + @$db->query("DROP USER 'server_{$home_id}'@'localhost'"); + @$db->query("DROP DATABASE IF EXISTS user_{$home_id}"); + @$db->query("DROP DATABASE IF EXISTS server_{$home_id}"); + } else { + $db->logger("BILLING-CRON: Step C - WARNING: no remote server info for home {$home_id}; removing panel record only"); + $db->deleteGameHome($home_id); + } + } else { + $db->logger("BILLING-CRON: Step C - WARNING: home {$home_id} not found in panel DB (already removed)"); } - - // Update order status and clear home_id - $db->query("UPDATE " . $table_prefix . "billing_orders - SET status='deleted', home_id='0' - WHERE order_id={$order_id}"); - - // Mark all unpaid invoices for this order as deleted - $db->query("UPDATE " . $table_prefix . "billing_invoices - SET status='deleted' - WHERE order_id={$order_id} AND status='unpaid'"); - - $db->logger("BILLING-CRON: DELETED server {$home_id} for order {$order_id} after 7 days suspended"); - - // Send deletion email - $settings = $db->getSettings(); - $subject = "Server Permanently Deleted - " . $order['home_name'] . " - " . $panel_settings['panel_name']; - $message = "Your server '" . $order['home_name'] . "' (ID: {$home_id}) has been permanently deleted." . - "

The server was suspended 7 days ago due to non-payment and has now been removed." . - "

If this was an error and you contact us immediately, we may be able to restore your server from backups." . - "

Thank you for being a customer. We hope to serve you again in the future."; - - $mail = mymail($order['users_email'], $subject, $message, $settings); - - if (!$mail) { - $db->logger("BILLING-CRON: Email FAILED - Deletion notice for order {$order_id}"); + + // Mark billing_orders record as Expired and clear home_id reference + $db->query(" + UPDATE {$table_prefix}billing_orders + SET status = 'Expired', + home_id = '0' + WHERE home_id = '{$home_id}' + "); + + // Mark any open gsp_invoices for this home as Expired + $db->query(" + UPDATE {$table_prefix}invoices + SET billing_status = 'Expired' + WHERE home_id = {$home_id} + AND billing_status = 'Invoiced' + "); + + $db->logger("BILLING-CRON: Step C - DELETED home {$home_id}"); + + // Notify user + if (!empty($srv['users_email'])) { + $settings = $db->getSettings(); + $subject = "Server Permanently Deleted - {$home_name} - " . ($panel_settings['panel_name'] ?? 'Game Server Panel'); + $message = "Your server '{$home_name}' (ID: {$home_id}) has been permanently deleted." + . "

The server expired and was removed after the grace period." + . "

If this was an error, contact us immediately - we may be able to restore from backup." + . "

Thank you for being a customer. We hope to serve you again."; + if (!mymail($srv['users_email'], $subject, $message, $settings)) { + $db->logger("BILLING-CRON: Step C - Email FAILED for home {$home_id}"); + } } } } -$db->logger("BILLING-CRON: Server lifecycle automation completed"); -?> +// ====================================================================== +// STEP D - Paid invoice safety net +// If a payment was recorded on a {prefix}invoices row but the +// server_home was not updated (e.g. race condition at capture time), +// correct it here so the server is restored to Active. +// ====================================================================== +$db->logger("BILLING-CRON: --- Step D: Paid invoice safety-net ---"); +$paid_invoices = $db->resultQuery(" + SELECT inv.invoice_id, inv.home_id, inv.rate_type, + sh.billing_status + FROM {$table_prefix}invoices inv + INNER JOIN {$table_prefix}server_homes sh ON sh.home_id = inv.home_id + WHERE inv.billing_status = 'Invoiced' + AND sh.billing_status = 'Invoiced' + AND (inv.paid_at IS NOT NULL OR inv.payment_id IS NOT NULL) + ORDER BY inv.invoice_id ASC +"); -//THESE SERVERS HAVE REACHED THE DATE FOR INVOICE, END_DATE - 7 (OR WHAT IS IN SETTINGS) -//SET STATUS 'invoiced' MEANING INVOICE SHOULD BE CREATED -//LOOP THROUGH ALL SERVERS WITH STATUS = 'paid' OR 'installed' (ACTIVE) ----------------------------------------------------------- -$user_homes = $db->resultQuery( "SELECT * - FROM " . $table_prefix . "billing_orders - WHERE status IN ('paid', 'installed') AND end_date <" . $invoice_date); +if (is_array($paid_invoices)) { + foreach ($paid_invoices as $inv) { + $home_id = intval($inv['home_id']); + $invoice_id = intval($inv['invoice_id']); + $rate_type = $inv['rate_type'] ?? 'monthly'; -if (!is_array($user_homes)) -{ -} -else -{ - foreach ((array)$user_homes as $user_home) - { + // Calculate next_invoice_date based on rate_type + $period_map = ['daily' => '+1 day', 'monthly' => '+1 month', 'yearly' => '+1 year']; + $next_invoice_date = date('Y-m-d H:i:s', strtotime($period_map[$rate_type] ?? '+1 month')); - // Developer note: - // In future we may want to change the renewal/invoice strategy so that a - // new order record is created for the renewal (leaving the original order - // intact) instead of mutating the existing order's status/end_date. - // Creating a separate renewal order gives a clearer, immutable purchase - // history and simplifies auditing. For now this cron job continues to - // update the existing order (change status/end_date) as implemented - // below. + $db->query(" + UPDATE {$table_prefix}invoices + SET billing_status = 'Active' + WHERE invoice_id = {$invoice_id} + "); - $user_id = $user_home['user_id']; - $home_id = $user_home['home_id']; - - - // Reset the STATUS 'invoiced' so cart.php will create an invoice - $db->query( "UPDATE " . $table_prefix . "billing_orders - SET status='invoiced' - WHERE order_id=".$db->realEscapeSingle($user_home['order_id'])); + $db->query(" + UPDATE {$table_prefix}server_homes + SET billing_status = 'Active', + next_invoice_date = '" . $db->realEscapeSingle($next_invoice_date) . "', + server_expiration_date = NULL + WHERE home_id = {$home_id} + "); - // SEND EMAIL - $settings = $db->getSettings(); - $subject = "You have an INVOICE at ". $panel_settings['panel_name']; - $email = $db->resultQuery(" SELECT DISTINCT users_email - FROM " . $table_prefix . "users, " . $table_prefix . "billing_orders - WHERE " . $table_prefix . "users.user_id = $user_id")[0]["users_email"]; - $message = "Your server with ID ". $home_id . " will expire soon. Please log in and VIEW INVOICES on the Dashboard to renew your server.


~
Thanks!
"; - $mail = mymail($email, $subject, $message, $settings); - //logger - $db->logger( "AUTO-CLEAN: INVOICE created for server " . $home_id); - - if (!$mail) - $db->logger( "AUTO-CLEAN: Email FAILED - Server Invoiced " . $home_id); - - // END EMAIL - - - } + $db->logger("BILLING-CRON: Step D - RESTORED home {$home_id} to Active via paid invoice #{$invoice_id}"); + } } -//THESE ARE THE SERVERS THAT HAVE NOT BEEN PAID AND THE END_DATE IS TODAY -//THESE SERVERS GET SUSPENDED -//LOOP THROUGH ALL ORDERS WITH STATUS 'invoiced' OR 'in-cart' OR 'unknown' (INACTIVE OR INVOICED) -$user_homes = $db->resultQuery( "SELECT * - FROM " . $table_prefix . "billing_orders - WHERE status IN ('invoiced', 'in-cart', 'unknown') AND end_date < ".$today); - -if (!is_array($user_homes)) -{ -} -else -{ - foreach ((array)$user_homes as $user_home) - { - $user_id = $user_home['user_id']; - $home_id = $user_home['home_id']; - $home_info = $db->getGameHomeWithoutMods($home_id); - $server_info = $db->getRemoteServerById($home_info['remote_server_id']); - $remote = new OGPRemoteLibrary($server_info['agent_ip'], $server_info['agent_port'], $server_info['encryption_key'],$server_info['timeout']); - $ftp_login = isset($home_info['ftp_login']) ? $home_info['ftp_login'] : $home_id; - $remote->ftp_mgr("userdel", $ftp_login); - $db->changeFtpStatus('disabled',$home_id); - $server_xml = read_server_config(SERVER_CONFIG_LOCATION."/".$home_info['home_cfg_file']); - if(isset($server_xml->control_protocol_type))$control_type = $server_xml->control_protocol_type; else $control_type = ""; - $addresses = $db->getHomeIpPorts($home_id); - foreach ((array)$addresses as $address) - { - $remote->remote_stop_server($home_id,$address['ip'],$address['port'],$server_xml->control_protocol,$home_info['control_password'],$control_type,$home_info['home_path']); - } - $db->unassignHomeFrom("user", $user_id, $home_id); - - // Reset the invoice end date to 'suspended' - // User can still RENEW server - $db->query( "UPDATE " . $table_prefix . "billing_orders - SET status='suspended' - WHERE order_id=".$db->realEscapeSingle($user_home['order_id'])); - - //logger - $db->logger( "AUTO-CLEAN: SUSPENDED server " . $home_id); - - // SEND EMAIL - $settings = $db->getSettings(); - $subject = "GameServer Suspended at ". $panel_settings['panel_name']; - $email = $db->resultQuery(" SELECT DISTINCT users_email - FROM " . $table_prefix . "users, " . $table_prefix . "billing_orders - WHERE " . $table_prefix . "users.user_id = $user_id")[0]["users_email"]; - $message = "Your server with ID ". $home_id . " has expired and has been suspended. Please log in and VIEW INVOICES on the Dashboard to renew your server.
~
Thanks!
"; - $mail = mymail($email, $subject, $message, $settings); - if (!$mail) - $db->logger( "AUTO-CLEAN: Email FAILED - Server Suspended " . $home_id); - // END EMAIL - - } -} - -// end date = 'suspended' (suspended) and its been suspended for $removal_date days -//set removed servers as 'deleted' -$user_homes = $db->resultQuery( "SELECT * - FROM " . $table_prefix . "billing_orders - WHERE status = 'suspended' AND end_date < ".$removal_date ); - -if (!is_array($user_homes)) -{ -} -else -{ - foreach ((array)$user_homes as $user_home) - { - $user_id = $user_home['user_id']; - $home_id = $user_home['home_id']; - $home_info = $db->getGameHomeWithoutMods($home_id); - $server_info = $db->getRemoteServerById($home_info['remote_server_id']); - $remote = new OGPRemoteLibrary($server_info['agent_ip'], $server_info['agent_port'], $server_info['encryption_key'],$server_info['timeout']); - - // Remove the game home from db - $db->deleteGameHome($home_id); - - // Remove the game home files from remote server - $remote->remove_home($home_info['home_path']); - - - - // Reset the invoice end date - $db->query( "UPDATE " . $table_prefix . "billing_orders - SET status='deleted' - WHERE order_id=".$db->realEscapeSingle($user_home['order_id'])); - - - // Set order as not installed - $db->query( "UPDATE " . $table_prefix . "billing_orders - SET home_id=0 - WHERE order_id=".$db->realEscapeSingle($user_home['order_id'])); - - // Mark all unpaid invoices for this order as deleted - $db->query("UPDATE " . $table_prefix . "billing_invoices - SET status='deleted' - WHERE order_id=".$db->realEscapeSingle($user_home['order_id'])." AND status='unpaid'"); - - // remove userid and table from database (both user_#### and server_#### formats) - @$db->query( "DROP USER 'user_" .$home_id ."'@'%'"); - @$db->query( "DROP USER 'user_" .$home_id ."'@'localhost'"); - @$db->query( "DROP USER 'server_" .$home_id ."'@'%'"); - @$db->query( "DROP USER 'server_" .$home_id ."'@'localhost'"); - @$db->query( "DROP DATABASE IF EXISTS user_" .$home_id); - @$db->query( "DROP DATABASE IF EXISTS server_" .$home_id); - - //logger - $db->logger( "AUTO-CLEAN: DELETED server " . $home_id); - - - // SEND EMAIL - $settings = $db->getSettings(); - $settings = $db->getSettings(); - $subject = "GameServer DELETED at ". $panel_settings['panel_name']; - $email = $db->resultQuery(" SELECT DISTINCT users_email - FROM " . $table_prefix . "users, " . $table_prefix . "billing_orders - WHERE " . $table_prefix . "users.user_id = $user_id")[0]["users_email"]; - $message = "Your server with ID ". $home_id . " has been deleted

You did not renew the service and it was PERMANENTLY REMOVED today. If this was an error, if you contact us immediately we may be able to restore your server.
Thanks for being a customer and we hope we can provide a server for you again.

"; - $mail = mymail($email, $subject, $message, $settings); - if (!$mail) - $db->logger( "AUTO-CLEAN: Email FAILED - Server Deleted " . $home_id); - // END EMAIL - - - } -} -?> - - - - - - +$db->logger("BILLING-CRON: ===== Lifecycle automation completed at " . date('Y-m-d H:i:s') . " ====="); diff --git a/modules/billing/includes/payment_processor.php b/modules/billing/includes/payment_processor.php index 91397b7e..671eeff2 100644 --- a/modules/billing/includes/payment_processor.php +++ b/modules/billing/includes/payment_processor.php @@ -164,7 +164,7 @@ function process_payment_record(array $record) { $dt = new DateTime($extend_from); if ($months > 0) $dt->modify('+' . intval($months) . ' months'); $new_end = $dt->format('Y-m-d H:i:s'); - $update = "UPDATE `" . $TABLE_PREFIX . "billing_orders` SET end_date = ?, status='paid', payment_txid = ?, paid_ts = ? WHERE order_id = ?"; + $update = "UPDATE `" . $TABLE_PREFIX . "billing_orders` SET end_date = ?, status='Active', payment_txid = ?, paid_ts = ? WHERE order_id = ?"; if ($u = mysqli_prepare($db, $update)) { mysqli_stmt_bind_param($u, 'sssi', $new_end, $esc_txid, $now, $order_id); mysqli_stmt_execute($u); @@ -189,7 +189,7 @@ function process_payment_record(array $record) { $price = number_format($invoice_amount, 2, '.', ''); $insert2 = sprintf( - "INSERT INTO `%s` (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', 'paid', '%s', '%s', '%s', '%s')", + "INSERT INTO `%s` (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')", $TABLE_PREFIX . 'billing_orders', $user_id, $service_id, $esc_home, $ip, $max_players, $qty, $esc_duration, $price, $esc_rcon, $esc_ftp, $now, $end_date, $esc_txid, $now ); diff --git a/modules/billing/my_orders_panel.php b/modules/billing/my_orders_panel.php index 1d84e2be..7200907c 100644 --- a/modules/billing/my_orders_panel.php +++ b/modules/billing/my_orders_panel.php @@ -12,20 +12,22 @@ function exec_ogp_module() echo "

My Server Orders

"; - // Get paid but not installed orders for this user + // Get Active (paid) but not yet provisioned orders for this user if ($isAdmin) { $orders = $db->resultQuery("SELECT o.*, s.service_name, u.users_login FROM OGP_DB_PREFIXbilling_orders o LEFT JOIN OGP_DB_PREFIXbilling_services s ON o.service_id = s.service_id LEFT JOIN OGP_DB_PREFIXusers u ON o.user_id = u.user_id - WHERE o.status IN ('paid') + WHERE o.status = 'Active' + AND (o.home_id = '0' OR o.home_id = '') ORDER BY o.order_date DESC"); } else { $orders = $db->resultQuery("SELECT o.*, s.service_name FROM OGP_DB_PREFIXbilling_orders o LEFT JOIN OGP_DB_PREFIXbilling_services s ON o.service_id = s.service_id WHERE o.user_id = ".$db->realEscapeSingle($user_id)." - AND o.status IN ('paid') + AND o.status = 'Active' + AND (o.home_id = '0' OR o.home_id = '') ORDER BY o.order_date DESC"); } diff --git a/modules/billing/my_servers.php b/modules/billing/my_servers.php index 5d800600..62ca9e25 100644 --- a/modules/billing/my_servers.php +++ b/modules/billing/my_servers.php @@ -42,12 +42,13 @@ $query = "SELECT h.home_id, h.home_name, h.enabled, + h.billing_status, + h.server_expiration_date, rs.remote_server_name, gc.game_name, o.order_id, o.status, o.invoice_duration, - -- use end_date as the expiration marker (set when order is paid/created) o.end_date AS expiration_date, bs.service_name, bs.price_monthly, @@ -88,22 +89,31 @@ $result = mysqli_query($db, $query); 'text-success', + 'Invoiced' => 'text-warning', + 'Expired' => 'text-danger', + ]; + $status_class = $status_class_map[$billing_status] ?? 'text-secondary'; + $exp_date = $server['server_expiration_date'] ?? $server['expiration_date']; ?> - - + + 0) { FROM {$table_prefix}billing_orders o LEFT JOIN {$table_prefix}billing_services s ON o.service_id = s.service_id WHERE o.user_id = $user_id - AND o.status = 'paid' + AND o.status = 'Active' ORDER BY o.order_date DESC LIMIT 10"; diff --git a/modules/billing/renew_server.php b/modules/billing/renew_server.php index 6e1b90d8..a2d98694 100644 --- a/modules/billing/renew_server.php +++ b/modules/billing/renew_server.php @@ -210,9 +210,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['confirm_renewal'])) { // Determine price based on duration (fall back to monthly if missing) $price = ($duration === 'year' && !empty($order['price_year']) && floatval($order['price_year']) > 0) ? floatval($order['price_year']) : floatval($order['price_monthly']); - // Prepare update to set this order into renew state + // Prepare update to set this order into Invoiced state (renewal requested) if ($upd = $db->prepare("UPDATE {$table_prefix}billing_orders SET status = ?, invoice_duration = ?, qty = ?, price = ? WHERE order_id = ? AND user_id = ? LIMIT 1")) { - $new_status = 'renew'; + $new_status = 'Invoiced'; $orderIdInt = intval($order_id); $userIdInt = intval($user_id); $price_val = number_format($price, 2, '.', ''); diff --git a/sql/update_billing_status_active_invoiced_expired.sql b/sql/update_billing_status_active_invoiced_expired.sql new file mode 100644 index 00000000..9e0b1284 --- /dev/null +++ b/sql/update_billing_status_active_invoiced_expired.sql @@ -0,0 +1,260 @@ +-- ============================================================ +-- GSP Billing Status Simplification Migration +-- Simplifies server billing lifecycle to: Active | Invoiced | Expired +-- +-- Run manually ONCE on an existing installation. +-- Safe to re-run: every ALTER uses IF NOT EXISTS / PREPARE guards. +-- Table prefix: gsp_ (matches modules/billing/includes/config.inc.php) +-- +-- BACK UP YOUR DATABASE BEFORE RUNNING THIS SCRIPT. +-- ============================================================ + +SET @dbname = DATABASE(); + +-- ============================================================ +-- SECTION 1: Fix gsp_server_homes.server_expiration_date +-- Convert from VARCHAR(21) with 'X' default to DATETIME NULL +-- ============================================================ + +-- Clear placeholder 'X' and empty-string values so the column +-- can be safely converted to DATETIME. +UPDATE `gsp_server_homes` +SET `server_expiration_date` = NULL +WHERE `server_expiration_date` IN ('X', '', '0', '0000-00-00 00:00:00') + OR `server_expiration_date` IS NULL; + +-- Convert to DATETIME only when it is still stored as VARCHAR. +SET @col_type = ''; +SELECT DATA_TYPE INTO @col_type +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = @dbname + AND TABLE_NAME = 'gsp_server_homes' + AND COLUMN_NAME = 'server_expiration_date'; + +SET @sql = IF( + @col_type = 'varchar', + 'ALTER TABLE `gsp_server_homes` MODIFY COLUMN `server_expiration_date` DATETIME NULL DEFAULT NULL', + 'SELECT "server_expiration_date already DATETIME – skipping" AS _msg' +); +PREPARE _stmt FROM @sql; EXECUTE _stmt; DEALLOCATE PREPARE _stmt; + +-- ============================================================ +-- SECTION 2: Add new billing lifecycle columns to gsp_server_homes +-- ============================================================ + +-- billing_status +SET @col_exists = 0; +SELECT COUNT(*) INTO @col_exists +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'gsp_server_homes' AND COLUMN_NAME = 'billing_status'; +SET @sql = IF( + @col_exists = 0, + 'ALTER TABLE `gsp_server_homes` ADD COLUMN `billing_status` ENUM(\'Active\',\'Invoiced\',\'Expired\') NOT NULL DEFAULT \'Active\' AFTER `server_expiration_date`', + 'SELECT "billing_status already exists" AS _msg' +); +PREPARE _stmt FROM @sql; EXECUTE _stmt; DEALLOCATE PREPARE _stmt; + +-- next_invoice_date +SET @col_exists = 0; +SELECT COUNT(*) INTO @col_exists +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'gsp_server_homes' AND COLUMN_NAME = 'next_invoice_date'; +SET @sql = IF( + @col_exists = 0, + 'ALTER TABLE `gsp_server_homes` ADD COLUMN `next_invoice_date` DATETIME NULL DEFAULT NULL AFTER `billing_status`', + 'SELECT "next_invoice_date already exists" AS _msg' +); +PREPARE _stmt FROM @sql; EXECUTE _stmt; DEALLOCATE PREPARE _stmt; + +-- last_invoice_id +SET @col_exists = 0; +SELECT COUNT(*) INTO @col_exists +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'gsp_server_homes' AND COLUMN_NAME = 'last_invoice_id'; +SET @sql = IF( + @col_exists = 0, + 'ALTER TABLE `gsp_server_homes` ADD COLUMN `last_invoice_id` INT NULL DEFAULT NULL AFTER `next_invoice_date`', + 'SELECT "last_invoice_id already exists" AS _msg' +); +PREPARE _stmt FROM @sql; EXECUTE _stmt; DEALLOCATE PREPARE _stmt; + +-- billing_enabled +SET @col_exists = 0; +SELECT COUNT(*) INTO @col_exists +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'gsp_server_homes' AND COLUMN_NAME = 'billing_enabled'; +SET @sql = IF( + @col_exists = 0, + 'ALTER TABLE `gsp_server_homes` ADD COLUMN `billing_enabled` TINYINT(1) NOT NULL DEFAULT 1 AFTER `last_invoice_id`', + 'SELECT "billing_enabled already exists" AS _msg' +); +PREPARE _stmt FROM @sql; EXECUTE _stmt; DEALLOCATE PREPARE _stmt; + +-- ============================================================ +-- SECTION 3: Create gsp_invoices (post-purchase renewal invoices) +-- Distinct from gsp_billing_invoices (pre-purchase cart) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `gsp_invoices` ( + `invoice_id` INT NOT NULL AUTO_INCREMENT, + `home_id` INT NOT NULL, + `user_id` INT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `due_date` DATETIME NOT NULL, + `paid_at` DATETIME NULL DEFAULT NULL, + `billing_status` ENUM('Invoiced','Active','Expired') + NOT NULL DEFAULT 'Invoiced', + `rate_type` ENUM('daily','monthly','yearly') + NOT NULL DEFAULT 'monthly', + `price_per_player` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `player_slots` INT NOT NULL DEFAULT 0, + `quantity` INT NOT NULL DEFAULT 1, + `subtotal` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `total_due` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `payment_method` VARCHAR(64) NOT NULL DEFAULT 'PayPal', + `payment_id` VARCHAR(255) NULL DEFAULT NULL, + `notes` TEXT NULL, + PRIMARY KEY (`invoice_id`), + KEY `idx_home_id` (`home_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_billing_status`(`billing_status`), + KEY `idx_due_date` (`due_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- SECTION 4: Create gsp_billing_config (per-game or global rates) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `gsp_billing_config` ( + `config_id` INT NOT NULL AUTO_INCREMENT, + `game_key` VARCHAR(128) NULL DEFAULT NULL COMMENT 'NULL = global default', + `rate_type` ENUM('daily','monthly','yearly') + NOT NULL DEFAULT 'monthly', + `price_per_player` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `grace_days` INT NOT NULL DEFAULT 0, + `delete_after_expired_days`INT NOT NULL DEFAULT 7, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`config_id`), + KEY `idx_game_key` (`game_key`), + KEY `idx_enabled` (`enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Insert global default config if none exists +INSERT INTO `gsp_billing_config` + (`game_key`, `rate_type`, `price_per_player`, `grace_days`, `delete_after_expired_days`, `enabled`) +SELECT NULL, 'monthly', 0.00, 0, 7, 1 +WHERE NOT EXISTS ( + SELECT 1 FROM `gsp_billing_config` WHERE `game_key` IS NULL LIMIT 1 +); + +-- ============================================================ +-- SECTION 5: Populate server_homes.billing_status from existing +-- gsp_billing_orders data +-- Priority: Expired > Invoiced > Active +-- ============================================================ + +-- Active: paid, installed, active, running, enabled, online +UPDATE `gsp_server_homes` sh +INNER JOIN `gsp_billing_orders` bo + ON bo.home_id = sh.home_id + AND CAST(bo.home_id AS UNSIGNED) > 0 +SET sh.`billing_status` = 'Active', + sh.`server_expiration_date` = bo.`end_date`, + sh.`next_invoice_date` = bo.`end_date` +WHERE bo.`status` IN ('paid', 'installed', 'active', 'running', 'enabled', 'online'); + +-- Invoiced: renew, unpaid, pending, overdue, invoice, invoiced, in-cart +UPDATE `gsp_server_homes` sh +INNER JOIN `gsp_billing_orders` bo + ON bo.home_id = sh.home_id + AND CAST(bo.home_id AS UNSIGNED) > 0 +SET sh.`billing_status` = 'Invoiced', + sh.`server_expiration_date` = bo.`end_date`, + sh.`next_invoice_date` = bo.`end_date` +WHERE bo.`status` IN ('renew', 'unpaid', 'pending', 'overdue', 'invoice', 'invoiced', 'in-cart'); + +-- Expired: expired, cancelled, terminated, suspended, deleted +UPDATE `gsp_server_homes` sh +INNER JOIN `gsp_billing_orders` bo + ON bo.home_id = sh.home_id + AND CAST(bo.home_id AS UNSIGNED) > 0 +SET sh.`billing_status` = 'Expired', + sh.`server_expiration_date` = bo.`end_date` +WHERE bo.`status` IN ('expired', 'cancelled', 'terminated', 'suspended', 'deleted'); + +-- Backfill server_expiration_date from billing_orders where still NULL +UPDATE `gsp_server_homes` sh +INNER JOIN `gsp_billing_orders` bo + ON bo.home_id = sh.home_id + AND CAST(bo.home_id AS UNSIGNED) > 0 +SET sh.`server_expiration_date` = bo.`end_date` +WHERE sh.`server_expiration_date` IS NULL + AND bo.`end_date` IS NOT NULL; + +-- ============================================================ +-- SECTION 6: Normalise gsp_billing_orders.status to new values +-- ============================================================ + +-- Active (was: paid, installed, active, running, enabled, online) +UPDATE `gsp_billing_orders` +SET `status` = 'Active' +WHERE `status` IN ('paid', 'installed', 'active', 'running', 'enabled', 'online'); + +-- Invoiced (was: renew, unpaid, pending, overdue, invoice, invoiced, in-cart) +UPDATE `gsp_billing_orders` +SET `status` = 'Invoiced' +WHERE `status` IN ('renew', 'unpaid', 'pending', 'overdue', 'invoice', 'invoiced', 'in-cart'); + +-- Expired (was: expired, cancelled, terminated, suspended, deleted) +UPDATE `gsp_billing_orders` +SET `status` = 'Expired' +WHERE `status` IN ('expired', 'cancelled', 'terminated', 'suspended', 'deleted'); + +-- ============================================================ +-- SECTION 7: Add indexes to gsp_server_homes for billing queries +-- ============================================================ + +SET @idx = 0; +SELECT COUNT(*) INTO @idx FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'gsp_server_homes' AND INDEX_NAME = 'idx_billing_status'; +SET @sql = IF(@idx = 0, + 'ALTER TABLE `gsp_server_homes` ADD INDEX `idx_billing_status` (`billing_status`)', + 'SELECT "idx_billing_status exists" AS _msg'); +PREPARE _stmt FROM @sql; EXECUTE _stmt; DEALLOCATE PREPARE _stmt; + +SET @idx = 0; +SELECT COUNT(*) INTO @idx FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'gsp_server_homes' AND INDEX_NAME = 'idx_server_expiration_date'; +SET @sql = IF(@idx = 0, + 'ALTER TABLE `gsp_server_homes` ADD INDEX `idx_server_expiration_date` (`server_expiration_date`)', + 'SELECT "idx_server_expiration_date exists" AS _msg'); +PREPARE _stmt FROM @sql; EXECUTE _stmt; DEALLOCATE PREPARE _stmt; + +SET @idx = 0; +SELECT COUNT(*) INTO @idx FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'gsp_server_homes' AND INDEX_NAME = 'idx_next_invoice_date'; +SET @sql = IF(@idx = 0, + 'ALTER TABLE `gsp_server_homes` ADD INDEX `idx_next_invoice_date` (`next_invoice_date`)', + 'SELECT "idx_next_invoice_date exists" AS _msg'); +PREPARE _stmt FROM @sql; EXECUTE _stmt; DEALLOCATE PREPARE _stmt; + +SET @idx = 0; +SELECT COUNT(*) INTO @idx FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'gsp_server_homes' AND INDEX_NAME = 'idx_billing_enabled'; +SET @sql = IF(@idx = 0, + 'ALTER TABLE `gsp_server_homes` ADD INDEX `idx_billing_enabled` (`billing_enabled`)', + 'SELECT "idx_billing_enabled exists" AS _msg'); +PREPARE _stmt FROM @sql; EXECUTE _stmt; DEALLOCATE PREPARE _stmt; + +-- ============================================================ +-- DONE +-- ============================================================ + +SELECT CONCAT( + 'Migration complete. ', + 'gsp_server_homes now has billing_status/next_invoice_date/last_invoice_id/billing_enabled. ', + 'gsp_invoices and gsp_billing_config tables created. ', + 'gsp_billing_orders.status normalised to Active/Invoiced/Expired.' +) AS Migration_Result; From 79d78f074bc18b8c88f101505e0bd1d8193b2b67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 22:19:17 +0000 Subject: [PATCH 2/2] fix: address code review comments (grace_days in Step B, simplified rate_map, spelling, comments) Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/5dbd58e1-7aa0-41e2-8dd3-c56b69ede05e Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- modules/billing/api/capture_order.php | 5 ++++- modules/billing/cron-shop.php | 10 ++++------ sql/update_billing_status_active_invoiced_expired.sql | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/modules/billing/api/capture_order.php b/modules/billing/api/capture_order.php index 1468e4d3..1659eefe 100644 --- a/modules/billing/api/capture_order.php +++ b/modules/billing/api/capture_order.php @@ -210,7 +210,10 @@ if ($coupon_id > 0) { } } -// Mark all due invoices for this user as paid +// Mark all due invoices for this user as paid. +// Note: billing_invoices is the pre-purchase cart table and uses its own +// status vocabulary (due -> paid). This is separate from gsp_invoices +// (renewal invoices) and server_homes.billing_status (Active/Invoiced/Expired). $updateInvoicesSql = "UPDATE {$table_prefix}billing_invoices SET status='paid', paid_date='$now', payment_txid='$esc_txid', payment_method='paypal' WHERE user_id=$user_id AND status='due'"; diff --git a/modules/billing/cron-shop.php b/modules/billing/cron-shop.php index 18aedb12..72d82707 100644 --- a/modules/billing/cron-shop.php +++ b/modules/billing/cron-shop.php @@ -123,10 +123,8 @@ if (is_array($due_for_invoice)) { // Normalise rate_type to the ENUM values used in {prefix}invoices $raw_rate = strtolower($srv['invoice_duration'] ?? $default_rate_type); - $rate_map = ['day' => 'daily', 'daily' => 'daily', - 'month' => 'monthly', 'monthly' => 'monthly', - 'year' => 'yearly', 'yearly' => 'yearly']; - $rate_type = $rate_map[$raw_rate] ?? 'monthly'; + $rate_map = ['day' => 'daily', 'month' => 'monthly', 'year' => 'yearly']; + $rate_type = $rate_map[$raw_rate] ?? $raw_rate; // Pricing: billing_config > billing_orders flat price $price_per_player = $default_price_player; @@ -206,7 +204,7 @@ if (is_array($due_for_invoice)) { // Servers whose expiration date has passed and whose last invoice // is still unpaid. // ====================================================================== -$db->logger("BILLING-CRON: --- Step B: Invoiced -> Expired ---"); +$db->logger("BILLING-CRON: --- Step B: Invoiced -> Expired (grace_days={$grace_days}) ---"); $past_due = $db->resultQuery(" SELECT sh.home_id, sh.home_name, sh.user_id_main AS user_id, @@ -217,7 +215,7 @@ $past_due = $db->resultQuery(" WHERE sh.billing_enabled = 1 AND sh.billing_status = 'Invoiced' AND sh.server_expiration_date IS NOT NULL - AND sh.server_expiration_date < NOW() + AND sh.server_expiration_date < DATE_SUB(NOW(), INTERVAL {$grace_days} DAY) AND ( sh.last_invoice_id IS NULL OR EXISTS ( diff --git a/sql/update_billing_status_active_invoiced_expired.sql b/sql/update_billing_status_active_invoiced_expired.sql index 9e0b1284..364278bd 100644 --- a/sql/update_billing_status_active_invoiced_expired.sql +++ b/sql/update_billing_status_active_invoiced_expired.sql @@ -194,7 +194,7 @@ WHERE sh.`server_expiration_date` IS NULL AND bo.`end_date` IS NOT NULL; -- ============================================================ --- SECTION 6: Normalise gsp_billing_orders.status to new values +-- SECTION 6: Normalize gsp_billing_orders.status to new values -- ============================================================ -- Active (was: paid, installed, active, running, enabled, online) @@ -256,5 +256,5 @@ SELECT CONCAT( 'Migration complete. ', 'gsp_server_homes now has billing_status/next_invoice_date/last_invoice_id/billing_enabled. ', 'gsp_invoices and gsp_billing_config tables created. ', - 'gsp_billing_orders.status normalised to Active/Invoiced/Expired.' + 'gsp_billing_orders.status normalized to Active/Invoiced/Expired.' ) AS Migration_Result;