Merge pull request #123 from GameServerPanel/copilot/fix-billing-order-expiration-logic

This commit is contained in:
Frank Harris 2026-05-05 12:01:45 -05:00 committed by GitHub
commit 5f3013b4a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 119 additions and 14 deletions

View file

@ -197,5 +197,45 @@ function exec_ogp_module()
echo "</tbody></table>";
echo "</div>";
// Orphaned home_id diagnostics —————————————————————————————————————————
// Find billing_orders rows where home_id != 0 but no matching gsp_server_homes
// record exists. These indicate provisioning failures or stale data, and they
// are the reason the game monitor may show "No expiration date found".
$orphans = $db->resultQuery(
"SELECT o.order_id, o.user_id, o.home_name, o.home_id, o.status, o.end_date
FROM OGP_DB_PREFIXbilling_orders o
LEFT JOIN OGP_DB_PREFIXserver_homes sh ON sh.home_id = o.home_id
WHERE o.home_id != '0'
AND o.home_id != ''
AND sh.home_id IS NULL
ORDER BY o.order_id ASC"
);
echo "<div style='margin-top: 30px;'>";
echo "<h3>Orphaned home_id Diagnostics</h3>";
echo "<p style='color:#666;'>Billing orders that reference a <code>home_id</code> which no longer exists in <code>gsp_server_homes</code>. ";
echo "These orders will not show an expiration date on the game monitor. ";
echo "Reset <code>home_id</code> to <code>0</code> or re-provision these orders to fix them. ";
echo "Run <code>normalize_billing_order_status.sql</code> to standardize any legacy status values.</p>";
if (empty($orphans)) {
echo "<p style='color:green;'>&#10003; No orphaned billing orders found.</p>";
} else {
echo "<table class='tablesorter' style='width:100%;'>";
echo "<thead><tr><th>Order ID</th><th>User ID</th><th>Server Name</th><th>home_id (missing)</th><th>Status</th><th>End Date</th></tr></thead><tbody>";
foreach ($orphans as $row) {
echo "<tr>";
echo "<td>".intval($row['order_id'])."</td>";
echo "<td>".intval($row['user_id'])."</td>";
echo "<td>".htmlspecialchars($row['home_name'] ?? '')."</td>";
echo "<td style='color:red;'>".htmlspecialchars($row['home_id'] ?? '')."</td>";
echo "<td>".htmlspecialchars($row['status'] ?? '')."</td>";
echo "<td>".htmlspecialchars($row['end_date'] ?? 'NULL')."</td>";
echo "</tr>";
}
echo "</tbody></table>";
}
echo "</div>";
}
?>

View file

@ -210,7 +210,7 @@ $status_config = [
'paid' => ['label' => 'Paid Invoices', 'class' => 'paid'],
'completed' => ['label' => 'Completed Invoices', 'class' => 'paid'],
'in-cart' => ['label' => 'In Cart', 'class' => 'pending'],
'installed' => ['label' => 'Installed/Active', 'class' => 'paid'],
'installed' => ['label' => 'Active', 'class' => 'paid'],
'expired' => ['label' => 'Expired Invoices', 'class' => 'expired'],
'cancelled' => ['label' => 'Cancelled Invoices', 'class' => 'expired'],
];
@ -338,7 +338,10 @@ $status_config = [
<div class="server-actions">
<?php
// Show Renew action for servers that can be renewed
$renewable_statuses = array('paid','installed','invoiced','suspended');
// Status comparison is case-insensitive (strtolower). Canonical
// values are 'Active' and 'Invoiced'; legacy values are included
// as a fallback until normalize_billing_order_status.sql has run.
$renewable_statuses = array('active','invoiced','paid','installed','suspended');
if (!empty($server['status']) && in_array(strtolower($server['status']), $renewable_statuses)): ?>
<a href="renew_server.php?order_id=<?php echo intval($server['order_id']); ?>" class="gsw-btn renew-btn">Renew</a>
<?php endif; ?>

View file

@ -0,0 +1,64 @@
-- normalize_billing_order_status.sql
--
-- One-time migration: standardize gsp_billing_orders.status to the canonical
-- three-value set used by cron-shop.php, create_servers.php, and the game
-- monitor expiration lookup:
--
-- Active server provisioned and billing current
-- Invoiced renewal invoice open; service still running
-- Expired invoice unpaid past grace period; server suspended/awaiting deletion
--
-- Legacy → canonical mapping applied by this script:
-- 'installed' → 'Active' (provisioned via old invoice-first flow)
-- 'paid' → 'Active' (payment captured but before explicit provisioning step)
-- 'suspended' → 'Invoiced' (overdue; renewal invoice was open — maps to Invoiced
-- so cron Step B will expire them on the next run if
-- still unpaid, rather than silently treating them as Active)
--
-- All other statuses ('in-cart', 'cancelled', 'refunded', 'Active', 'Invoiced',
-- 'Expired') are left unchanged.
--
-- Compatible with MySQL 5.7+ and MariaDB 10.2+.
-- Table prefix is hardcoded to gsp_ (standalone billing module context).
-- Run ONCE on an existing installation; safe to run again (no-op on clean data).
-- 'installed' → 'Active'
UPDATE `gsp_billing_orders`
SET `status` = 'Active'
WHERE `status` = 'installed';
-- 'paid' → 'Active'
UPDATE `gsp_billing_orders`
SET `status` = 'Active'
WHERE `status` = 'paid';
-- 'suspended' → 'Invoiced'
-- These rows had an open renewal invoice; cron-shop Step B will move them to
-- 'Expired' on the next run if the invoice remains unpaid.
UPDATE `gsp_billing_orders`
SET `status` = 'Invoiced'
WHERE `status` = 'suspended';
-- Diagnostic: show any remaining non-canonical status values after migration.
-- Expected result: only rows with status IN ('Active','Invoiced','Expired',
-- 'in-cart','cancelled','refunded') should appear.
SELECT `status`, COUNT(*) AS `count`
FROM `gsp_billing_orders`
GROUP BY `status`
ORDER BY `status`;
-- Diagnostic: billing_orders whose home_id references a non-existent server home.
-- These orders will show "No expiration date found" on the game monitor until
-- home_id is corrected (set to the real home_id or to 0 if the server is gone).
SELECT o.`order_id`,
o.`user_id`,
o.`home_name`,
o.`home_id` AS missing_home_id,
o.`status`,
o.`end_date`
FROM `gsp_billing_orders` o
LEFT JOIN `gsp_server_homes` sh ON sh.`home_id` = o.`home_id`
WHERE o.`home_id` != '0'
AND o.`home_id` != ''
AND sh.`home_id` IS NULL
ORDER BY o.`order_id`;

View file

@ -514,15 +514,13 @@ function get_server_billing_expiration_html(int $home_id): string
// billing_orders.home_id is VARCHAR(255), so we quote the value in the query.
// We exclude rows where home_id = '0' (not yet provisioned).
//
// Status whitelist — covers all active/paid states regardless of billing flow:
// 'Active' set by payment_processor.php after PayPal capture
// 'Invoiced' renewal invoice generated by cron-shop.php
// 'installed' used by the invoice-first provisioning flow
// 'paid' used by the invoice-first provisioning flow
// 'suspended' overdue but not yet fully expired; date still meaningful
// Only canonical active statuses are considered:
// 'Active' provisioned and current (set by payment capture or admin creation)
// 'Invoiced' renewal invoice generated; service still running while unpaid
//
// Terminal statuses ('Expired', 'in-cart', 'cancelled', 'refunded') are excluded
// because they represent orders with no active service period.
// Terminal statuses ('Expired', 'in-cart', 'cancelled', 'refunded') and legacy
// statuses ('installed', 'paid', 'suspended') are not matched here.
// Run normalize_billing_order_status.sql to migrate any legacy rows first.
//
// OGP_DB_PREFIX is replaced at runtime by the panel DB wrapper (str_replace).
$rows = $db->resultQuery(
@ -530,7 +528,7 @@ function get_server_billing_expiration_html(int $home_id): string
FROM OGP_DB_PREFIXbilling_orders
WHERE home_id = '" . intval($home_id) . "'
AND home_id != '0'
AND status IN ('Active','Invoiced','installed','paid','suspended')
AND status IN ('Active','Invoiced')
ORDER BY end_date DESC
LIMIT 1"
);

View file

@ -5,7 +5,7 @@
* Shared helper for recording admin-created game servers in the billing tables,
* so they are treated identically to FREE website orders:
* billing_invoices (status='paid', amount=0)
* billing_orders (status='installed', price=0)
* billing_orders (status='Active', price=0)
*
* This does NOT re-provision the server the caller (add_home.php) already
* created the server via the panel DB layer. We only write the billing ledger
@ -113,7 +113,7 @@ if (!function_exists('admin_register_server_in_billing')) {
}
// ------------------------------------------------------------------ //
// 4. Insert billing_order (status='installed', already provisioned). //
// 4. Insert billing_order (status='Active', already provisioned). //
// ------------------------------------------------------------------ //
$order_fields = array(
'user_id' => intval($user_id),
@ -128,7 +128,7 @@ if (!function_exists('admin_register_server_in_billing')) {
'remote_control_password' => '',
'ftp_password' => '',
'home_id' => intval($home_id),
'status' => 'installed',
'status' => 'Active',
'order_date' => $now,
'end_date' => $end_date,
'payment_txid' => 'admin-created',