$imgFile) {
if (str_starts_with($normImgKey, $key) || str_starts_with($key, $normImgKey)) {
return $imgFile;
}
}
}
return '';
}
$db = billing_get_db();
if (!($db instanceof mysqli)) {
die("Database connection failed.");
}
include(__DIR__ . '/includes/top.php');
include(__DIR__ . '/includes/menu.php');
/* -----------------------------------------------------------------------
Auto-sync: keep billing_services in step with config_homes
Source: one row per config_homes entry, keyed by home_cfg_id.
Runs on every page load; INSERT and soft-disable only — never hard-delete.
----------------------------------------------------------------------- */
function sync_billing_services(mysqli $db, string $prefix): array
{
$messages = [];
$tableName = $prefix . 'billing_services';
// Schema auto-repair: ensure all expected columns exist.
// col_exists() is provided by bootstrap.php.
$autoRepairCols = [
'home_cfg_id' => "ADD COLUMN `home_cfg_id` INT(11) NOT NULL DEFAULT 0",
'description' => "ADD COLUMN `description` VARCHAR(1000) NOT NULL DEFAULT ''",
'img_url' => "ADD COLUMN `img_url` VARCHAR(255) NOT NULL DEFAULT ''",
'slot_min_qty' => "ADD COLUMN `slot_min_qty` INT(11) NOT NULL DEFAULT 1",
'slot_max_qty' => "ADD COLUMN `slot_max_qty` INT(11) NOT NULL DEFAULT 100",
'price_daily' => "ADD COLUMN `price_daily` FLOAT(15,4) NOT NULL DEFAULT 0",
'price_monthly' => "ADD COLUMN `price_monthly` FLOAT(15,4) NOT NULL DEFAULT 0",
'price_year' => "ADD COLUMN `price_year` FLOAT(15,4) NOT NULL DEFAULT 0",
'remote_server_id' => "ADD COLUMN `remote_server_id` VARCHAR(255) NOT NULL DEFAULT ''",
];
foreach ($autoRepairCols as $col => $alterFragment) {
if (!col_exists($db, $tableName, $col)) {
if ($db->query("ALTER TABLE `{$tableName}` {$alterFragment}")) {
$messages[] = "✔ Auto-repaired: added column '{$col}' to {$tableName}.";
} else {
$messages[] = "✖ Could not add column '{$col}' to {$tableName}: " . $db->error;
}
}
}
// If critical columns are still absent after repair, abort to avoid SQL errors.
foreach (['service_name', 'home_cfg_id', 'enabled'] as $critical) {
if (!col_exists($db, $tableName, $critical)) {
$messages[] = "⚠ Critical column '{$critical}' missing from {$tableName}; skipping sync.";
return $messages;
}
}
// Load all game configs from config_homes — one entry per game XML.
$configHomes = [];
$res = $db->query(
"SELECT home_cfg_id, game_name, home_cfg_file
FROM `{$prefix}config_homes`
ORDER BY game_name"
);
if ($res) {
while ($row = $res->fetch_assoc()) {
$configHomes[(int)$row['home_cfg_id']] = $row;
}
}
if (empty($configHomes)) {
// config_homes is empty or the table does not exist yet — nothing to sync.
return $messages;
}
// Load existing billing_services indexed by home_cfg_id.
$existing = [];
$svcRes = $db->query(
"SELECT service_id, home_cfg_id, enabled
FROM `{$tableName}`"
);
if ($svcRes) {
while ($row = $svcRes->fetch_assoc()) {
$hid = (int)$row['home_cfg_id'];
if ($hid > 0) {
$existing[$hid] = $row;
}
}
}
// Insert a new row for every config_homes entry not yet in billing_services.
// Admin-editable fields (prices, slots, enabled, etc.) get safe defaults so
// the service is visible to the admin but not yet live in the store.
$availableImages = list_game_images();
foreach ($configHomes as $homeCfgId => $ch) {
if (isset($existing[$homeCfgId])) {
continue;
}
$svcName = $db->real_escape_string($ch['game_name']);
$guessedImg = $db->real_escape_string(
guess_game_image((string)$ch['game_name'], (string)($ch['home_cfg_file'] ?? ''), $availableImages)
);
$db->query(
"INSERT INTO `{$tableName}`
(home_cfg_id, mod_cfg_id, service_name, description,
remote_server_id, enabled,
price_daily, price_monthly, price_year,
slot_min_qty, slot_max_qty,
img_url, ftp, install_method, manual_url, access_rights)
VALUES
({$homeCfgId}, 0, '{$svcName}', '{$svcName}',
'', 0,
0.00, 0.00, 0.00,
1, 100,
'{$guessedImg}', '', 'steamcmd', '', '')"
);
$msg = "Added new service: " . $ch['game_name'];
if ($guessedImg !== '') {
$msg .= " (image auto-set: {$guessedImg})";
}
$messages[] = $msg;
}
// Soft-disable billing_services whose home_cfg_id no longer appears in config_homes.
foreach ($existing as $homeCfgId => $svcRow) {
if (!isset($configHomes[$homeCfgId])) {
$sid = (int)$svcRow['service_id'];
$db->query(
"UPDATE `{$tableName}`
SET enabled = 0
WHERE service_id = {$sid} AND enabled = 1"
);
if ($db->affected_rows > 0) {
$messages[] = "Service ID {$sid} disabled — game config no longer in config_homes.";
}
}
}
return $messages;
}
$syncMessages = sync_billing_services($db, $table_prefix);
$flash = [];
$flashType = 'ok';
$sort = strtolower((string)($_GET['sort'] ?? $_POST['sort'] ?? 'game'));
$dir = strtolower((string)($_GET['dir'] ?? $_POST['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
$gameMode = strtolower((string)($_GET['game_mode'] ?? $_POST['game_mode'] ?? 'name'));
if (!in_array($sort, ['game', 'config', 'enabled', 'day', 'month', 'year', 'servers'], true)) {
$sort = 'game';
}
if (!in_array($gameMode, ['name', 'enabled'], true)) {
$gameMode = 'name';
}
$sortQuery = http_build_query([
'sort' => $sort,
'dir' => $dir,
'game_mode' => $gameMode,
]);
function sort_link_params(string $column, string $sort, string $dir, string $gameMode): array
{
$nextDir = ($sort === $column && $dir === 'asc') ? 'desc' : 'asc';
$nextGameMode = $gameMode;
if ($column === 'game' && $sort === 'game' && $gameMode === 'name') {
$nextGameMode = 'enabled';
$nextDir = 'asc';
} elseif ($column === 'game' && $sort === 'game' && $gameMode === 'enabled') {
$nextGameMode = 'name';
$nextDir = 'asc';
} elseif ($column !== 'game') {
$nextGameMode = 'name';
}
return [
'sort' => $column,
'dir' => $nextDir,
'game_mode' => $nextGameMode,
];
}
/* -----------------------------------------------------------------------
SAVE: service configuration form submitted
Only admin-editable fields are updated; service_name and home_cfg_id
are never overwritten here.
----------------------------------------------------------------------- */
if (isset($_POST['save_services']) || isset($_POST['save_row'])) {
// Load valid remote server IDs for validation
$validServerIds = [];
$rsRes = $db->query("SELECT remote_server_id FROM `{$table_prefix}remote_servers`");
while ($rsRes && ($rsRow = $rsRes->fetch_assoc())) {
$validServerIds[] = (int)$rsRow['remote_server_id'];
}
$validSet = array_flip($validServerIds);
$postedServices = $_POST['svc'] ?? [];
$postedServers = $_POST['servers'] ?? [];
$rowOnlyServiceId = isset($_POST['save_row']) ? (int)$_POST['save_row'] : 0;
$updatedCount = 0;
foreach ((array)$postedServices as $sid => $svcData) {
$sid = (int)$sid;
if ($rowOnlyServiceId > 0 && $sid !== $rowOnlyServiceId) {
continue;
}
$enabled = isset($svcData['enabled']) ? 1 : 0;
$priceDaily = number_format((float)($svcData['price_daily'] ?? 0), 2, '.', '');
$priceMonthly = number_format((float)($svcData['price_monthly'] ?? 0), 2, '.', '');
$priceYear = number_format((float)($svcData['price_year'] ?? 0), 2, '.', '');
$slotMin = max(1, (int)($svcData['slot_min_qty'] ?? 1));
$slotMax = max(1, (int)($svcData['slot_max_qty'] ?? 1));
if ($slotMax < $slotMin) { $slotMax = $slotMin; }
$description = $db->real_escape_string(substr((string)($svcData['description'] ?? ''), 0, 1000));
// Merge dropdown and fallback text input:
// - dropdown value "__other__" means use the text fallback field
// - otherwise use the dropdown value (bare filename or '')
$rawImgUrl = (string)($svcData['img_url'] ?? '');
if ($rawImgUrl === '__other__') {
$rawImgUrl = (string)($svcData['img_url_other'] ?? '');
}
$imgUrl = $db->real_escape_string(substr($rawImgUrl, 0, 255));
// Build comma-separated remote_server_id from checkboxes, validating each ID
$checkedIds = [];
foreach ((array)($postedServers[$sid] ?? []) as $rawId) {
$rid = (int)$rawId;
if (isset($validSet[$rid])) {
$checkedIds[] = $rid;
}
}
$remoteServerIdStr = $db->real_escape_string(implode(',', $checkedIds));
$ok = $db->query(
"UPDATE `{$table_prefix}billing_services`
SET enabled = {$enabled},
price_daily = '{$priceDaily}',
price_monthly = '{$priceMonthly}',
price_year = '{$priceYear}',
slot_min_qty = {$slotMin},
slot_max_qty = {$slotMax},
description = '{$description}',
img_url = '{$imgUrl}',
remote_server_id = '{$remoteServerIdStr}'
WHERE service_id = {$sid}"
);
if ($ok) {
$updatedCount++;
}
}
if ($updatedCount > 0) {
if ($rowOnlyServiceId > 0) {
$flash[] = "Service row #{$rowOnlyServiceId} saved.";
} else {
$flash[] = "{$updatedCount} service row(s) saved.";
}
} else {
$flashType = 'err';
if ($rowOnlyServiceId > 0) {
$flash[] = "No changes were saved for service row #{$rowOnlyServiceId}.";
} else {
$flash[] = "No service rows were updated.";
}
}
$_SESSION['billing_adminserverlist_flash'] = ['type' => $flashType, 'messages' => $flash];
header("Location: /adminserverlist.php?{$sortQuery}");
exit;
}
if (!empty($_SESSION['billing_adminserverlist_flash'])) {
$flashData = $_SESSION['billing_adminserverlist_flash'];
unset($_SESSION['billing_adminserverlist_flash']);
$flashType = ($flashData['type'] ?? 'ok') === 'err' ? 'err' : 'ok';
$flash = array_values(array_filter((array)($flashData['messages'] ?? []), 'is_string'));
}
/* -----------------------------------------------------------------------
Load data for display — join config_homes to show the config XML filename
----------------------------------------------------------------------- */
$remoteServers = [];
$rsRes = $db->query(
"SELECT remote_server_id, remote_server_name
FROM `{$table_prefix}remote_servers`
ORDER BY remote_server_name"
);
while ($rsRes && ($row = $rsRes->fetch_assoc())) {
$remoteServers[] = $row;
}
$services = [];
$svcRes = $db->query(
"SELECT bs.service_id, bs.service_name, bs.enabled,
bs.price_daily, bs.price_monthly, bs.price_year,
bs.slot_min_qty, bs.slot_max_qty,
bs.remote_server_id, bs.description, bs.img_url,
ch.home_cfg_file
FROM `{$table_prefix}billing_services` bs
LEFT JOIN `{$table_prefix}config_homes` ch ON ch.home_cfg_id = bs.home_cfg_id
ORDER BY bs.service_name"
);
while ($svcRes && ($row = $svcRes->fetch_assoc())) {
$services[] = $row;
}
if (!empty($services)) {
usort($services, function (array $a, array $b) use ($sort, $dir, $gameMode): int {
$cmp = 0;
switch ($sort) {
case 'config':
$cmp = strcasecmp((string)($a['home_cfg_file'] ?? ''), (string)($b['home_cfg_file'] ?? ''));
break;
case 'enabled':
$cmp = ((int)($a['enabled'] ?? 0)) <=> ((int)($b['enabled'] ?? 0));
break;
case 'day':
$cmp = ((float)($a['price_daily'] ?? 0)) <=> ((float)($b['price_daily'] ?? 0));
break;
case 'month':
$cmp = ((float)($a['price_monthly'] ?? 0)) <=> ((float)($b['price_monthly'] ?? 0));
break;
case 'year':
$cmp = ((float)($a['price_year'] ?? 0)) <=> ((float)($b['price_year'] ?? 0));
break;
case 'servers':
$countA = trim((string)($a['remote_server_id'] ?? '')) === '' ? 0 : count(array_filter(explode(',', (string)$a['remote_server_id']), 'strlen'));
$countB = trim((string)($b['remote_server_id'] ?? '')) === '' ? 0 : count(array_filter(explode(',', (string)$b['remote_server_id']), 'strlen'));
$cmp = $countA <=> $countB;
break;
case 'game':
default:
if ($gameMode === 'enabled') {
$cmp = ((int)($b['enabled'] ?? 0)) <=> ((int)($a['enabled'] ?? 0));
if ($cmp === 0) {
$cmp = strcasecmp((string)($a['service_name'] ?? ''), (string)($b['service_name'] ?? ''));
}
} else {
$cmp = strcasecmp((string)($a['service_name'] ?? ''), (string)($b['service_name'] ?? ''));
}
break;
}
if ($cmp === 0) {
$cmp = ((int)($a['service_id'] ?? 0)) <=> ((int)($b['service_id'] ?? 0));
}
return $dir === 'desc' ? -$cmp : $cmp;
});
}
?>
Service Configuration
Enable services, configure pricing and slot ranges, and select which remote servers
each game can be installed on. The service list is automatically kept in sync with
the panel game configuration (config_homes). Check one or more servers
to make a game available for purchase; leaving all servers unchecked prevents the
game from appearing in the store.
No billing services found. Ensure game configs are loaded in the panel (Home → Games configuration).
Notes:
- A service will only appear in the store when Enabled is checked
and at least one server is selected.
- The Game Name and Config XML columns are sourced
from
and are read-only
here. To change them, update the game XML config in the panel.
- Available servers are stored as a comma-separated list of server IDs in
.
- The service list is automatically synced with the panel game configuration on
every page load. New games are added with Enabled = off so they do not
appear in the store until you configure and enable them.
- Games removed from the panel configuration are disabled automatically; they are
never deleted while orders may reference them.