From 439e57b3339f33267ba273e9bb6e3c96fc3aa2ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 22:44:53 +0000 Subject: [PATCH 1/2] Fix billing provisioning and admin defaults Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/1e47877f-c80e-4514-bdff-2bd022c84f13 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- .github/module-map.md | 4 +- CHANGELOG.md | 5 + modules/billing/add_to_cart.php | 158 ++++++++++--- modules/billing/api/capture_order.php | 218 +++++++++++++---- modules/billing/cart.php | 8 +- modules/billing/checkout_free.php | 134 ++++++++--- modules/billing/classes/BillingRepository.php | 219 ++++++++++++++++-- modules/billing/create_servers.php | 84 +++++-- modules/billing/module.php | 24 +- modules/billing/order.php | 6 +- modules/billing/paypal/webhook.php | 55 ++++- modules/billing/serverlist.php | 4 +- modules/user_games/add_home.php | 4 +- modules/user_games/billing_integration.php | 28 ++- modules/user_games/show_homes.php | 6 +- 15 files changed, 772 insertions(+), 185 deletions(-) diff --git a/.github/module-map.md b/.github/module-map.md index 87a9e7b3..1b19935c 100644 --- a/.github/module-map.md +++ b/.github/module-map.md @@ -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. | diff --git a/CHANGELOG.md b/CHANGELOG.md index bef9b644..e2ab0265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/modules/billing/add_to_cart.php b/modules/billing/add_to_cart.php index efb75ad8..5278a690 100644 --- a/modules/billing/add_to_cart.php +++ b/modules/billing/add_to_cart.php @@ -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( + 'iisisssssdsssssdissssddsi', + $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; diff --git a/modules/billing/api/capture_order.php b/modules/billing/api/capture_order.php index 588bfca6..29014a38 100644 --- a/modules/billing/api/capture_order.php +++ b/modules/billing/api/capture_order.php @@ -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_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_end_date(array $invoice, ?string $fromDate = null): string { + $meta = cap_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_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_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_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, diff --git a/modules/billing/cart.php b/modules/billing/cart.php index bf862c0e..93e59523 100644 --- a/modules/billing/cart.php +++ b/modules/billing/cart.php @@ -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: '', amount: { currency_code: 'USD', value: '', @@ -845,4 +850,3 @@ $siteBase = $protocol . $host; - diff --git a/modules/billing/checkout_free.php b/modules/billing/checkout_free.php index da0af1b7..f0f11b56 100644 --- a/modules/billing/checkout_free.php +++ b/modules/billing/checkout_free.php @@ -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 = []; +$durationMeta = 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 = $durationMeta($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 diff --git a/modules/billing/classes/BillingRepository.php b/modules/billing/classes/BillingRepository.php index de859970..be19be85 100644 --- a/modules/billing/classes/BillingRepository.php +++ b/modules/billing/classes/BillingRepository.php @@ -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]; + } } diff --git a/modules/billing/create_servers.php b/modules/billing/create_servers.php index bf3027d1..963832af 100644 --- a/modules/billing/create_servers.php +++ b/modules/billing/create_servers.php @@ -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() ?> - - diff --git a/modules/billing/module.php b/modules/billing/module.php index 90913412..1c737175 100644 --- a/modules/billing/module.php +++ b/modules/billing/module.php @@ -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}`"); + } +); + ?> diff --git a/modules/billing/order.php b/modules/billing/order.php index 96d86381..14b03283 100644 --- a/modules/billing/order.php +++ b/modules/billing/order.php @@ -185,8 +185,8 @@ if ($row['price_monthly'] == 0.0) { - - + +
Game Server Name @@ -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 * ; + totalvalue = slider.value * invoiceslider.value * ; price.innerHTML = "Total Price: $"+totalvalue.toFixed(2) ; } invoiceslider.oninput = function() { diff --git a/modules/billing/paypal/webhook.php b/modules/billing/paypal/webhook.php index 2681b351..883669f7 100644 --- a/modules/billing/paypal/webhook.php +++ b/modules/billing/paypal/webhook.php @@ -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; } diff --git a/modules/billing/serverlist.php b/modules/billing/serverlist.php index 9491a5cc..b235b642 100644 --- a/modules/billing/serverlist.php +++ b/modules/billing/serverlist.php @@ -116,8 +116,8 @@ include(__DIR__ . '/includes/menu.php'); - - + + diff --git a/modules/user_games/add_home.php b/modules/user_games/add_home.php index f031f2b3..4650fd03 100644 --- a/modules/user_games/add_home.php +++ b/modules/user_games/add_home.php @@ -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&p=edit&home_id=$new_home_id", 0); diff --git a/modules/user_games/billing_integration.php b/modules/user_games/billing_integration.php index 08b2cb2b..a2367d75 100644 --- a/modules/user_games/billing_integration.php +++ b/modules/user_games/billing_integration.php @@ -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, diff --git a/modules/user_games/show_homes.php b/modules/user_games/show_homes.php index c02b5d7c..38a87e52 100644 --- a/modules/user_games/show_homes.php +++ b/modules/user_games/show_homes.php @@ -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 ""; } From d3ba167d414376b7b3647f2bdf85724ed70b3991 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 22:46:48 +0000 Subject: [PATCH 2/2] Fix billing review follow-ups Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/1e47877f-c80e-4514-bdff-2bd022c84f13 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- modules/billing/add_to_cart.php | 2 +- modules/billing/api/capture_order.php | 12 ++++++------ modules/billing/checkout_free.php | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/modules/billing/add_to_cart.php b/modules/billing/add_to_cart.php index 5278a690..74313637 100644 --- a/modules/billing/add_to_cart.php +++ b/modules/billing/add_to_cart.php @@ -215,7 +215,7 @@ if ($stmt) { $invoice_duration = $durationInfo['invoice_duration']; $rate_type = $durationInfo['rate_type']; $stmt->bind_param( - 'iisisssssdsssssdissssddsi', + 'iisiissssdsssssssdissddsi', $esc_user_id, $esc_service_id, $home_name, diff --git a/modules/billing/api/capture_order.php b/modules/billing/api/capture_order.php index 29014a38..1d5f6a68 100644 --- a/modules/billing/api/capture_order.php +++ b/modules/billing/api/capture_order.php @@ -123,7 +123,7 @@ function cap_invoice_ids_from_custom_id(?string $customId): array { return array_values(array_unique($invoiceIds)); } -function cap_duration_metadata(array $invoice): array { +function cap_get_duration_metadata(array $invoice): array { $duration = strtolower((string)($invoice['invoice_duration'] ?? $invoice['rate_type'] ?? 'month')); switch ($duration) { case 'day': @@ -139,8 +139,8 @@ function cap_duration_metadata(array $invoice): array { } } -function cap_end_date(array $invoice, ?string $fromDate = null): string { - $meta = cap_duration_metadata($invoice); +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)) { @@ -279,7 +279,7 @@ foreach ($invoices as $inv) { $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_duration_metadata($inv); + $durationMeta = cap_get_duration_metadata($inv); $invoiceUpdate = [ 'coupon_id' => $couponId, @@ -319,7 +319,7 @@ foreach ($invoices as $inv) { // Existing order linked to this invoice — extend it and mark Active. $order = $repo->getOrder($orderId); if ($order) { - $newEnd = cap_end_date($inv, $order['end_date'] ?? null); + $newEnd = cap_get_end_date($inv, $order['end_date'] ?? null); $currentHomeId = intval($order['home_id'] ?? 0); $repo->updateOrderFields($orderId, [ 'status' => 'Active', @@ -342,7 +342,7 @@ foreach ($invoices as $inv) { } } else { // No billing_orders row yet — create one now so the provisioner can run. - $newEnd = cap_end_date($inv, null); + $newEnd = cap_get_end_date($inv, null); $newOrderId = $repo->createOrder([ 'user_id' => intval($inv['user_id']), 'service_id' => intval($inv['service_id']), diff --git a/modules/billing/checkout_free.php b/modules/billing/checkout_free.php index f0f11b56..a6937247 100644 --- a/modules/billing/checkout_free.php +++ b/modules/billing/checkout_free.php @@ -99,7 +99,7 @@ require_once __DIR__ . '/classes/BillingService.php'; $repo = new BillingRepository($db, $table_prefix); $newOrderIds = []; -$durationMeta = static function (array $invoice): array { +$duration_meta = static function (array $invoice): array { $duration = strtolower((string)($invoice['invoice_duration'] ?? $invoice['rate_type'] ?? 'month')); switch ($duration) { case 'day': @@ -119,7 +119,7 @@ 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 = $durationMeta($inv); + $meta = $duration_meta($inv); $repo->updateInvoiceFields($invoiceId, [ 'order_id' => $orderId,
Game Server Name".$expiration_date." - [".get_lang('delete')."] - [".get_lang('edit')."] - [".get_lang('migrate')."] + ".get_lang('delete')." + ".get_lang('edit')." + ".get_lang('migrate')."