Merge pull request #133 from GameServerPanel/copilot/gsp-fix-admin-migrate-button-formatting

Fix billing fulfillment/home linking and default admin server billing term
This commit is contained in:
Frank Harris 2026-05-06 17:51:20 -05:00 committed by GitHub
commit 21c163a4b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 772 additions and 185 deletions

View file

@ -17,7 +17,7 @@ This file captures how the control panel, storefront, agents, and helper scripts
1. **Auth/session** Driven by `index.php` (panel) and `modules/billing/login.php` (storefront). Both set `$_SESSION['user_id']`, `users_login`, `users_group`, and `website_user_id`. The shared session cookie `opengamepanel_web` means logging into either surface immediately authenticates the other.
2. **Catalog** `modules/config_games` hosts XML definitions. Panel modules (`gamemanager`, `config_games`) and storefront pages (`serverlist.php`, `order.php`, documentation pages, and the XML-notes mirror) parse these files for display and provisioning metadata.
3. **Provisioning** Orders land in `gsp_billing_orders`. `modules/billing/includes/provisioner.php` reuses `modules/billing/create_servers.php` logic to allocate homes, assign nodes/IPs, configure mods, and kick off SteamCMD/rsync/manual installers. The same provisioner is invoked by:
3. **Provisioning** Orders land in `gsp_billing_orders`. `modules/billing/create_servers.php` allocates homes, assigns nodes/IPs, configures mods, kicks off SteamCMD/rsync/manual installers, and then syncs the resulting `home_id` back into `billing_orders`, `billing_invoices`, and `billing_transactions` so paid services never stay orphaned. The same provisioner is invoked by:
- PayPal capture endpoint (`modules/billing/api/capture_order.php`).
- Panel module page `home.php?m=billing&p=provision_servers`.
- Cron/repair actions in `modules/billing/cron-shop.php`.
@ -48,7 +48,7 @@ This file captures how the control panel, storefront, agents, and helper scripts
| Public pages | `index.php`, `serverlist.php`, `order.php`, `cart.php`, `payment_success.php`, `docs.php` | All include `bootstrap.php`, header/footer, shared CSS. Links remain root-relative. |
| Auth | `login.php`, `register.php`, `reset_password.php`, `forgot_password.php`, `includes/login_required.php`, `includes/admin_auth.php` | Share `opengamepanel_web` session, call into panel DB to validate roles. |
| Admin | `admin.php`, `adminserverlist.php`, `admin_orders.php`, `admin_coupons.php`, `admin_config.php`, `my_orders_panel.php` | Manage services, coupons, prices, and provisioning. `adminserverlist.php` controls service availability per node. |
| PayPal API | `api/create_order.php`, `api/capture_order.php`, `webhook.php`, `logs/payment_capture.log` | Implements REST checkout. Once capture is confirmed, writes invoices/orders, updates coupons, and kicks `BillingProvisioner`. |
| PayPal API | `api/create_order.php`, `api/capture_order.php`, `webhook.php`, `logs/payment_capture.log` | Implements REST checkout. The cart stamps PayPal `custom_id` with the exact invoice IDs being purchased; capture/webhook handlers use that to mark the correct invoices paid, create/extend orders, and kick provisioning idempotently. |
| Provisioning bridge | `create_servers.php`, `includes/provisioner.php`, `includes/panel_bridge.php` | Shared between panel module and storefront backend. Encapsulates whole server creation/renewal pipeline. |
| Cron helpers | `cron-shop.php`, `diag_remote.php` | Automations for renewals, diagnostics, health checks. |
| Documentation | `docs.php`, `docs/*`, `docs/admin_xml_notes.php` (PHP mirror of XML wiki) | Provide guidance for editing XML and game configs directly inside repo. |

View file

@ -1,5 +1,10 @@
# Changelog
## 2026-05-06
- **Billing/admin provisioning hardening:** Styled the panel Migrate action like the other server action buttons, switched admin-created billing rows to the canonical monthly/31-day default, and made paid checkout fulfillment sync `billing_orders.home_id`, `billing_invoices.home_id`, and `billing_transactions.home_id` after provisioning so paid orders no longer stay at `home_id = 0`.
- **Billing cart data correctness:** `add_to_cart.php` now calculates invoice amounts from the selected slot count and duration, stores `subtotal`/`total_due` metadata, and replaces `ChangeMe` placeholders with securely generated passwords before anything is written to billing tables.
- **PayPal/coupon idempotency:** Cart checkout now stamps PayPal `custom_id` with the exact invoice IDs being purchased, capture/free/webhook handlers normalize month=31-day renewals, avoid duplicate transaction logs, and queue provisioning only for orders that still lack a home.
## 2026-05-05
- **Billing checkout — automatic server provisioning after payment:** Fixed the core provisioning gap where `capture_order.php` never populated `$newOrderIds`, so the auto-provisioner was always skipped. After a successful PayPal capture (or zero-dollar checkout), a `billing_orders` row is now created for each paid invoice and passed to `billing_invoke_provision()` so the game server is created/installed immediately without manual admin action.
- **Billing checkout — duplicate provisioning prevention:** Invoice→Order linkage is written atomically (`billing_invoices.order_id` updated after order creation). Because `getUnpaidInvoicesForUser()` filters on `payment_status NOT IN ('paid',…)`, a retried PayPal capture will find no invoices and skip all processing — preventing duplicate servers.

View file

@ -13,7 +13,36 @@ require_once(__DIR__ . '/includes/log.php');
/** @var string $table_prefix Table prefix for database tables */
// Start session if not already
if (session_status() === PHP_SESSION_NONE) session_start();
if (session_status() === PHP_SESSION_NONE) {
session_name('opengamepanel_web');
session_start();
}
function billing_generate_password(int $bytes = 12): string
{
try {
return substr(bin2hex(random_bytes($bytes)), 0, $bytes * 2);
} catch (Throwable $e) {
return substr(hash('sha256', uniqid('gsp', true) . microtime(true)), 0, $bytes * 2);
}
}
function billing_normalize_duration(string $duration): array
{
$duration = strtolower(trim($duration));
switch ($duration) {
case 'day':
case 'daily':
return ['invoice_duration' => 'day', 'rate_type' => 'daily', 'days' => 1];
case 'year':
case 'yearly':
return ['invoice_duration' => 'year', 'rate_type' => 'yearly', 'days' => 365];
case 'month':
case 'monthly':
default:
return ['invoice_duration' => 'month', 'rate_type' => 'monthly', 'days' => 31];
}
}
// Immediate request tracing log (helps confirm the script is hit)
@mkdir(__DIR__ . '/logs', 0775, true);
@ -51,11 +80,11 @@ $ip_id = isset($_POST['ip_id']) ? intval($_POST['ip_id']) : 0;
$max_players = isset($_POST['max_players']) ? intval($_POST['max_players']) : 0;
$qty = isset($_POST['qty']) ? intval($_POST['qty']) : 1;
$invoice_duration = isset($_POST['invoice_duration']) ? $_POST['invoice_duration'] : 'month';
$remote_control_password = isset($_POST['remote_control_password']) ? $_POST['remote_control_password'] : '';
$ftp_password = isset($_POST['ftp_password']) ? $_POST['ftp_password'] : '';
$remote_control_password = isset($_POST['remote_control_password']) ? trim((string)$_POST['remote_control_password']) : '';
$ftp_password = isset($_POST['ftp_password']) ? trim((string)$_POST['ftp_password']) : '';
// Price lookup: try to find service price_monthly
$db = mysqli_connect($db_host, $db_user, $db_pass, $db_name);
$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
@mkdir(__DIR__ . '/logs', 0775, true);
@ -94,15 +123,25 @@ if (!empty($resolve_username_for_user_id) && $db) {
}
}
$price = 0.0;
$service_name = '';
$base_rate = 0.0;
$slot_min_qty = 1;
$slot_max_qty = 1;
$durationInfo = billing_normalize_duration($invoice_duration);
if ($service_id > 0) {
$stmt = $db->prepare("SELECT price_monthly, slot_min_qty, slot_max_qty FROM {$table_prefix}billing_services WHERE service_id = ? LIMIT 1");
$stmt = $db->prepare("SELECT service_name, price_daily, price_monthly, price_year, slot_min_qty, slot_max_qty FROM {$table_prefix}billing_services WHERE service_id = ? LIMIT 1");
if ($stmt) {
$stmt->bind_param('i', $service_id);
$stmt->execute();
$stmt->bind_result($price_monthly, $slot_min_qty, $slot_max_qty);
$stmt->bind_result($service_name, $price_daily, $price_monthly, $price_year, $slot_min_qty, $slot_max_qty);
if ($stmt->fetch()) {
$price = floatval($price_monthly);
if ($durationInfo['rate_type'] === 'daily') {
$base_rate = floatval($price_daily);
} elseif ($durationInfo['rate_type'] === 'yearly') {
$base_rate = floatval($price_year);
} else {
$base_rate = floatval($price_monthly);
}
// constrain slots
if ($max_players < $slot_min_qty) $max_players = $slot_min_qty;
if ($max_players > $slot_max_qty) $max_players = $slot_max_qty;
@ -111,14 +150,27 @@ if ($service_id > 0) {
}
}
if ($remote_control_password === '' || strcasecmp($remote_control_password, 'ChangeMe') === 0) {
$remote_control_password = billing_generate_password();
}
if ($ftp_password === '' || strcasecmp($ftp_password, 'ChangeMe') === 0) {
$ftp_password = billing_generate_password();
}
// Insert into {table_prefix}billing_invoices (NOT orders - invoice created first)
$now = date('Y-m-d H:i:s');
$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);
$amount = $subtotal;
$period_end = date('Y-m-d H:i:s', strtotime('+' . ($durationInfo['days'] * $qty) . ' days'));
// Normal flow: process POST immediately. If debug=1 is passed, we'll still log SQL and show results in logs.
$debug = (isset($_GET['debug']) && $_GET['debug'] == '1') || (isset($_POST['debug']) && $_POST['debug'] == '1');
// Build and execute a simple INSERT using mysqli_query for debugging clarity
// Build and execute the INSERT with prepared statements
@mkdir(__DIR__ . '/logs', 0775, true);
$logfile = __DIR__ . '/logs/add_to_cart.log';
site_log_info('add_to_cart_invoked', ['user_id'=>$user_id, 'service_id'=>$service_id]);
@ -145,35 +197,71 @@ $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);
$esc_invoice_duration = mysqli_real_escape_string($db, $invoice_duration);
$esc_price = number_format((float)$price, 2, '.', '');
$esc_remote_control_password = mysqli_real_escape_string($db, $remote_control_password);
$esc_ftp_password = mysqli_real_escape_string($db, $ftp_password);
$esc_status = mysqli_real_escape_string($db, $status);
$esc_customer_name = mysqli_real_escape_string($db, $customer_name);
$esc_customer_email = mysqli_real_escape_string($db, $customer_email);
$esc_due_date = mysqli_real_escape_string($db, $due_date);
$esc_description = mysqli_real_escape_string($db, "New server: {$home_name}");
$description = trim(($service_name !== '' ? $service_name : 'Game Server') . ': ' . $home_name);
$sql = "INSERT INTO {$table_prefix}billing_invoices (
user_id, service_id, home_name, ip, max_players, qty, invoice_duration,
amount, remote_control_password, ftp_password, status, customer_name,
customer_email, due_date, description, currency, order_id
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 (
{$esc_user_id}, {$esc_service_id}, '{$esc_home_name}', {$esc_ip_id},
{$esc_max_players}, {$esc_qty}, '{$esc_invoice_duration}', {$esc_price},
'{$esc_remote_control_password}', '{$esc_ftp_password}', '{$esc_status}',
'{$esc_customer_name}', '{$esc_customer_email}', '{$esc_due_date}',
'{$esc_description}', 'USD', 0
0, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, 0.00, 'USD', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0
)";
site_log_info('add_to_cart_sql', ['sql'=>$sql]);
file_put_contents($logfile, date('c') . " - Creating invoice (not order): status=due\n", FILE_APPEND);
file_put_contents($logfile, date('c') . " - SQL: " . $sql . "\n", FILE_APPEND);
$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
);
$res = @$stmt->execute();
$err_no = mysqli_errno($db);
$err = mysqli_error($db);
} else {
$err_no = mysqli_errno($db);
$err = mysqli_error($db);
}
$res = @mysqli_query($db, $sql);
$err_no = mysqli_errno($db);
$err = mysqli_error($db);
site_log_info('add_to_cart_invoice', [
'user_id' => $user_id,
'service_id' => $service_id,
'home_name' => $home_name,
'remote_server_id' => $ip_id,
'players' => $max_players,
'qty' => $qty,
'invoice_duration' => $invoice_duration,
'subtotal' => $subtotal,
'total_due' => $amount,
]);
file_put_contents($logfile, date('c') . " - Creating invoice (not order): status=due total_due={$amount}\n", FILE_APPEND);
if (!$res || $err_no > 0) {
site_log_error('mysqli_query_failed', ['errno'=>$err_no, 'error'=>$err, 'sql'=>$sql]);
@ -193,6 +281,10 @@ if (!$res || $err_no > 0) {
file_put_contents($logfile, date('c') . " - Invoice created: invoice_id={$insert_id}\n", FILE_APPEND);
}
if ($stmt instanceof mysqli_stmt) {
$stmt->close();
}
// Redirect to cart page
header('Location: cart.php');
exit;

View file

@ -101,7 +101,87 @@ mysqli_set_charset($mysqli, 'utf8mb4');
$prefix = $table_prefix ?? 'gsp_';
$repo = new BillingRepository($mysqli, $prefix);
$svc = new BillingService($repo);
function cap_invoice_ids_from_custom_id(?string $customId): array {
if (!is_string($customId) || $customId === '') {
return [];
}
if (ctype_digit($customId)) {
return [intval($customId)];
}
if (stripos($customId, 'cart:') !== 0) {
return [];
}
$parts = explode(',', substr($customId, 5));
$invoiceIds = [];
foreach ($parts as $part) {
$part = trim($part);
if ($part !== '' && ctype_digit($part)) {
$invoiceIds[] = intval($part);
}
}
return array_values(array_unique($invoiceIds));
}
function cap_get_duration_metadata(array $invoice): array {
$duration = strtolower((string)($invoice['invoice_duration'] ?? $invoice['rate_type'] ?? 'month'));
switch ($duration) {
case 'day':
case 'daily':
return ['invoice_duration' => 'day', 'rate_type' => 'daily', 'days' => 1];
case 'year':
case 'yearly':
return ['invoice_duration' => 'year', 'rate_type' => 'yearly', 'days' => 365];
case 'month':
case 'monthly':
default:
return ['invoice_duration' => 'month', 'rate_type' => 'monthly', 'days' => 31];
}
}
function cap_get_end_date(array $invoice, ?string $fromDate = null): string {
$meta = cap_get_duration_metadata($invoice);
$qty = max(1, intval($invoice['qty'] ?? 1));
$baseTs = time();
if (!empty($fromDate)) {
$fromTs = strtotime($fromDate);
if ($fromTs !== false && $fromTs > time()) {
$baseTs = $fromTs;
}
}
return date('Y-m-d H:i:s', $baseTs + ($meta['days'] * $qty * 86400));
}
function cap_discount_map(array $invoices, float $paidAmount): array {
$baseTotals = [];
$baseAmount = 0.0;
foreach ($invoices as $invoice) {
$invoiceId = intval($invoice['invoice_id'] ?? 0);
$lineBase = round((float)($invoice['subtotal'] ?? $invoice['total_due'] ?? $invoice['amount'] ?? 0), 2);
$baseTotals[$invoiceId] = $lineBase;
$baseAmount += $lineBase;
}
$discountTotal = round(max(0, $baseAmount - $paidAmount), 2);
if ($discountTotal <= 0 || $baseAmount <= 0) {
return array_fill_keys(array_keys($baseTotals), 0.0);
}
$discounts = [];
$remaining = $discountTotal;
$lastInvoiceId = array_key_last($baseTotals);
foreach ($baseTotals as $invoiceId => $lineBase) {
if ($invoiceId === $lastInvoiceId) {
$lineDiscount = $remaining;
} else {
$lineDiscount = round($discountTotal * ($lineBase / $baseAmount), 2);
$remaining = round($remaining - $lineDiscount, 2);
}
$discounts[$invoiceId] = min($lineBase, max(0, $lineDiscount));
}
return $discounts;
}
// Capture payment via PayPal gateway
try {
@ -160,104 +240,157 @@ if (!$capture['success']) {
}
$txid = $capture['transaction_id'] ?? '';
$paidAmount = round((float)($capture['amount'] ?? 0), 2);
$capture['payment_method'] = 'paypal';
$invoiceIds = cap_invoice_ids_from_custom_id($capture['custom_id'] ?? null);
$invoices = !empty($invoiceIds)
? $repo->getInvoicesForUserByIds($userId, $invoiceIds, true)
: $repo->getUnpaidInvoicesForUser($userId);
$invoicesPaid = 0;
$ordersCreated = 0;
$newOrderIds = [];
$now = date('Y-m-d H:i:s');
$couponId = intval($_SESSION['cart_coupon_id'] ?? 0);
$discountMap = cap_discount_map($invoices, $paidAmount);
$couponCode = trim((string)($_SESSION['cart_coupon_code'] ?? ''));
// Process each unpaid invoice for this user
$invoices = $repo->getUnpaidInvoicesForUser($userId);
$invoicesPaid = 0;
$ordersCreated = 0;
$newOrderIds = [];
$now = date('Y-m-d H:i:s');
if ($couponId <= 0 && $couponCode !== '') {
$coupon = $repo->getCouponByCode($couponCode);
$couponId = intval($coupon['coupon_id'] ?? 0);
}
if (empty($invoices)) {
cap_log('NO_INVOICES', ['user_id' => $userId]);
cap_log('NO_INVOICES', ['user_id' => $userId, 'custom_id' => $capture['custom_id'] ?? null]);
ob_clean();
echo json_encode([
'success' => false,
'error_code' => 'no_matching_invoices',
'message' => 'No matching unpaid invoices were found for this payment.',
'timestamp' => date('c'),
'request_id' => $requestId,
]);
mysqli_close($mysqli);
exit;
}
foreach ($invoices as $inv) {
$invoiceId = intval($inv['invoice_id']);
$homeId = intval($inv['home_id'] ?? 0);
$homeId = intval($inv['home_id'] ?? 0);
$invoiceBase = round((float)($inv['subtotal'] ?? $inv['total_due'] ?? $inv['amount'] ?? 0), 2);
$lineDiscount = round((float)($discountMap[$invoiceId] ?? 0), 2);
$lineTotal = round(max(0, $invoiceBase - $lineDiscount), 2);
$durationMeta = cap_get_duration_metadata($inv);
$result = $svc->processPaymentSuccess($capture, $invoiceId, $userId, $homeId, $inv);
if (!$result['success']) {
cap_log('INVOICE_PAY_FAILED', ['invoice_id' => $invoiceId, 'error' => $result['error'] ?? '']);
$invoiceUpdate = [
'coupon_id' => $couponId,
'discount_amount' => $lineDiscount,
'subtotal' => $invoiceBase,
'amount' => $lineTotal,
'total_due' => $lineTotal,
'status' => 'paid',
'billing_status' => 'Active',
'payment_status' => 'paid',
'payment_txid' => $txid,
'payment_method' => 'paypal',
'paid_date' => $now,
'invoice_duration' => $durationMeta['invoice_duration'],
'rate_type' => $durationMeta['rate_type'],
];
if (!$repo->updateInvoiceFields($invoiceId, $invoiceUpdate)) {
cap_log('INVOICE_PAY_FAILED', ['invoice_id' => $invoiceId, 'db_error' => $mysqli->error]);
continue;
}
$invoicesPaid++;
cap_log('INVOICE_PAID', ['invoice_id' => $invoiceId, 'txid' => $txid]);
cap_log('INVOICE_PAID', ['invoice_id' => $invoiceId, 'txid' => $txid, 'amount' => $lineTotal]);
// Record transaction in billing_transactions (idempotent — skip on duplicate external ID)
$rawCapture = $capture['raw_response'] ?? [];
if (is_array($rawCapture)) {
unset($rawCapture['client_secret'], $rawCapture['access_token']); // never log secrets
}
$repo->logTransaction([
'invoice_id' => $invoiceId,
'user_id' => $userId,
'home_id' => $homeId,
'payment_method' => 'paypal',
'transaction_external_id' => $txid,
'amount' => (float)($inv['amount'] ?? $inv['total_due'] ?? 0),
'currency' => (string)($inv['currency'] ?? 'USD'),
'status' => 'completed',
'raw_response' => $rawCapture,
]);
// Resolve (or create) the billing_orders row for this invoice so the provisioner can run.
// billing_orders.status='Active' is what create_servers.php queries.
$orderId = intval($inv['order_id'] ?? 0);
$durMap = [
'daily' => '+1 day', 'monthly' => '+1 month', 'yearly' => '+1 year',
'day' => '+1 day', 'month' => '+1 month', 'year' => '+1 year',
];
$dur = strtolower($inv['rate_type'] ?? $inv['invoice_duration'] ?? 'month');
$newEnd = date('Y-m-d H:i:s', strtotime($durMap[$dur] ?? '+1 month'));
$currentHomeId = $homeId;
if ($orderId > 0) {
// Existing order linked to this invoice — extend it and mark Active.
$order = $repo->getOrder($orderId);
if ($order) {
$fromTs = (strtotime($order['end_date'] ?? '') > time()) ? strtotime($order['end_date']) : time();
$newEnd = date('Y-m-d H:i:s', strtotime($durMap[$dur] ?? '+1 month', $fromTs));
$repo->extendOrder($orderId, $newEnd, $txid, $now);
$newEnd = cap_get_end_date($inv, $order['end_date'] ?? null);
$currentHomeId = intval($order['home_id'] ?? 0);
$repo->updateOrderFields($orderId, [
'status' => 'Active',
'end_date' => $newEnd,
'payment_txid' => $txid,
'paid_ts' => $now,
'price' => $lineTotal,
'discount_amount' => $lineDiscount,
'coupon_id' => $couponId,
]);
if ($currentHomeId > 0) {
$repo->updateInvoiceFields($invoiceId, ['home_id' => $currentHomeId]);
}
$ordersCreated++;
// Queue for provisioning only if not yet provisioned (home_id still '0' / empty).
$currentHomeId = (string)($order['home_id'] ?? '0');
if ($currentHomeId === '' || $currentHomeId === '0') {
if ($currentHomeId <= 0) {
$newOrderIds[] = $orderId;
cap_log('ORDER_QUEUED_PROVISION', ['order_id' => $orderId]);
}
}
} else {
// No billing_orders row yet — create one now so the provisioner can run.
$newEnd = cap_get_end_date($inv, null);
$newOrderId = $repo->createOrder([
'user_id' => intval($inv['user_id']),
'service_id' => intval($inv['service_id']),
'home_name' => $inv['home_name'] ?? '',
'ip' => (string)($inv['ip'] ?? '0'),
'qty' => intval($inv['qty'] ?? 1),
'invoice_duration' => $inv['invoice_duration'] ?? 'month',
'invoice_duration' => $durationMeta['invoice_duration'],
'max_players' => intval($inv['max_players'] ?? 0),
'price' => (float)($inv['amount'] ?? $inv['total_due'] ?? 0),
'price' => $lineTotal,
'discount_amount' => $lineDiscount,
'remote_control_password' => $inv['remote_control_password'] ?? '',
'ftp_password' => $inv['ftp_password'] ?? '',
'status' => 'Active',
'end_date' => $newEnd,
'payment_txid' => $txid,
'paid_ts' => $now,
'coupon_id' => intval($inv['coupon_id'] ?? 0),
'coupon_id' => $couponId,
]);
if ($newOrderId > 0) {
// Link invoice → order so retried captures are idempotent.
$repo->updateInvoiceOrderId($invoiceId, $newOrderId);
$repo->updateInvoiceFields($invoiceId, ['order_id' => $newOrderId]);
$newOrderIds[] = $newOrderId;
$ordersCreated++;
cap_log('ORDER_CREATED', ['invoice_id' => $invoiceId, 'order_id' => $newOrderId]);
} else {
cap_log('ORDER_CREATE_FAILED', ['invoice_id' => $invoiceId, 'db_error' => $mysqli->error]);
continue;
}
}
$repo->logTransaction([
'invoice_id' => $invoiceId,
'user_id' => $userId,
'home_id' => $currentHomeId,
'payment_method' => 'paypal',
'transaction_external_id' => $txid,
'amount' => $lineTotal,
'currency' => (string)($inv['currency'] ?? 'USD'),
'status' => 'completed',
'raw_response' => $rawCapture,
]);
}
if ($couponId > 0 && $invoicesPaid > 0) {
$mysqli->query("UPDATE `{$prefix}billing_coupons`
SET current_uses = current_uses + 1
WHERE coupon_id = " . intval($couponId));
}
// Auto-provision new servers (orders without a home_id)
@ -278,12 +411,15 @@ if (!empty($newOrderIds)) {
}
}
unset($_SESSION['cart_coupon_code'], $_SESSION['cart_coupon_id']);
mysqli_close($mysqli);
cap_log('COMPLETE', ['invoices_paid' => $invoicesPaid, 'txid' => $txid]);
cap_log('COMPLETE', ['invoices_paid' => $invoicesPaid, 'txid' => $txid, 'orders' => $newOrderIds]);
ob_clean();
echo json_encode([
'success' => true,
'status' => 'COMPLETED',
'txid' => $txid,
'invoices_paid' => $invoicesPaid,

View file

@ -258,19 +258,23 @@ $sandbox = function_exists('gsp_paypal_is_sandbox') ? gsp_paypal_is_sandbox
// Prepare PayPal items
$paypal_items = [];
$paypal_invoice_ids = [];
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);
$paypal_items[] = [
'name' => $inv['home_name'] . ' (' . $game_display . ')',
'description' => $inv['description'] ?? '',
'quantity' => $qty,
'unit_amount' => [
'currency_code' => 'USD',
'value' => number_format(floatval($inv['amount']) / $qty, 2, '.', '')
'value' => number_format($lineAmount / $qty, 2, '.', '')
]
];
}
$paypal_custom_id = 'cart:' . implode(',', $paypal_invoice_ids);
// Get site base URL
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://';
@ -721,6 +725,7 @@ $siteBase = $protocol . $host;
setStatus('Creating order...');
return actions.order.create({
purchase_units: [{
custom_id: '<?php echo htmlspecialchars($paypal_custom_id, ENT_QUOTES, 'UTF-8'); ?>',
amount: {
currency_code: 'USD',
value: '<?php echo number_format($final_amount, 2, '.', ''); ?>',
@ -845,4 +850,3 @@ $siteBase = $protocol . $host;
<?php include(__DIR__ . '/includes/footer.php'); ?>
</body>
</html>

View file

@ -97,17 +97,47 @@ $txid = 'free-' . time() . '-' . $userId;
require_once __DIR__ . '/classes/BillingRepository.php';
require_once __DIR__ . '/classes/BillingService.php';
$repo = new BillingRepository($db, $table_prefix);
$svc = new BillingService($repo);
$repo = new BillingRepository($db, $table_prefix);
$newOrderIds = [];
$duration_meta = static function (array $invoice): array {
$duration = strtolower((string)($invoice['invoice_duration'] ?? $invoice['rate_type'] ?? 'month'));
switch ($duration) {
case 'day':
case 'daily':
return ['invoice_duration' => 'day', 'rate_type' => 'daily', 'days' => 1];
case 'year':
case 'yearly':
return ['invoice_duration' => 'year', 'rate_type' => 'yearly', 'days' => 365];
case 'month':
case 'monthly':
default:
return ['invoice_duration' => 'month', 'rate_type' => 'monthly', 'days' => 31];
}
};
foreach ($invoices as $inv) {
$invoiceId = intval($inv['invoice_id']);
$invoiceBase = round((float)($inv['subtotal'] ?? $inv['total_due'] ?? $inv['amount'] ?? 0), 2);
$orderId = intval($inv['order_id'] ?? 0);
$meta = $duration_meta($inv);
// Mark invoice paid (zero-dollar, method=coupon)
$repo->markInvoicePaid($invoiceId, $txid, 'coupon', $now);
$repo->updateInvoiceFields($invoiceId, [
'order_id' => $orderId,
'coupon_id' => $couponId,
'discount_amount' => $invoiceBase,
'subtotal' => $invoiceBase,
'amount' => 0.00,
'total_due' => 0.00,
'status' => 'paid',
'billing_status' => 'Active',
'payment_status' => 'paid',
'payment_txid' => $txid,
'payment_method' => 'coupon',
'paid_date' => $now,
'invoice_duration' => $meta['invoice_duration'],
'rate_type' => $meta['rate_type'],
]);
// Log a $0 transaction for the audit trail
$repo->logTransaction([
'invoice_id' => $invoiceId,
'user_id' => $userId,
@ -120,40 +150,72 @@ foreach ($invoices as $inv) {
'raw_response' => ['coupon_id' => $couponId, 'discount_pct' => $discountPct, 'original_amount' => (float)($inv['amount'] ?? 0)],
]);
// Increment coupon use counter
if ($couponId > 0) {
mysqli_query($db, "UPDATE {$table_prefix}billing_coupons
SET current_uses = current_uses + 1
WHERE coupon_id = " . intval($couponId));
$currentHomeId = 0;
$extendFrom = null;
if ($orderId > 0) {
$order = $repo->getOrder($orderId);
if ($order) {
$currentHomeId = intval($order['home_id'] ?? 0);
$extendFrom = $order['end_date'] ?? null;
}
}
// Create billing_orders row so the provisioner can run
$durMap = ['daily'=>'+1 day','monthly'=>'+1 month','yearly'=>'+1 year','day'=>'+1 day','month'=>'+1 month','year'=>'+1 year'];
$dur = strtolower($inv['invoice_duration'] ?? 'month');
$endDate = date('Y-m-d H:i:s', strtotime($durMap[$dur] ?? '+1 month'));
$newOrderId = $repo->createOrder([
'user_id' => intval($inv['user_id']),
'service_id' => intval($inv['service_id']),
'home_name' => $inv['home_name'] ?? '',
'ip' => (string)($inv['ip'] ?? '0'),
'qty' => intval($inv['qty'] ?? 1),
'invoice_duration' => $inv['invoice_duration'] ?? 'month',
'max_players' => intval($inv['max_players'] ?? 0),
'price' => 0.00,
'remote_control_password' => $inv['remote_control_password'] ?? '',
'ftp_password' => $inv['ftp_password'] ?? '',
'status' => 'Active',
'end_date' => $endDate,
'payment_txid' => $txid,
'paid_ts' => $now,
'coupon_id' => $couponId,
]);
if ($newOrderId > 0) {
$repo->updateInvoiceOrderId($invoiceId, $newOrderId);
$newOrderIds[] = $newOrderId;
$baseTs = time();
if (!empty($extendFrom)) {
$extendTs = strtotime($extendFrom);
if ($extendTs !== false && $extendTs > time()) {
$baseTs = $extendTs;
}
}
$endDate = date('Y-m-d H:i:s', $baseTs + ($meta['days'] * max(1, intval($inv['qty'] ?? 1)) * 86400));
if ($orderId > 0) {
$repo->updateOrderFields($orderId, [
'status' => 'Active',
'end_date' => $endDate,
'payment_txid' => $txid,
'paid_ts' => $now,
'price' => 0.00,
'discount_amount' => $invoiceBase,
'coupon_id' => $couponId,
]);
if ($currentHomeId > 0) {
$repo->updateInvoiceFields($invoiceId, ['home_id' => $currentHomeId]);
} else {
$newOrderIds[] = $orderId;
}
} else {
$newOrderId = $repo->createOrder([
'user_id' => intval($inv['user_id']),
'service_id' => intval($inv['service_id']),
'home_name' => $inv['home_name'] ?? '',
'ip' => (string)($inv['ip'] ?? '0'),
'qty' => intval($inv['qty'] ?? 1),
'invoice_duration' => $meta['invoice_duration'],
'max_players' => intval($inv['max_players'] ?? 0),
'price' => 0.00,
'discount_amount' => $invoiceBase,
'remote_control_password' => $inv['remote_control_password'] ?? '',
'ftp_password' => $inv['ftp_password'] ?? '',
'status' => 'Active',
'end_date' => $endDate,
'payment_txid' => $txid,
'paid_ts' => $now,
'coupon_id' => $couponId,
]);
if ($newOrderId > 0) {
$repo->updateInvoiceOrderId($invoiceId, $newOrderId);
$repo->updateInvoiceFields($invoiceId, ['order_id' => $newOrderId]);
$newOrderIds[] = $newOrderId;
}
}
}
if ($couponId > 0 && !empty($invoices)) {
mysqli_query($db, "UPDATE {$table_prefix}billing_coupons
SET current_uses = current_uses + 1
WHERE coupon_id = " . intval($couponId));
}
// Clear coupon from session

View file

@ -7,6 +7,7 @@ class BillingRepository
{
private mysqli $db;
private string $prefix;
private array $columnCache = [];
public function __construct(mysqli $db, string $prefix = 'gsp_')
{
@ -47,6 +48,32 @@ class BillingRepository
return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
}
/** Get invoice rows for a specific user and invoice id list. */
public function getInvoicesForUserByIds(int $userId, array $invoiceIds, bool $onlyUnpaid = true): array
{
$invoiceIds = array_values(array_unique(array_filter(array_map('intval', $invoiceIds), static fn($id) => $id > 0)));
if (empty($invoiceIds)) {
return [];
}
$placeholders = implode(',', array_fill(0, count($invoiceIds), '?'));
$types = str_repeat('i', count($invoiceIds) + 1);
$params = array_merge([$userId], $invoiceIds);
$where = $onlyUnpaid ? " AND payment_status IN ('unpaid','due')" : '';
$sql = "SELECT * FROM `{$this->prefix}billing_invoices`
WHERE user_id = ? AND invoice_id IN ({$placeholders}){$where}
ORDER BY invoice_id ASC";
$stmt = $this->db->prepare($sql);
if (!$stmt) {
return [];
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
$rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
return $rows;
}
/** Mark an invoice as paid. Also sets status='paid' so it disappears from cart queries. */
public function markInvoicePaid(int $invoiceId, string $txid, string $method, string $paidAt): bool
{
@ -78,6 +105,7 @@ class BillingRepository
$txid = (string)($data['payment_txid'] ?? '');
$paidTs = (string)($data['paid_ts'] ?? $now);
$couponId = intval($data['coupon_id'] ?? 0);
$discount = (float)($data['discount_amount'] ?? 0);
$ip = (string)($data['ip'] ?? '0');
$qty = intval($data['qty'] ?? 1);
$maxPl = intval($data['max_players'] ?? 0);
@ -88,25 +116,32 @@ class BillingRepository
$invDur = (string)($data['invoice_duration'] ?? 'month');
$rcp = (string)($data['remote_control_password'] ?? '');
$ftp = (string)($data['ftp_password'] ?? '');
$stmt = $this->db->prepare(
"INSERT INTO `{$this->prefix}billing_orders`
(user_id, service_id, home_name, ip, qty, invoice_duration, max_players,
price, remote_control_password, ftp_password, home_id, status,
order_date, end_date, payment_txid, paid_ts, coupon_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '0', ?, ?, ?, ?, ?, ?)"
);
if (!$stmt) return 0;
$stmt->bind_param(
'iissiisdsssssssi',
$userId, $svcId, $homeName, $ip, $qty, $invDur, $maxPl,
$price, $rcp, $ftp,
$status, $now, $endDate, $txid, $paidTs, $couponId
);
if (!$stmt->execute()) { $stmt->close(); return 0; }
$id = (int)$stmt->insert_id;
$stmt->close();
return $id;
$fields = [
'user_id' => $userId,
'service_id' => $svcId,
'home_name' => $homeName,
'ip' => $ip,
'qty' => $qty,
'invoice_duration' => $invDur,
'max_players' => $maxPl,
'price' => $price,
'discount_amount' => $discount,
'remote_control_password' => $rcp,
'ftp_password' => $ftp,
'home_id' => '0',
'status' => $status,
'order_date' => $now,
'end_date' => $endDate,
'payment_txid' => $txid,
'paid_ts' => $paidTs,
'coupon_id' => $couponId,
];
if ($this->hasColumn('billing_orders', 'paypal_data')) {
$fields['paypal_data'] = isset($data['paypal_data'])
? (is_array($data['paypal_data']) ? json_encode($data['paypal_data']) : (string)$data['paypal_data'])
: null;
}
return $this->insertAssoc('billing_orders', $fields);
}
/**
@ -240,6 +275,24 @@ class BillingRepository
public function logTransaction(array $data): int
{
$this->ensureBillingTransactionsTable();
$invoiceId = intval($data['invoice_id'] ?? 0);
$extId = (string)($data['transaction_external_id'] ?? '');
if ($invoiceId > 0 && $extId !== '') {
$existing = $this->db->prepare(
"SELECT transaction_id FROM `{$this->prefix}billing_transactions`
WHERE invoice_id = ? AND transaction_external_id = ?
LIMIT 1"
);
if ($existing) {
$existing->bind_param('is', $invoiceId, $extId);
$existing->execute();
$row = $existing->get_result()->fetch_assoc();
$existing->close();
if (!empty($row['transaction_id'])) {
return (int)$row['transaction_id'];
}
}
}
$stmt = $this->db->prepare(
"INSERT INTO `{$this->prefix}billing_transactions`
(invoice_id, user_id, home_id, payment_method, transaction_external_id,
@ -248,11 +301,9 @@ class BillingRepository
);
if (!$stmt) return 0;
$rawJson = is_array($data['raw_response']) ? json_encode($data['raw_response']) : (string)($data['raw_response'] ?? '');
$invoiceId = intval($data['invoice_id'] ?? 0);
$userId = intval($data['user_id'] ?? 0);
$homeId = intval($data['home_id'] ?? 0);
$method = (string)($data['payment_method'] ?? 'paypal');
$extId = (string)($data['transaction_external_id'] ?? '');
$amount = (float)($data['amount'] ?? 0);
$currency = (string)($data['currency'] ?? 'USD');
$status = (string)($data['status'] ?? 'completed');
@ -494,4 +545,130 @@ class BillingRepository
$stmt->close();
return $ok;
}
public function getCouponByCode(string $couponCode): ?array
{
$stmt = $this->db->prepare(
"SELECT * FROM `{$this->prefix}billing_coupons`
WHERE code = ? AND is_active = 1
LIMIT 1"
);
if (!$stmt) {
return null;
}
$stmt->bind_param('s', $couponCode);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
return $row ?: null;
}
public function updateInvoiceFields(int $invoiceId, array $data): bool
{
return $this->updateAssoc('billing_invoices', 'invoice_id', $invoiceId, $data);
}
public function updateOrderFields(int $orderId, array $data): bool
{
return $this->updateAssoc('billing_orders', 'order_id', $orderId, $data);
}
private function hasColumn(string $table, string $column): bool
{
$cacheKey = $table . '.' . $column;
if (array_key_exists($cacheKey, $this->columnCache)) {
return $this->columnCache[$cacheKey];
}
$tableName = $this->db->real_escape_string($this->prefix . $table);
$columnName = $this->db->real_escape_string($column);
$res = $this->db->query(
"SELECT COUNT(*) AS cnt
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$tableName}'
AND COLUMN_NAME = '{$columnName}'"
);
$exists = $res ? ((int)($res->fetch_assoc()['cnt'] ?? 0) > 0) : false;
$this->columnCache[$cacheKey] = $exists;
return $exists;
}
private function insertAssoc(string $table, array $data): int
{
if (empty($data)) {
return 0;
}
$columns = array_keys($data);
$placeholders = implode(',', array_fill(0, count($columns), '?'));
$sql = sprintf(
"INSERT INTO `%s%s` (%s) VALUES (%s)",
$this->prefix,
$table,
implode(',', array_map(static fn($field) => "`{$field}`", $columns)),
$placeholders
);
$stmt = $this->db->prepare($sql);
if (!$stmt) {
return 0;
}
[$types, $values] = $this->prepareBindValues($data);
$stmt->bind_param($types, ...$values);
if (!$stmt->execute()) {
$stmt->close();
return 0;
}
$id = (int)$stmt->insert_id;
$stmt->close();
return $id;
}
private function updateAssoc(string $table, string $idColumn, int $idValue, array $data): bool
{
$data = array_filter($data, static fn($value) => $value !== null);
if (empty($data)) {
return true;
}
$set = [];
foreach (array_keys($data) as $field) {
$set[] = "`{$field}` = ?";
}
$sql = sprintf(
"UPDATE `%s%s` SET %s WHERE `%s` = ? LIMIT 1",
$this->prefix,
$table,
implode(', ', $set),
$idColumn
);
$stmt = $this->db->prepare($sql);
if (!$stmt) {
return false;
}
[$types, $values] = $this->prepareBindValues($data);
$types .= 'i';
$values[] = $idValue;
$stmt->bind_param($types, ...$values);
$ok = $stmt->execute();
$stmt->close();
return $ok;
}
private function prepareBindValues(array $data): array
{
$types = '';
$values = [];
foreach ($data as $value) {
if (is_int($value)) {
$types .= 'i';
$values[] = $value;
} elseif (is_float($value)) {
$types .= 'd';
$values[] = $value;
} else {
$types .= 's';
$values[] = ($value === null) ? null : (string)$value;
}
}
return [$types, $values];
}
}

View file

@ -2,6 +2,17 @@
require_once __DIR__ . '/../../includes/lib_remote.php';
require_once __DIR__ . '/../config_games/server_config_parser.php';
if (!function_exists('billing_generate_provision_password')) {
function billing_generate_provision_password(int $bytes = 12)
{
try {
return substr(bin2hex(random_bytes($bytes)), 0, $bytes * 2);
} catch (Throwable $e) {
return substr(hash('sha256', uniqid('gsp-provision', true) . microtime(true)), 0, $bytes * 2);
}
}
}
if (!function_exists('billing_invoke_provision')) {
function billing_invoke_provision(array $options = array())
{
@ -18,7 +29,8 @@ if (!function_exists('billing_invoke_provision')) {
function exec_ogp_module()
{
global $db,$view,$settings;
global $db,$view,$settings,$table_prefix;
$db_prefix = isset($table_prefix) ? $table_prefix : '';
// $now is used in multiple branches below — define it once here so it is
// always a string that date() / strtotime() can handle safely (PHP 8 fix).
@ -48,9 +60,9 @@ function exec_ogp_module()
// 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='Active' AND (home_id='0' OR home_id='') ORDER BY order_id" );
$orders = $db->resultQuery( "SELECT * FROM `{$db_prefix}billing_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='Active' AND (home_id='0' OR home_id='') ORDER BY order_id" );
$orders = $db->resultQuery( "SELECT * FROM `{$db_prefix}billing_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 +74,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='Active'" );
$orders = $db->resultQuery( "SELECT * FROM `{$db_prefix}billing_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='Active'" );
$orders = $db->resultQuery( "SELECT * FROM `{$db_prefix}billing_orders` WHERE order_id IN ($idList) AND user_id=".$db->realEscapeSingle($user_id)." AND status='Active'" );
}
}
$processed_orders = array();
@ -75,19 +87,28 @@ function exec_ogp_module()
foreach ((array)$orders as $order)
{
$end_date = null;
$end_date_str = null;
$order_id = $order['order_id'];
$processed_orders[] = intval($order_id);
$service_id = $order['service_id'];
$home_name = $order['home_name'];
$remote_control_password = $order['remote_control_password'];
$ftp_password = $order['ftp_password'];
if ($remote_control_password === '' || strcasecmp((string)$remote_control_password, 'ChangeMe') === 0) {
$remote_control_password = billing_generate_provision_password();
}
if ($ftp_password === '' || strcasecmp((string)$ftp_password, 'ChangeMe') === 0) {
$ftp_password = billing_generate_provision_password();
}
$ip = $order['ip'];
$max_players = $order['max_players'];
$user_id = $order['user_id'];
$extended = isset($order['extended']) && $order['extended'] == "1" ? TRUE : FALSE;
$alreadyProvisioned = !$extended && intval($order['home_id'] ?? 0) > 0;
//Query service info
$service = $db->resultQuery( "SELECT *
FROM OGP_DB_PREFIXbilling_services
FROM `{$db_prefix}billing_services`
WHERE service_id=".$db->realEscapeSingle($service_id) );
if( !empty( $service[0] ) )
@ -106,7 +127,11 @@ function exec_ogp_module()
else
return;
if($extended)
if($alreadyProvisioned)
{
$home_id = intval($order['home_id']);
}
elseif($extended)
{
$home_id = $order['home_id'];
@ -167,7 +192,7 @@ function exec_ogp_module()
//Add IP:Port Pair to the Game Home
//need to get the IP_ID for this remote server.
$result = $db->resultQuery("SELECT ip_id FROM OGP_DB_PREFIXremote_server_ips WHERE remote_server_id=".$ip);
$result = $db->resultQuery("SELECT ip_id FROM `{$db_prefix}remote_server_ips` WHERE remote_server_id=".$ip);
foreach ((array)$result as $rs)
{
$ip_id = $rs['ip_id'];
@ -290,7 +315,15 @@ function exec_ogp_module()
// 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")
if ($alreadyProvisioned)
{
$existing_end = strtotime((string)($order['end_date'] ?? ''));
if ($existing_end === false || $existing_end <= 0) {
$existing_end = time();
}
$end_date_str = date('Y-m-d H:i:s', $existing_end);
}
elseif ($order['invoice_duration'] == "day")
{
if(empty($order['end_date']) || $order['end_date'] === NULL){
@ -310,7 +343,7 @@ function exec_ogp_module()
{
// this is a new order
if(empty($order['end_date']) || $order['end_date'] === NULL){
$end_date = strtotime('+'.$order['qty'].' month');
$end_date = strtotime('+'.(intval($order['qty']) * 31).' day');
}
else{
@ -319,7 +352,7 @@ function exec_ogp_module()
if ($current_end === false) {
$current_end = time(); // fallback to now if date is invalid
}
$end_date = strtotime('+'.$order['qty'].' month', $current_end);
$end_date = strtotime('+'.(intval($order['qty']) * 31).' day', $current_end);
}
}
elseif ($order['invoice_duration'] == "year")
@ -339,24 +372,37 @@ function exec_ogp_module()
}
}
$end_date_str = date('Y-m-d H:i:s', $end_date);
if (!isset($end_date_str)) {
$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
$db->query("UPDATE `{$db_prefix}billing_orders`
SET status='Active'
WHERE order_id=".$db->realEscapeSingle($order_id));
// Set the order expiration / next renewal date
$db->query("UPDATE OGP_DB_PREFIXbilling_orders
SET end_date='" . $db->realEscapeSingle($end_date_str) . "'
$db->query("UPDATE `{$db_prefix}billing_orders`
SET end_date='" . $db->realEscapeSingle($end_date_str) . "',
remote_control_password='" . $db->realEscapeSingle($remote_control_password) . "',
ftp_password='" . $db->realEscapeSingle($ftp_password) . "'
WHERE order_id=".$db->realEscapeSingle($order_id));
// Save home_id created by this order
$db->query("UPDATE OGP_DB_PREFIXbilling_orders
$db->query("UPDATE `{$db_prefix}billing_orders`
SET home_id='" . $db->realEscapeSingle($home_id) . "' WHERE order_id=".$db->realEscapeSingle($order_id));
$db->query("UPDATE `{$db_prefix}billing_invoices`
SET home_id=" . $db->realEscapeSingle($home_id) . ",
billing_status='Active'
WHERE order_id=" . $db->realEscapeSingle($order_id));
$db->query("UPDATE `{$db_prefix}billing_transactions`
SET home_id=" . $db->realEscapeSingle($home_id) . "
WHERE invoice_id IN (SELECT invoice_id FROM `{$db_prefix}billing_invoices` WHERE order_id=" . $db->realEscapeSingle($order_id) . ")");
// Set billing_status and next_invoice_date on server_homes
$db->query("UPDATE OGP_DB_PREFIXserver_homes
$db->query("UPDATE `{$db_prefix}server_homes`
SET billing_status = 'Active',
next_invoice_date = '" . $db->realEscapeSingle($end_date_str) . "',
billing_enabled = 1
@ -366,7 +412,7 @@ function exec_ogp_module()
}
$db->query( "UPDATE OGP_DB_PREFIXgame_mods SET max_players= ".$order['max_players']." WHERE home_id=".$db->realEscapeSingle($home_id));
$db->query( "UPDATE `{$db_prefix}game_mods` SET max_players= ".$order['max_players']." WHERE home_id=".$db->realEscapeSingle($home_id));
// Show results and redirect
if ($provisioned_count > 0) {
@ -401,5 +447,3 @@ function exec_ogp_module()
?>

View file

@ -25,7 +25,7 @@
// Module general information
$module_title = "billing";
$module_version = "3.4";
$db_version = 4;
$db_version = 5;
$module_required = FALSE;
// Module description
$module_description = "Billing storefront / provisioning integration. Public ordering runs as a standalone site; panel pages provide provisioning and admin order management.";
@ -203,8 +203,8 @@ $install_queries[1] = array(
KEY `enabled` (`enabled`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;",
// Drop legacy mapping table if it still exists from older installs
"DROP TABLE IF EXISTS `".OGP_DB_PREFIX."billing_service_remote_servers`"
// Legacy mapping table is handled by a later idempotent migration.
"SELECT 1"
);
// -----------------------------------------------------------------------
@ -368,4 +368,22 @@ $install_queries[4] = array(
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"
);
// -----------------------------------------------------------------------
// db_version 5 — Preserve the unused legacy service/node mapping table by
// renaming it to a *_deprecated_backup table instead of dropping it.
// -----------------------------------------------------------------------
$install_queries[5] = array(
function($db) {
$legacy = 'OGP_DB_PREFIXbilling_service_remote_servers';
$backup = 'OGP_DB_PREFIXbilling_service_remote_servers_deprecated_backup';
$legacyCheck = $db->resultQuery("SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$legacy}'");
if (!$legacyCheck || empty($legacyCheck[0]['cnt']) || (int)$legacyCheck[0]['cnt'] === 0) return true;
$backupCheck = $db->resultQuery("SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$backup}'");
if ($backupCheck && !empty($backupCheck[0]['cnt']) && (int)$backupCheck[0]['cnt'] > 0) return true;
return (bool)$db->query("RENAME TABLE `{$legacy}` TO `{$backup}`");
}
);
?>

View file

@ -185,8 +185,8 @@ if ($row['price_monthly'] == 0.0) {
<table class="float-left">
<form method="post" action="add_to_cart.php">
<input type="hidden" name="service_id" size="15" value="<?php echo intval($_REQUEST['service_id'] ?? $row['service_id'] ?? 0); ?>">
<input type="hidden" name="remote_control_password" size="15" value="ChangeMe">
<input type="hidden" name="ftp_password" size="15" value="ChangeMe">
<input type="hidden" name="remote_control_password" size="15" value="">
<input type="hidden" name="ftp_password" size="15" value="">
<tr>
<td align="right"><b>Game Server Name</b> </td>
<td align="left">
@ -269,7 +269,7 @@ if ($row['price_monthly'] == 0.0) {
slider.oninput = function() {
output.innerHTML = this.value;
invoiceDuration.innerHTML = "Duration: "+invoiceslider.value+" months";
totalvalue = invoiceslider.value * <?php echo number_format($row['price_monthly'],2);?>;
totalvalue = slider.value * invoiceslider.value * <?php echo number_format($row['price_monthly'],2);?>;
price.innerHTML = "Total Price: $"+totalvalue.toFixed(2) ;
}
invoiceslider.oninput = function() {

View file

@ -428,6 +428,27 @@ function wh_fetch_paypal_order(string $api_base, string $access_token, string $o
* Match the PayPal capture to a billing invoice, mark it paid, create/extend billing_orders,
* and trigger server provisioning. Returns the billing_order_id or 0.
*/
function wh_invoice_ids_from_custom_id($custom_id): array
{
if (!is_string($custom_id) || $custom_id === '') {
return [];
}
if (ctype_digit($custom_id)) {
return [intval($custom_id)];
}
if (stripos($custom_id, 'cart:') !== 0) {
return [];
}
$invoice_ids = [];
foreach (explode(',', substr($custom_id, 5)) as $part) {
$part = trim($part);
if ($part !== '' && ctype_digit($part)) {
$invoice_ids[] = intval($part);
}
}
return array_values(array_unique($invoice_ids));
}
function wh_fulfill_payment(mysqli $db, string $pfx, array $payment, string $billing_dir = ''): int
{
$txid = $payment['capture_id'] ?? '';
@ -441,7 +462,17 @@ function wh_fulfill_payment(mysqli $db, string $pfx, array $payment, string $bil
$invoices = [];
// 1) Match by numeric custom_id (which we set to invoice_id when creating the PayPal order)
if (!empty($custom_id) && ctype_digit((string)$custom_id)) {
$custom_invoice_ids = wh_invoice_ids_from_custom_id($custom_id);
if (!empty($custom_invoice_ids)) {
$id_list = implode(',', array_map('intval', $custom_invoice_ids));
$res = mysqli_query($db, "SELECT * FROM `{$pfx}billing_invoices` WHERE invoice_id IN ({$id_list}) AND status = 'due' ORDER BY invoice_id ASC");
if ($res) {
while ($row = mysqli_fetch_assoc($res)) {
$invoices[] = $row;
}
}
}
elseif (!empty($custom_id) && ctype_digit((string)$custom_id)) {
$inv_id = intval($custom_id);
$res = mysqli_query($db, "SELECT * FROM `{$pfx}billing_invoices` WHERE invoice_id = {$inv_id} AND status = 'due' LIMIT 1");
if ($res && $row = mysqli_fetch_assoc($res)) {
@ -477,6 +508,7 @@ function wh_fulfill_payment(mysqli $db, string $pfx, array $payment, string $bil
}
$last_order_id = 0;
$applied_coupon_id = 0;
foreach ($invoices as $inv) {
$invoice_id = intval($inv['invoice_id']);
@ -497,15 +529,14 @@ function wh_fulfill_payment(mysqli $db, string $pfx, array $payment, string $bil
// Increment coupon usage if applicable
$coupon_id = intval($inv['coupon_id'] ?? 0);
if ($coupon_id > 0) {
mysqli_query($db, "UPDATE `{$pfx}billing_coupons` SET current_uses = current_uses + 1 WHERE coupon_id = {$coupon_id}");
$applied_coupon_id = $coupon_id;
}
// Duration → months
$months = 1;
if (stripos($duration, 'year') !== false) {
$months = $qty * 12;
} else {
$months = $qty;
$duration_days = 31 * $qty;
if (stripos($duration, 'day') !== false) {
$duration_days = $qty;
} elseif (stripos($duration, 'year') !== false) {
$duration_days = 365 * $qty;
}
if ($order_id > 0) {
@ -515,7 +546,7 @@ function wh_fulfill_payment(mysqli $db, string $pfx, array $payment, string $bil
$current_end = $row['end_date'] ?? $now;
$extend_from = (strtotime($current_end) > time()) ? $current_end : $now;
$dt = new DateTime($extend_from);
$dt->modify('+' . $months . ' months');
$dt->modify('+' . $duration_days . ' days');
$new_end = $dt->format('Y-m-d H:i:s');
$stmt = mysqli_prepare($db, "UPDATE `{$pfx}billing_orders` SET end_date=?, status='Active', payment_txid=?, paid_ts=? WHERE order_id=? LIMIT 1");
@ -530,7 +561,7 @@ function wh_fulfill_payment(mysqli $db, string $pfx, array $payment, string $bil
} else {
// New order: create billing_orders row
$dt = new DateTime($now);
$dt->modify('+' . $months . ' months');
$dt->modify('+' . $duration_days . ' days');
$end_date = $dt->format('Y-m-d H:i:s');
$invoice_amount = floatval($inv['amount'] ?? $inv['total_due'] ?? 0);
$price = number_format($invoice_amount, 2, '.', '');
@ -572,6 +603,10 @@ function wh_fulfill_payment(mysqli $db, string $pfx, array $payment, string $bil
}
}
if ($applied_coupon_id > 0) {
mysqli_query($db, "UPDATE `{$pfx}billing_coupons` SET current_uses = current_uses + 1 WHERE coupon_id = {$applied_coupon_id}");
}
return $last_order_id;
}

View file

@ -116,8 +116,8 @@ include(__DIR__ . '/includes/menu.php');
<!-- Order Form -->
<form method="post" action="order_server.php">
<input type="hidden" name="service_id" value="<?php echo $row['service_id']; ?>">
<input type="hidden" name="remote_control_password" value="ChangeMe">
<input type="hidden" name="ftp_password" value="ChangeMe">
<input type="hidden" name="remote_control_password" value="">
<input type="hidden" name="ftp_password" value="">
<table class="float-left">
<tr>
<td align="right"><b>Game Server Name</b></td>

View file

@ -164,7 +164,9 @@ function exec_ogp_module()
0, // max_players — set later via edit_home
$access_rights,
$ftp,
$new_home_id
$new_home_id,
$control_password,
$ftppassword
);
$view->refresh("?m=user_games&amp;p=edit&amp;home_id=$new_home_id", 0);

View file

@ -45,7 +45,9 @@ if (!function_exists('admin_register_server_in_billing')) {
$max_players,
$access_rights,
$ftp,
$home_id
$home_id,
$remote_control_password = '',
$ftp_password = ''
) {
// ------------------------------------------------------------------ //
// 1. Resolve service_id: find an existing billing_service matching //
@ -76,7 +78,7 @@ if (!function_exists('admin_register_server_in_billing')) {
}
$now = date('Y-m-d H:i:s');
$end_date = date('Y-m-d H:i:s', strtotime('+1 year'));
$end_date = date('Y-m-d H:i:s', strtotime('+31 days'));
$ftp_flag = $ftp ? 'enabled' : 'disabled';
// ------------------------------------------------------------------ //
@ -90,24 +92,34 @@ if (!function_exists('admin_register_server_in_billing')) {
'order_id' => 0,
'user_id' => intval($user_id),
'service_id' => $service_id,
'home_id' => intval($home_id),
'home_name' => $home_name,
'ip' => intval($rserver_id),
'max_players' => intval($max_players),
'remote_control_password' => '',
'ftp_password' => '',
'remote_control_password' => $remote_control_password,
'ftp_password' => $ftp_password,
'customer_name' => $customer_name,
'customer_email' => $customer_email,
'amount' => '0.00',
'discount_amount' => '0.00',
'currency' => 'USD',
'status' => 'paid',
'billing_status' => 'Active',
'invoice_date' => $now,
'due_date' => $now,
'paid_date' => $now,
'payment_txid' => 'admin-created',
'payment_method' => 'admin',
'description' => 'Admin-created server: ' . $home_name,
'invoice_duration' => 'year',
'invoice_duration' => 'month',
'rate_type' => 'monthly',
'rate_per_player' => '0.0000',
'players' => intval($max_players),
'period_start' => $now,
'period_end' => $end_date,
'subtotal' => '0.00',
'total_due' => '0.00',
'payment_status' => 'paid',
'qty' => 1,
);
@ -125,12 +137,12 @@ if (!function_exists('admin_register_server_in_billing')) {
'home_name' => $home_name,
'ip' => intval($rserver_id),
'qty' => 1,
'invoice_duration' => 'year',
'invoice_duration' => 'month',
'max_players' => intval($max_players),
'price' => '0.00',
'discount_amount' => '0.00',
'remote_control_password' => '',
'ftp_password' => '',
'remote_control_password' => $remote_control_password,
'ftp_password' => $ftp_password,
'home_id' => intval($home_id),
'status' => 'Active',
'order_date' => $now,

View file

@ -90,9 +90,9 @@ function exec_ogp_module()
echo empty($row['home_name']) ? get_lang('not_available') : htmlentities($row['home_name']);
$expiration_date = $row['server_expiration_date'] == "X" ? "X" : date('d/m/Y H:i:s', is_numeric($row['server_expiration_date']) ? (int)$row['server_expiration_date'] : strtotime($row['server_expiration_date']));
echo "</td><td>".$expiration_date."</td><td>
<a href='?m=user_games&amp;p=del&amp;home_id=$row[home_id]'>[".get_lang('delete')."]</a>
<a href='?m=user_games&amp;p=edit&amp;home_id=$row[home_id]'>[".get_lang('edit')."]</a>
<a href='?m=user_games&amp;p=migrate&amp;home_id=$row[home_id]'>[".get_lang('migrate')."]</a>
<a class='btn btn-danger btn-xs' href='?m=user_games&amp;p=del&amp;home_id=$row[home_id]'>".get_lang('delete')."</a>
<a class='btn btn-primary btn-xs' href='?m=user_games&amp;p=edit&amp;home_id=$row[home_id]'>".get_lang('edit')."</a>
<a class='btn btn-info btn-xs' href='?m=user_games&amp;p=migrate&amp;home_id=$row[home_id]'>".get_lang('migrate')."</a>
</td></tr>";
}