Fix billing cart schema/pricing and storefront OS grouping
Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/0b6f5123-e13c-4bf7-94c0-339760fe3034 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
This commit is contained in:
parent
10aff1e1c6
commit
c9cf3ac298
10 changed files with 214 additions and 89 deletions
|
|
@ -1,5 +1,8 @@
|
|||
# Changelog
|
||||
|
||||
## 2026-05-07
|
||||
- **Billing/cart/storefront stability pass:** Hardened `add_to_cart.php` to build schema-compatible invoice inserts dynamically (including legacy installs missing `period_start`), fixed free-checkout DB close handling so wrapper objects are never passed to `mysqli_close()`, switched cart/free-total decisions to cent-based math so low nonzero prices (e.g. $0.02) never show as FREE, improved canonical game deduplication + OS variant matching in storefront list/order pages, and aligned Steam Workshop behavior labels with the new restart/update wording.
|
||||
|
||||
## 2026-05-06
|
||||
- **Panel settings language defaults:** Added missing English labels/help text for `login_ban_time`, `allow_setting_cpu_affinity`, `regex_invalid_file_name_chars`, `discord_invite_url`, `discord_webhook_main`, and `discord_webhook_admin`; language lookup now loads English fallback strings when the active locale is missing a key so settings pages stop rendering raw `_key_` tokens.
|
||||
- **Config XML section editor redesign:** Added a top-level section-based XML editor in `config_games` with per-section Validate/Save/Reset actions, optional-section add/remove controls, required-section removal protection, and schema validation for section updates; kept the existing detailed node editor and full raw XML editor for advanced edits.
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@
|
|||
- Add Workshop result preview thumbnails and author links in the picker for easier browsing.
|
||||
- Add a lightweight admin UI report that flags remaining PHP files still relying on legacy PHP 7 constructs not covered by the automated compatibility pass.
|
||||
- Add a side-by-side before/after diff preview panel to the config_games top-level XML section editor before section saves.
|
||||
- Add an integration smoke test that exercises paid checkout, free checkout, and add-to-cart on installs with/without `period_start` to prevent billing schema drift regressions.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,23 @@ function billing_normalize_duration(string $duration): array
|
|||
}
|
||||
}
|
||||
|
||||
function billing_money_to_cents(float $amount): int
|
||||
{
|
||||
return (int) round($amount * 100);
|
||||
}
|
||||
|
||||
function billing_cents_to_money(int $cents): float
|
||||
{
|
||||
return $cents / 100;
|
||||
}
|
||||
|
||||
function billing_fail_add_to_cart(string $message, array $context = []): void
|
||||
{
|
||||
site_log_error('add_to_cart_failed', array_merge(['message' => $message], $context));
|
||||
header('Location: /cart.php?error=add_to_cart');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Immediate request tracing log (helps confirm the script is hit)
|
||||
@mkdir(__DIR__ . '/logs', 0775, true);
|
||||
$trace_file = __DIR__ . '/logs/add_to_cart_requests.log';
|
||||
|
|
@ -86,12 +103,13 @@ $ftp_password = isset($_POST['ftp_password']) ? trim((string)$_POST['ftp_passwor
|
|||
// Price lookup: try to find service price_monthly
|
||||
$db = mysqli_connect($db_host, $db_user, $db_pass, $db_name, isset($db_port) ? (int)$db_port : null);
|
||||
if (!$db) {
|
||||
// Log connection error and exit
|
||||
// Log connection error and return user to cart with a friendly error flag
|
||||
@mkdir(__DIR__ . '/logs', 0775, true);
|
||||
$trace = __DIR__ . '/logs/add_to_cart.log';
|
||||
file_put_contents($trace, date('c') . " - mysqli_connect failed: " . mysqli_connect_error() . "\n", FILE_APPEND);
|
||||
die('DB connection failed');
|
||||
billing_fail_add_to_cart('DB connection failed');
|
||||
} else {
|
||||
mysqli_set_charset($db, 'utf8mb4');
|
||||
// Log that config was loaded (mask password)
|
||||
@mkdir(__DIR__ . '/logs', 0775, true);
|
||||
$trace = __DIR__ . '/logs/add_to_cart.log';
|
||||
|
|
@ -163,7 +181,8 @@ $status = 'due'; // Invoice status: due (unpaid), paid
|
|||
$payment_status = 'unpaid';
|
||||
$qty = max(1, $qty);
|
||||
$max_players = max(1, $max_players);
|
||||
$subtotal = round($base_rate * $max_players * $qty, 2);
|
||||
$subtotal_cents = billing_money_to_cents((float)$base_rate * $max_players * $qty);
|
||||
$subtotal = billing_cents_to_money($subtotal_cents);
|
||||
$amount = $subtotal;
|
||||
$period_end = date('Y-m-d H:i:s', strtotime('+' . ($durationInfo['days'] * $qty) . ' days'));
|
||||
|
||||
|
|
@ -193,55 +212,89 @@ $due_date = $due_dt->format('Y-m-d H:i:s');
|
|||
// Escape values
|
||||
$esc_user_id = intval($user_id);
|
||||
$esc_service_id = intval($service_id);
|
||||
$esc_home_name = mysqli_real_escape_string($db, $home_name);
|
||||
$esc_ip_id = intval($ip_id);
|
||||
$esc_max_players = intval($max_players);
|
||||
$esc_qty = intval($qty);
|
||||
$description = trim(($service_name !== '' ? $service_name : 'Game Server') . ': ' . $home_name);
|
||||
$sql = "INSERT INTO {$table_prefix}billing_invoices (
|
||||
order_id, user_id, service_id, home_id, home_name, ip, max_players, remote_control_password,
|
||||
ftp_password, customer_name, customer_email, amount, discount_amount, currency, status,
|
||||
billing_status, invoice_date, due_date, description, invoice_duration, rate_type, rate_per_player,
|
||||
players, period_start, period_end, subtotal, total_due, payment_status, qty, coupon_id
|
||||
) VALUES (
|
||||
0, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, 0.00, 'USD', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0
|
||||
)";
|
||||
$invoiceTable = $table_prefix . 'billing_invoices';
|
||||
$invoiceColumns = [];
|
||||
$columnsResult = mysqli_query($db, "SHOW COLUMNS FROM `{$invoiceTable}`");
|
||||
if (!$columnsResult) {
|
||||
billing_fail_add_to_cart('Could not inspect billing invoice schema', ['table' => $invoiceTable, 'error' => mysqli_error($db)]);
|
||||
}
|
||||
while ($col = mysqli_fetch_assoc($columnsResult)) {
|
||||
$invoiceColumns[$col['Field']] = true;
|
||||
}
|
||||
mysqli_free_result($columnsResult);
|
||||
|
||||
$invoice_duration = $durationInfo['invoice_duration'];
|
||||
$rate_type = $durationInfo['rate_type'];
|
||||
$rowData = [
|
||||
'order_id' => 0,
|
||||
'user_id' => $esc_user_id,
|
||||
'service_id' => $esc_service_id,
|
||||
'home_id' => 0,
|
||||
'home_name' => $home_name,
|
||||
'ip' => $esc_ip_id,
|
||||
'max_players' => $esc_max_players,
|
||||
'remote_control_password' => $remote_control_password,
|
||||
'ftp_password' => $ftp_password,
|
||||
'customer_name' => $customer_name,
|
||||
'customer_email' => $customer_email,
|
||||
'amount' => $amount,
|
||||
'discount_amount' => 0.00,
|
||||
'currency' => 'USD',
|
||||
'status' => $status,
|
||||
'billing_status' => $status,
|
||||
'invoice_date' => $now,
|
||||
'due_date' => $due_date,
|
||||
'description' => $description,
|
||||
'invoice_duration' => $invoice_duration,
|
||||
'rate_type' => $rate_type,
|
||||
'rate_per_player' => (float)$base_rate,
|
||||
'players' => $max_players,
|
||||
'period_start' => $now,
|
||||
'period_end' => $period_end,
|
||||
'subtotal' => $subtotal,
|
||||
'total_due' => $amount,
|
||||
'payment_status' => $payment_status,
|
||||
'qty' => $esc_qty,
|
||||
'coupon_id' => 0,
|
||||
];
|
||||
|
||||
$insertColumns = [];
|
||||
$placeholders = [];
|
||||
$bindTypes = '';
|
||||
$bindValues = [];
|
||||
foreach ($rowData as $column => $value) {
|
||||
if (!isset($invoiceColumns[$column])) {
|
||||
continue;
|
||||
}
|
||||
$insertColumns[] = "`{$column}`";
|
||||
$placeholders[] = '?';
|
||||
if (is_int($value)) {
|
||||
$bindTypes .= 'i';
|
||||
} elseif (is_float($value)) {
|
||||
$bindTypes .= 'd';
|
||||
} else {
|
||||
$bindTypes .= 's';
|
||||
}
|
||||
$bindValues[] = $value;
|
||||
}
|
||||
|
||||
if (empty($insertColumns)) {
|
||||
billing_fail_add_to_cart('No compatible invoice columns were found for insert', ['table' => $invoiceTable]);
|
||||
}
|
||||
|
||||
$sql = "INSERT INTO `{$invoiceTable}` (" . implode(', ', $insertColumns) . ")
|
||||
VALUES (" . implode(', ', $placeholders) . ")";
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
$res = false;
|
||||
$err_no = 0;
|
||||
$err = '';
|
||||
if ($stmt) {
|
||||
$invoice_duration = $durationInfo['invoice_duration'];
|
||||
$rate_type = $durationInfo['rate_type'];
|
||||
$stmt->bind_param(
|
||||
'iisiissssdsssssssdissddsi',
|
||||
$esc_user_id,
|
||||
$esc_service_id,
|
||||
$home_name,
|
||||
$esc_ip_id,
|
||||
$esc_max_players,
|
||||
$remote_control_password,
|
||||
$ftp_password,
|
||||
$customer_name,
|
||||
$customer_email,
|
||||
$amount,
|
||||
$status,
|
||||
$status,
|
||||
$now,
|
||||
$due_date,
|
||||
$description,
|
||||
$invoice_duration,
|
||||
$rate_type,
|
||||
$base_rate,
|
||||
$max_players,
|
||||
$now,
|
||||
$period_end,
|
||||
$subtotal,
|
||||
$amount,
|
||||
$payment_status,
|
||||
$esc_qty
|
||||
);
|
||||
$stmt->bind_param($bindTypes, ...$bindValues);
|
||||
$res = @$stmt->execute();
|
||||
$err_no = mysqli_errno($db);
|
||||
$err = mysqli_error($db);
|
||||
|
|
@ -272,8 +325,7 @@ if (!$res || $err_no > 0) {
|
|||
site_log_warn('billing_invoices_exists', ['exists'=>$tbl_exists]);
|
||||
file_put_contents($logfile, date('c') . " - Table exists check: {$tbl_exists}\n", FILE_APPEND);
|
||||
|
||||
// Show user-friendly error
|
||||
die("Error adding to cart: " . htmlspecialchars($err) . ". Please contact support.");
|
||||
billing_fail_add_to_cart('Invoice insert failed', ['errno' => $err_no, 'error' => $err]);
|
||||
} else {
|
||||
$insert_id = mysqli_insert_id($db);
|
||||
$affected = mysqli_affected_rows($db);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ if (session_status() === PHP_SESSION_NONE) {
|
|||
// Load configuration
|
||||
require_once(__DIR__ . '/bootstrap.php');
|
||||
|
||||
function billing_cart_money_to_cents(float $amount): int
|
||||
{
|
||||
return (int) round($amount * 100);
|
||||
}
|
||||
|
||||
function billing_cart_cents_to_money(int $cents): float
|
||||
{
|
||||
return $cents / 100;
|
||||
}
|
||||
|
||||
// Variables from config.inc.php (helps IDEs understand scope)
|
||||
/** @var string $db_host Database host */
|
||||
/** @var string $db_user Database user */
|
||||
|
|
@ -45,7 +55,9 @@ $db_error = '';
|
|||
// Initialize variables
|
||||
$invoices = [];
|
||||
$total_amount = 0.00;
|
||||
$total_amount_cents = 0;
|
||||
$discount_amount = 0.00;
|
||||
$discount_amount_cents = 0;
|
||||
$coupon_discount_percent = 0;
|
||||
$applied_coupon = null;
|
||||
$error_message = '';
|
||||
|
|
@ -69,12 +81,14 @@ if (!$db) {
|
|||
if ($result) {
|
||||
while ($row = mysqli_fetch_assoc($result)) {
|
||||
$invoices[] = $row;
|
||||
$total_amount += floatval($row['amount']);
|
||||
$lineAmount = (float)($row['total_due'] ?? $row['amount'] ?? 0);
|
||||
$total_amount_cents += billing_cart_money_to_cents($lineAmount);
|
||||
}
|
||||
mysqli_free_result($result);
|
||||
}
|
||||
|
||||
$cart_empty = (count((array)$invoices) === 0);
|
||||
$total_amount = billing_cart_cents_to_money($total_amount_cents);
|
||||
}
|
||||
|
||||
// Handle coupon application
|
||||
|
|
@ -165,7 +179,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_coupon'])) {
|
|||
}
|
||||
|
||||
// Re-validate coupon from session if present
|
||||
if (empty($applied_coupon) && isset($_SESSION['cart_coupon_code'])) {
|
||||
if ($db && empty($applied_coupon) && isset($_SESSION['cart_coupon_code'])) {
|
||||
$coupon_code = $_SESSION['cart_coupon_code'];
|
||||
$safe_code = mysqli_real_escape_string($db, $coupon_code);
|
||||
$coupon_query = "SELECT * FROM {$table_prefix}billing_coupons
|
||||
|
|
@ -247,10 +261,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_invoice']) &&
|
|||
|
||||
// Calculate discount
|
||||
if ($applied_coupon && $coupon_discount_percent > 0) {
|
||||
$discount_amount = $total_amount * ($coupon_discount_percent / 100);
|
||||
$discount_amount_cents = (int) round($total_amount_cents * ($coupon_discount_percent / 100));
|
||||
$discount_amount_cents = min($discount_amount_cents, $total_amount_cents);
|
||||
}
|
||||
|
||||
$final_amount = $total_amount - $discount_amount;
|
||||
$discount_amount = billing_cart_cents_to_money($discount_amount_cents);
|
||||
$final_amount_cents = max(0, $total_amount_cents - $discount_amount_cents);
|
||||
$final_amount = billing_cart_cents_to_money($final_amount_cents);
|
||||
|
||||
// PayPal configuration (from config)
|
||||
$client_id = function_exists('gsp_paypal_get_client_id') ? gsp_paypal_get_client_id() : ($paypal_client_id ?? '');
|
||||
|
|
@ -263,7 +280,8 @@ foreach ((array)$invoices as $inv) {
|
|||
$game_display = !empty($inv['game_name']) ? $inv['game_name'] : 'Game Server';
|
||||
$qty = max(1, intval($inv['qty']));
|
||||
$paypal_invoice_ids[] = intval($inv['invoice_id']);
|
||||
$lineAmount = (float)($inv['total_due'] ?? $inv['amount'] ?? 0);
|
||||
$lineAmountCents = billing_cart_money_to_cents((float)($inv['total_due'] ?? $inv['amount'] ?? 0));
|
||||
$lineAmount = billing_cart_cents_to_money($lineAmountCents);
|
||||
$paypal_items[] = [
|
||||
'name' => $inv['home_name'] . ' (' . $game_display . ')',
|
||||
'description' => $inv['description'] ?? '',
|
||||
|
|
@ -626,7 +644,7 @@ $siteBase = $protocol . $host;
|
|||
</div>
|
||||
|
||||
<!-- Checkout Section -->
|
||||
<?php if ($final_amount <= 0.00): ?>
|
||||
<?php if ($final_amount_cents === 0): ?>
|
||||
<!-- Zero-dollar checkout: coupon covers the full amount, no PayPal needed -->
|
||||
<div class="checkout-section">
|
||||
<h3>🎉 Complete Your Free Order</h3>
|
||||
|
|
@ -688,7 +706,7 @@ $siteBase = $protocol . $host;
|
|||
}
|
||||
</script>
|
||||
|
||||
<?php if ($final_amount > 0.00 && !empty($client_id)): ?>
|
||||
<?php if ($final_amount_cents > 0 && !empty($client_id)): ?>
|
||||
<script>
|
||||
function showPaymentError(msg) {
|
||||
var statusDiv = document.getElementById('status-message');
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ if (session_status() === PHP_SESSION_NONE) {
|
|||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/includes/login_required.php';
|
||||
|
||||
function billing_free_money_to_cents(float $amount): int
|
||||
{
|
||||
return (int) round($amount * 100);
|
||||
}
|
||||
|
||||
$userId = intval($_SESSION['website_user_id'] ?? $_SESSION['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
header('Location: /login.php');
|
||||
|
|
@ -30,15 +35,15 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||
}
|
||||
|
||||
// DB connection
|
||||
$db = mysqli_connect($db_host, $db_user, $db_pass, $db_name, isset($db_port) ? (int)$db_port : null);
|
||||
if (!$db) {
|
||||
die('<p>Database connection failed. Please <a href="/order.php">return to the shop</a> or contact support.</p>');
|
||||
$mysqli = mysqli_connect($db_host, $db_user, $db_pass, $db_name, isset($db_port) ? (int)$db_port : null);
|
||||
if (!$mysqli) {
|
||||
die('<p>Database connection failed. Please <a href="/serverlist.php">return to the shop</a> or contact support.</p>');
|
||||
}
|
||||
mysqli_set_charset($db, 'utf8mb4');
|
||||
mysqli_set_charset($mysqli, 'utf8mb4');
|
||||
|
||||
// Fetch unpaid invoices for this user (prepared statement)
|
||||
$invoices = [];
|
||||
$stmt = mysqli_prepare($db, "SELECT * FROM {$table_prefix}billing_invoices
|
||||
$stmt = mysqli_prepare($mysqli, "SELECT * FROM {$table_prefix}billing_invoices
|
||||
WHERE user_id = ?
|
||||
AND (status = 'due' OR status = '')
|
||||
AND (payment_status IS NULL OR payment_status NOT IN ('paid','cancelled','refunded'))
|
||||
|
|
@ -54,7 +59,9 @@ if ($stmt) {
|
|||
}
|
||||
|
||||
if (empty($invoices)) {
|
||||
mysqli_close($db);
|
||||
if ($mysqli instanceof mysqli) {
|
||||
mysqli_close($mysqli);
|
||||
}
|
||||
header('Location: /cart.php?msg=empty');
|
||||
exit;
|
||||
}
|
||||
|
|
@ -65,8 +72,8 @@ $couponCode = trim($_POST['coupon_code'] ?? $_SESSION['cart_coupon_code'] ?? '')
|
|||
$discountPct = 0.0;
|
||||
|
||||
if ($couponCode !== '') {
|
||||
$safe = mysqli_real_escape_string($db, $couponCode);
|
||||
$cr = mysqli_query($db, "SELECT * FROM {$table_prefix}billing_coupons
|
||||
$safe = mysqli_real_escape_string($mysqli, $couponCode);
|
||||
$cr = mysqli_query($mysqli, "SELECT * FROM {$table_prefix}billing_coupons
|
||||
WHERE code = '$safe' AND is_active = 1 LIMIT 1");
|
||||
if ($cr && mysqli_num_rows($cr) === 1) {
|
||||
$coupon = mysqli_fetch_assoc($cr);
|
||||
|
|
@ -76,16 +83,20 @@ if ($couponCode !== '') {
|
|||
}
|
||||
|
||||
// Calculate total and verify it is $0 after discount
|
||||
$totalAmount = 0.0;
|
||||
$totalAmountCents = 0;
|
||||
foreach ($invoices as $inv) {
|
||||
$totalAmount += (float)($inv['amount'] ?? 0);
|
||||
$lineAmount = (float)($inv['amount'] ?? 0);
|
||||
$totalAmountCents += billing_free_money_to_cents($lineAmount);
|
||||
}
|
||||
$discountAmount = $totalAmount * ($discountPct / 100.0);
|
||||
$finalAmount = round($totalAmount - $discountAmount, 2);
|
||||
$discountAmountCents = (int) round($totalAmountCents * ($discountPct / 100.0));
|
||||
$discountAmountCents = min($discountAmountCents, $totalAmountCents);
|
||||
$finalAmountCents = max(0, $totalAmountCents - $discountAmountCents);
|
||||
|
||||
if ($finalAmount > 0.00) {
|
||||
if ($finalAmountCents !== 0) {
|
||||
// Coupon no longer covers the full amount — redirect to cart
|
||||
mysqli_close($db);
|
||||
if ($mysqli instanceof mysqli) {
|
||||
mysqli_close($mysqli);
|
||||
}
|
||||
header('Location: /cart.php?msg=coupon_insufficient');
|
||||
exit;
|
||||
}
|
||||
|
|
@ -97,7 +108,7 @@ $txid = 'free-' . time() . '-' . $userId;
|
|||
require_once __DIR__ . '/classes/BillingRepository.php';
|
||||
require_once __DIR__ . '/classes/BillingService.php';
|
||||
|
||||
$repo = new BillingRepository($db, $table_prefix);
|
||||
$repo = new BillingRepository($mysqli, $table_prefix);
|
||||
$newOrderIds = [];
|
||||
$duration_meta = static function (array $invoice): array {
|
||||
$duration = strtolower((string)($invoice['invoice_duration'] ?? $invoice['rate_type'] ?? 'month'));
|
||||
|
|
@ -213,7 +224,7 @@ foreach ($invoices as $inv) {
|
|||
}
|
||||
|
||||
if ($couponId > 0 && !empty($invoices)) {
|
||||
mysqli_query($db, "UPDATE {$table_prefix}billing_coupons
|
||||
mysqli_query($mysqli, "UPDATE {$table_prefix}billing_coupons
|
||||
SET current_uses = current_uses + 1
|
||||
WHERE coupon_id = " . intval($couponId));
|
||||
}
|
||||
|
|
@ -234,7 +245,9 @@ if (!empty($newOrderIds)) {
|
|||
// If panel bootstrap fails the order is Active and admins can provision via the orders panel.
|
||||
}
|
||||
|
||||
mysqli_close($db);
|
||||
if ($mysqli instanceof mysqli) {
|
||||
mysqli_close($mysqli);
|
||||
}
|
||||
|
||||
header('Location: /payment_success.php?order_id=' . urlencode($txid));
|
||||
exit;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,21 @@ function order_game_key_os(string $gameKey): string
|
|||
return 'any';
|
||||
}
|
||||
|
||||
function order_price_is_free($value): bool
|
||||
{
|
||||
return ((int) round(((float)$value) * 100)) === 0;
|
||||
}
|
||||
|
||||
function order_canonical_game_key(string $gameKey): string
|
||||
{
|
||||
$gameKey = strtolower(trim($gameKey));
|
||||
if ($gameKey === '') {
|
||||
return '';
|
||||
}
|
||||
$canonical = preg_replace('/_(linux|linux32|linux64|win|win32|win64|windows|windows32|windows64)$/i', '', $gameKey);
|
||||
return $canonical !== '' ? $canonical : $gameKey;
|
||||
}
|
||||
|
||||
// --- Fetch the requested service with config_homes join for canonical game info ---
|
||||
$req_service_id = intval($_REQUEST['service_id'] ?? 0);
|
||||
if ($req_service_id !== 0) {
|
||||
|
|
@ -146,7 +161,7 @@ if ($imgSrc === '') { $imgSrc = '/images/games/default_server.png'; }
|
|||
<?php echo htmlspecialchars((string)($row['cfg_game_name'] ?? $row['service_name']), ENT_QUOTES, 'UTF-8'); ?>
|
||||
<br>
|
||||
<?php
|
||||
if (floatval($row['price_monthly']) == 0.0) {
|
||||
if (order_price_is_free($row['price_monthly'] ?? 0)) {
|
||||
echo "FREE";
|
||||
} else {
|
||||
echo "$" . number_format(floatval($row['price_monthly']), 2) . " Monthly";
|
||||
|
|
@ -164,20 +179,30 @@ echo "$" . number_format(floatval($row['price_monthly']), 2) . " Monthly";
|
|||
$svcGameKey = (string)($row['cfg_game_key'] ?? '');
|
||||
$svcGameOs = order_game_key_os($svcGameKey);
|
||||
$canonicalGameName = (string)($row['cfg_game_name'] ?? $row['service_name']);
|
||||
$canonicalGameKey = order_canonical_game_key($svcGameKey);
|
||||
|
||||
// Build map of OS variant service IDs for JS-based automatic selection.
|
||||
// Look for sibling services that share the same cfg_game_name (canonical) but differ in OS.
|
||||
// e.g. if current service is arma3_linux64, find the arma3_win64 service too.
|
||||
$osServiceMap = []; // ['linux' => service_id, 'windows' => service_id]
|
||||
if ($svcGameOs !== 'any' && !empty($canonicalGameName)) {
|
||||
$escapedName = $db->real_escape_string($canonicalGameName);
|
||||
$siblingQuery = "SELECT bs.service_id, ch.game_key AS cfg_game_key
|
||||
if ($svcGameOs !== 'any' && (!empty($canonicalGameName) || !empty($canonicalGameKey))) {
|
||||
$siblingQuery = "SELECT bs.service_id, ch.game_key AS cfg_game_key, ch.game_name AS cfg_game_name
|
||||
FROM {$table_prefix}billing_services bs
|
||||
LEFT JOIN {$table_prefix}config_homes ch ON ch.home_cfg_id = bs.home_cfg_id
|
||||
WHERE bs.enabled = 1 AND ch.game_name = '{$escapedName}'";
|
||||
WHERE bs.enabled = 1";
|
||||
$siblingResult = $db->query($siblingQuery);
|
||||
if ($siblingResult) {
|
||||
while ($sib = $siblingResult->fetch_assoc()) {
|
||||
$sibGameKey = (string)($sib['cfg_game_key'] ?? '');
|
||||
$sibCanonical = order_canonical_game_key($sibGameKey);
|
||||
$sibName = (string)($sib['cfg_game_name'] ?? '');
|
||||
if ($canonicalGameKey !== '') {
|
||||
if ($sibCanonical !== $canonicalGameKey) {
|
||||
continue;
|
||||
}
|
||||
} elseif ($canonicalGameName !== '' && strcasecmp($sibName, $canonicalGameName) !== 0) {
|
||||
continue;
|
||||
}
|
||||
$sibOs = order_game_key_os((string)($sib['cfg_game_key'] ?? ''));
|
||||
$osServiceMap[$sibOs] = (int)$sib['service_id'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,22 @@ if (!$db) {
|
|||
die("Connection failed: " . mysqli_connect_error());
|
||||
}
|
||||
|
||||
function billing_service_price_is_free($value): bool
|
||||
{
|
||||
return ((int) round(((float)$value) * 100)) === 0;
|
||||
}
|
||||
|
||||
function billing_canonical_game_identity(array $row): string
|
||||
{
|
||||
$gameKey = strtolower(trim((string)($row['cfg_game_key'] ?? '')));
|
||||
if ($gameKey !== '') {
|
||||
$canonicalKey = preg_replace('/_(linux|linux32|linux64|win|win32|win64|windows|windows32|windows64)$/i', '', $gameKey);
|
||||
return 'key:' . ($canonicalKey !== '' ? $canonicalKey : $gameKey);
|
||||
}
|
||||
$gameName = strtolower(trim((string)($row['cfg_game_name'] ?? $row['service_name'] ?? '')));
|
||||
return 'name:' . $gameName;
|
||||
}
|
||||
|
||||
// Save new description if admin
|
||||
if (isset($_POST['save']) && !empty($_POST['description'])) {
|
||||
$new_description = str_replace("\\r\\n", "<br>", $_POST['description']);
|
||||
|
|
@ -80,15 +96,12 @@ while ($row = $result_services->fetch_assoc()) {
|
|||
}
|
||||
// Derive canonical display name: prefer config_homes game_name (consistent across OS
|
||||
// variants), fall back to service_name.
|
||||
$canonicalName = !empty($row['cfg_game_name'])
|
||||
? $row['cfg_game_name']
|
||||
: $row['service_name'];
|
||||
|
||||
if (isset($seenCanonical[$canonicalName])) {
|
||||
$canonicalIdentity = billing_canonical_game_identity($row);
|
||||
if (isset($seenCanonical[$canonicalIdentity])) {
|
||||
// Already have this game — skip the duplicate OS variant
|
||||
continue;
|
||||
}
|
||||
$seenCanonical[$canonicalName] = true;
|
||||
$seenCanonical[$canonicalIdentity] = true;
|
||||
$serviceRows[] = $row;
|
||||
}
|
||||
$result_services->free();
|
||||
|
|
@ -115,7 +128,7 @@ include(__DIR__ . '/includes/menu.php');
|
|||
onerror="this.src='/images/games/default_server.png'; this.onerror=null;"><br>
|
||||
<strong><?php echo htmlspecialchars((string)($row['cfg_game_name'] ?? $row['service_name']), ENT_QUOTES, 'UTF-8'); ?></strong><br>
|
||||
<?php
|
||||
echo (floatval($row['price_monthly']) == 0.0) ? "FREE" : "$" . number_format(floatval($row['price_monthly']), 2) . " Monthly";
|
||||
echo billing_service_price_is_free($row['price_monthly'] ?? 0) ? "FREE" : "$" . number_format((float)$row['price_monthly'], 2) . " Monthly";
|
||||
?>
|
||||
<br>
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Last Updated at 3:52pm on 2026-05-06
|
||||
Last Updated at 12:31am on 2026-05-07
|
||||
|
|
|
|||
|
|
@ -314,7 +314,7 @@ function sw_admin_edit_form(array $profile, array $detected = array(), $showDete
|
|||
<span>Default Install / Update Mode</span>
|
||||
<select name="default_update_mode">
|
||||
<option value="manual" <?= (($profile['default_update_mode'] ?? 'manual') === 'manual') ? 'selected' : '' ?>>Manual only (safe default)</option>
|
||||
<option value="on_restart" <?= (($profile['default_update_mode'] ?? 'manual') === 'on_restart') ? 'selected' : '' ?>>On next server restart</option>
|
||||
<option value="on_restart" <?= (($profile['default_update_mode'] ?? 'manual') === 'on_restart') ? 'selected' : '' ?>>On next restart</option>
|
||||
<option value="before_start" <?= (($profile['default_update_mode'] ?? 'manual') === 'before_start') ? 'selected' : '' ?>>Before every server start</option>
|
||||
<option value="scheduled" <?= (($profile['default_update_mode'] ?? 'manual') === 'scheduled') ? 'selected' : '' ?>>Scheduled update check</option>
|
||||
</select>
|
||||
|
|
@ -323,8 +323,8 @@ function sw_admin_edit_form(array $profile, array $detected = array(), $showDete
|
|||
<span>Default Restart Behavior</span>
|
||||
<select name="default_restart_behavior">
|
||||
<option value="none" <?= (($profile['default_restart_behavior'] ?? 'none') === 'none') ? 'selected' : '' ?>>Do not restart automatically (safe default)</option>
|
||||
<option value="if_empty" <?= (($profile['default_restart_behavior'] ?? 'none') === 'if_empty') ? 'selected' : '' ?>>Restart only if server is empty</option>
|
||||
<option value="immediate" <?= (($profile['default_restart_behavior'] ?? 'none') === 'immediate') ? 'selected' : '' ?>>Restart immediately after warning</option>
|
||||
<option value="if_empty" <?= (($profile['default_restart_behavior'] ?? 'none') === 'if_empty') ? 'selected' : '' ?>>Restart if empty</option>
|
||||
<option value="immediate" <?= (($profile['default_restart_behavior'] ?? 'none') === 'immediate') ? 'selected' : '' ?>>Restart after warning</option>
|
||||
<option value="next_restart" <?= (($profile['default_restart_behavior'] ?? 'none') === 'next_restart') ? 'selected' : '' ?>>Install on next manual restart only</option>
|
||||
</select>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -584,7 +584,7 @@ function sw_user_render($db, $home_id, array $home, array $profile)
|
|||
<td style="padding:8px;">
|
||||
<select name="update_mode" style="width:100%;">
|
||||
<option value="manual" <?= ($settings['update_mode'] === 'manual') ? 'selected' : '' ?>>Manual only</option>
|
||||
<option value="on_restart" <?= ($settings['update_mode'] === 'on_restart') ? 'selected' : '' ?>>On next server restart</option>
|
||||
<option value="on_restart" <?= ($settings['update_mode'] === 'on_restart') ? 'selected' : '' ?>>On next restart</option>
|
||||
<option value="before_start" <?= ($settings['update_mode'] === 'before_start') ? 'selected' : '' ?>>Before every server start</option>
|
||||
<option value="scheduled" <?= ($settings['update_mode'] === 'scheduled') ? 'selected' : '' ?>>Scheduled update check</option>
|
||||
</select>
|
||||
|
|
@ -602,8 +602,8 @@ function sw_user_render($db, $home_id, array $home, array $profile)
|
|||
<td style="padding:8px;">
|
||||
<select name="restart_behavior" style="width:100%;">
|
||||
<option value="none" <?= ($settings['restart_behavior'] === 'none') ? 'selected' : '' ?>>Do not restart automatically</option>
|
||||
<option value="if_empty" <?= ($settings['restart_behavior'] === 'if_empty') ? 'selected' : '' ?>>Restart only if server is empty</option>
|
||||
<option value="immediate" <?= ($settings['restart_behavior'] === 'immediate') ? 'selected' : '' ?>>Restart immediately after warning</option>
|
||||
<option value="if_empty" <?= ($settings['restart_behavior'] === 'if_empty') ? 'selected' : '' ?>>Restart if empty</option>
|
||||
<option value="immediate" <?= ($settings['restart_behavior'] === 'immediate') ? 'selected' : '' ?>>Restart after warning</option>
|
||||
<option value="next_restart" <?= ($settings['restart_behavior'] === 'next_restart') ? 'selected' : '' ?>>Install on next manual restart only</option>
|
||||
</select>
|
||||
</td>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue