refactor(billing): clean architecture with payment gateway abstraction

- Add PaymentGatewayInterface contract for all payment providers
- Add PayPalGateway (reads credentials from config, not hardcoded)
- Add ManualGateway for admin-triggered payments
- Add StripeGateway stub for future implementation
- Add GatewayFactory for gateway instantiation by name
- Add BillingRepository: parameterized-SQL data layer
- Add BillingService: pricing, invoice creation, payment processing
- Add gsp_billing_transactions table (DB version 2) for audit trail
- Add new columns to gsp_billing_invoices (home_id, rate_type, players, period_start/end, subtotal, total_due, payment_status)
- Add gsp_billing_service_remote_servers mapping table
- Move PayPal credentials from api files into config.inc.php
- Fix double session_start() bug in capture_order.php
- Replace raw SQL with prepared statements throughout
- Refactor admin_invoices.php to use billing_invoices + BillingRepository
- Refactor admin_payments.php to read from gsp_billing_transactions
- Update admin.php with links to Transaction Log and Manage Invoices

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-02 12:17:36 +00:00 committed by GitHub
parent b0e00c9370
commit 986a4e53b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1227 additions and 749 deletions

View file

@ -22,8 +22,9 @@ function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
<p>Welcome to the admin area. From here you can manage servers, payments, and site settings.</p>
<div class="admin-flex-wrap">
<a class="gsw-btn" href="adminserverlist.php">Manage Servers & Services</a>
<a class="gsw-btn" href="./invoices.php">Invoice History</a>
<a class="gsw-btn" href="adminserverlist.php">Manage Servers &amp; Services</a>
<a class="gsw-btn" href="admin_invoices.php">Manage Invoices</a>
<a class="gsw-btn" href="admin_payments.php">Transaction Log</a>
<a class="gsw-btn" href="admin_coupons.php">Manage Coupons</a>
<a class="gsw-btn" href="admin_config.php">Edit Site Config</a>
<a class="gsw-btn" href="admin_xml_editor.php">XML Config Editor</a>
@ -39,7 +40,7 @@ function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
</ul>
<h3>Sandbox account (testing)</h3>
<p>Use PayPal sandbox credentials when testing payments. Set your sandbox <code>client_id</code> and <code>client_secret</code> in the runtime config that the payment handlers use (for this site those are in the respective files under <code>_website/api/</code> or in a central config if you moved credentials).</p>
<p>Use PayPal sandbox credentials when testing payments. Set your sandbox <code>client_id</code> and <code>client_secret</code> in <code>modules/billing/includes/config.inc.php</code> (the <code>$paypal_client_id</code> and <code>$paypal_client_secret</code> variables). Set <code>$paypal_sandbox = false</code> for live payments.</p>
<ul>
<li>Create a sandbox business account at <a href="https://developer.paypal.com">PayPal Developer</a> and obtain a sandbox client ID/secret.</li>
<li>Update the payment handler config and restart the webserver if required.</li>

View file

@ -1,160 +1,167 @@
<?php
// Admin invoices viewer and editor
$session_name = session_name(); session_start();
require_once(__DIR__ . '/bootstrap.php');
require_once(__DIR__ . '/includes/admin_auth.php');
// Admin invoices management
if (session_status() === PHP_SESSION_NONE) {
session_name('opengamepanel_web');
session_start();
}
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/includes/admin_auth.php';
require_once __DIR__ . '/classes/BillingRepository.php';
require_once __DIR__ . '/classes/BillingService.php';
require_once __DIR__ . '/classes/GatewayFactory.php';
function h($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
$db = mysqli_connect($db_host, $db_user, $db_pass, $db_name);
if (!$db) die('DB connection failed');
mysqli_set_charset($db, 'utf8mb4');
$prefix = $table_prefix ?? 'gsp_';
$repo = new BillingRepository($db, $prefix);
$svc = new BillingService($repo);
// Handle POST requests for invoice updates
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['update_invoice'])) {
$orderId = intval($_POST['order_id']);
$newStatus = mysqli_real_escape_string($db, $_POST['status']);
$newPrice = floatval($_POST['price']);
$sql = "UPDATE {$table_prefix}billing_orders SET status = '$newStatus', price = $newPrice WHERE order_id = $orderId LIMIT 1";
mysqli_query($db, $sql);
header('Location: admin_invoices.php?updated=' . $orderId);
$message = '';
$msgType = 'success';
// Handle POST: mark as paid (manual), cancel, or refund
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'], $_POST['invoice_id'])) {
$invId = intval($_POST['invoice_id']);
$action = $_POST['action'];
$now = date('Y-m-d H:i:s');
// Fetch invoice to verify it exists
$invRow = null;
$stmt = $db->prepare("SELECT * FROM `{$prefix}billing_invoices` WHERE invoice_id = ? LIMIT 1");
if ($stmt) {
$stmt->bind_param('i', $invId);
$stmt->execute();
$invRow = $stmt->get_result()->fetch_assoc();
$stmt->close();
}
if (!$invRow) {
$message = "Invoice #{$invId} not found.";
$msgType = 'error';
} elseif ($action === 'mark_paid') {
$gateway = GatewayFactory::make('manual');
$captureResult = $gateway->handleCallback(['amount' => $invRow['total_due'] ?? $invRow['amount'] ?? 0, 'currency' => $invRow['currency'] ?? 'USD']);
$captureResult['payment_method'] = 'manual';
$homeId = intval($invRow['home_id'] ?? 0);
$result = $svc->processPaymentSuccess($captureResult, $invId, intval($invRow['user_id']), $homeId, $invRow);
$message = $result['success'] ? "Invoice #{$invId} marked as paid (manual)." : "Failed to mark invoice #{$invId} as paid.";
if (!$result['success']) $msgType = 'error';
} elseif ($action === 'cancel') {
$stmt = $db->prepare("UPDATE `{$prefix}billing_invoices` SET payment_status='cancelled' WHERE invoice_id=? LIMIT 1");
if ($stmt) { $stmt->bind_param('i', $invId); $stmt->execute(); $stmt->close(); }
$message = "Invoice #{$invId} cancelled.";
} elseif ($action === 'refund') {
$stmt = $db->prepare("UPDATE `{$prefix}billing_invoices` SET payment_status='refunded' WHERE invoice_id=? LIMIT 1");
if ($stmt) { $stmt->bind_param('i', $invId); $stmt->execute(); $stmt->close(); }
$message = "Invoice #{$invId} marked as refunded.";
}
if (!headers_sent()) {
header('Location: admin_invoices.php?msg=' . urlencode($message) . '&type=' . $msgType);
mysqli_close($db);
exit;
}
}
// Fetch all orders with coupon information
$orders = mysqli_query($db, "SELECT o.*, u.user_name, c.code AS coupon_code, c.discount_percent AS coupon_discount
FROM {$table_prefix}billing_orders o
LEFT JOIN {$table_prefix}users u ON o.user_id = u.user_id
LEFT JOIN {$table_prefix}billing_coupons c ON o.coupon_id = c.coupon_id
ORDER BY o.order_id DESC");
// Fetch invoices
$invoices = [];
$res = $db->query(
"SELECT i.*, u.users_login, u.users_email
FROM `{$prefix}billing_invoices` i
LEFT JOIN `{$prefix}users` u ON u.user_id = i.user_id
ORDER BY i.invoice_id DESC
LIMIT 500"
);
if ($res) $invoices = $res->fetch_all(MYSQLI_ASSOC);
mysqli_close($db);
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
if (isset($_GET['msg'])) $message = $_GET['msg'];
if (isset($_GET['type'])) $msgType = $_GET['type'];
?>
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Admin Invoices</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/header.css">
<style>
.edit-row { background: #f9f9f9; }
.edit-input { width: 80px; padding: 4px; border: 1px solid #ccc; border-radius: 3px; }
.edit-select { padding: 4px; border: 1px solid #ccc; border-radius: 3px; }
.btn-save { background: #28a745; color: white; border: none; padding: 5px 12px; border-radius: 3px; cursor: pointer; }
.btn-save:hover { background: #218838; }
.status-badge { display: inline-block; padding: 3px 8px; border-radius: 3px; font-size: 12px; font-weight: 600; }
.status-Active { background: #d4edda; color: #155724; }
.status-Invoiced { background: #fff3cd; color: #856404; }
.status-Expired { background: #f8d7da; color: #721c24; }
.status-badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; font-weight:600; }
.status-paid { background:#d4edda; color:#155724; }
.status-unpaid { background:#fff3cd; color:#856404; }
.status-cancelled { background:#e2e3e5; color:#383d41; }
.status-refunded { background:#f8d7da; color:#721c24; }
.action-btn { padding:3px 8px; font-size:12px; border:none; border-radius:3px; cursor:pointer; }
.btn-pay { background:#28a745; color:#fff; }
.btn-cancel { background:#6c757d; color:#fff; }
.btn-refund { background:#dc3545; color:#fff; }
</style>
</head>
<body>
<?php include(__DIR__ . '/includes/top.php'); include(__DIR__ . '/includes/menu.php'); ?>
<?php include __DIR__ . '/includes/top.php'; include __DIR__ . '/includes/menu.php'; ?>
<div class="container-wide panel">
<h1>Admin All Invoices</h1>
<?php if (isset($_GET['updated'])): ?>
<div style="background: #d4edda; padding: 10px; margin-bottom: 15px; border-radius: 3px; color: #155724;">
Invoice #<?php echo h($_GET['updated']); ?> updated successfully.
<?php if ($message): ?>
<div style="background:<?= $msgType==='error' ? '#f8d7da' : '#d4edda' ?>;padding:10px;margin-bottom:15px;border-radius:3px;color:<?= $msgType==='error' ? '#721c24' : '#155724' ?>;">
<?= h($message) ?>
</div>
<?php endif; ?>
<?php if (!$orders || mysqli_num_rows($orders) === 0): ?>
<p>No invoices found.</p>
<?php else: ?>
<table class="cart-table">
<thead>
<tr>
<th>Order ID</th>
<th>User</th>
<th>Home ID</th>
<th>Home Name</th>
<th>IP</th>
<th>Price</th>
<th>Duration</th>
<th>Status</th>
<th>Created</th>
<th>Finish Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php while ($row = mysqli_fetch_assoc($orders)): ?>
<tr id="row-<?php echo $row['order_id']; ?>">
<td><?php echo h($row['order_id']); ?></td>
<td><?php echo h($row['user_name'] ?? 'N/A'); ?></td>
<td><?php echo h($row['home_id'] ?? 'N/A'); ?></td>
<td><?php echo h($row['home_name']); ?></td>
<td><?php echo h($row['ip']); ?></td>
<td>
<?php
$price = floatval($row['price']);
$discount = floatval($row['discount_amount'] ?? 0);
if ($discount > 0 && !empty($row['coupon_code'])) {
echo '<span style="text-decoration: line-through; color: #999;">$' . number_format($price + $discount, 2) . '</span><br>';
echo '<strong>$' . number_format($price, 2) . '</strong>';
echo '<br><small style="color: #28a745;">(' . h($row['coupon_code']) . ' -' . number_format($row['coupon_discount'], 0) . '%)</small>';
} else {
echo '$' . number_format($price, 2);
}
?>
</td>
<td><?php echo h($row['invoice_duration']); ?></td>
<td>
<span class="status-badge status-<?php echo h($row['status']); ?>">
<?php echo strtoupper(h($row['status'])); ?>
</span>
</td>
<td><?php echo h($row['order_date']); ?></td>
<td><?php echo h($row['end_date'] ?? 'N/A'); ?></td>
<td>
<button onclick="editRow(<?php echo $row['order_id']; ?>)" class="gsw-btn" style="padding: 4px 10px; font-size: 12px;">Edit</button>
</td>
</tr>
<tr id="edit-<?php echo $row['order_id']; ?>" class="edit-row" style="display: none;">
<td colspan="11">
<form method="post" action="" style="padding: 10px;">
<input type="hidden" name="order_id" value="<?php echo $row['order_id']; ?>">
<strong>Edit Invoice #<?php echo $row['order_id']; ?></strong>
<div style="margin-top: 10px;">
<label style="margin-right: 15px;">
<strong>Price:</strong>
<input type="number" name="price" value="<?php echo $row['price']; ?>" step="0.01" class="edit-input" required>
</label>
<label style="margin-right: 15px;">
<strong>Status:</strong>
<select name="status" class="edit-select" required>
<option value="Active" <?php echo $row['status'] === 'Active' ? 'selected' : ''; ?>>ACTIVE</option>
<option value="Invoiced" <?php echo $row['status'] === 'Invoiced' ? 'selected' : ''; ?>>INVOICED</option>
<option value="Expired" <?php echo $row['status'] === 'Expired' ? 'selected' : ''; ?>>EXPIRED</option>
</select>
</label>
<button type="submit" name="update_invoice" class="btn-save">Save Changes</button>
<button type="button" onclick="cancelEdit(<?php echo $row['order_id']; ?>)" class="gsw-btn" style="padding: 5px 12px; margin-left: 5px;">Cancel</button>
</div>
<table class="cart-table">
<thead>
<tr>
<th>#</th><th>User</th><th>Server</th><th>Service</th>
<th>Rate</th><th>Players</th><th>Period</th>
<th>Total</th><th>Status</th><th>Method</th><th>Txn ID</th><th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($invoices)): ?>
<tr><td colspan="12" style="text-align:center">No invoices found.</td></tr>
<?php else: foreach ($invoices as $inv): ?>
<tr>
<td><?= h($inv['invoice_id']) ?></td>
<td><?= h($inv['users_login'] ?? $inv['user_id']) ?></td>
<td><?= h($inv['home_id'] ?: '—') ?></td>
<td><?= h($inv['service_id']) ?></td>
<td><?= h($inv['rate_type'] ?? '—') ?></td>
<td><?= h($inv['players'] ?? '—') ?></td>
<td style="font-size:11px"><?= h(substr($inv['period_start'] ?? '', 0, 10)) ?> <?= h(substr($inv['period_end'] ?? '', 0, 10)) ?></td>
<td><?= h(number_format((float)($inv['total_due'] ?? $inv['amount'] ?? 0), 2)) ?></td>
<td><span class="status-badge status-<?= h($inv['payment_status'] ?? 'unpaid') ?>"><?= h($inv['payment_status'] ?? 'unpaid') ?></span></td>
<td><?= h($inv['payment_method'] ?? '—') ?></td>
<td style="font-size:11px;max-width:120px;overflow:hidden"><?= h($inv['payment_txid'] ?? '—') ?></td>
<td>
<?php if (($inv['payment_status'] ?? '') !== 'paid'): ?>
<form method="post" style="display:inline">
<input type="hidden" name="invoice_id" value="<?= intval($inv['invoice_id']) ?>">
<input type="hidden" name="action" value="mark_paid">
<button type="submit" class="action-btn btn-pay">Mark Paid</button>
</form>
</td>
</tr>
<?php endwhile; ?>
</tbody>
</table>
<?php endif; ?>
<?php endif; ?>
<?php if (!in_array($inv['payment_status'] ?? '', ['cancelled','refunded'])): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Cancel this invoice?')">
<input type="hidden" name="invoice_id" value="<?= intval($inv['invoice_id']) ?>">
<input type="hidden" name="action" value="cancel">
<button type="submit" class="action-btn btn-cancel">Cancel</button>
</form>
<?php endif; ?>
<?php if (($inv['payment_status'] ?? '') === 'paid'): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Mark as refunded?')">
<input type="hidden" name="invoice_id" value="<?= intval($inv['invoice_id']) ?>">
<input type="hidden" name="action" value="refund">
<button type="submit" class="action-btn btn-refund">Refund</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div>
<script>
function editRow(orderId) {
document.getElementById('row-' + orderId).style.display = 'none';
document.getElementById('edit-' + orderId).style.display = 'table-row';
}
function cancelEdit(orderId) {
document.getElementById('row-' + orderId).style.display = 'table-row';
document.getElementById('edit-' + orderId).style.display = 'none';
}
</script>
<?php include(__DIR__ . '/includes/footer.php'); ?>
<?php include __DIR__ . '/includes/footer.php'; ?>
</body>
</html>

View file

@ -1,59 +1,92 @@
<?php
// Admin payments viewer — lists persisted PayPal webhook JSON files
$session_name = session_name(); session_start();
require_once(__DIR__ . '/includes/config_loader.php');
require_once(__DIR__ . '/includes/admin_auth.php');
$dataDir = (isset($SITE_DATA_DIR) && $SITE_DATA_DIR) ? $SITE_DATA_DIR : realpath(__DIR__ . '/') . DIRECTORY_SEPARATOR . 'data';
$files = [];
if (is_dir($dataDir)) {
foreach (glob($dataDir . '/*.json') as $file) {
$files[] = $file;
}
// Admin payment transaction log viewer
if (session_status() === PHP_SESSION_NONE) {
session_name('opengamepanel_web');
session_start();
}
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/includes/admin_auth.php';
require_once __DIR__ . '/classes/BillingRepository.php';
function h($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
$db = mysqli_connect($db_host, $db_user, $db_pass, $db_name);
$transactions = [];
$errorMsg = '';
if (!$db) {
$errorMsg = 'Database connection failed.';
} else {
mysqli_set_charset($db, 'utf8mb4');
$prefix = $table_prefix ?? 'gsp_';
$repo = new BillingRepository($db, $prefix);
// Build filter from GET params
$filter = [];
if (!empty($_GET['user_id'])) $filter['user_id'] = intval($_GET['user_id']);
if (!empty($_GET['home_id'])) $filter['home_id'] = intval($_GET['home_id']);
if (!empty($_GET['payment_method'])) $filter['payment_method'] = trim($_GET['payment_method']);
$transactions = $repo->getTransactions($filter, 200, 0);
mysqli_close($db);
}
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
?>
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Admin Payments</title>
<title>Admin Payment Transactions</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/header.css">
</head>
<body>
<?php include(__DIR__ . '/includes/top.php'); include(__DIR__ . '/includes/menu.php'); ?>
<?php include __DIR__ . '/includes/top.php'; include __DIR__ . '/includes/menu.php'; ?>
<div class="container-wide panel">
<h1>Payments (webhook)</h1>
<?php if (!$files): ?>
<p>No payment records found in <?php echo h($dataDir); ?></p>
<h1>Payment Transaction Log</h1>
<?php if ($errorMsg): ?><div class="alert alert-error"><?= h($errorMsg) ?></div><?php endif; ?>
<form method="get" style="margin-bottom:15px;">
<label>User ID: <input name="user_id" value="<?= h($_GET['user_id'] ?? '') ?>" style="width:80px"></label>
<label>Server ID: <input name="home_id" value="<?= h($_GET['home_id'] ?? '') ?>" style="width:80px"></label>
<label>Method:
<select name="payment_method">
<option value="">All</option>
<option value="paypal" <?= ($_GET['payment_method'] ?? '') === 'paypal' ? 'selected' : '' ?>>PayPal</option>
<option value="stripe" <?= ($_GET['payment_method'] ?? '') === 'stripe' ? 'selected' : '' ?>>Stripe</option>
<option value="manual" <?= ($_GET['payment_method'] ?? '') === 'manual' ? 'selected' : '' ?>>Manual</option>
</select>
</label>
<button type="submit" class="gsw-btn">Filter</button>
<a href="admin_payments.php" class="gsw-btn-secondary">Clear</a>
</form>
<?php if (empty($transactions)): ?>
<p>No transactions found<?= (!empty($filter) ? ' matching filters' : '') ?>.</p>
<?php else: ?>
<table class="cart-table">
<thead>
<tr>
<th>Filename</th>
<th>Invoice</th>
<th>Amount</th>
<th>Payer</th>
<th>Date</th>
<th>View</th>
</tr>
</thead>
<tbody>
<?php foreach ((array)$files as $f): $j = json_decode(file_get_contents($f), true) ?: []; ?>
<tr>
<td><?php echo h(basename($f)); ?></td>
<td><?php echo h($j['invoice'] ?? ($j['custom'] ?? '')); ?></td>
<td><?php echo h(($j['currency'] ?? '') . ' ' . number_format((float)($j['amount'] ?? 0),2)); ?></td>
<td><?php echo h($j['payer'] ?? ''); ?></td>
<td><?php echo h($j['ts'] ?? ''); ?></td>
<td><a href="return.php?invoice=<?php echo urlencode($j['invoice'] ?? ($j['custom'] ?? '')); ?>">View</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<table class="cart-table">
<thead>
<tr>
<th>#</th><th>Invoice</th><th>User</th><th>Server</th>
<th>Method</th><th>Txn ID</th><th>Amount</th><th>Status</th><th>Date</th>
</tr>
</thead>
<tbody>
<?php foreach ($transactions as $t): ?>
<tr>
<td><?= h($t['transaction_id']) ?></td>
<td><?= h($t['invoice_id']) ?></td>
<td><?= h($t['users_login'] ?? $t['user_id']) ?></td>
<td><?= $t['home_id'] ? h($t['home_id']) : '—' ?></td>
<td><?= h($t['payment_method']) ?></td>
<td style="font-size:11px;max-width:160px;overflow:hidden;text-overflow:ellipsis"><?= h($t['transaction_external_id']) ?></td>
<td><?= h($t['currency'] . ' ' . number_format((float)$t['amount'], 2)) ?></td>
<td><span class="status-badge status-<?= h(ucfirst($t['status'])) ?>"><?= h($t['status']) ?></span></td>
<td><?= h($t['created_at']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php include(__DIR__ . '/includes/footer.php'); ?>
<?php include __DIR__ . '/includes/footer.php'; ?>
</body>
</html>

View file

@ -1,26 +1,30 @@
<?php
/**
* PayPal Order Capture Endpoint
* Processes PayPal payment, marks invoices paid, creates order records
* Standalone billing module - uses only standard PHP mysqli
* Uses PayPalGateway, BillingService, and BillingRepository.
* Credentials come from config NOT hardcoded here.
*/
require_once(__DIR__ . '/../includes/config_loader.php');
// Prevent any output before JSON
ob_start();
ini_set('display_errors', '0');
error_reporting(E_ALL);
ob_start();
// Setup logging
$logDir = __DIR__ . '/../logs';
require_once __DIR__ . '/../includes/config_loader.php';
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
require_once __DIR__ . '/../classes/PayPalGateway.php';
require_once __DIR__ . '/../classes/GatewayFactory.php';
require_once __DIR__ . '/../classes/BillingRepository.php';
require_once __DIR__ . '/../classes/BillingService.php';
// Logging setup
$logDir = __DIR__ . '/../logs';
@mkdir($logDir, 0755, true);
$logFile = $logDir . '/payment_capture.log';
$logFile = $logDir . '/payment_capture.log';
$requestId = uniqid('req_', true);
function log_payment($label, $data) {
function cap_log(string $label, $data): void {
global $logFile, $requestId;
$entry = "[" . date('Y-m-d H:i:s') . "] [$requestId] $label\n";
$entry = '[' . date('Y-m-d H:i:s') . "] [$requestId] $label\n";
$entry .= is_array($data) || is_object($data) ? print_r($data, true) : (string)$data;
$entry .= "\n" . str_repeat('-', 80) . "\n";
@file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
@ -28,365 +32,140 @@ function log_payment($label, $data) {
header('Content-Type: application/json');
// Parse input
$rawInput = file_get_contents('php://input');
$input = json_decode($rawInput, true);
if (json_last_error() !== JSON_ERROR_NONE) {
log_payment('JSON_ERROR', json_last_error_msg());
ob_clean();
echo json_encode(['error' => '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,
]);

View file

@ -1,266 +1,84 @@
<?php
/**
* PayPal Create Order API Endpoint
* Enhanced with comprehensive logging for debugging
* Uses PayPalGateway class. Credentials come from config NOT hardcoded here.
*/
// Ensure all errors are logged, not displayed (to prevent JSON corruption)
ini_set('display_errors', '0');
error_reporting(E_ALL);
require_once(__DIR__ . '/../includes/config_loader.php');
// create_order for PayPal — adapted to run from _website/api
$sandbox = true; // flip to false for Live
$client_id = 'AfvY_C2zA_hTHxHq7TIhtOeub4xBdySYrt_Hjj3d_WYQwjWI9NfOAVOTeResx2rgZ_nP5tOoxQSAHw8c';
$client_secret = 'EJ216np9cAj9n7KSddez3fLVxGe-zi4oKKKl1YGqPp88XIikr4Qzbxh0XW2as-V6LgdX-upjtQAg9dC0';
require_once __DIR__ . '/../includes/config_loader.php';
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
require_once __DIR__ . '/../classes/PayPalGateway.php';
require_once __DIR__ . '/../classes/GatewayFactory.php';
// Setup comprehensive logging
$logDir = __DIR__ . '/../logs';
// Logging
$logDir = __DIR__ . '/../logs';
@mkdir($logDir, 0755, true);
$logFile = $logDir . '/paypal_create_order.log';
$requestId = uniqid('req_', true); // Unique request identifier for tracking
$logFile = $logDir . '/paypal_create_order.log';
$requestId = uniqid('req_', true);
function create_order_log($label, $data) {
function co_log(string $label, $data): void {
global $logFile, $requestId;
$timestamp = date('Y-m-d H:i:s');
$entry = "[$timestamp] [$requestId] $label\n";
if (is_array($data) || is_object($data)) {
$entry .= print_r($data, true);
} else {
$entry .= (string)$data;
}
$entry = '[' . date('Y-m-d H:i:s') . "] [$requestId] $label\n";
$entry .= is_array($data) || is_object($data) ? print_r($data, true) : (string)$data;
$entry .= "\n" . str_repeat('-', 80) . "\n";
@file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
}
create_order_log('REQUEST_START', [
'method' => $_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']);

View file

@ -0,0 +1,279 @@
<?php
/**
* BillingRepository data layer for the billing module.
* All SQL lives here. Accepts a mysqli connection.
*/
class BillingRepository
{
private mysqli $db;
private string $prefix;
public function __construct(mysqli $db, string $prefix = 'gsp_')
{
$this->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;
}
}

View file

@ -0,0 +1,188 @@
<?php
require_once __DIR__ . '/../classes/BillingRepository.php';
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
/**
* BillingService core business logic for the billing module.
*
* Responsibilities:
* - Calculate pricing
* - Create invoices
* - Process payment results (log transaction, mark invoice paid, update server home)
* - Extend / reset server billing expiration
*/
class BillingService
{
private BillingRepository $repo;
public function __construct(BillingRepository $repo)
{
$this->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,
]);
}
}

View file

@ -0,0 +1,30 @@
<?php
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
require_once __DIR__ . '/../classes/PayPalGateway.php';
require_once __DIR__ . '/../classes/ManualGateway.php';
require_once __DIR__ . '/../classes/StripeGateway.php';
/**
* Factory for instantiating payment gateways by name.
*/
class GatewayFactory
{
/**
* @param string $name Gateway name: 'paypal', 'stripe', 'manual'
* @return PaymentGatewayInterface
* @throws InvalidArgumentException
*/
public static function make(string $name): PaymentGatewayInterface
{
switch (strtolower($name)) {
case 'paypal':
return PayPalGateway::fromConfig();
case 'manual':
return new ManualGateway();
case 'stripe':
return new StripeGateway();
default:
throw new InvalidArgumentException("Unknown payment gateway: {$name}");
}
}
}

View file

@ -0,0 +1,36 @@
<?php
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
/**
* Manual / offline payment gateway.
* Used when an admin marks a payment as paid directly.
*/
class ManualGateway implements PaymentGatewayInterface
{
public function getName(): string { return 'manual'; }
public function createPayment(array $params): array
{
return ['success' => 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;
}
}

View file

@ -0,0 +1,188 @@
<?php
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
class PayPalGateway implements PaymentGatewayInterface
{
private string $clientId;
private string $clientSecret;
private bool $sandbox;
private string $apiBase;
public function __construct(string $clientId, string $clientSecret, bool $sandbox = true)
{
$this->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;
}
}

View file

@ -0,0 +1,40 @@
<?php
/**
* Payment Gateway Interface
* All payment providers must implement this contract.
*/
interface PaymentGatewayInterface
{
/**
* Create a payment/order on the provider side.
* @param array $params { amount, currency, invoice_id, description, return_url, cancel_url, items? }
* @return array { success: bool, provider_order_id: string, redirect_url?: string, error?: string }
*/
public function createPayment(array $params): array;
/**
* Handle a provider callback/capture (webhook or return).
* @param array $params Provider-specific parameters (e.g. { order_id } for PayPal)
* @return array { success: bool, transaction_id: string, amount: float, status: string, raw_response: array, error?: string }
*/
public function handleCallback(array $params): array;
/**
* Verify that a payment/webhook is authentic.
* @param array $payload Raw request body / headers
* @return bool
*/
public function verifyPayment(array $payload): bool;
/**
* Get the provider's external transaction ID from a capture result.
* @param array $captureResult Result from handleCallback()
* @return string|null
*/
public function getTransactionId(array $captureResult): ?string;
/**
* Return a short machine name for this gateway (e.g. 'paypal', 'stripe', 'manual').
*/
public function getName(): string;
}

View file

@ -0,0 +1,25 @@
<?php
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
/**
* Stripe payment gateway stub.
* Implement this class when Stripe support is needed.
*/
class StripeGateway implements PaymentGatewayInterface
{
public function getName(): string { return 'stripe'; }
public function createPayment(array $params): array
{
return ['success' => 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; }
}

View file

@ -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
?>

View file

@ -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;"
);
?>