diff --git a/Panel/modules/website/README.md b/Panel/modules/website/README.md index 459f3fa4..42e91cad 100644 --- a/Panel/modules/website/README.md +++ b/Panel/modules/website/README.md @@ -15,15 +15,42 @@ This module is the public Gameservers.World sales and documentation website. Panel/modules/website/ index.php serverlist.php + order.php cart.php + checkout.php + payment_success.php + payment_cancel.php docs.php login.php + register.php + forgot_password.php + reset_password.php + account.php + orders.php + invoices.php + my_servers.php + staff.php + staff_services.php + staff_locations.php + staff_coupons.php + staff_orders.php + staff_invoices.php + staff_payments.php + staff_paypal.php + staff_settings.php + staff_provisioning.php + staff_migrations.php + webhook.php pricing.php locations.php support.php doc_asset.php + api/ + create_order.php + capture_order.php includes/ bootstrap.php + billing.php footer.php header.php navigation.php @@ -57,7 +84,7 @@ The website uses a central bootstrap instead of scattered relative paths. - `website_order_url(1)` - `documentation_url('minecraft')` -## Billing and database behavior +## Billing, database, and staff behavior The public site does not include `Panel/modules/billing/includes/config.inc.php` directly. @@ -74,6 +101,20 @@ Effects: - `serverlist.php` shows a clean fallback message instead of a fatal include error - shared navigation never crashes because billing config is missing +Sales and billing tables are installed through the staff migration runner: + +- `staff_migrations.php` + +The runner is idempotent and uses the current configured Panel table prefix. It +creates invoice, order, payment, coupon, password-reset, PayPal webhook, website +settings, and provisioning-attempt tables when missing. It also adds missing +catalog columns to an existing `billing_services` table. + +Website staff pages are separate from GSP Panel administration. They manage +website sales, catalog, pricing, coupons, invoices, payments, PayPal settings, +and the paid-order provisioning queue. Access currently requires a Panel admin +account. + ## Shared Accounts The Panel user table is the identity source for the website. Website login checks @@ -106,6 +147,23 @@ and customer confirmation. Payment approval and final server provisioning remain server-side responsibilities; browser requests must not call private provisioning methods directly. +Checkout creates invoice/order records only after login or registration. The +anonymous cart remains in the website session until that point. PayPal order +creation and capture are handled through `api/create_order.php` and +`api/capture_order.php`; `webhook.php` verifies PayPal webhook signatures and +deduplicates events. + +PayPal credentials must not be committed to source. Preferred runtime settings: + +- `GSP_WEBSITE_PAYPAL_CLIENT_ID` +- `GSP_WEBSITE_PAYPAL_CLIENT_SECRET` +- `GSP_WEBSITE_PAYPAL_WEBHOOK_ID` + +The staff PayPal page can store fallback settings in `website_settings`, but +secrets are masked after save. + +More detail: `docs/modules/website_billing_rebuild.md`. + ## Documentation source Customer documentation is read from the existing billing docs directory: diff --git a/Panel/modules/website/admin.php b/Panel/modules/website/admin.php new file mode 100644 index 00000000..1ad65bc5 --- /dev/null +++ b/Panel/modules/website/admin.php @@ -0,0 +1,8 @@ + 'login_required']); + exit; +} + +$input = json_decode((string)file_get_contents('php://input'), true); +$invoiceId = (int)($input['invoice_id'] ?? 0); +$paypalOrderId = trim((string)($input['order_id'] ?? '')); +$config = website_paypal_config(); +$db = website_db(); +if (!$config['enabled'] || $paypalOrderId === '' || !$db instanceof mysqli) { + http_response_code(400); + echo json_encode(['error' => 'invalid_request']); + exit; +} + +$invoiceTable = website_table('billing_invoices'); +$uid = (int)$user['user_id']; +$stmt = $db->prepare("SELECT * FROM `{$invoiceTable}` WHERE `invoice_id` = ? AND `user_id` = ? AND `status` = 'due' LIMIT 1"); +$stmt->bind_param('ii', $invoiceId, $uid); +$stmt->execute(); +$invoice = $stmt->get_result()->fetch_assoc(); +$stmt->close(); +if (!is_array($invoice) || (string)($invoice['paypal_order_id'] ?? '') !== $paypalOrderId) { + http_response_code(404); + echo json_encode(['error' => 'invoice_not_found']); + exit; +} + +$access = website_paypal_oauth($config); +if (!$access) { + http_response_code(502); + echo json_encode(['error' => 'paypal_oauth_failed']); + exit; +} + +$ch = curl_init(website_paypal_api_base($config) . '/v2/checkout/orders/' . rawurlencode($paypalOrderId) . '/capture'); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $access], + CURLOPT_TIMEOUT => 30, +]); +$response = curl_exec($ch); +$http = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if (($http !== 200 && $http !== 201) || !is_string($response)) { + website_log('PayPal capture failed for invoice ' . $invoiceId); + http_response_code(502); + echo json_encode(['error' => 'paypal_capture_failed']); + exit; +} + +$json = json_decode($response, true); +$status = (string)($json['status'] ?? ''); +$capture = $json['purchase_units'][0]['payments']['captures'][0] ?? []; +$captureId = (string)($capture['id'] ?? ''); +$payerEmail = (string)($json['payer']['email_address'] ?? ''); +$paidAmount = (float)($capture['amount']['value'] ?? 0); +$currency = (string)($capture['amount']['currency_code'] ?? ''); + +if ($status !== 'COMPLETED' || $captureId === '' || abs($paidAmount - (float)$invoice['amount']) > 0.01 || $currency !== (string)$invoice['currency']) { + http_response_code(409); + echo json_encode(['error' => 'payment_not_verified']); + exit; +} + +website_mark_invoice_paid($invoiceId, 'paypal', $paypalOrderId, $captureId, $payerEmail, ['paypal_status' => $status]); +echo json_encode(['status' => 'COMPLETED', 'invoice_id' => $invoiceId]); diff --git a/Panel/modules/website/api/create_order.php b/Panel/modules/website/api/create_order.php new file mode 100644 index 00000000..0dd132c4 --- /dev/null +++ b/Panel/modules/website/api/create_order.php @@ -0,0 +1,97 @@ + 'login_required']); + exit; +} + +$input = json_decode((string)file_get_contents('php://input'), true); +$invoiceId = (int)($input['invoice_id'] ?? 0); +$db = website_db(); +$config = website_paypal_config(); + +if (!$config['enabled'] || $config['client_id'] === '' || $config['client_secret'] === '') { + http_response_code(503); + echo json_encode(['error' => 'paypal_not_configured']); + exit; +} +if (!$db instanceof mysqli || $invoiceId <= 0) { + http_response_code(400); + echo json_encode(['error' => 'invalid_invoice']); + exit; +} + +$invoiceTable = website_table('billing_invoices'); +$stmt = $db->prepare("SELECT * FROM `{$invoiceTable}` WHERE `invoice_id` = ? AND `user_id` = ? AND `status` = 'due' LIMIT 1"); +$uid = (int)$user['user_id']; +$stmt->bind_param('ii', $invoiceId, $uid); +$stmt->execute(); +$invoice = $stmt->get_result()->fetch_assoc(); +$stmt->close(); +if (!is_array($invoice)) { + http_response_code(404); + echo json_encode(['error' => 'invoice_not_found']); + exit; +} + +$access = website_paypal_oauth($config); +if (!$access) { + http_response_code(502); + echo json_encode(['error' => 'paypal_oauth_failed']); + exit; +} + +$amount = number_format((float)$invoice['amount'], 2, '.', ''); +$currency = (string)$invoice['currency']; +$body = [ + 'intent' => 'CAPTURE', + 'purchase_units' => [[ + 'invoice_id' => 'GSW-' . $invoiceId, + 'custom_id' => (string)$invoiceId, + 'description' => $config['description_prefix'] . ' #' . $invoiceId, + 'amount' => ['currency_code' => $currency, 'value' => $amount], + ]], + 'application_context' => [ + 'return_url' => website_canonical_url('payment_success.php?invoice_id=' . $invoiceId), + 'cancel_url' => website_canonical_url('payment_cancel.php?invoice_id=' . $invoiceId), + 'user_action' => 'PAY_NOW', + ], +]; + +$ch = curl_init(website_paypal_api_base($config) . '/v2/checkout/orders'); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($body), + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $access], + CURLOPT_TIMEOUT => 20, +]); +$response = curl_exec($ch); +$http = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if ($http !== 201 || !is_string($response)) { + website_log('PayPal order create failed for invoice ' . $invoiceId); + http_response_code(502); + echo json_encode(['error' => 'paypal_order_failed']); + exit; +} + +$json = json_decode($response, true); +$paypalOrderId = (string)($json['id'] ?? ''); +if ($paypalOrderId !== '') { + $stmt = $db->prepare("UPDATE `{$invoiceTable}` SET `paypal_order_id` = ? WHERE `invoice_id` = ?"); + $stmt->bind_param('si', $paypalOrderId, $invoiceId); + $stmt->execute(); + $stmt->close(); +} + +echo json_encode(['id' => $paypalOrderId]); diff --git a/Panel/modules/website/cart.php b/Panel/modules/website/cart.php index 63b38ad7..dd9819e2 100644 --- a/Panel/modules/website/cart.php +++ b/Panel/modules/website/cart.php @@ -9,13 +9,13 @@ $error = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = (string)($_POST['action'] ?? ''); - if ($action === 'remove') { + if (!website_verify_csrf()) { + $error = 'Your form expired. Please try again.'; + } elseif ($action === 'remove') { website_cart_remove((string)($_POST['cart_key'] ?? '')); header('Location: ' . website_cart_url(), true, 302); exit; - } - - if ($action === 'checkout') { + } elseif ($action === 'checkout') { if (website_cart_count() === 0) { $error = 'Your cart is empty.'; } elseif (!website_is_logged_in()) { @@ -24,8 +24,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { header('Location: ' . website_login_url('cart.php?checkout=1'), true, 302); exit; } else { - website_log_activity('Website checkout requested', (int)($_SESSION['website_user_id'] ?? 0), 'checkout_requested'); - $message = 'Checkout is ready for account validation, but the payment gateway is not connected in this repository checkout. Please contact support to complete this order.'; + $invoiceId = website_create_invoice_from_cart((int)$_SESSION['website_user_id']); + if ($invoiceId === null) { + $error = 'We could not create an invoice from this cart. Please contact support.'; + } else { + website_log_activity('Website checkout invoice created #' . $invoiceId, (int)$_SESSION['website_user_id'], 'checkout_requested'); + header('Location: ' . website_url('checkout.php?invoice_id=' . $invoiceId), true, 302); + exit; + } } } } @@ -39,7 +45,13 @@ if (isset($_GET['checkout']) && (string)$_GET['checkout'] === '1') { header('Location: ' . website_login_url('cart.php?checkout=1'), true, 302); exit; } else { - $message = 'You are logged in and your cart is preserved. Payment checkout still needs the active payment runtime before public orders can be completed.'; + $invoiceId = website_create_invoice_from_cart((int)$_SESSION['website_user_id']); + if ($invoiceId === null) { + $error = 'We could not create an invoice from this cart. Please contact support.'; + } else { + header('Location: ' . website_url('checkout.php?invoice_id=' . $invoiceId), true, 302); + exit; + } } } diff --git a/Panel/modules/website/checkout.php b/Panel/modules/website/checkout.php new file mode 100644 index 00000000..663ab6ec --- /dev/null +++ b/Panel/modules/website/checkout.php @@ -0,0 +1,46 @@ + 0 && website_table_exists(website_table('billing_invoices'))) { + $invoiceTable = website_table('billing_invoices'); + $orderTable = website_table('billing_orders'); + $stmt = $db->prepare("SELECT * FROM `{$invoiceTable}` WHERE `invoice_id` = ? AND `user_id` = ? LIMIT 1"); + if ($stmt) { + $uid = (int)$user['user_id']; + $stmt->bind_param('ii', $invoiceId, $uid); + $stmt->execute(); + $invoice = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + } + if ($invoice && website_table_exists($orderTable)) { + $stmt = $db->prepare("SELECT * FROM `{$orderTable}` WHERE `invoice_id` = ? ORDER BY `order_id` ASC"); + if ($stmt) { + $stmt->bind_param('i', $invoiceId); + $stmt->execute(); + $result = $stmt->get_result(); + while ($result instanceof mysqli_result && ($row = $result->fetch_assoc())) { + $orders[] = $row; + } + $stmt->close(); + } + } +} + +website_render('checkout.php', [ + 'activePage' => 'cart', + 'pageTitle' => 'Checkout - Gameservers.World', + 'metaDescription' => 'Pay a Gameservers.World invoice.', + 'canonicalPath' => 'checkout.php', + 'invoice' => $invoice, + 'orders' => $orders, + 'paypalConfig' => website_paypal_config(), +]); diff --git a/Panel/modules/website/forgot_password.php b/Panel/modules/website/forgot_password.php new file mode 100644 index 00000000..1492fe73 --- /dev/null +++ b/Panel/modules/website/forgot_password.php @@ -0,0 +1,54 @@ +prepare("SELECT `user_id`, `users_email` FROM `{$usersTable}` WHERE `users_login` = ? OR `users_email` = ? LIMIT 1"); + if ($stmt) { + $stmt->bind_param('ss', $identifier, $identifier); + $stmt->execute(); + $user = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if (is_array($user)) { + $rawToken = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $rawToken); + $resetTable = website_table('password_reset_tokens'); + $userId = (int)$user['user_id']; + $ip = website_client_ip(); + $db->query("DELETE FROM `{$resetTable}` WHERE `user_id` = {$userId} AND `used_at` IS NULL"); + $insert = $db->prepare("INSERT INTO `{$resetTable}` (`user_id`, `token_hash`, `expires_at`, `created_at`, `originating_ip`) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 1 HOUR), NOW(), ?)"); + if ($insert) { + $insert->bind_param('iss', $userId, $tokenHash, $ip); + $insert->execute(); + $insert->close(); + website_log_activity('Website password reset requested', $userId, 'password_reset_requested'); + $resetUrl = website_canonical_url('reset_password.php?token=' . rawurlencode($rawToken)); + @mail((string)$user['users_email'], 'Gameservers.World password reset', "Use this link to reset your password:\n\n" . $resetUrl . "\n\nThis link expires in 1 hour."); + } + } + } + } + } +} + +website_render('forgot_password.php', [ + 'activePage' => 'account', + 'pageTitle' => 'Forgot Password - Gameservers.World', + 'metaDescription' => 'Reset your Gameservers.World password.', + 'canonicalPath' => 'forgot_password.php', + 'message' => $message, + 'error' => $error, +]); diff --git a/Panel/modules/website/includes/billing.php b/Panel/modules/website/includes/billing.php new file mode 100644 index 00000000..fc407001 --- /dev/null +++ b/Panel/modules/website/includes/billing.php @@ -0,0 +1,538 @@ +'; +} + +function website_verify_csrf(): bool +{ + website_start_session(); + $token = (string)($_POST['csrf_token'] ?? ''); + return $token !== '' && hash_equals((string)($_SESSION['website_csrf_token'] ?? ''), $token); +} + +function website_require_login(string $returnPath = 'account.php'): ?array +{ + $user = website_current_user(); + if ($user === null) { + $_SESSION['website_login_return'] = website_safe_return_path($returnPath, 'account.php'); + header('Location: ' . website_login_url($returnPath), true, 302); + exit; + } + return $user; +} + +function website_require_staff(): array +{ + $user = website_require_login('staff.php'); + if ($user === null || !website_current_user_is_staff()) { + http_response_code(403); + website_render('message.php', [ + 'activePage' => 'account', + 'pageTitle' => 'Access Denied - Gameservers.World', + 'heading' => 'Access Denied', + 'message' => 'This page is available only to authorized website staff.', + ]); + exit; + } + return $user; +} + +function website_column_exists(string $tableName, string $columnName): bool +{ + return isset(website_table_columns($tableName)[$columnName]); +} + +function website_billing_migrations(): array +{ + $prefix = website_table_prefix(); + return [ + "CREATE TABLE IF NOT EXISTS `{$prefix}billing_invoices` ( + `invoice_id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `status` VARCHAR(24) NOT NULL DEFAULT 'due', + `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `currency` CHAR(3) NOT NULL DEFAULT 'USD', + `description` VARCHAR(255) DEFAULT NULL, + `invoice_date` DATETIME NOT NULL, + `due_date` DATETIME DEFAULT NULL, + `paid_date` DATETIME DEFAULT NULL, + `payment_method` VARCHAR(40) DEFAULT NULL, + `payment_txid` VARCHAR(128) DEFAULT NULL, + `paypal_order_id` VARCHAR(128) DEFAULT NULL, + `paypal_capture_id` VARCHAR(128) DEFAULT NULL, + `payer_email` VARCHAR(255) DEFAULT NULL, + `coupon_id` INT DEFAULT NULL, + `discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `order_id` INT DEFAULT NULL, + `metadata_json` TEXT DEFAULT NULL, + PRIMARY KEY (`invoice_id`), + KEY `idx_invoice_user_status` (`user_id`, `status`), + KEY `idx_invoice_payment` (`paypal_order_id`, `paypal_capture_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `{$prefix}billing_orders` ( + `order_id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `invoice_id` INT UNSIGNED DEFAULT NULL, + `user_id` INT NOT NULL, + `service_id` INT NOT NULL, + `home_id` INT DEFAULT NULL, + `home_name` VARCHAR(120) NOT NULL, + `remote_server_id` INT DEFAULT NULL, + `max_players` INT NOT NULL DEFAULT 0, + `qty` INT NOT NULL DEFAULT 1, + `invoice_duration` VARCHAR(24) NOT NULL DEFAULT 'month', + `price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `status` VARCHAR(32) NOT NULL DEFAULT 'paid', + `order_date` DATETIME NOT NULL, + `end_date` DATETIME DEFAULT NULL, + `payment_txid` VARCHAR(128) DEFAULT NULL, + `paid_ts` DATETIME DEFAULT NULL, + `provisioning_claim` VARCHAR(64) DEFAULT NULL, + `provisioning_error` TEXT DEFAULT NULL, + `metadata_json` TEXT DEFAULT NULL, + PRIMARY KEY (`order_id`), + UNIQUE KEY `uniq_invoice_service` (`invoice_id`, `service_id`, `home_name`), + KEY `idx_order_user_status` (`user_id`, `status`), + KEY `idx_order_home` (`home_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `{$prefix}billing_payments` ( + `payment_id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `invoice_id` INT UNSIGNED DEFAULT NULL, + `user_id` INT DEFAULT NULL, + `provider` VARCHAR(40) NOT NULL, + `provider_order_id` VARCHAR(128) DEFAULT NULL, + `provider_capture_id` VARCHAR(128) DEFAULT NULL, + `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `currency` CHAR(3) NOT NULL DEFAULT 'USD', + `status` VARCHAR(40) NOT NULL, + `payer_email` VARCHAR(255) DEFAULT NULL, + `created_at` DATETIME NOT NULL, + `metadata_json` TEXT DEFAULT NULL, + PRIMARY KEY (`payment_id`), + UNIQUE KEY `uniq_provider_capture` (`provider`, `provider_capture_id`), + KEY `idx_payment_invoice` (`invoice_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `{$prefix}billing_coupons` ( + `coupon_id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `code` VARCHAR(64) NOT NULL, + `name` VARCHAR(120) NOT NULL, + `description` TEXT DEFAULT NULL, + `discount_type` VARCHAR(16) NOT NULL DEFAULT 'percent', + `discount_value` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `starts_at` DATETIME DEFAULT NULL, + `expires` DATETIME DEFAULT NULL, + `max_uses` INT DEFAULT NULL, + `current_uses` INT NOT NULL DEFAULT 0, + `minimum_amount` DECIMAL(10,2) DEFAULT NULL, + `service_filter_json` TEXT DEFAULT NULL, + `created_at` DATETIME NOT NULL, + PRIMARY KEY (`coupon_id`), + UNIQUE KEY `uniq_coupon_code` (`code`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `{$prefix}website_settings` ( + `setting_key` VARCHAR(120) NOT NULL, + `setting_value` TEXT DEFAULT NULL, + `is_secret` TINYINT(1) NOT NULL DEFAULT 0, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`setting_key`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `{$prefix}password_reset_tokens` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `token_hash` CHAR(64) NOT NULL, + `expires_at` DATETIME NOT NULL, + `used_at` DATETIME DEFAULT NULL, + `created_at` DATETIME NOT NULL, + `originating_ip` VARCHAR(64) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_reset_token_hash` (`token_hash`), + KEY `idx_reset_user` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `{$prefix}payment_webhook_events` ( + `event_id` VARCHAR(128) NOT NULL, + `provider` VARCHAR(40) NOT NULL, + `event_type` VARCHAR(120) DEFAULT NULL, + `received_at` DATETIME NOT NULL, + `processed_at` DATETIME DEFAULT NULL, + `status` VARCHAR(40) NOT NULL DEFAULT 'received', + `metadata_json` TEXT DEFAULT NULL, + PRIMARY KEY (`event_id`, `provider`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `{$prefix}provisioning_attempts` ( + `attempt_id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_id` INT UNSIGNED NOT NULL, + `status` VARCHAR(40) NOT NULL, + `message` TEXT DEFAULT NULL, + `created_at` DATETIME NOT NULL, + PRIMARY KEY (`attempt_id`), + KEY `idx_provision_order` (`order_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + ]; +} + +function website_run_billing_migrations(): array +{ + $db = website_db(); + if (!$db instanceof mysqli) { + return ['Database connection is unavailable.']; + } + + $errors = []; + foreach (website_billing_migrations() as $sql) { + if (!$db->query($sql)) { + $errors[] = $db->error; + } + } + $serviceTable = website_table('billing_services'); + if (website_table_exists($serviceTable)) { + $serviceColumns = [ + 'description' => 'TEXT DEFAULT NULL', + 'img_url' => 'VARCHAR(255) DEFAULT NULL', + 'price_monthly' => 'DECIMAL(10,2) NOT NULL DEFAULT 0.00', + 'slot_min_qty' => 'INT NOT NULL DEFAULT 16', + 'slot_max_qty' => 'INT NOT NULL DEFAULT 0', + 'enabled' => 'TINYINT(1) NOT NULL DEFAULT 1', + 'remote_server_id' => 'VARCHAR(255) DEFAULT NULL', + ]; + $existing = website_table_columns($serviceTable); + foreach ($serviceColumns as $column => $definition) { + if (isset($existing[$column])) { + continue; + } + $safeTable = str_replace('`', '``', $serviceTable); + $safeColumn = str_replace('`', '``', $column); + if (!$db->query("ALTER TABLE `{$safeTable}` ADD COLUMN `{$safeColumn}` {$definition}")) { + $errors[] = $db->error; + } + } + } + return $errors; +} + +function website_setting(string $key, ?string $default = null): ?string +{ + $db = website_db(); + if (!$db instanceof mysqli || !website_table_exists(website_table('website_settings'))) { + return $default; + } + $table = website_table('website_settings'); + $stmt = $db->prepare("SELECT `setting_value` FROM `{$table}` WHERE `setting_key` = ? LIMIT 1"); + if (!$stmt) { + return $default; + } + $stmt->bind_param('s', $key); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + $stmt->close(); + return is_array($row) ? (string)$row['setting_value'] : $default; +} + +function website_set_setting(string $key, string $value, bool $secret = false): bool +{ + $db = website_db(); + if (!$db instanceof mysqli || !website_table_exists(website_table('website_settings'))) { + return false; + } + $table = website_table('website_settings'); + $isSecret = $secret ? 1 : 0; + $stmt = $db->prepare("INSERT INTO `{$table}` (`setting_key`, `setting_value`, `is_secret`, `updated_at`) VALUES (?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE `setting_value` = VALUES(`setting_value`), `is_secret` = VALUES(`is_secret`), `updated_at` = VALUES(`updated_at`)"); + if (!$stmt) { + return false; + } + $stmt->bind_param('ssi', $key, $value, $isSecret); + $ok = $stmt->execute(); + $stmt->close(); + return $ok; +} + +function website_paypal_config(): array +{ + return [ + 'enabled' => website_setting('paypal_enabled', '0') === '1', + 'sandbox' => website_setting('paypal_sandbox', '1') === '1', + 'client_id' => (string)(getenv('GSP_WEBSITE_PAYPAL_CLIENT_ID') ?: website_setting('paypal_client_id', '')), + 'client_secret' => (string)(getenv('GSP_WEBSITE_PAYPAL_CLIENT_SECRET') ?: website_setting('paypal_client_secret', '')), + 'webhook_id' => (string)(getenv('GSP_WEBSITE_PAYPAL_WEBHOOK_ID') ?: website_setting('paypal_webhook_id', '')), + 'currency' => (string)website_setting('paypal_currency', 'USD'), + 'description_prefix' => (string)website_setting('paypal_description_prefix', 'Gameservers.World order'), + ]; +} + +function website_paypal_api_base(array $config): string +{ + return $config['sandbox'] ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; +} + +function website_paypal_oauth(array $config): ?string +{ + if ($config['client_id'] === '' || $config['client_secret'] === '') { + return null; + } + $ch = curl_init(website_paypal_api_base($config) . '/v1/oauth2/token'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => 'grant_type=client_credentials', + CURLOPT_HTTPHEADER => ['Accept: application/json'], + CURLOPT_USERPWD => $config['client_id'] . ':' . $config['client_secret'], + CURLOPT_TIMEOUT => 20, + ]); + $response = curl_exec($ch); + $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($http !== 200 || !is_string($response)) { + return null; + } + $json = json_decode($response, true); + return is_array($json) ? (string)($json['access_token'] ?? '') : null; +} + +function website_services_have_columns(array $columns): bool +{ + $table = website_table('billing_services'); + if (!website_table_exists($table)) { + return false; + } + $existing = website_table_columns($table); + foreach ($columns as $column) { + if (!isset($existing[$column])) { + return false; + } + } + return true; +} + +function website_fetch_remote_servers(): array +{ + $db = website_db(); + $table = website_table('remote_servers'); + if (!$db instanceof mysqli || !website_table_exists($table)) { + return []; + } + $rows = []; + $result = @$db->query("SELECT `remote_server_id`, `remote_server_name`, `agent_ip`, `enabled` FROM `{$table}` ORDER BY `remote_server_name` ASC"); + if ($result instanceof mysqli_result) { + while ($row = $result->fetch_assoc()) { + $rows[] = $row; + } + $result->free(); + } + return $rows; +} + +function website_fetch_invoices_for_user(int $userId): array +{ + $db = website_db(); + $table = website_table('billing_invoices'); + if (!$db instanceof mysqli || !website_table_exists($table)) { + return []; + } + $stmt = $db->prepare("SELECT * FROM `{$table}` WHERE `user_id` = ? ORDER BY `invoice_id` DESC"); + if (!$stmt) { + return []; + } + $stmt->bind_param('i', $userId); + $stmt->execute(); + $result = $stmt->get_result(); + $rows = []; + while ($result instanceof mysqli_result && ($row = $result->fetch_assoc())) { + $rows[] = $row; + } + $stmt->close(); + return $rows; +} + +function website_fetch_orders_for_user(int $userId): array +{ + $db = website_db(); + $table = website_table('billing_orders'); + if (!$db instanceof mysqli || !website_table_exists($table)) { + return []; + } + $stmt = $db->prepare("SELECT * FROM `{$table}` WHERE `user_id` = ? ORDER BY `order_id` DESC"); + if (!$stmt) { + return []; + } + $stmt->bind_param('i', $userId); + $stmt->execute(); + $result = $stmt->get_result(); + $rows = []; + while ($result instanceof mysqli_result && ($row = $result->fetch_assoc())) { + $rows[] = $row; + } + $stmt->close(); + return $rows; +} + +function website_create_invoice_from_cart(int $userId): ?int +{ + $db = website_db(); + if (!$db instanceof mysqli || !website_table_exists(website_table('billing_invoices')) || !website_table_exists(website_table('billing_orders'))) { + return null; + } + + $items = website_cart_items(); + if (empty($items)) { + return null; + } + + $validItems = []; + $total = 0.0; + foreach ($items as $item) { + $service = website_fetch_service_by_id((int)($item['service_id'] ?? 0)); + if (!$service) { + continue; + } + $locations = website_service_locations($service); + $locationId = (string)($item['location_id'] ?? ''); + $slots = (int)($item['slots'] ?? 0); + $min = website_service_min_slots($service); + $max = website_service_max_slots($service); + if ($slots < $min || ($max > 0 && $slots > $max) || !isset($locations[$locationId])) { + continue; + } + $price = (float)($service['price_monthly'] ?? 0); + $durationMonths = max(1, (int)($item['duration_months'] ?? 1)); + $line = [ + 'service_id' => (int)$service['service_id'], + 'service_name' => website_service_name($service), + 'home_name' => trim((string)($item['server_name'] ?? $item['service_name'] ?? website_service_name($service))), + 'remote_server_id' => (int)$locationId, + 'slots' => $slots, + 'duration_months' => $durationMonths, + 'price' => $price * $durationMonths, + 'price_monthly' => $price, + ]; + $validItems[] = $line; + $total += (float)$line['price']; + } + if (empty($validItems)) { + return null; + } + + $invoiceTable = website_table('billing_invoices'); + $orderTable = website_table('billing_orders'); + $currency = website_paypal_config()['currency'] ?: 'USD'; + $metadata = json_encode(['items' => $validItems], JSON_UNESCAPED_SLASHES); + $db->begin_transaction(); + try { + $description = 'Gameservers.World server order'; + $stmt = $db->prepare("INSERT INTO `{$invoiceTable}` (`user_id`, `status`, `amount`, `currency`, `description`, `invoice_date`, `due_date`, `metadata_json`) VALUES (?, 'due', ?, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 3 DAY), ?)"); + if (!$stmt) { + throw new RuntimeException('invoice prepare failed'); + } + $stmt->bind_param('idsss', $userId, $total, $currency, $description, $metadata); + $stmt->execute(); + $invoiceId = (int)$db->insert_id; + $stmt->close(); + + foreach ($validItems as $line) { + $status = 'pending_payment'; + $duration = (string)$line['duration_months']; + $stmt = $db->prepare("INSERT INTO `{$orderTable}` (`invoice_id`, `user_id`, `service_id`, `home_name`, `remote_server_id`, `max_players`, `qty`, `invoice_duration`, `price`, `status`, `order_date`, `metadata_json`) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, NOW(), ?)"); + if (!$stmt) { + throw new RuntimeException('order prepare failed'); + } + $lineMeta = json_encode($line, JSON_UNESCAPED_SLASHES); + $stmt->bind_param('iiisiisdss', $invoiceId, $userId, $line['service_id'], $line['home_name'], $line['remote_server_id'], $line['slots'], $duration, $line['price'], $status, $lineMeta); + $stmt->execute(); + $stmt->close(); + } + + $db->commit(); + $_SESSION['website_cart'] = []; + website_log_activity('Website invoice created from cart #' . $invoiceId, $userId, 'invoice_created'); + return $invoiceId; + } catch (Throwable $e) { + $db->rollback(); + website_log('Invoice creation failed: ' . $e->getMessage()); + return null; + } +} + +function website_mark_invoice_paid(int $invoiceId, string $provider, string $providerOrderId, string $captureId, string $payerEmail, array $metadata = []): bool +{ + $db = website_db(); + if (!$db instanceof mysqli) { + return false; + } + $invoiceTable = website_table('billing_invoices'); + $orderTable = website_table('billing_orders'); + $paymentTable = website_table('billing_payments'); + if (!website_table_exists($invoiceTable) || !website_table_exists($orderTable) || !website_table_exists($paymentTable)) { + return false; + } + + $db->begin_transaction(); + try { + $stmt = $db->prepare("SELECT `user_id`, `amount`, `currency`, `status` FROM `{$invoiceTable}` WHERE `invoice_id` = ? FOR UPDATE"); + $stmt->bind_param('i', $invoiceId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if (!is_array($row) || (string)$row['status'] === 'paid') { + $db->commit(); + return true; + } + + $userId = (int)$row['user_id']; + $amount = (float)$row['amount']; + $currency = (string)$row['currency']; + $meta = json_encode($metadata, JSON_UNESCAPED_SLASHES); + + $stmt = $db->prepare("UPDATE `{$invoiceTable}` SET `status` = 'paid', `paid_date` = NOW(), `payment_method` = ?, `payment_txid` = ?, `paypal_order_id` = ?, `paypal_capture_id` = ?, `payer_email` = ? WHERE `invoice_id` = ?"); + $stmt->bind_param('sssssi', $provider, $captureId, $providerOrderId, $captureId, $payerEmail, $invoiceId); + $stmt->execute(); + $stmt->close(); + + $paid = 'paid'; + $provisioning = 'paid'; + $stmt = $db->prepare("UPDATE `{$orderTable}` SET `status` = ?, `payment_txid` = ?, `paid_ts` = NOW() WHERE `invoice_id` = ? AND `status` IN ('pending_payment', 'paid')"); + $stmt->bind_param('ssi', $provisioning, $captureId, $invoiceId); + $stmt->execute(); + $stmt->close(); + + $stmt = $db->prepare("INSERT IGNORE INTO `{$paymentTable}` (`invoice_id`, `user_id`, `provider`, `provider_order_id`, `provider_capture_id`, `amount`, `currency`, `status`, `payer_email`, `created_at`, `metadata_json`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?)"); + $stmt->bind_param('iisssdssss', $invoiceId, $userId, $provider, $providerOrderId, $captureId, $amount, $currency, $paid, $payerEmail, $meta); + $stmt->execute(); + $stmt->close(); + + $db->commit(); + website_log_activity('Website invoice paid #' . $invoiceId, $userId, 'invoice_paid'); + return true; + } catch (Throwable $e) { + $db->rollback(); + website_log('Payment mark-paid failed: ' . $e->getMessage()); + return false; + } +} diff --git a/Panel/modules/website/includes/bootstrap.php b/Panel/modules/website/includes/bootstrap.php index 35a3d1e8..c6aaf41f 100644 --- a/Panel/modules/website/includes/bootstrap.php +++ b/Panel/modules/website/includes/bootstrap.php @@ -32,6 +32,10 @@ foreach ($websiteConfigFiles as $configFile) { } } +if (is_readable(__DIR__ . '/billing.php')) { + require_once __DIR__ . '/billing.php'; +} + $websiteDefaults = [ 'site_name' => 'Gameservers.World', 'site_tagline' => 'Developer-backed game hosting for modern and legacy communities, with full server access, daily backups, and optional custom engineering help through Runlevel Systems.', @@ -311,7 +315,7 @@ function website_database_settings(): ?array $merged = array_replace($merged, website_read_php_assignments($billingConfig, $keys)); } - foreach (['db_host', 'db_user', 'db_name', 'table_prefix'] as $requiredKey) { + foreach (['db_host', 'db_user', 'db_name'] as $requiredKey) { if (empty($merged[$requiredKey])) { $settings = null; return $settings; @@ -418,7 +422,7 @@ function website_panel_user_by_id(int $userId): ?array { $db = website_db(); $prefix = website_table_prefix(); - if (!$db instanceof mysqli || $prefix === '' || $userId <= 0) { + if (!$db instanceof mysqli || $userId <= 0) { return null; } @@ -439,7 +443,7 @@ function website_panel_user_by_login(string $login): ?array { $db = website_db(); $prefix = website_table_prefix(); - if (!$db instanceof mysqli || $prefix === '' || $login === '') { + if (!$db instanceof mysqli || $login === '') { return null; } @@ -532,7 +536,7 @@ function website_log_activity(string $message, int $userId = 0, string $eventTyp { $db = website_db(); $prefix = website_table_prefix(); - if (!$db instanceof mysqli || $prefix === '') { + if (!$db instanceof mysqli) { return; } @@ -602,14 +606,18 @@ function website_checkout_url(): string function website_register_url(string $returnPath = 'cart.php'): string { - return panel_url('index.php?m=register'); + $path = 'register.php'; + if ($returnPath !== '') { + $path .= '?return=' . rawurlencode(website_safe_return_path($returnPath, 'cart.php')); + } + return website_url($path); } function website_fetch_service_by_id(int $serviceId): ?array { $db = website_db(); $prefix = website_table_prefix(); - if (!$db instanceof mysqli || $prefix === '' || $serviceId <= 0) { + if (!$db instanceof mysqli || $serviceId <= 0) { return null; } @@ -658,7 +666,7 @@ function website_service_name(array $service): string function website_service_min_slots(array $service): int { - foreach (['min_slots', 'minimum_slots', 'slots_min'] as $column) { + foreach (['slot_min_qty', 'min_slots', 'minimum_slots', 'slots_min'] as $column) { if (isset($service[$column]) && (int)$service[$column] > 0) { return (int)$service[$column]; } @@ -670,7 +678,7 @@ function website_service_min_slots(array $service): int function website_service_max_slots(array $service): int { - foreach (['max_slots', 'maximum_slots', 'slots_max', 'max_players'] as $column) { + foreach (['slot_max_qty', 'max_slots', 'maximum_slots', 'slots_max', 'max_players'] as $column) { if (isset($service[$column]) && (int)$service[$column] > 0) { return (int)$service[$column]; } @@ -687,7 +695,7 @@ function website_service_locations(array $service): array } $locations = []; - foreach (preg_split('/\s*,\s*/', $raw) ?: [] as $remoteServerId) { + foreach (preg_split('/[\s,]+/', $raw) ?: [] as $remoteServerId) { $remoteServerId = trim($remoteServerId); if ($remoteServerId === '' || !ctype_digit($remoteServerId)) { continue; @@ -732,7 +740,7 @@ function website_cart_total(): float { $total = 0.0; foreach (website_cart_items() as $item) { - $total += (float)($item['monthly_total'] ?? 0); + $total += (float)($item['line_total'] ?? $item['monthly_total'] ?? 0); } return $total; } @@ -805,7 +813,7 @@ function website_service_image_url(string $imageValue): string return website_asset('images/games/' . $fileName); } -function website_fetch_services(int $limit = 0): array +function website_fetch_services(int $limit = 0, bool $includeDisabled = false): array { $db = website_db(); if (!$db instanceof mysqli) { @@ -813,24 +821,16 @@ function website_fetch_services(int $limit = 0): array } $prefix = website_table_prefix(); - if ($prefix === '') { - return []; - } - $sql = "SELECT bs.service_id, - bs.service_name, - bs.description, - bs.img_url, - bs.price_monthly, - bs.remote_server_id, + $sql = "SELECT bs.*, ch.game_name AS cfg_game_name, ch.game_key AS cfg_game_key, ch.home_cfg_file AS cfg_file FROM `{$prefix}billing_services` bs LEFT JOIN `{$prefix}config_homes` ch ON ch.home_cfg_id = bs.home_cfg_id - WHERE bs.enabled = 1 + WHERE " . ($includeDisabled ? '1 = 1' : "bs.enabled = 1 AND bs.remote_server_id <> '' - AND bs.remote_server_id IS NOT NULL + AND bs.remote_server_id IS NOT NULL") . " ORDER BY bs.service_name ASC"; if ($limit > 0) { diff --git a/Panel/modules/website/includes/footer.php b/Panel/modules/website/includes/footer.php index 55b40216..981d30b3 100644 --- a/Panel/modules/website/includes/footer.php +++ b/Panel/modules/website/includes/footer.php @@ -38,9 +38,11 @@ $currentUser = website_current_user();