From 1fe9911e3f9ddd4ffe820a46982ea8b68930ac3d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 29 Oct 2025 10:47:03 +0000
Subject: [PATCH 1/9] Initial plan
From 91a63a780f71ae71e5df0e283c086f2a19afe7e8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 29 Oct 2025 10:52:32 +0000
Subject: [PATCH 2/9] Fix PayPal capture_order.php to use standalone database
connection
Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
---
.github/copilot-instructions.md | 3 +--
modules/billing/api/capture_order.php | 10 ++++----
modules/billing/includes/config.inc.php | 32 +++++++++++++++++++++++++
3 files changed, 39 insertions(+), 6 deletions(-)
create mode 100644 modules/billing/includes/config.inc.php
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 50fa810e..066b6ccb 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -67,8 +67,7 @@ header('Location: /modules/billing/cart.php');
## 3) Scope & principles
- **Website ↔ Panel on the same host.** Website uses the **panel DB for authentication** and the **panel’s internal APIs** for provisioning. **Sessions remain separate** (website session ≠ panel session).
-- **Billing module flexibility.** The `modules/billing/` frontend can run on the **same machine as the panel** or on an **external web host**, interfacing primarily via MySQL table edits. All interaction with panel DB happens through direct MySQL queries using credentials in `modules/billing/includes/config.inc.php`.
-- **Billing module flexibility.** The `modules/billing/` frontend can run on the **same machine as the panel** or on an **external web host**, interfacing primarily via MySQL table edits. All interaction with panel DB happens through direct MySQL queries using credentials in `modules/billing/includes/config.inc.php`.
+- **Billing module is STANDALONE.** The `modules/billing/` frontend is a **standalone product** that can run on the **same machine as the panel** or on an **external web host**. It must **NEVER** use `require_once` to include panel files like `includes/database_mysqli.php` or any panel helper functions. Instead, it connects directly to the MySQL database using standard `mysqli_connect()` with credentials from `modules/billing/includes/config.inc.php`. All database operations must use native mysqli functions (mysqli_query, mysqli_real_escape_string, etc.), NOT panel-specific helper functions like `$db->query()` or `createDatabaseConnection()`.
- **Catalog = XML.** Enable **every game** present under `modules/config_games/server_configs/`. The website reads those XMLs for ports, params, install/update metadata. New XMLs should become available without code changes.
- **Regions/Nodes = panel DB.** Regions and nodes are configured in the panel and must be **queried live** from the panel DB. Never hardcode or mirror region lists on the website.
- **Slotless model.** Pricing/UX must not enforce slot caps. If an engine requires a player count parameter, set a safe high default and surface engine limits transparently if they exist.
diff --git a/modules/billing/api/capture_order.php b/modules/billing/api/capture_order.php
index 38a946ea..ef0087ae 100644
--- a/modules/billing/api/capture_order.php
+++ b/modules/billing/api/capture_order.php
@@ -1,6 +1,7 @@
'db_connection_failed', 'status' => $captureStatus]);
exit;
}
diff --git a/modules/billing/includes/config.inc.php b/modules/billing/includes/config.inc.php
new file mode 100644
index 00000000..41a6df6b
--- /dev/null
+++ b/modules/billing/includes/config.inc.php
@@ -0,0 +1,32 @@
+
From 9d541d55a2298a3d425657ec9f0e2d11cd692493 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 29 Oct 2025 10:54:52 +0000
Subject: [PATCH 3/9] Add enhanced coupon system with game filters and admin
management page
Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
---
modules/billing/admin.php | 1 +
modules/billing/admin_coupons.php | 414 +++++++++++++++++++++++
modules/billing/create_coupons_table.sql | 106 ++++++
3 files changed, 521 insertions(+)
create mode 100644 modules/billing/admin_coupons.php
create mode 100644 modules/billing/create_coupons_table.sql
diff --git a/modules/billing/admin.php b/modules/billing/admin.php
index b08e4690..61f9444a 100644
--- a/modules/billing/admin.php
+++ b/modules/billing/admin.php
@@ -24,6 +24,7 @@ function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
diff --git a/modules/billing/admin_coupons.php b/modules/billing/admin_coupons.php
new file mode 100644
index 00000000..80bcd63c
--- /dev/null
+++ b/modules/billing/admin_coupons.php
@@ -0,0 +1,414 @@
+ 0) {
+ $error = "Coupon code '$code' already exists.";
+ } else {
+ $sql = "INSERT INTO {$table_prefix}billing_coupons
+ (code, name, description, discount_percent, usage_type, game_filter_type, game_filter_list, max_uses, expires, is_active)
+ VALUES ('$code', '$name', '$description', $discount_percent, '$usage_type', '$game_filter_type', " .
+ ($game_filter_list === 'NULL' ? 'NULL' : "'$game_filter_list'") . ", $max_uses, $expires, 1)";
+
+ if (mysqli_query($db, $sql)) {
+ $status = "Coupon '$code' added successfully.";
+ } else {
+ $error = "Error adding coupon: " . mysqli_error($db);
+ }
+ }
+ }
+
+ // Update existing coupon
+ elseif (isset($_POST['update_coupon'])) {
+ $coupon_id = intval($_POST['coupon_id']);
+ $code = mysqli_real_escape_string($db, trim($_POST['code']));
+ $name = mysqli_real_escape_string($db, trim($_POST['name']));
+ $description = mysqli_real_escape_string($db, trim($_POST['description']));
+ $discount_percent = floatval($_POST['discount_percent']);
+ $usage_type = mysqli_real_escape_string($db, $_POST['usage_type']);
+ $game_filter_type = mysqli_real_escape_string($db, $_POST['game_filter_type']);
+ $game_filter_list = isset($_POST['game_filter_list']) && $_POST['game_filter_type'] === 'specific_games'
+ ? mysqli_real_escape_string($db, json_encode($_POST['game_filter_list']))
+ : 'NULL';
+ $max_uses = !empty($_POST['max_uses']) ? intval($_POST['max_uses']) : 'NULL';
+ $expires = !empty($_POST['expires']) ? "'" . mysqli_real_escape_string($db, $_POST['expires']) . "'" : 'NULL';
+ $is_active = isset($_POST['is_active']) ? 1 : 0;
+
+ $sql = "UPDATE {$table_prefix}billing_coupons SET
+ code = '$code',
+ name = '$name',
+ description = '$description',
+ discount_percent = $discount_percent,
+ usage_type = '$usage_type',
+ game_filter_type = '$game_filter_type',
+ game_filter_list = " . ($game_filter_list === 'NULL' ? 'NULL' : "'$game_filter_list'") . ",
+ max_uses = $max_uses,
+ expires = $expires,
+ is_active = $is_active
+ WHERE coupon_id = $coupon_id";
+
+ if (mysqli_query($db, $sql)) {
+ $status = "Coupon updated successfully.";
+ } else {
+ $error = "Error updating coupon: " . mysqli_error($db);
+ }
+ }
+
+ // Delete coupon
+ elseif (isset($_POST['delete_coupon'])) {
+ $coupon_id = intval($_POST['coupon_id']);
+ if (mysqli_query($db, "DELETE FROM {$table_prefix}billing_coupons WHERE coupon_id = $coupon_id")) {
+ $status = "Coupon deleted successfully.";
+ } else {
+ $error = "Error deleting coupon: " . mysqli_error($db);
+ }
+ }
+ }
+}
+
+// Get all available games from server configs
+$game_options = [];
+$games_dir = __DIR__ . '/../../config_games/server_configs/';
+if (is_dir($games_dir)) {
+ $files = scandir($games_dir);
+ foreach ($files as $file) {
+ if (pathinfo($file, PATHINFO_EXTENSION) === 'xml' && strpos($file, '.bak') === false) {
+ $game_key = str_replace('.xml', '', $file);
+ $game_options[] = $game_key;
+ }
+ }
+ sort($game_options);
+}
+
+// Get all coupons
+$coupons_result = mysqli_query($db, "SELECT * FROM {$table_prefix}billing_coupons ORDER BY created_date DESC");
+?>
+
+
+
+
+
+ Admin — Coupon Management
+
+
+
+
+
+
+
+
Coupon Management
+
+
+
+
+
+
+
+
+
+
+
Add New Coupon
+
+
+
+
Existing Coupons
+
+ 0): ?>
+
+
+
No coupons found. Add your first coupon above.
+
+
+
+
+
+
+
+
diff --git a/modules/billing/create_coupons_table.sql b/modules/billing/create_coupons_table.sql
new file mode 100644
index 00000000..07c9093f
--- /dev/null
+++ b/modules/billing/create_coupons_table.sql
@@ -0,0 +1,106 @@
+-- Enhanced coupon system for billing module
+-- This creates a flexible coupon system with game filters and usage tracking
+
+-- Drop existing table if upgrading from old coupon module
+DROP TABLE IF EXISTS `ogp_billing_coupons`;
+
+-- Create enhanced coupons table
+CREATE TABLE `ogp_billing_coupons` (
+ `coupon_id` INT(11) NOT NULL AUTO_INCREMENT,
+ `code` VARCHAR(50) NOT NULL UNIQUE,
+ `name` VARCHAR(255) NOT NULL DEFAULT '',
+ `description` TEXT,
+ `discount_percent` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
+ `usage_type` ENUM('one_time', 'permanent') NOT NULL DEFAULT 'one_time',
+ `game_filter_type` ENUM('all_games', 'specific_games') NOT NULL DEFAULT 'all_games',
+ `game_filter_list` TEXT COMMENT 'JSON array of game keys when game_filter_type=specific_games',
+ `max_uses` INT(11) DEFAULT NULL COMMENT 'NULL for unlimited uses',
+ `current_uses` INT(11) NOT NULL DEFAULT 0,
+ `expires` DATETIME DEFAULT NULL,
+ `created_date` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `created_by` INT(11) DEFAULT NULL,
+ `is_active` TINYINT(1) NOT NULL DEFAULT 1,
+ PRIMARY KEY (`coupon_id`),
+ UNIQUE KEY `idx_code` (`code`),
+ KEY `idx_active_expires` (`is_active`, `expires`),
+ KEY `idx_created_by` (`created_by`)
+) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;
+
+-- Add coupon_id field to billing_orders if it doesn't exist
+SET @tablename = 'ogp_billing_orders';
+SET @checkIfColumnExists = (
+ SELECT COUNT(*)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = @tablename
+ AND COLUMN_NAME = 'coupon_id'
+);
+
+SET @addColumn = IF(@checkIfColumnExists = 0,
+ 'ALTER TABLE `ogp_billing_orders` ADD COLUMN `coupon_id` INT(11) DEFAULT NULL AFTER `user_id`, ADD KEY `idx_coupon` (`coupon_id`)',
+ 'SELECT "Column coupon_id already exists in ogp_billing_orders"'
+);
+
+PREPARE stmt FROM @addColumn;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- Add coupon_id field to billing_invoices if it doesn't exist
+SET @tablename = 'ogp_billing_invoices';
+SET @checkIfColumnExists = (
+ SELECT COUNT(*)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = @tablename
+ AND COLUMN_NAME = 'coupon_id'
+);
+
+SET @addColumn = IF(@checkIfColumnExists = 0,
+ 'ALTER TABLE `ogp_billing_invoices` ADD COLUMN `coupon_id` INT(11) DEFAULT NULL AFTER `user_id`, ADD KEY `idx_coupon` (`coupon_id`)',
+ 'SELECT "Column coupon_id already exists in ogp_billing_invoices"'
+);
+
+PREPARE stmt FROM @addColumn;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- Add discount_amount field to billing_invoices to track actual discount applied
+SET @checkIfColumnExists = (
+ SELECT COUNT(*)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'ogp_billing_invoices'
+ AND COLUMN_NAME = 'discount_amount'
+);
+
+SET @addColumn = IF(@checkIfColumnExists = 0,
+ 'ALTER TABLE `ogp_billing_invoices` ADD COLUMN `discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER `amount`',
+ 'SELECT "Column discount_amount already exists in ogp_billing_invoices"'
+);
+
+PREPARE stmt FROM @addColumn;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- Add discount_amount field to billing_orders to track permanent discounts
+SET @checkIfColumnExists = (
+ SELECT COUNT(*)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'ogp_billing_orders'
+ AND COLUMN_NAME = 'discount_amount'
+);
+
+SET @addColumn = IF(@checkIfColumnExists = 0,
+ 'ALTER TABLE `ogp_billing_orders` ADD COLUMN `discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER `price`',
+ 'SELECT "Column discount_amount already exists in ogp_billing_orders"'
+);
+
+PREPARE stmt FROM @addColumn;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- Sample coupons for testing
+INSERT INTO `ogp_billing_coupons` (`code`, `name`, `description`, `discount_percent`, `usage_type`, `game_filter_type`, `game_filter_list`, `expires`) VALUES
+('WELCOME10', 'Welcome 10% Off', 'New customer welcome discount - 10% off any game', 10.00, 'one_time', 'all_games', NULL, DATE_ADD(NOW(), INTERVAL 1 YEAR)),
+('ARMA25', 'Arma Series 25% Off', 'Save 25% on any Arma game server', 25.00, 'permanent', 'specific_games', '["arma2_win32", "arma2oa_win32", "arma3_linux32", "arma3_linux64", "arma3_win64", "arma-reforger_linux64", "arma-reforger_win64"]', NULL);
From 93d47dba4021b8dc27b5dc010b8dfa8e91c81441 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 29 Oct 2025 10:59:31 +0000
Subject: [PATCH 4/9] Add coupon application logic to cart.php with discount
calculation
Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
---
modules/billing/cart.php | 121 +++++-
modules/billing/cart.php.backup | 641 ++++++++++++++++++++++++++++++++
2 files changed, 760 insertions(+), 2 deletions(-)
create mode 100644 modules/billing/cart.php.backup
diff --git a/modules/billing/cart.php b/modules/billing/cart.php
index 558ce18a..83f2ca5a 100644
--- a/modules/billing/cart.php
+++ b/modules/billing/cart.php
@@ -271,6 +271,55 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_single'])) {
}
}
+// Handle coupon application
+$coupon_message = '';
+$coupon_error = '';
+$applied_coupon = null;
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['apply_coupon'])) {
+ $coupon_code = trim($_POST['coupon_code']);
+
+ if (!empty($coupon_code)) {
+ // Validate and fetch coupon
+ $safe_code = mysqli_real_escape_string($db, $coupon_code);
+ $coupon_query = "SELECT * FROM {$table_prefix}billing_coupons
+ WHERE code = '$safe_code'
+ AND is_active = 1
+ AND (expires IS NULL OR expires > NOW())
+ LIMIT 1";
+ $coupon_result = mysqli_query($db, $coupon_query);
+
+ if ($coupon_result && mysqli_num_rows($coupon_result) === 1) {
+ $coupon = mysqli_fetch_assoc($coupon_result);
+
+ // Check usage limits
+ if ($coupon['max_uses'] !== null && intval($coupon['current_uses']) >= intval($coupon['max_uses'])) {
+ $coupon_error = "This coupon has reached its usage limit.";
+ } else {
+ // Store coupon in session for later use
+ $_SESSION['applied_coupon'] = $coupon;
+ $applied_coupon = $coupon;
+ $coupon_message = "Coupon '{$coupon['code']}' applied successfully! " .
+ number_format($coupon['discount_percent'], 2) . "% discount " .
+ ($coupon['usage_type'] === 'permanent' ? '(permanent - applies to all renewals)' : '(one-time only)');
+ }
+ } else {
+ $coupon_error = "Invalid or expired coupon code.";
+ }
+ }
+}
+
+// Handle coupon removal
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_coupon'])) {
+ unset($_SESSION['applied_coupon']);
+ $coupon_message = "Coupon removed.";
+}
+
+// Check if there's a coupon in session
+if (isset($_SESSION['applied_coupon']) && !$applied_coupon) {
+ $applied_coupon = $_SESSION['applied_coupon'];
+}
+
if ($db){
$carts = $db->query("SELECT * FROM ogp_billing_invoices AS cart
WHERE status = 'due' AND user_id = " . $user_id . " ORDER BY invoice_id ASC");
@@ -301,6 +350,28 @@ if ($db){
num_rows > 0) {
while ($row = $carts->fetch_assoc()) {
@@ -319,14 +390,28 @@ if ($db){
$
-
+
'invoice-' . $row['invoice_id'],
'amount' => number_format($rowtotal, 2, '.', ''),
- 'invoice_id' => intval($row['invoice_id'])
+ 'invoice_id' => intval($row['invoice_id']),
+ 'discount' => number_format($itemDiscount, 2, '.', ''),
+ 'original_amount' => number_format($row['amount'] * $row['qty'] * $row['max_players'], 2, '.', ''),
+ 'coupon_id' => $applied_coupon ? intval($applied_coupon['coupon_id']) : null
];
?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Active Coupon:
+ (% off)
+
+ Remove
+
+
+
+
+ Have a coupon code?
+
+ Apply Coupon
+
+
+
+
+
+
+
+
+ Shopping Cart - GameServers.World
+
+
+ 0) {
+ $ar = mysqli_query($db, "SELECT users_role FROM ogp_users WHERE user_id = " . intval($actor_id) . " LIMIT 1");
+ if ($ar && mysqli_num_rows($ar) === 1) {
+ $arr = mysqli_fetch_assoc($ar);
+ if (strtolower((string)($arr['users_role'] ?? '')) === 'admin') {
+ $is_admin = true;
+ $_SESSION['website_user_role'] = 'admin';
+ }
+ }
+ } elseif (isset($_SESSION['website_username']) && !empty($_SESSION['website_username'])) {
+ $safe_un = mysqli_real_escape_string($db, $_SESSION['website_username']);
+ $ar = mysqli_query($db, "SELECT user_id, users_role FROM ogp_users WHERE users_login = '$safe_un' LIMIT 1");
+ if ($ar && mysqli_num_rows($ar) === 1) {
+ $arr = mysqli_fetch_assoc($ar);
+ if (strtolower((string)($arr['users_role'] ?? '')) === 'admin') {
+ $is_admin = true;
+ $_SESSION['website_user_role'] = 'admin';
+ $_SESSION['website_user_id'] = intval($arr['user_id'] ?? 0);
+ }
+ }
+ }
+ }
+ $orderId = (int)$_POST['create_free_for'];
+ if ($orderId > 0) {
+ // load invoice to verify ownership/price (invoice-first flow)
+ $stmt = $db->prepare("SELECT user_id, amount, status, qty, invoice_duration, service_id, home_name, ip, max_players, remote_control_password, ftp_password FROM " . $table_prefix . "billing_invoices WHERE invoice_id = ? LIMIT 1");
+ if ($stmt) {
+ $stmt->bind_param('i', $orderId);
+ $stmt->execute();
+ $stmt->bind_result($owner_id, $order_price, $prev_status, $order_qty, $order_invoice_duration, $service_id, $home_name, $ip, $max_players, $remote_control_password, $ftp_password);
+ $found = $stmt->fetch();
+ $stmt->close();
+ } else {
+ $found = false;
+ }
+
+ $audit_file = __DIR__ . '/logs/free_create_audit.log';
+
+ if ($found) {
+ $allowed = false;
+ $reason = '';
+ // Admin may force-create paid records for testing
+ if ($is_admin) {
+ $allowed = true;
+ $reason = 'admin_create';
+ }
+ // Owner may claim a free order if the price is zero
+ elseif ($actor_id > 0 && $actor_id === intval($owner_id) && floatval($order_price) == 0.0) {
+ $allowed = true;
+ $reason = 'user_claim_free';
+ }
+
+ if ($allowed) {
+ // Mark invoice as paid
+ $upd_inv = $db->prepare("UPDATE " . $table_prefix . "billing_invoices SET status = 'paid', paid_date = NOW() WHERE invoice_id = ? LIMIT 1");
+ if ($upd_inv) {
+ $upd_inv->bind_param('i', $orderId);
+ $upd_inv->execute();
+ $upd_inv->close();
+ }
+
+ // Now create the order record (invoice -> order after payment)
+ // Compute end_date: months based on invoice_duration and qty
+ $months = 0;
+ $q = intval($order_qty ?? 0);
+ $invdur = strtolower(trim($order_invoice_duration ?? ''));
+ if (strpos($invdur, 'year') !== false) {
+ $months = $q * 12;
+ } else {
+ // default to months for anything else (month, monthly, etc.)
+ $months = $q;
+ }
+ $end_date = null;
+ if ($months > 0) {
+ $dt = new DateTime('now');
+ $dt->modify('+' . intval($months) . ' months');
+ $end_date = $dt->format('Y-m-d H:i:s');
+ } else {
+ // if no months specified, set to now
+ $end_date = date('Y-m-d H:i:s');
+ }
+
+ // INSERT new order record (invoice->order after payment)
+ $esc_service_id = intval($service_id);
+ $esc_home_name = mysqli_real_escape_string($db, $home_name);
+ $esc_ip = intval($ip);
+ $esc_max_players = intval($max_players);
+ $esc_qty = intval($order_qty);
+ $esc_inv_dur = mysqli_real_escape_string($db, $order_invoice_duration);
+ $esc_price = floatval($order_price);
+ $esc_rc_pass = mysqli_real_escape_string($db, $remote_control_password);
+ $esc_ftp_pass = mysqli_real_escape_string($db, $ftp_password);
+ $esc_user_id = intval($owner_id);
+ $esc_end_date = mysqli_real_escape_string($db, $end_date);
+
+ $insert_sql = "INSERT INTO " . $table_prefix . "billing_orders
+ (user_id, service_id, home_name, ip, max_players, qty, invoice_duration, price, remote_control_password, ftp_password, status, end_date, payment_txid, paid_ts)
+ VALUES
+ ({$esc_user_id}, {$esc_service_id}, '{$esc_home_name}', {$esc_ip}, {$esc_max_players}, {$esc_qty}, '{$esc_inv_dur}', {$esc_price}, '{$esc_rc_pass}', '{$esc_ftp_pass}', 'paid', '{$esc_end_date}', 'FREE-{$orderId}', NOW())";
+
+ $insert_res = mysqli_query($db, $insert_sql);
+ $new_order_id = 0;
+ if ($insert_res) {
+ $new_order_id = mysqli_insert_id($db);
+ // Update invoice with the new order_id
+ $upd_inv_order = $db->prepare("UPDATE " . $table_prefix . "billing_invoices SET order_id = ? WHERE invoice_id = ? LIMIT 1");
+ if ($upd_inv_order) {
+ $upd_inv_order->bind_param('ii', $new_order_id, $orderId);
+ $upd_inv_order->execute();
+ $upd_inv_order->close();
+ }
+ }
+
+ // write audit log (include end_date if set)
+ site_log_info('free_create', ['actor'=>$actor_id, 'role'=>$actor_role, 'action'=>$reason, 'invoice'=>$orderId, 'new_order'=>$new_order_id, 'owner'=>$owner_id, 'price'=>$order_price, 'prev_status'=>$prev_status, 'end_date'=>$end_date ?? '']);
+
+ // write a simulated webhook file (same behavior as previous admin flow)
+ $dataDir = (isset($SITE_DATA_DIR) && $SITE_DATA_DIR) ? $SITE_DATA_DIR : realpath(__DIR__ . '/') . DIRECTORY_SEPARATOR . 'data';
+ @mkdir($dataDir, 0775, true);
+ $rec = [
+ 'event_type' => 'PAYMENT.CAPTURE.COMPLETED',
+ 'status' => 'PAID',
+ 'amount' => floatval($order_price),
+ 'currency' => 'USD',
+ 'payer' => $_SESSION['website_user_email'] ?? ($_SESSION['website_username'] ?? ''),
+ 'invoice' => 'FREE-' . $orderId . '-' . time(),
+ // process_payment_record matches numeric custom values to order_id; use numeric order id here to ensure matching
+ 'custom' => (string)$orderId,
+ 'resource_id' => 'FREE-' . bin2hex(random_bytes(6)),
+ 'items' => [],
+ 'ts' => date('c'),
+ ];
+ $fname = $dataDir . DIRECTORY_SEPARATOR . $rec['invoice'] . '.json';
+ file_put_contents($fname, json_encode($rec, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));
+
+ // If available, process the payment record immediately so webhooks logic runs during creation
+ require_once(__DIR__ . '/includes/payment_processor.php');
+ try {
+ if (function_exists('process_payment_record')) {
+ process_payment_record($rec);
+ }
+ } catch (Exception $e) {
+ error_log('[cart create_free] process_payment_record failed: ' . $e->getMessage());
+ }
+
+ header('Location: return.php?invoice=' . urlencode($rec['invoice']));
+ exit;
+ } else {
+ // unauthorized attempt - log and continue
+ site_log_warn('unauthorized_free_create', ['actor'=>$actor_id, 'role'=>$actor_role, 'order'=>$orderId, 'owner'=>$owner_id, 'price'=>$order_price]);
+ }
+ }
+ }
+}
+
+// Include top bar and menu
+include(__DIR__ . '/includes/top.php');
+include(__DIR__ . '/includes/menu.php');
+
+// Use session user_id where available
+// Use session user_id where available; if not present but website_username exists, try to resolve it from DB
+$user_id = intval($_SESSION['website_user_id'] ?? $_SESSION['user_id'] ?? 0);
+if ($user_id <= 0 && isset($_SESSION['website_username']) && !empty($_SESSION['website_username'])) {
+ // try to resolve username to user_id in DB and persist into session
+ $safe_uname = mysqli_real_escape_string($db, $_SESSION['website_username']);
+ $qr = mysqli_query($db, "SELECT user_id FROM ogp_users WHERE users_login = '$safe_uname' LIMIT 1");
+ if ($qr && mysqli_num_rows($qr) === 1) {
+ $rr = mysqli_fetch_assoc($qr);
+ $user_id = intval($rr['user_id'] ?? 0);
+ if ($user_id > 0) {
+ $_SESSION['website_user_id'] = $user_id;
+ site_log_info('cart_resolved_user_id', ['username'=>$_SESSION['website_username'],'user_id'=>$user_id]);
+ // Resolve and persist the user's role to avoid extra DB lookups later
+ $role_q = mysqli_query($db, "SELECT users_role FROM ogp_users WHERE user_id = " . intval($user_id) . " LIMIT 1");
+ if ($role_q && mysqli_num_rows($role_q) === 1) {
+ $role_r = mysqli_fetch_assoc($role_q);
+ $_SESSION['website_user_role'] = $role_r['users_role'] ?? '';
+ }
+ }
+ } else {
+ site_log_warn('cart_resolve_user_failed', ['username'=>$_SESSION['website_username']]);
+ }
+}
+
+if ($user_id <= 0) {
+ echo "Please login to view your cart ";
+ mysqli_close($db);
+ echo "";
+ return;
+}
+
+// Determine admin status for UI: prefer session role, otherwise check DB
+$is_admin = false;
+if (isset($_SESSION['website_user_role']) && !empty($_SESSION['website_user_role'])) {
+ $is_admin = (strtolower($_SESSION['website_user_role']) === 'admin');
+} elseif ($user_id > 0) {
+ $rr = mysqli_query($db, "SELECT users_role FROM ogp_users WHERE user_id = " . intval($user_id) . " LIMIT 1");
+ if ($rr && mysqli_num_rows($rr) === 1) {
+ $rrow = mysqli_fetch_assoc($rr);
+ $is_admin = (strtolower((string)($rrow['users_role'] ?? '')) === 'admin');
+ }
+}
+
+
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_single'])) {
+ $invoice_id = intval($_POST['delete_single']);
+ if ($invoice_id > 0) {
+ // Check if this invoice is linked to an order (renewal case)
+ $stmt = $db->prepare("SELECT order_id FROM ogp_billing_invoices WHERE invoice_id = ? AND user_id = ?");
+ $stmt->bind_param("ii", $invoice_id, $user_id);
+ $stmt->execute();
+ $stmt->bind_result($linked_order_id);
+ $found = $stmt->fetch();
+ $stmt->close();
+
+ if ($found && $linked_order_id > 0) {
+ // This is a renewal invoice - just delete the invoice, keep the order
+ $delete = $db->prepare("DELETE FROM ogp_billing_invoices WHERE invoice_id = ? AND user_id = ?");
+ $delete->bind_param("ii", $invoice_id, $user_id);
+ $delete->execute();
+ if (isset($db) && method_exists($db, 'logger')) {
+ $db->logger("USER-CART: User " . intval($user_id) . " deleted renewal invoice " . intval($invoice_id));
+ }
+ $delete->close();
+ } else {
+ // New order invoice - delete it
+ $delete = $db->prepare("DELETE FROM ogp_billing_invoices WHERE invoice_id = ? AND user_id = ?");
+ $delete->bind_param("ii", $invoice_id, $user_id);
+ $delete->execute();
+ if (isset($db) && method_exists($db, 'logger')) {
+ $db->logger("USER-CART: User " . intval($user_id) . " deleted invoice " . intval($invoice_id));
+ }
+ $delete->close();
+ }
+ }
+}
+
+// Handle coupon application
+$coupon_message = '';
+$coupon_error = '';
+$applied_coupon = null;
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['apply_coupon'])) {
+ $coupon_code = trim($_POST['coupon_code']);
+
+ if (!empty($coupon_code)) {
+ // Validate and fetch coupon
+ $safe_code = mysqli_real_escape_string($db, $coupon_code);
+ $coupon_query = "SELECT * FROM {$table_prefix}billing_coupons
+ WHERE code = '$safe_code'
+ AND is_active = 1
+ AND (expires IS NULL OR expires > NOW())
+ LIMIT 1";
+ $coupon_result = mysqli_query($db, $coupon_query);
+
+ if ($coupon_result && mysqli_num_rows($coupon_result) === 1) {
+ $coupon = mysqli_fetch_assoc($coupon_result);
+
+ // Check usage limits
+ if ($coupon['max_uses'] !== null && intval($coupon['current_uses']) >= intval($coupon['max_uses'])) {
+ $coupon_error = "This coupon has reached its usage limit.";
+ } else {
+ // Store coupon in session for later use
+ $_SESSION['applied_coupon'] = $coupon;
+ $applied_coupon = $coupon;
+ $coupon_message = "Coupon '{$coupon['code']}' applied successfully! " .
+ number_format($coupon['discount_percent'], 2) . "% discount " .
+ ($coupon['usage_type'] === 'permanent' ? '(permanent - applies to all renewals)' : '(one-time only)');
+ }
+ } else {
+ $coupon_error = "Invalid or expired coupon code.";
+ }
+ }
+}
+
+// Handle coupon removal
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_coupon'])) {
+ unset($_SESSION['applied_coupon']);
+ $coupon_message = "Coupon removed.";
+}
+
+// Check if there's a coupon in session
+if (isset($_SESSION['applied_coupon']) && !$applied_coupon) {
+ $applied_coupon = $_SESSION['applied_coupon'];
+}
+
+if ($db){
+ $carts = $db->query("SELECT * FROM ogp_billing_invoices AS cart
+ WHERE status = 'due' AND user_id = " . $user_id . " ORDER BY invoice_id ASC");
+}
+
+?>
+
+
+
Your Cart
+
+
+
+
+
+
+
+ Server ID
+ Game Name
+ Location
+ Max Players
+ Price per Player
+ Months
+ Total
+
+
+
+ num_rows > 0) {
+ while ($row = $carts->fetch_assoc()) {
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ $
+
+
+ 'invoice-' . $row['invoice_id'],
+ 'amount' => number_format($rowtotal, 2, '.', ''),
+ 'invoice_id' => intval($row['invoice_id'])
+ ];
+ ?>
+
+
+
+
+
+
+
+
+ Admin: force-create a paid record for testing.
+
+
+
+
+
+
+ $
+
+
+
+
+
+
+ Cart Total:
+
+
+ $
+
+
+
+
+ No items in your cart.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Active Coupon:
+ (% off)
+
+ Remove
+
+
+
+
+ Have a coupon code?
+
+ Apply Coupon
+
+
+
+
+
+'srv123','amount'=>9.99], ['serverID'=>'srv999','amount'=>14.50]]
+
+// --- Sanity + normalization ---
+if (!isset($grandTotal) || !is_numeric($grandTotal)) {
+ $grandTotal = 0.00;
+}
+if (!isset($invoice) || !is_array($invoice)) {
+ $invoice = [];
+}
+$currency = 'USD';
+$amount = number_format((float)$grandTotal, 2, '.', '');
+$lineItems = [];
+
+// Build PayPal-friendly items array (name, unit_amount, quantity, sku)
+foreach ($invoice as $i) {
+ $sid = isset($i['serverID']) ? (string)$i['serverID'] : 'unknown';
+ $amt = isset($i['amount']) && is_numeric($i['amount']) ? number_format((float)$i['amount'], 2, '.', '') : '0.00';
+ $lineItems[] = [
+ 'name' => "Server $sid",
+ 'quantity' => '1',
+ 'unit_amount' => ['currency_code' => $currency, 'value' => $amt],
+ 'sku' => $sid
+ ];
+}
+
+// Single overall invoice id for the order
+$invoiceId = 'INV-' . date('Ymd-His') . '-' . bin2hex(random_bytes(3));
+
+// A short custom reference derived from your line items (<= 127 chars for PayPal)
+$customHash = substr(strtoupper(sha1(json_encode($invoice))), 0, 16);
+$customId = "INVREF-$customHash";
+// If the cart contains a single order, set custom_id to the numeric order id so webhooks
+// can match the order directly (payment_success matches numeric custom -> order_id).
+if (is_array($invoice) && count($invoice) === 1 && !empty($invoice[0]['order_id'])) {
+ $customId = (string) intval($invoice[0]['order_id']);
+}
+
+// Text on the PayPal side
+$description = 'Game server order (' . count($lineItems) . ' item' . (count($lineItems)===1?'': 's') . ')';
+
+// URLs
+// Define the site base URL - detect protocol and host dynamically
+$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://';
+$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
+$siteBase = $protocol . $host;
+
+// Return URLs are root-relative (website will be deployed at root, not modules/billing)
+$returnUrl = $siteBase . '/payment_success.php?invoice=' . urlencode($invoiceId);
+$cancelUrl = $siteBase . '/payment_cancel.php?invoice=' . urlencode($invoiceId);
+
+// API base (relative) - point to billing module API endpoints
+$apiBase = 'api';
+?>
+
+
+
+
+
+
+ Debug Info:
+ Grand Total: $
+ Invoice Items:
+ Line Items:
+ Amount:
+ Invoice ID:
+ Custom ID:
+
+
+
+
+
+
+
+
+
+
+
+
+
+