Panel/Panel/modules/website/includes/billing.php

538 lines
21 KiB
PHP

<?php
declare(strict_types=1);
if (defined('GSP_WEBSITE_BILLING_LOADED')) {
return;
}
define('GSP_WEBSITE_BILLING_LOADED', true);
function website_table(string $baseName): string
{
return website_table_prefix() . $baseName;
}
function website_client_ip(): string
{
return substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64);
}
function website_csrf_token(): string
{
website_start_session();
if (empty($_SESSION['website_csrf_token'])) {
$_SESSION['website_csrf_token'] = bin2hex(random_bytes(32));
}
return (string)$_SESSION['website_csrf_token'];
}
function website_csrf_field(): string
{
return '<input type="hidden" name="csrf_token" value="' . website_escape(website_csrf_token()) . '">';
}
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;
}
}