Admin — All Invoices
-
-
- ✓ Invoice # updated successfully.
+
+
+ = h($message) ?>
-
-
-
No invoices found.
-
-
-
-
- Order ID
- User
- Home ID
- Home Name
- IP
- Price
- Duration
- Status
- Created
- Finish Date
- Actions
-
-
-
-
-
-
-
-
-
-
-
- 0 && !empty($row['coupon_code'])) {
- echo '$' . number_format($price + $discount, 2) . ' ';
- echo '$' . number_format($price, 2) . ' ';
- echo '(' . h($row['coupon_code']) . ' -' . number_format($row['coupon_discount'], 0) . '%) ';
- } else {
- echo '$' . number_format($price, 2);
- }
- ?>
-
-
-
-
-
-
-
-
-
-
- Edit
-
-
-
-
-
+
+
+ Cancel
+
+
+
+
+
+
+ Refund
+
+
+
+
+
+
+
-
-
-
-
+
-
diff --git a/modules/billing/admin_payments.php b/modules/billing/admin_payments.php
index 11cfd3eb..da4c6839 100644
--- a/modules/billing/admin_payments.php
+++ b/modules/billing/admin_payments.php
@@ -1,59 +1,92 @@
getTransactions($filter, 200, 0);
+ mysqli_close($db);
}
-function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
?>
-
+
-
Admin — Payments
+
Admin — Payment Transactions
-
+
-
Payments (webhook)
-
-
No payment records found in
+
Payment Transaction Log
+
= h($errorMsg) ?>
+
+
+ User ID:
+ Server ID:
+ Method:
+
+ All
+ >PayPal
+ >Stripe
+ >Manual
+
+
+ Filter
+ Clear
+
+
+
+
No transactions found= (!empty($filter) ? ' matching filters' : '') ?>.
-
-
-
- Filename
- Invoice
- Amount
- Payer
- Date
- View
-
-
-
-
-
-
-
-
-
-
- View
-
-
-
-
+
+
+
+ # Invoice User Server
+ Method Txn ID Amount Status Date
+
+
+
+
+
+ = h($t['transaction_id']) ?>
+ = h($t['invoice_id']) ?>
+ = h($t['users_login'] ?? $t['user_id']) ?>
+ = $t['home_id'] ? h($t['home_id']) : '—' ?>
+ = h($t['payment_method']) ?>
+ = h($t['transaction_external_id']) ?>
+ = h($t['currency'] . ' ' . number_format((float)$t['amount'], 2)) ?>
+ = h($t['status']) ?>
+ = h($t['created_at']) ?>
+
+
+
+
-
+
diff --git a/modules/billing/api/capture_order.php b/modules/billing/api/capture_order.php
index 1659eefe..cea38cd8 100644
--- a/modules/billing/api/capture_order.php
+++ b/modules/billing/api/capture_order.php
@@ -1,26 +1,30 @@
'invalid_json', 'request_id' => $requestId]);
- exit;
-}
-
-$paypal_order_id = $input['order_id'] ?? null;
-if (!$paypal_order_id) {
- log_payment('MISSING_ORDER_ID', $input);
- ob_clean();
- echo json_encode(['error' => 'missing_order_id', 'request_id' => $requestId]);
- exit;
-}
-
-log_payment('REQUEST_START', ['order_id' => $paypal_order_id]);
-
-// PayPal API configuration
-$sandbox = true;
-$client_id = 'AfvY_C2zA_hTHxHq7TIhtOeub4xBdySYrt_Hjj3d_WYQwjWI9NfOAVOTeResx2rgZ_nP5tOoxQSAHw8c';
-$client_secret = 'EJ216np9cAj9n7KSddez3fLVxGe-zi4oKKKl1YGqPp88XIikr4Qzbxh0XW2as-V6LgdX-upjtQAg9dC0';
-$api = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
-
-// Get OAuth token
-$ch = curl_init("$api/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 => "$client_id:$client_secret",
-]);
-$tokenResponse = curl_exec($ch);
-$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
-curl_close($ch);
-
-if ($httpCode !== 200) {
- log_payment('OAUTH_FAILED', ['http_code' => $httpCode, 'response' => $tokenResponse]);
- ob_clean();
- echo json_encode(['error' => 'oauth_failed', 'request_id' => $requestId]);
- exit;
-}
-
-$tokenData = json_decode($tokenResponse, true);
-$accessToken = $tokenData['access_token'] ?? null;
-
-if (!$accessToken) {
- log_payment('NO_ACCESS_TOKEN', $tokenData);
- ob_clean();
- echo json_encode(['error' => 'no_access_token', 'request_id' => $requestId]);
- exit;
-}
-
-// Capture the PayPal order
-$ch = curl_init("$api/v2/checkout/orders/$paypal_order_id/capture");
-curl_setopt_array($ch, [
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_POST => true,
- CURLOPT_HTTPHEADER => [
- 'Content-Type: application/json',
- "Authorization: Bearer $accessToken"
- ],
-]);
-$captureResponse = curl_exec($ch);
-$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
-curl_close($ch);
-
-if ($httpCode !== 201 && $httpCode !== 200) {
- log_payment('CAPTURE_FAILED', ['http_code' => $httpCode, 'response' => substr($captureResponse, 0, 500)]);
- ob_clean();
- echo json_encode(['error' => 'capture_failed', 'http_code' => $httpCode, 'request_id' => $requestId]);
- exit;
-}
-
-$captureData = json_decode($captureResponse, true);
-if (json_last_error() !== JSON_ERROR_NONE) {
- log_payment('CAPTURE_JSON_ERROR', json_last_error_msg());
- ob_clean();
- echo json_encode(['error' => 'capture_json_error', 'request_id' => $requestId]);
- exit;
-}
-
-$status = $captureData['status'] ?? null;
-if ($status !== 'COMPLETED') {
- log_payment('NOT_COMPLETED', ['status' => $status]);
- ob_clean();
- echo json_encode(['error' => 'payment_not_completed', 'status' => $status, 'request_id' => $requestId]);
- exit;
-}
-
-// Extract transaction ID
-$txid = $captureData['purchase_units'][0]['payments']['captures'][0]['id'] ?? null;
-$payer_email = $captureData['payer']['email_address'] ?? '';
-$payer_name = ($captureData['payer']['name']['given_name'] ?? '') . ' ' . ($captureData['payer']['name']['surname'] ?? '');
-
-// Store full PayPal response as JSON for admin/refund tracking
-$paypal_json = json_encode($captureData);
-
-log_payment('PAYMENT_CAPTURED', [
- 'txid' => $txid,
- 'payer_email' => $payer_email,
- 'payer_name' => trim($payer_name)
-]);
-
-// Start session to get user_id (use billing website session name)
+// Session (single call)
if (session_status() === PHP_SESSION_NONE) {
- session_name("opengamepanel_web");
+ session_name('opengamepanel_web');
session_start();
}
-$user_id = isset($_SESSION['website_user_id']) ? intval($_SESSION['website_user_id']) :
- (isset($_SESSION['user_id']) ? intval($_SESSION['user_id']) : 0);
-if ($user_id <= 0) {
- log_payment('NO_USER_SESSION', $_SESSION);
+$userId = intval($_SESSION['website_user_id'] ?? $_SESSION['user_id'] ?? 0);
+if ($userId <= 0) {
+ cap_log('NO_USER_SESSION', ['session_keys' => array_keys($_SESSION)]);
ob_clean();
echo json_encode(['error' => 'no_user_session', 'request_id' => $requestId]);
exit;
}
-// Connect to database
-$mysqli = mysqli_connect($db_host, $db_user, $db_pass, $db_name);
+// Parse input
+$rawInput = file_get_contents('php://input');
+$input = json_decode($rawInput, true);
+if (json_last_error() !== JSON_ERROR_NONE) {
+ ob_clean();
+ echo json_encode(['error' => 'invalid_json', 'request_id' => $requestId]);
+ exit;
+}
+
+$paypalOrderId = $input['order_id'] ?? null;
+if (!$paypalOrderId) {
+ ob_clean();
+ echo json_encode(['error' => 'missing_order_id', 'request_id' => $requestId]);
+ exit;
+}
+
+cap_log('REQUEST', ['order_id' => $paypalOrderId, 'user_id' => $userId]);
+
+// DB connection
+$port = intval($db_port ?? 3306) ?: 3306;
+$mysqli = @mysqli_connect($db_host, $db_user, $db_pass, $db_name, $port);
if (!$mysqli) {
- log_payment('DB_CONNECTION_FAILED', mysqli_connect_error());
+ cap_log('DB_FAILED', mysqli_connect_error());
ob_clean();
echo json_encode(['error' => 'db_connection_failed', 'request_id' => $requestId]);
exit;
}
+mysqli_set_charset($mysqli, 'utf8mb4');
-$now = date('Y-m-d H:i:s');
-$esc_txid = mysqli_real_escape_string($mysqli, $txid);
-$esc_paypal_json = mysqli_real_escape_string($mysqli, $paypal_json);
+$prefix = $table_prefix ?? 'gsp_';
+$repo = new BillingRepository($mysqli, $prefix);
+$svc = new BillingService($repo);
-// Apply coupon from session to invoices before marking paid
-session_start();
-$coupon_id = isset($_SESSION['cart_coupon_id']) ? intval($_SESSION['cart_coupon_id']) : 0;
-if ($coupon_id > 0) {
- // Get unpaid invoices for this user to apply coupon
- $invoices_query = "SELECT invoice_id, amount FROM {$table_prefix}billing_invoices
- WHERE user_id=$user_id AND status='due'";
- $invoices_result = mysqli_query($mysqli, $invoices_query);
-
- // Get coupon details
- $coupon_query = "SELECT discount_percent FROM {$table_prefix}billing_coupons
- WHERE coupon_id=$coupon_id AND is_active=1 LIMIT 1";
- $coupon_result = mysqli_query($mysqli, $coupon_query);
-
- if ($coupon_result && mysqli_num_rows($coupon_result) === 1) {
- $coupon_row = mysqli_fetch_assoc($coupon_result);
- $discount_percent = floatval($coupon_row['discount_percent']);
-
- // Update each invoice with coupon
- while ($inv_row = mysqli_fetch_assoc($invoices_result)) {
- $inv_id = intval($inv_row['invoice_id']);
- $inv_amount = floatval($inv_row['amount']);
- $discount_amt = $inv_amount * ($discount_percent / 100);
- $new_amount = $inv_amount - $discount_amt;
-
- $update_coupon_sql = "UPDATE {$table_prefix}billing_invoices
- SET coupon_id=$coupon_id,
- discount_amount=" . number_format($discount_amt, 2, '.', '') . ",
- amount=" . number_format($new_amount, 2, '.', '') . "
- WHERE invoice_id=$inv_id";
- mysqli_query($mysqli, $update_coupon_sql);
- log_payment('COUPON_APPLIED', ['invoice_id' => $inv_id, 'discount' => $discount_amt]);
- }
-
- // Increment coupon usage
- $update_usage_sql = "UPDATE {$table_prefix}billing_coupons
- SET current_uses = current_uses + 1
- WHERE coupon_id=$coupon_id";
- mysqli_query($mysqli, $update_usage_sql);
-
- // Clear coupon from session
- unset($_SESSION['cart_coupon_code']);
- unset($_SESSION['cart_coupon_id']);
- }
-}
-
-// Mark all due invoices for this user as paid.
-// Note: billing_invoices is the pre-purchase cart table and uses its own
-// status vocabulary (due -> paid). This is separate from gsp_invoices
-// (renewal invoices) and server_homes.billing_status (Active/Invoiced/Expired).
-$updateInvoicesSql = "UPDATE {$table_prefix}billing_invoices
- SET status='paid', paid_date='$now', payment_txid='$esc_txid', payment_method='paypal'
- WHERE user_id=$user_id AND status='due'";
-$updateResult = mysqli_query($mysqli, $updateInvoicesSql);
-
-if (!$updateResult) {
- log_payment('UPDATE_INVOICES_FAILED', mysqli_error($mysqli));
- mysqli_close($mysqli);
+// Capture payment via PayPal gateway
+try {
+ $gateway = GatewayFactory::make('paypal');
+} catch (Exception $e) {
+ cap_log('GATEWAY_ERROR', $e->getMessage());
ob_clean();
- echo json_encode(['error' => 'update_invoices_failed', 'request_id' => $requestId]);
+ echo json_encode(['error' => 'gateway_init_failed', 'request_id' => $requestId]);
+ mysqli_close($mysqli);
exit;
}
-$affectedInvoices = mysqli_affected_rows($mysqli);
-log_payment('INVOICES_MARKED_PAID', ['count' => $affectedInvoices]);
+$capture = $gateway->handleCallback(['order_id' => $paypalOrderId]);
+cap_log('CAPTURE_RESULT', ['success' => $capture['success'], 'txid' => $capture['transaction_id'] ?? null]);
-// Get all invoices we just marked paid
-$getInvoicesSql = "SELECT * FROM {$table_prefix}billing_invoices
- WHERE user_id=$user_id AND payment_txid='$esc_txid'";
-$invoicesResult = mysqli_query($mysqli, $getInvoicesSql);
+if (!$capture['success']) {
+ cap_log('CAPTURE_FAILED', $capture);
+ ob_clean();
+ echo json_encode(['error' => $capture['error'] ?? 'capture_failed', 'request_id' => $requestId]);
+ mysqli_close($mysqli);
+ exit;
+}
+$txid = $capture['transaction_id'] ?? '';
+$capture['payment_method'] = 'paypal';
+
+// Process each unpaid invoice for this user
+$invoices = $repo->getUnpaidInvoicesForUser($userId);
+$invoicesPaid = 0;
$ordersCreated = 0;
-$renewedOrders = 0;
-$newOrderIds = [];
-while ($inv = mysqli_fetch_assoc($invoicesResult)) {
- $invoice_id = intval($inv['invoice_id']);
- $existing_order_id = intval($inv['order_id'] ?? 0);
-
- // Handle renewals by extending the existing order
- if ($existing_order_id > 0) {
- $durationUnit = strtolower(trim($inv['invoice_duration'] ?? 'month'));
- $allowedDurations = ['day','month','year'];
- if (!in_array($durationUnit, $allowedDurations, true)) {
- $durationUnit = 'month';
- }
- $qty = max(1, intval($inv['qty'] ?? 1));
- $orderInfoSql = "SELECT end_date FROM {$table_prefix}billing_orders WHERE order_id=$existing_order_id LIMIT 1";
- $orderInfoRes = mysqli_query($mysqli, $orderInfoSql);
- $currentEnd = null;
- if ($orderInfoRes && mysqli_num_rows($orderInfoRes) === 1) {
- $infoRow = mysqli_fetch_assoc($orderInfoRes);
- $currentEnd = $infoRow['end_date'] ?? null;
- }
- $baseTs = time();
- if (!empty($currentEnd)) {
- $parsed = strtotime($currentEnd);
- if ($parsed !== false && $parsed > time()) {
- $baseTs = $parsed;
- }
- }
- $newEndDate = date('Y-m-d H:i:s', strtotime("+$qty $durationUnit", $baseTs));
- $renewSql = "UPDATE {$table_prefix}billing_orders
- SET status='Active', end_date='$newEndDate', paid_ts='$now', payment_txid='$esc_txid'
- WHERE order_id=$existing_order_id LIMIT 1";
- if (mysqli_query($mysqli, $renewSql)) {
- $renewedOrders++;
- log_payment('ORDER_RENEWED', [
- 'order_id' => $existing_order_id,
- 'invoice_id' => $invoice_id,
- 'new_end_date' => $newEndDate
- ]);
+$newOrderIds = [];
+$now = date('Y-m-d H:i:s');
- // Also update server_homes.billing_status and next_invoice_date
- $homeIdRow = mysqli_query($mysqli, "SELECT home_id FROM {$table_prefix}billing_orders WHERE order_id=$existing_order_id LIMIT 1");
- if ($homeIdRow && mysqli_num_rows($homeIdRow) === 1) {
- $homeData = mysqli_fetch_assoc($homeIdRow);
- $home_id_upd = intval($homeData['home_id'] ?? 0);
- if ($home_id_upd > 0) {
- $next_inv_date = mysqli_real_escape_string($mysqli, $newEndDate);
- mysqli_query($mysqli, "UPDATE {$table_prefix}server_homes
- SET billing_status = 'Active',
- next_invoice_date = '$next_inv_date',
- server_expiration_date = NULL
- WHERE home_id = $home_id_upd");
- // Mark the matching gsp_invoices renewal invoice as Active
- mysqli_query($mysqli, "UPDATE {$table_prefix}invoices
- SET billing_status = 'Active',
- paid_at = '$now',
- payment_id = '$esc_txid'
- WHERE home_id = $home_id_upd AND billing_status = 'Invoiced'");
- log_payment('SERVER_HOME_ACTIVATED', ['home_id' => $home_id_upd]);
- }
- }
- } else {
- log_payment('ORDER_RENEWAL_FAILED', [
- 'order_id' => $existing_order_id,
- 'invoice_id' => $invoice_id,
- 'error' => mysqli_error($mysqli)
- ]);
- }
- continue;
+if (empty($invoices)) {
+ cap_log('NO_INVOICES', ['user_id' => $userId]);
+}
+
+foreach ($invoices as $inv) {
+ $invoiceId = intval($inv['invoice_id']);
+ $homeId = intval($inv['home_id'] ?? 0);
+
+ $result = $svc->processPaymentSuccess($capture, $invoiceId, $userId, $homeId, $inv);
+ if ($result['success']) {
+ $invoicesPaid++;
+ cap_log('INVOICE_PAID', ['invoice_id' => $invoiceId, 'txid' => $txid]);
}
-
- // Create new order
- $service_id = intval($inv['service_id']);
- $home_name = mysqli_real_escape_string($mysqli, $inv['home_name']);
- $ip = intval($inv['ip']);
- $max_players = intval($inv['max_players']);
- $qty = intval($inv['qty']);
- $duration = mysqli_real_escape_string($mysqli, $inv['invoice_duration']);
- $amount = floatval($inv['amount']);
- $rcon_pw = mysqli_real_escape_string($mysqli, $inv['remote_control_password']);
- $ftp_pw = mysqli_real_escape_string($mysqli, $inv['ftp_password']);
-
- // Calculate end_date
- $end_date = date('Y-m-d H:i:s', strtotime("+$qty $duration"));
-
- // Insert order with status='Active' (server will be provisioned automatically)
- $insertOrderSql = "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, order_date, end_date,
- payment_txid, paid_ts, paypal_data
- ) VALUES (
- $user_id, $service_id, '$home_name', $ip, $max_players, $qty, '$duration',
- $amount, '$rcon_pw', '$ftp_pw', 'Active', '$now', '$end_date',
- '$esc_txid', '$now', '$esc_paypal_json'
- )";
-
- log_payment('INSERT_ORDER_SQL', substr($insertOrderSql, 0, 300));
-
- if (mysqli_query($mysqli, $insertOrderSql)) {
- $new_order_id = mysqli_insert_id($mysqli);
- log_payment('ORDER_CREATED', ['order_id' => $new_order_id, 'invoice_id' => $invoice_id]);
- $newOrderIds[] = $new_order_id;
-
- // Link invoice to order
- $linkSql = "UPDATE {$table_prefix}billing_invoices SET order_id=$new_order_id WHERE invoice_id=$invoice_id";
- mysqli_query($mysqli, $linkSql);
-
- $ordersCreated++;
- } else {
- log_payment('INSERT_ORDER_FAILED', mysqli_error($mysqli));
+
+ // Handle legacy billing_orders linkage (backward compatibility)
+ $orderId = intval($inv['order_id'] ?? 0);
+ if ($orderId > 0) {
+ $order = $repo->getOrder($orderId);
+ if ($order) {
+ $dur = strtolower($inv['rate_type'] ?? $order['invoice_duration'] ?? 'month');
+ $durMap = [
+ 'daily' => '+1 day', 'monthly' => '+1 month', 'yearly' => '+1 year',
+ 'day' => '+1 day', 'month' => '+1 month', 'year' => '+1 year',
+ ];
+ $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);
+ $ordersCreated++;
+ }
+ }
+}
+
+// Auto-provision new servers (orders without a home_id)
+$autoProvision = ['provisioned_count' => 0, 'failed_count' => 0];
+if (!empty($newOrderIds)) {
+ require_once __DIR__ . '/../includes/panel_bridge.php';
+ $panelCtx = billing_panel_bootstrap();
+ if ($panelCtx && isset($panelCtx['db'])) {
+ $GLOBALS['db'] = $panelCtx['db'];
+ $GLOBALS['settings'] = $panelCtx['settings'];
+ require_once __DIR__ . '/../create_servers.php';
+ $autoProvision = billing_invoke_provision(['order_ids' => $newOrderIds, 'user_id' => $userId, 'is_admin' => true]);
}
}
mysqli_close($mysqli);
-$autoProvisionResult = ['provisioned_count' => 0, 'failed_count' => 0, 'orders' => []];
-if (!empty($newOrderIds)) {
- require_once __DIR__ . '/../includes/panel_bridge.php';
- $panelCtx = billing_panel_bootstrap();
- if ($panelCtx && isset($panelCtx['db']) && $panelCtx['db'] instanceof OGPDatabase) {
- $GLOBALS['db'] = $panelCtx['db'];
- $GLOBALS['settings'] = $panelCtx['settings'];
- require_once __DIR__ . '/../create_servers.php';
- $autoProvisionResult = billing_invoke_provision([
- 'order_ids' => $newOrderIds,
- 'user_id' => $user_id,
- 'is_admin' => true
- ]);
- log_payment('AUTO_PROVISION_COMPLETE', $autoProvisionResult);
- } else {
- log_payment('AUTO_PROVISION_SKIPPED', 'panel bootstrap failed');
- }
-}
+cap_log('COMPLETE', ['invoices_paid' => $invoicesPaid, 'txid' => $txid]);
-log_payment('PROCESSING_COMPLETE', [
- 'invoices_paid' => $affectedInvoices,
- 'orders_created' => $ordersCreated,
- 'orders_renewed' => $renewedOrders,
- 'txid' => $txid
-]);
-
-// Return success response
ob_clean();
echo json_encode([
- 'status' => 'COMPLETED',
- 'order_id' => $paypal_order_id,
- 'txid' => $txid,
- 'invoices_paid' => $affectedInvoices,
+ 'status' => 'COMPLETED',
+ 'txid' => $txid,
+ 'invoices_paid' => $invoicesPaid,
'orders_created' => $ordersCreated,
- 'orders_renewed' => $renewedOrders,
- 'provisioned' => $autoProvisionResult['provisioned_count'] ?? 0
+ 'provisioned' => $autoProvision['provisioned_count'] ?? 0,
+ 'request_id' => $requestId,
]);
-
-
diff --git a/modules/billing/api/create_order.php b/modules/billing/api/create_order.php
index cde625ba..8b0a4856 100644
--- a/modules/billing/api/create_order.php
+++ b/modules/billing/api/create_order.php
@@ -1,266 +1,84 @@
$_SERVER['REQUEST_METHOD'] ?? 'UNKNOWN',
- 'remote_addr' => $_SERVER['REMOTE_ADDR'] ?? 'UNKNOWN',
- 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'UNKNOWN',
-]);
-
header('Content-Type: application/json');
-// Read and parse input
$rawInput = file_get_contents('php://input');
-create_order_log('RAW_INPUT', substr($rawInput, 0, 2000)); // Log first 2000 chars
-
-$in = json_decode($rawInput, true);
-if (json_last_error() !== JSON_ERROR_NONE) {
- create_order_log('JSON_DECODE_ERROR', [
- 'error' => json_last_error_msg(),
- 'raw_input_length' => strlen($rawInput),
- 'raw_input_preview' => substr($rawInput, 0, 500)
- ]);
+$in = json_decode($rawInput, true);
+if (json_last_error() !== JSON_ERROR_NONE || !$in) {
http_response_code(400);
- echo json_encode(['error' => 'invalid_json', 'message' => json_last_error_msg(), 'request_id' => $requestId]);
+ echo json_encode(['error' => 'invalid_json', 'request_id' => $requestId]);
exit;
}
-if (!$in) {
- $in = [];
+co_log('REQUEST', ['amount' => $in['amount'] ?? null, 'invoice_id' => $in['invoice_id'] ?? null]);
+
+// Resolve site base for return/cancel URLs
+$siteBase = rtrim($GLOBALS['SITE_BASE_URL'] ?? '', '/');
+$returnUrl = $in['return_url'] ?? '/payment_success.php';
+$cancelUrl = $in['cancel_url'] ?? '/payment_cancel.php';
+
+// Ensure absolute URLs
+if (strpos($returnUrl, 'http') !== 0) {
+ $returnUrl = $siteBase . '/' . ltrim($returnUrl, '/');
+}
+if (strpos($cancelUrl, 'http') !== 0) {
+ $cancelUrl = $siteBase . '/' . ltrim($cancelUrl, '/');
}
-$amount_in = $in['amount'] ?? '0.00';
-$currency = $in['currency'] ?? 'USD';
-$invoice_id = $in['invoice_id'] ?? null;
-$custom_id = $in['custom_id'] ?? null;
-$description = $in['description'] ?? 'Order';
-$return_url = $in['return_url'] ?? null;
-$cancel_url = $in['cancel_url'] ?? null;
-$items = (isset($in['items']) && is_array($in['items'])) ? $in['items'] : null;
-$line_invoices= (isset($in['line_invoices']) && is_array($in['line_invoices'])) ? $in['line_invoices'] : null;
-
-create_order_log('PARSED_INPUT', [
- 'amount' => $amount_in,
- 'currency' => $currency,
- 'invoice_id' => $invoice_id,
- 'custom_id' => $custom_id,
- 'items_count' => $items ? count((array)$items) : 0,
- 'line_invoices_count' => $line_invoices ? count((array)$line_invoices) : 0
-]);
-
-$amount_value = number_format((float)$amount_in, 2, '.', '');
-if ($items) {
- $sum = 0.00;
- foreach ((array)$items as $it) {
- $qty = isset($it['quantity']) ? (int)$it['quantity'] : 1;
- $val = isset($it['unit_amount']['value']) ? (float)$it['unit_amount']['value'] : 0.00;
- $sum += $qty * $val;
- }
- $amount_value = number_format($sum, 2, '.', '');
- create_order_log('AMOUNT_CALCULATED', [
- 'original_amount' => $amount_in,
- 'calculated_from_items' => $amount_value,
- 'items_sum' => $sum
- ]);
-}
-
-$api = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
-create_order_log('PAYPAL_API_CONFIG', [
- 'sandbox_mode' => $sandbox,
- 'api_base' => $api,
- 'has_client_id' => !empty($client_id),
- 'has_client_secret' => !empty($client_secret)
-]);
-
-// Step 1: Get OAuth token
-create_order_log('OAUTH_REQUEST_START', ['endpoint' => "$api/v1/oauth2/token"]);
-
-$ch = curl_init("$api/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 => $client_id . ':' . $client_secret,
-]);
-$tok = curl_exec($ch);
-$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
-$curl_errno = curl_errno($ch);
-$curl_error = curl_error($ch);
-curl_close($ch);
-
-create_order_log('OAUTH_RESPONSE', [
- 'http_code' => $http,
- 'curl_errno' => $curl_errno,
- 'curl_error' => $curl_error,
- 'response_length' => strlen($tok),
- 'response_preview' => substr($tok, 0, 200)
-]);
-
-if ($curl_errno !== 0) {
- create_order_log('OAUTH_CURL_ERROR', ['errno' => $curl_errno, 'error' => $curl_error]);
- http_response_code(502);
- echo json_encode(['error' => 'oauth_curl_fail', 'details' => $curl_error, 'request_id' => $requestId]);
- exit;
-}
-
-if ($http !== 200) {
- create_order_log('OAUTH_HTTP_ERROR', ['http_code' => $http, 'response' => $tok]);
- http_response_code(500);
- echo json_encode(['error' => 'oauth_fail', 'http_code' => $http, 'request_id' => $requestId]);
- exit;
-}
-
-$access = json_decode($tok, true)['access_token'] ?? null;
-if (!$access) {
- create_order_log('OAUTH_NO_TOKEN', ['response' => $tok]);
- http_response_code(500);
- echo json_encode(['error' => 'oauth_no_token', 'request_id' => $requestId]);
- exit;
-}
-
-create_order_log('OAUTH_SUCCESS', ['token_length' => strlen($access)]);
-
-// Update site base URL to exclude 'modules/billing'
-$siteBaseUrl = 'http://gameservers.world';
-
-create_order_log('URL_PROCESSING_BEFORE', [
- 'return_url' => $return_url,
- 'cancel_url' => $cancel_url,
- 'site_base' => $siteBaseUrl
-]);
-
-// Ensure return_url and cancel_url are absolute URLs (relative to site root)
-if (strpos($return_url, 'http') !== 0) {
- $return_url = $siteBaseUrl . '/' . ltrim($return_url, '/');
-}
-if (strpos($cancel_url, 'http') !== 0) {
- $cancel_url = $siteBaseUrl . '/' . ltrim($cancel_url, '/');
-}
-
-create_order_log('URL_PROCESSING_AFTER', [
- 'return_url' => $return_url,
- 'cancel_url' => $cancel_url
-]);
-
-$purchaseUnit = [
- 'amount' => [ 'currency_code' => $currency, 'value' => $amount_value ],
- 'description' => $description,
- 'invoice_id' => $invoice_id,
- 'custom_id' => $custom_id
-];
-if ($items) {
- $purchaseUnit['items'] = $items;
- $purchaseUnit['amount']['breakdown'] = [ 'item_total' => ['currency_code'=>$currency,'value'=>$amount_value] ];
-}
-
-$body = [
- 'intent' => 'CAPTURE',
- 'purchase_units' => [ $purchaseUnit ],
- 'application_context' => [ 'return_url'=>$return_url, 'cancel_url'=>$cancel_url, 'user_action'=>'PAY_NOW' ]
+// Build gateway params
+$params = [
+ 'amount' => $in['amount'] ?? '0.00',
+ 'currency' => $in['currency'] ?? 'USD',
+ 'invoice_id' => $in['invoice_id'] ?? null,
+ 'custom_id' => $in['custom_id'] ?? $in['invoice_id'] ?? null,
+ 'description' => $in['description'] ?? 'Game Server Order',
+ 'return_url' => $returnUrl,
+ 'cancel_url' => $cancelUrl,
+ 'items' => $in['items'] ?? null,
];
-create_order_log('PAYPAL_ORDER_PAYLOAD', $body);
-
-// Step 2: Create PayPal order
-create_order_log('CREATE_ORDER_REQUEST_START', ['endpoint' => "$api/v2/checkout/orders"]);
-
-$ch = curl_init("$api/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 ],
-]);
-$res = curl_exec($ch);
-$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
-$curl_errno = curl_errno($ch);
-$curl_error = curl_error($ch);
-curl_close($ch);
-
-create_order_log('CREATE_ORDER_RESPONSE', [
- 'http_code' => $http,
- 'curl_errno' => $curl_errno,
- 'curl_error' => $curl_error,
- 'response_length' => strlen($res),
- 'response' => substr($res, 0, 1000) // First 1000 chars of response
-]);
-
-if ($curl_errno !== 0) {
- create_order_log('CREATE_ORDER_CURL_ERROR', ['errno' => $curl_errno, 'error' => $curl_error]);
- http_response_code(502);
- echo json_encode(['error' => 'create_order_curl_fail', 'details' => $curl_error, 'request_id' => $requestId]);
+try {
+ $gateway = GatewayFactory::make('paypal');
+ $result = $gateway->createPayment($params);
+} catch (Exception $e) {
+ co_log('EXCEPTION', $e->getMessage());
+ http_response_code(500);
+ echo json_encode(['error' => 'gateway_error', 'message' => $e->getMessage(), 'request_id' => $requestId]);
exit;
}
-if ($http !== 201) {
- create_order_log('CREATE_ORDER_HTTP_ERROR', [
- 'http_code' => $http,
- 'response' => $res,
- 'payload_sent' => $body
- ]);
-
- // Try to parse PayPal error response
- $errorData = json_decode($res, true);
- http_response_code($http);
- echo json_encode([
- 'error' => 'create_order_failed',
- 'http_code' => $http,
- 'paypal_error' => $errorData,
- 'request_id' => $requestId
- ]);
+if (!$result['success']) {
+ co_log('CREATE_FAILED', $result);
+ http_response_code(500);
+ echo json_encode(['error' => $result['error'] ?? 'create_failed', 'request_id' => $requestId]);
exit;
}
-// Success - parse and validate response
-$orderData = json_decode($res, true);
-if (json_last_error() !== JSON_ERROR_NONE) {
- create_order_log('CREATE_ORDER_INVALID_JSON', [
- 'json_error' => json_last_error_msg(),
- 'response' => $res
- ]);
- http_response_code(502);
- echo json_encode(['error' => 'invalid_paypal_response', 'request_id' => $requestId]);
- exit;
-}
-
-create_order_log('CREATE_ORDER_SUCCESS', [
- 'order_id' => $orderData['id'] ?? 'UNKNOWN',
- 'status' => $orderData['status'] ?? 'UNKNOWN'
-]);
-
-echo $res;
-
-?>
+co_log('CREATE_SUCCESS', ['provider_order_id' => $result['provider_order_id']]);
+echo json_encode($result['raw_response']);
diff --git a/modules/billing/classes/BillingRepository.php b/modules/billing/classes/BillingRepository.php
new file mode 100644
index 00000000..c059d5c1
--- /dev/null
+++ b/modules/billing/classes/BillingRepository.php
@@ -0,0 +1,279 @@
+db = $db;
+ $this->prefix = $prefix;
+ }
+
+ // ---------------------------------------------------------------
+ // Invoice helpers
+ // ---------------------------------------------------------------
+
+ /** Find a single 'unpaid' invoice by ID, owned by $userId. */
+ public function getUnpaidInvoice(int $invoiceId, int $userId): ?array
+ {
+ $stmt = $this->db->prepare(
+ "SELECT * FROM `{$this->prefix}billing_invoices`
+ WHERE invoice_id = ? AND user_id = ? AND payment_status IN ('unpaid','due') LIMIT 1"
+ );
+ if (!$stmt) return null;
+ $stmt->bind_param('ii', $invoiceId, $userId);
+ $stmt->execute();
+ $row = $stmt->get_result()->fetch_assoc();
+ $stmt->close();
+ return $row ?: null;
+ }
+
+ /** Get all unpaid invoices for a user. */
+ public function getUnpaidInvoicesForUser(int $userId): array
+ {
+ $stmt = $this->db->prepare(
+ "SELECT * FROM `{$this->prefix}billing_invoices`
+ WHERE user_id = ? AND payment_status IN ('unpaid','due')
+ ORDER BY invoice_id ASC"
+ );
+ if (!$stmt) return [];
+ $stmt->bind_param('i', $userId);
+ $stmt->execute();
+ return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
+ }
+
+ /** Mark an invoice as paid. */
+ public function markInvoicePaid(int $invoiceId, string $txid, string $method, string $paidAt): bool
+ {
+ $stmt = $this->db->prepare(
+ "UPDATE `{$this->prefix}billing_invoices`
+ SET payment_status='paid', payment_txid=?, payment_method=?, paid_date=?
+ WHERE invoice_id = ? LIMIT 1"
+ );
+ if (!$stmt) return false;
+ $stmt->bind_param('sssi', $txid, $method, $paidAt, $invoiceId);
+ $ok = $stmt->execute();
+ $stmt->close();
+ return $ok;
+ }
+
+ /** Create a new invoice record. Returns new invoice_id or 0 on failure. */
+ public function createInvoice(array $data): int
+ {
+ $fields = [
+ 'user_id', 'service_id', 'home_id', 'home_name',
+ 'customer_name', 'customer_email',
+ 'rate_type', 'rate_per_player', 'players',
+ 'period_start', 'period_end',
+ 'subtotal', 'total_due',
+ 'currency', 'payment_status', 'payment_method', 'description',
+ ];
+ $cols = implode(',', array_map(fn($f) => "`$f`", $fields));
+ $places = implode(',', array_fill(0, count($fields), '?'));
+ $types = 'iiissssssiissssss';
+
+ $stmt = $this->db->prepare(
+ "INSERT INTO `{$this->prefix}billing_invoices` ({$cols}) VALUES ({$places})"
+ );
+ if (!$stmt) return 0;
+
+ $vals = [];
+ foreach ($fields as $f) {
+ $vals[] = $data[$f] ?? null;
+ }
+ $stmt->bind_param($types, ...$vals);
+ if (!$stmt->execute()) { $stmt->close(); return 0; }
+ $id = (int)$stmt->insert_id;
+ $stmt->close();
+ return $id;
+ }
+
+ // ---------------------------------------------------------------
+ // Transaction (payment log) helpers
+ // ---------------------------------------------------------------
+
+ /** Insert a row into gsp_billing_transactions. Returns new transaction_id. */
+ public function logTransaction(array $data): int
+ {
+ $stmt = $this->db->prepare(
+ "INSERT INTO `{$this->prefix}billing_transactions`
+ (invoice_id, user_id, home_id, payment_method, transaction_external_id,
+ amount, currency, status, raw_response)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ );
+ if (!$stmt) return 0;
+ $rawJson = is_array($data['raw_response']) ? json_encode($data['raw_response']) : (string)($data['raw_response'] ?? '');
+ $stmt->bind_param(
+ 'iiissdss' . 's',
+ $data['invoice_id'],
+ $data['user_id'],
+ $data['home_id'],
+ $data['payment_method'],
+ $data['transaction_external_id'],
+ $data['amount'],
+ $data['currency'],
+ $data['status'],
+ $rawJson
+ );
+ if (!$stmt->execute()) { $stmt->close(); return 0; }
+ $id = (int)$stmt->insert_id;
+ $stmt->close();
+ return $id;
+ }
+
+ /** Get all transactions, optionally filtered. */
+ public function getTransactions(array $filter = [], int $limit = 100, int $offset = 0): array
+ {
+ $where = '1=1';
+ $params = [];
+ $types = '';
+ if (!empty($filter['user_id'])) {
+ $where .= ' AND t.user_id = ?';
+ $params[] = intval($filter['user_id']);
+ $types .= 'i';
+ }
+ if (!empty($filter['home_id'])) {
+ $where .= ' AND t.home_id = ?';
+ $params[] = intval($filter['home_id']);
+ $types .= 'i';
+ }
+ if (!empty($filter['payment_method'])) {
+ $where .= ' AND t.payment_method = ?';
+ $params[] = $filter['payment_method'];
+ $types .= 's';
+ }
+ $sql = "SELECT t.*, u.users_login, u.users_email
+ FROM `{$this->prefix}billing_transactions` t
+ LEFT JOIN `{$this->prefix}users` u ON u.user_id = t.user_id
+ WHERE {$where}
+ ORDER BY t.transaction_id DESC
+ LIMIT ? OFFSET ?";
+ $params[] = $limit;
+ $params[] = $offset;
+ $types .= 'ii';
+ $stmt = $this->db->prepare($sql);
+ if (!$stmt) return [];
+ $stmt->bind_param($types, ...$params);
+ $stmt->execute();
+ return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
+ }
+
+ // ---------------------------------------------------------------
+ // Server home (billing state) helpers
+ // ---------------------------------------------------------------
+
+ /** Get server home billing info by home_id. */
+ public function getServerHomeBilling(int $homeId): ?array
+ {
+ $stmt = $this->db->prepare(
+ "SELECT home_id, home_name, user_id_main, billing_status, billing_expires_at,
+ billing_price, billing_rate_type, billing_players, billing_enabled,
+ next_invoice_date, server_expiration_date, billing_invoice_sent_at
+ FROM `{$this->prefix}server_homes`
+ WHERE home_id = ? LIMIT 1"
+ );
+ if (!$stmt) return null;
+ $stmt->bind_param('i', $homeId);
+ $stmt->execute();
+ $row = $stmt->get_result()->fetch_assoc();
+ $stmt->close();
+ return $row ?: null;
+ }
+
+ /** Update billing state fields on server_homes. */
+ public function updateServerHomeBilling(int $homeId, array $data): bool
+ {
+ $allowed = [
+ 'billing_status', 'billing_expires_at', 'billing_price',
+ 'billing_rate_type', 'billing_players', 'billing_enabled',
+ 'next_invoice_date', 'server_expiration_date', 'billing_invoice_sent_at',
+ ];
+ $set = [];
+ $params = [];
+ $types = '';
+ foreach ($allowed as $col) {
+ if (array_key_exists($col, $data)) {
+ $set[] = "`{$col}` = ?";
+ $params[] = $data[$col];
+ $types .= is_int($data[$col]) ? 'i' : 's';
+ }
+ }
+ if (empty($set)) return false;
+ $params[] = $homeId;
+ $types .= 'i';
+ $stmt = $this->db->prepare(
+ "UPDATE `{$this->prefix}server_homes` SET " . implode(', ', $set) . " WHERE home_id = ? LIMIT 1"
+ );
+ if (!$stmt) return false;
+ $stmt->bind_param($types, ...$params);
+ $ok = $stmt->execute();
+ $stmt->close();
+ return $ok;
+ }
+
+ // ---------------------------------------------------------------
+ // Service helpers
+ // ---------------------------------------------------------------
+
+ /** Get a billing service by ID. Returns null if not found / disabled. */
+ public function getService(int $serviceId, bool $mustBeEnabled = true): ?array
+ {
+ $extra = $mustBeEnabled ? ' AND enabled = 1' : '';
+ $stmt = $this->db->prepare(
+ "SELECT * FROM `{$this->prefix}billing_services` WHERE service_id = ?{$extra} LIMIT 1"
+ );
+ if (!$stmt) return null;
+ $stmt->bind_param('i', $serviceId);
+ $stmt->execute();
+ $row = $stmt->get_result()->fetch_assoc();
+ $stmt->close();
+ return $row ?: null;
+ }
+
+ /** Get enabled services (for storefront listing). */
+ public function getEnabledServices(): array
+ {
+ $res = $this->db->query(
+ "SELECT * FROM `{$this->prefix}billing_services` WHERE enabled = 1 ORDER BY service_name"
+ );
+ return $res ? $res->fetch_all(MYSQLI_ASSOC) : [];
+ }
+
+ // ---------------------------------------------------------------
+ // Legacy billing_orders helpers (kept for backward compat during migration)
+ // ---------------------------------------------------------------
+
+ /** Get an active order by order_id. */
+ public function getOrder(int $orderId): ?array
+ {
+ $stmt = $this->db->prepare(
+ "SELECT * FROM `{$this->prefix}billing_orders` WHERE order_id = ? LIMIT 1"
+ );
+ if (!$stmt) return null;
+ $stmt->bind_param('i', $orderId);
+ $stmt->execute();
+ $row = $stmt->get_result()->fetch_assoc();
+ $stmt->close();
+ return $row ?: null;
+ }
+
+ /** Extend an existing order's end_date. */
+ public function extendOrder(int $orderId, string $newEndDate, string $txid, string $now): bool
+ {
+ $stmt = $this->db->prepare(
+ "UPDATE `{$this->prefix}billing_orders`
+ SET status='Active', end_date=?, payment_txid=?, paid_ts=?
+ WHERE order_id=? LIMIT 1"
+ );
+ if (!$stmt) return false;
+ $stmt->bind_param('sssi', $newEndDate, $txid, $now, $orderId);
+ $ok = $stmt->execute();
+ $stmt->close();
+ return $ok;
+ }
+}
diff --git a/modules/billing/classes/BillingService.php b/modules/billing/classes/BillingService.php
new file mode 100644
index 00000000..78faae4d
--- /dev/null
+++ b/modules/billing/classes/BillingService.php
@@ -0,0 +1,188 @@
+repo = $repo;
+ }
+
+ /**
+ * Calculate pricing for a new order.
+ *
+ * @param array $service Row from gsp_billing_services
+ * @param string $rateType 'daily' | 'monthly' | 'yearly'
+ * @param int $players Number of player slots
+ * @param int $qty Duration quantity (e.g. 2 = 2 months)
+ * @return array { rate_per_player, subtotal, total_due, period_days }
+ */
+ public function calculatePrice(array $service, string $rateType, int $players, int $qty = 1): array
+ {
+ $qty = max(1, $qty);
+ $players = max(1, $players);
+
+ switch ($rateType) {
+ case 'daily':
+ $basePrice = (float)($service['price_daily'] ?? 0);
+ $periodDays = $qty;
+ break;
+ case 'yearly':
+ $basePrice = (float)($service['price_year'] ?? 0);
+ $periodDays = $qty * 365;
+ break;
+ case 'monthly':
+ default:
+ $rateType = 'monthly';
+ $basePrice = (float)($service['price_monthly'] ?? 0);
+ $periodDays = $qty * 31;
+ break;
+ }
+
+ // price_monthly etc is the per-player per-period rate
+ $ratePerPlayer = $basePrice;
+ $subtotal = round($ratePerPlayer * $players * $qty, 2);
+ $totalDue = $subtotal;
+
+ return [
+ 'rate_type' => $rateType,
+ 'rate_per_player' => $ratePerPlayer,
+ 'players' => $players,
+ 'qty' => $qty,
+ 'subtotal' => $subtotal,
+ 'total_due' => $totalDue,
+ 'period_days' => $periodDays,
+ ];
+ }
+
+ /**
+ * Create a billing invoice row.
+ *
+ * @param array $pricing Result from calculatePrice()
+ * @param array $context { user_id, service_id, home_id, home_name, customer_name, customer_email, description }
+ * @return int New invoice_id (0 on failure)
+ */
+ public function createInvoice(array $pricing, array $context): int
+ {
+ $now = date('Y-m-d H:i:s');
+ $periodStart = $now;
+ $periodEnd = date('Y-m-d H:i:s', strtotime('+' . $pricing['period_days'] . ' days'));
+
+ return $this->repo->createInvoice([
+ 'user_id' => intval($context['user_id'] ?? 0),
+ 'service_id' => intval($context['service_id'] ?? 0),
+ 'home_id' => intval($context['home_id'] ?? 0),
+ 'home_name' => $context['home_name'] ?? '',
+ 'customer_name' => $context['customer_name'] ?? '',
+ 'customer_email' => $context['customer_email'] ?? '',
+ 'rate_type' => $pricing['rate_type'],
+ 'rate_per_player' => $pricing['rate_per_player'],
+ 'players' => $pricing['players'],
+ 'period_start' => $periodStart,
+ 'period_end' => $periodEnd,
+ 'subtotal' => $pricing['subtotal'],
+ 'total_due' => $pricing['total_due'],
+ 'currency' => $context['currency'] ?? 'USD',
+ 'payment_status' => 'unpaid',
+ 'payment_method' => '',
+ 'description' => $context['description'] ?? '',
+ ]);
+ }
+
+ /**
+ * Process a successful payment result from a gateway.
+ *
+ * 1. Log the transaction
+ * 2. Mark invoice paid
+ * 3. Update server home billing state (extend or reset expiration)
+ *
+ * @param array $captureResult Result from PaymentGatewayInterface::handleCallback()
+ * @param int $invoiceId
+ * @param int $userId
+ * @param int $homeId
+ * @param array $invoiceRow The invoice row (from DB) — needed for period/pricing
+ * @return array { success: bool, transaction_id: string, error?: string }
+ */
+ public function processPaymentSuccess(
+ array $captureResult,
+ int $invoiceId,
+ int $userId,
+ int $homeId,
+ array $invoiceRow
+ ): array {
+ $txid = $captureResult['transaction_id'] ?? null;
+ $method = $captureResult['payment_method'] ?? 'paypal';
+ $amount = (float)($captureResult['amount'] ?? $invoiceRow['total_due'] ?? 0);
+ $currency = $captureResult['currency'] ?? $invoiceRow['currency'] ?? 'USD';
+ $now = date('Y-m-d H:i:s');
+
+ // 1. Log transaction
+ $this->repo->logTransaction([
+ 'invoice_id' => $invoiceId,
+ 'user_id' => $userId,
+ 'home_id' => $homeId,
+ 'payment_method' => $method,
+ 'transaction_external_id' => $txid ?? '',
+ 'amount' => $amount,
+ 'currency' => $currency,
+ 'status' => 'completed',
+ 'raw_response' => $captureResult['raw_response'] ?? [],
+ ]);
+
+ // 2. Mark invoice paid
+ if ($invoiceId > 0) {
+ $this->repo->markInvoicePaid($invoiceId, $txid ?? '', $method, $now);
+ }
+
+ // 3. Update server home billing state
+ if ($homeId > 0) {
+ $this->extendServerBilling($homeId, $invoiceRow, $now);
+ }
+
+ return ['success' => true, 'transaction_id' => $txid];
+ }
+
+ /**
+ * Extend or reset a server's billing expiration based on the invoice period.
+ */
+ public function extendServerBilling(int $homeId, array $invoiceRow, string $now): void
+ {
+ $home = $this->repo->getServerHomeBilling($homeId);
+ $periodEnd = $invoiceRow['period_end'] ?? null;
+
+ if (!$periodEnd) {
+ $rateType = $invoiceRow['rate_type'] ?? 'monthly';
+ $periodMap = ['daily' => '+1 day', 'monthly' => '+31 days', 'yearly' => '+365 days'];
+ $periodEnd = date('Y-m-d H:i:s', strtotime($periodMap[$rateType] ?? '+31 days'));
+ }
+
+ // If current expiry is in the future, extend from it; otherwise reset from period_end
+ $currentExpiry = $home['billing_expires_at'] ?? null;
+ if ($currentExpiry && strtotime($currentExpiry) > time()) {
+ $currentPeriodSecs = strtotime($invoiceRow['period_end'] ?? $now) - strtotime($invoiceRow['period_start'] ?? $now);
+ $newExpiry = date('Y-m-d H:i:s', strtotime($currentExpiry) + max(86400, $currentPeriodSecs));
+ } else {
+ $newExpiry = $periodEnd;
+ }
+
+ $this->repo->updateServerHomeBilling($homeId, [
+ 'billing_status' => 'active',
+ 'billing_expires_at' => $newExpiry,
+ 'next_invoice_date' => $newExpiry,
+ 'server_expiration_date' => null,
+ 'billing_invoice_sent_at' => null,
+ ]);
+ }
+}
diff --git a/modules/billing/classes/GatewayFactory.php b/modules/billing/classes/GatewayFactory.php
new file mode 100644
index 00000000..d9518ca2
--- /dev/null
+++ b/modules/billing/classes/GatewayFactory.php
@@ -0,0 +1,30 @@
+ true, 'provider_order_id' => 'MANUAL-' . uniqid(), 'raw_response' => []];
+ }
+
+ public function handleCallback(array $params): array
+ {
+ $txid = $params['admin_txid'] ?? ('MANUAL-' . uniqid());
+ return [
+ 'success' => true,
+ 'transaction_id' => $txid,
+ 'amount' => (float)($params['amount'] ?? 0),
+ 'currency' => $params['currency'] ?? 'USD',
+ 'status' => 'completed',
+ 'raw_response' => $params,
+ ];
+ }
+
+ public function verifyPayment(array $payload): bool { return true; }
+
+ public function getTransactionId(array $captureResult): ?string
+ {
+ return $captureResult['transaction_id'] ?? null;
+ }
+}
diff --git a/modules/billing/classes/PayPalGateway.php b/modules/billing/classes/PayPalGateway.php
new file mode 100644
index 00000000..5aeb27aa
--- /dev/null
+++ b/modules/billing/classes/PayPalGateway.php
@@ -0,0 +1,188 @@
+clientId = $clientId;
+ $this->clientSecret = $clientSecret;
+ $this->sandbox = $sandbox;
+ $this->apiBase = $sandbox
+ ? 'https://api-m.sandbox.paypal.com'
+ : 'https://api-m.paypal.com';
+ }
+
+ /**
+ * Build a PayPalGateway instance from global config variables.
+ * Expects $paypal_client_id, $paypal_client_secret, $paypal_sandbox in scope.
+ */
+ public static function fromConfig(): self
+ {
+ $clientId = $GLOBALS['paypal_client_id'] ?? '';
+ $clientSecret = $GLOBALS['paypal_client_secret'] ?? '';
+ $sandbox = (bool)($GLOBALS['paypal_sandbox'] ?? true);
+ return new self($clientId, $clientSecret, $sandbox);
+ }
+
+ public function getName(): string { return 'paypal'; }
+
+ /** Exchange client credentials for a Bearer token. Returns token or null. */
+ private function getAccessToken(): ?string
+ {
+ $ch = curl_init("{$this->apiBase}/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 => "{$this->clientId}:{$this->clientSecret}",
+ ]);
+ $body = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ if ($code !== 200 || !$body) return null;
+ $data = json_decode($body, true);
+ return $data['access_token'] ?? null;
+ }
+
+ public function createPayment(array $params): array
+ {
+ $token = $this->getAccessToken();
+ if (!$token) {
+ return ['success' => false, 'error' => 'paypal_oauth_failed'];
+ }
+
+ $amount = number_format((float)($params['amount'] ?? 0), 2, '.', '');
+ $currency = $params['currency'] ?? 'USD';
+ $invoiceId = $params['invoice_id'] ?? null;
+ $description = $params['description'] ?? 'Game Server Order';
+ $returnUrl = $params['return_url'] ?? '';
+ $cancelUrl = $params['cancel_url'] ?? '';
+ $items = $params['items'] ?? null;
+
+ $purchaseUnit = [
+ 'amount' => ['currency_code' => $currency, 'value' => $amount],
+ 'description' => $description,
+ 'custom_id' => (string)($params['custom_id'] ?? $invoiceId ?? ''),
+ ];
+ if ($invoiceId) {
+ $purchaseUnit['invoice_id'] = (string)$invoiceId;
+ }
+ if ($items) {
+ $purchaseUnit['items'] = $items;
+ $purchaseUnit['amount']['breakdown'] = [
+ 'item_total' => ['currency_code' => $currency, 'value' => $amount],
+ ];
+ }
+
+ $body = [
+ 'intent' => 'CAPTURE',
+ 'purchase_units' => [$purchaseUnit],
+ 'application_context' => [
+ 'return_url' => $returnUrl,
+ 'cancel_url' => $cancelUrl,
+ 'user_action' => 'PAY_NOW',
+ ],
+ ];
+
+ $ch = curl_init("{$this->apiBase}/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 {$token}",
+ ],
+ ]);
+ $res = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($code !== 201 || !$res) {
+ return ['success' => false, 'error' => 'paypal_create_order_failed', 'http_code' => $code];
+ }
+ $data = json_decode($res, true);
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ return ['success' => false, 'error' => 'paypal_invalid_response'];
+ }
+ return [
+ 'success' => true,
+ 'provider_order_id' => $data['id'] ?? '',
+ 'raw_response' => $data,
+ ];
+ }
+
+ public function handleCallback(array $params): array
+ {
+ $providerOrderId = $params['order_id'] ?? null;
+ if (!$providerOrderId) {
+ return ['success' => false, 'error' => 'missing_order_id'];
+ }
+
+ $token = $this->getAccessToken();
+ if (!$token) {
+ return ['success' => false, 'error' => 'paypal_oauth_failed'];
+ }
+
+ $ch = curl_init("{$this->apiBase}/v2/checkout/orders/{$providerOrderId}/capture");
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_POST => true,
+ CURLOPT_HTTPHEADER => [
+ 'Content-Type: application/json',
+ "Authorization: Bearer {$token}",
+ ],
+ ]);
+ $res = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if (($code !== 200 && $code !== 201) || !$res) {
+ return ['success' => false, 'error' => 'paypal_capture_failed', 'http_code' => $code];
+ }
+ $data = json_decode($res, true);
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ return ['success' => false, 'error' => 'paypal_invalid_capture_response'];
+ }
+
+ $status = $data['status'] ?? '';
+ if ($status !== 'COMPLETED') {
+ return ['success' => false, 'error' => 'payment_not_completed', 'status' => $status];
+ }
+
+ $capture = $data['purchase_units'][0]['payments']['captures'][0] ?? [];
+ $txid = $capture['id'] ?? null;
+ $amount = (float)($capture['amount']['value'] ?? 0);
+ $currency = $capture['amount']['currency_code'] ?? 'USD';
+ $customId = $data['purchase_units'][0]['custom_id'] ?? null;
+
+ return [
+ 'success' => true,
+ 'transaction_id' => $txid,
+ 'amount' => $amount,
+ 'currency' => $currency,
+ 'status' => 'completed',
+ 'custom_id' => $customId,
+ 'raw_response' => $data,
+ ];
+ }
+
+ public function verifyPayment(array $payload): bool
+ {
+ // For REST API flow (JS SDK capture), verification is done by the capture response itself.
+ // Webhook signature verification would be implemented here for webhook events.
+ return true;
+ }
+
+ public function getTransactionId(array $captureResult): ?string
+ {
+ return $captureResult['transaction_id'] ?? null;
+ }
+}
diff --git a/modules/billing/classes/PaymentGatewayInterface.php b/modules/billing/classes/PaymentGatewayInterface.php
new file mode 100644
index 00000000..2a0a424c
--- /dev/null
+++ b/modules/billing/classes/PaymentGatewayInterface.php
@@ -0,0 +1,40 @@
+ false, 'error' => 'stripe_not_implemented'];
+ }
+
+ public function handleCallback(array $params): array
+ {
+ return ['success' => false, 'error' => 'stripe_not_implemented'];
+ }
+
+ public function verifyPayment(array $payload): bool { return false; }
+
+ public function getTransactionId(array $captureResult): ?string { return null; }
+}
diff --git a/modules/billing/includes/config.inc.php b/modules/billing/includes/config.inc.php
index 4dfd0d72..fac3bb5d 100644
--- a/modules/billing/includes/config.inc.php
+++ b/modules/billing/includes/config.inc.php
@@ -29,4 +29,9 @@ $SITE_BACKGROUND = trim((string)$SITE_BACKGROUND);
// Data directory for persisted payment webhook JSON files (relative to repo root)
$SITE_DATA_DIR = realpath(__DIR__ . '/..') . DIRECTORY_SEPARATOR . 'data';
+
+// PayPal configuration — set credentials here, never in API files
+$paypal_sandbox = true; // Set to false for live payments
+$paypal_client_id = ''; // Your PayPal Client ID
+$paypal_client_secret = ''; // Your PayPal Client Secret
?>
diff --git a/modules/billing/module.php b/modules/billing/module.php
index 61d60e38..a9e38c7e 100644
--- a/modules/billing/module.php
+++ b/modules/billing/module.php
@@ -25,7 +25,7 @@
// Module general information
$module_title = "billing";
$module_version = "3.0";
-$db_version = 1;
+$db_version = 2;
$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.";
@@ -124,4 +124,53 @@ $install_queries[0] = array(
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;"
);
+// Version 2: New columns on billing_invoices, transaction log table, service-to-node mapping
+$install_queries[1] = array(
+ // Add new columns to billing_invoices (IF NOT EXISTS for idempotence)
+ "ALTER TABLE `".OGP_DB_PREFIX."billing_invoices`
+ ADD COLUMN IF NOT EXISTS `home_id` INT(11) NOT NULL DEFAULT 0 AFTER `service_id`,
+ ADD COLUMN IF NOT EXISTS `rate_type` ENUM('daily','monthly','yearly') NOT NULL DEFAULT 'monthly' AFTER `invoice_duration`,
+ ADD COLUMN IF NOT EXISTS `rate_per_player` FLOAT(15,4) NOT NULL DEFAULT 0 AFTER `rate_type`,
+ ADD COLUMN IF NOT EXISTS `players` INT(11) NOT NULL DEFAULT 0 AFTER `rate_per_player`,
+ ADD COLUMN IF NOT EXISTS `period_start` DATETIME NULL AFTER `players`,
+ ADD COLUMN IF NOT EXISTS `period_end` DATETIME NULL AFTER `period_start`,
+ ADD COLUMN IF NOT EXISTS `subtotal` FLOAT(15,2) NOT NULL DEFAULT 0 AFTER `period_end`,
+ ADD COLUMN IF NOT EXISTS `total_due` FLOAT(15,2) NOT NULL DEFAULT 0 AFTER `subtotal`,
+ ADD COLUMN IF NOT EXISTS `payment_status` ENUM('unpaid','paid','cancelled','refunded') NOT NULL DEFAULT 'unpaid' AFTER `total_due`",
+
+ // Payment transaction log — immutable audit trail
+ "CREATE TABLE IF NOT EXISTS `".OGP_DB_PREFIX."billing_transactions` (
+ `transaction_id` INT(11) NOT NULL AUTO_INCREMENT,
+ `invoice_id` INT(11) NOT NULL DEFAULT 0,
+ `user_id` INT(11) NOT NULL DEFAULT 0,
+ `home_id` INT(11) NOT NULL DEFAULT 0,
+ `payment_method` VARCHAR(50) NOT NULL DEFAULT 'paypal',
+ `transaction_external_id` VARCHAR(255) NOT NULL DEFAULT '',
+ `amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
+ `currency` VARCHAR(3) NOT NULL DEFAULT 'USD',
+ `status` ENUM('pending','completed','failed','refunded') NOT NULL DEFAULT 'pending',
+ `raw_response` MEDIUMTEXT NULL,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`transaction_id`),
+ KEY `invoice_id` (`invoice_id`),
+ KEY `user_id` (`user_id`),
+ KEY `home_id` (`home_id`),
+ KEY `status` (`status`),
+ KEY `payment_method` (`payment_method`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
+
+ // Service-to-remote-server mapping (admin can enable/disable per service)
+ "CREATE TABLE IF NOT EXISTS `".OGP_DB_PREFIX."billing_service_remote_servers` (
+ `id` INT(11) NOT NULL AUTO_INCREMENT,
+ `service_id` INT(11) NOT NULL,
+ `remote_server_id` INT(11) NOT NULL,
+ `enabled` TINYINT(1) NOT NULL DEFAULT 1,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `svc_rs` (`service_id`, `remote_server_id`),
+ KEY `service_id` (`service_id`),
+ KEY `remote_server_id` (`remote_server_id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"
+);
+
?>