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:
parent
b0e00c9370
commit
986a4e53b4
14 changed files with 1227 additions and 749 deletions
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
279
modules/billing/classes/BillingRepository.php
Normal file
279
modules/billing/classes/BillingRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
188
modules/billing/classes/BillingService.php
Normal file
188
modules/billing/classes/BillingService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
modules/billing/classes/GatewayFactory.php
Normal file
30
modules/billing/classes/GatewayFactory.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
36
modules/billing/classes/ManualGateway.php
Normal file
36
modules/billing/classes/ManualGateway.php
Normal 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;
|
||||
}
|
||||
}
|
||||
188
modules/billing/classes/PayPalGateway.php
Normal file
188
modules/billing/classes/PayPalGateway.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
modules/billing/classes/PaymentGatewayInterface.php
Normal file
40
modules/billing/classes/PaymentGatewayInterface.php
Normal 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;
|
||||
}
|
||||
25
modules/billing/classes/StripeGateway.php
Normal file
25
modules/billing/classes/StripeGateway.php
Normal 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; }
|
||||
}
|
||||
|
|
@ -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
|
||||
?>
|
||||
|
|
|
|||
|
|
@ -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;"
|
||||
);
|
||||
|
||||
?>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue