fix: billing checkout — auto-provisioning, zero-dollar flow, duplicate prevention, paid-invoice cart fix
Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/d18a8f6c-0715-46c4-9c97-94ec7e2a22fc Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
This commit is contained in:
parent
a7a8dd791a
commit
73f125ea21
7 changed files with 429 additions and 21 deletions
|
|
@ -1,6 +1,12 @@
|
|||
# Changelog
|
||||
|
||||
## 2026-05-05
|
||||
- **Billing checkout — automatic server provisioning after payment:** Fixed the core provisioning gap where `capture_order.php` never populated `$newOrderIds`, so the auto-provisioner was always skipped. After a successful PayPal capture (or zero-dollar checkout), a `billing_orders` row is now created for each paid invoice and passed to `billing_invoke_provision()` so the game server is created/installed immediately without manual admin action.
|
||||
- **Billing checkout — duplicate provisioning prevention:** Invoice→Order linkage is written atomically (`billing_invoices.order_id` updated after order creation). Because `getUnpaidInvoicesForUser()` filters on `payment_status NOT IN ('paid',…)`, a retried PayPal capture will find no invoices and skip all processing — preventing duplicate servers.
|
||||
- **Billing checkout — paid invoices no longer reappear in cart:** `markInvoicePaid()` now sets both `payment_status='paid'` and `status='paid'`. The cart query was also tightened to exclude any invoice where `payment_status` is paid/cancelled/refunded.
|
||||
- **Billing checkout — zero-dollar checkout:** New `checkout_free.php` handles orders where a coupon reduces the total to $0. The cart now shows a "Complete Free Order" button instead of the PayPal button when `$final_amount <= 0`. Free checkout marks invoices paid (method=coupon), creates orders, increments the coupon use counter, and triggers provisioning — identical flow to a PayPal capture.
|
||||
- **Billing checkout — payment_success.php JOIN fix:** Fixed a broken `SELECT … s.game_name` JOIN that referenced a non-existent column; corrected to `s.service_name`.
|
||||
- **Billing checkout — SQL migration:** Added `sql/002_billing_checkout_fixes.sql` — idempotent migration that adds `coupon_id`, `discount_amount`, `payment_status`, `subtotal`, and `total_due` columns to `gsp_billing_invoices`, and `coupon_id`/`discount_amount` to `gsp_billing_orders` for older installations missing these columns.
|
||||
- **Billing order status standardization:** Canonical `billing_orders.status` values are now `Active`, `Invoiced`, and `Expired` only. All old writes of `installed`, `paid` (as order status), and `suspended` have been replaced. A SQL migration script `modules/billing/sql/normalize_billing_order_status.sql` converts any existing legacy rows. Backward-compatibility read paths (e.g. renewable-status checks in `my_account.php`) are preserved until the migration runs.
|
||||
- **Expiration display date-only:** The billing expiration shown on the game server monitor (`server_monitor.php`) now displays as `YYYY-MM-DD` only instead of `YYYY-MM-DD HH:MM`.
|
||||
- **Full-day expiration grace rule:** A server whose `end_date` falls on today is treated as active for the entire calendar day. Expiration is only processed starting the next calendar day. This rule is applied consistently in: billing cron (`cron-shop.php` Steps B and C), the server monitor expiration helper (`home_handling_functions.php::get_server_billing_expiration_html`), and the OGP user/group assignment expiration processor (`user_games/check_expire.php`). All comparisons now use `DATE(end_date) < CURDATE()` (SQL) or `< strtotime(date('Y-m-d'))` (PHP) — never `<= NOW()` or `<= time()`.
|
||||
|
|
|
|||
|
|
@ -120,25 +120,67 @@ foreach ($invoices as $inv) {
|
|||
$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]);
|
||||
if (!$result['success']) {
|
||||
cap_log('INVOICE_PAY_FAILED', ['invoice_id' => $invoiceId, 'error' => $result['error'] ?? '']);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle legacy billing_orders linkage (backward compatibility)
|
||||
$invoicesPaid++;
|
||||
cap_log('INVOICE_PAID', ['invoice_id' => $invoiceId, 'txid' => $txid]);
|
||||
|
||||
// Resolve (or create) the billing_orders row for this invoice so the provisioner can run.
|
||||
// billing_orders.status='Active' is what create_servers.php queries.
|
||||
$orderId = intval($inv['order_id'] ?? 0);
|
||||
|
||||
$durMap = [
|
||||
'daily' => '+1 day', 'monthly' => '+1 month', 'yearly' => '+1 year',
|
||||
'day' => '+1 day', 'month' => '+1 month', 'year' => '+1 year',
|
||||
];
|
||||
$dur = strtolower($inv['rate_type'] ?? $inv['invoice_duration'] ?? 'month');
|
||||
$newEnd = date('Y-m-d H:i:s', strtotime($durMap[$dur] ?? '+1 month'));
|
||||
|
||||
if ($orderId > 0) {
|
||||
// Existing order linked to this invoice — extend it and mark Active.
|
||||
$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++;
|
||||
// Queue for provisioning only if not yet provisioned (home_id still '0' / empty).
|
||||
$currentHomeId = (string)($order['home_id'] ?? '0');
|
||||
if ($currentHomeId === '' || $currentHomeId === '0') {
|
||||
$newOrderIds[] = $orderId;
|
||||
cap_log('ORDER_QUEUED_PROVISION', ['order_id' => $orderId]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No billing_orders row yet — create one now so the provisioner can run.
|
||||
$newOrderId = $repo->createOrder([
|
||||
'user_id' => intval($inv['user_id']),
|
||||
'service_id' => intval($inv['service_id']),
|
||||
'home_name' => $inv['home_name'] ?? '',
|
||||
'ip' => (string)($inv['ip'] ?? '0'),
|
||||
'qty' => intval($inv['qty'] ?? 1),
|
||||
'invoice_duration' => $inv['invoice_duration'] ?? 'month',
|
||||
'max_players' => intval($inv['max_players'] ?? 0),
|
||||
'price' => (float)($inv['amount'] ?? $inv['total_due'] ?? 0),
|
||||
'remote_control_password' => $inv['remote_control_password'] ?? '',
|
||||
'ftp_password' => $inv['ftp_password'] ?? '',
|
||||
'status' => 'Active',
|
||||
'end_date' => $newEnd,
|
||||
'payment_txid' => $txid,
|
||||
'paid_ts' => $now,
|
||||
'coupon_id' => intval($inv['coupon_id'] ?? 0),
|
||||
]);
|
||||
if ($newOrderId > 0) {
|
||||
// Link invoice → order so retried captures are idempotent.
|
||||
$repo->updateInvoiceOrderId($invoiceId, $newOrderId);
|
||||
$newOrderIds[] = $newOrderId;
|
||||
$ordersCreated++;
|
||||
cap_log('ORDER_CREATED', ['invoice_id' => $invoiceId, 'order_id' => $newOrderId]);
|
||||
} else {
|
||||
cap_log('ORDER_CREATE_FAILED', ['invoice_id' => $invoiceId, 'db_error' => $mysqli->error]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ if (!$db) {
|
|||
// columns that may not exist in all deployments (some schemas differ).
|
||||
$query = "SELECT i.*
|
||||
FROM {$table_prefix}billing_invoices i
|
||||
WHERE i.user_id = " . intval($user_id) . " AND i.status = 'due'
|
||||
WHERE i.user_id = " . intval($user_id) . "
|
||||
AND (i.status = 'due' OR i.status = '')
|
||||
AND (i.payment_status IS NULL OR i.payment_status NOT IN ('paid','cancelled','refunded'))
|
||||
ORDER BY i.invoice_date ASC";
|
||||
|
||||
$result = mysqli_query($db, $query);
|
||||
|
|
@ -195,8 +197,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_invoice_ajax']
|
|||
exit;
|
||||
}
|
||||
|
||||
// Verify ownership and that invoice is still due
|
||||
$check_q = "SELECT invoice_id FROM {$table_prefix}billing_invoices WHERE invoice_id = " . intval($remove_id) . " AND user_id = " . intval($user_id) . " AND status = 'due' LIMIT 1";
|
||||
// Verify ownership and that invoice is still unpaid/due
|
||||
$check_q = "SELECT invoice_id FROM {$table_prefix}billing_invoices WHERE invoice_id = " . intval($remove_id) . " AND user_id = " . intval($user_id) . " AND (status = 'due' OR status = '') AND (payment_status IS NULL OR payment_status NOT IN ('paid','cancelled','refunded')) LIMIT 1";
|
||||
$check_r = mysqli_query($db, $check_q);
|
||||
if (!($check_r && mysqli_num_rows($check_r) === 1)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invoice not found or cannot be removed.']);
|
||||
|
|
@ -204,7 +206,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_invoice_ajax']
|
|||
}
|
||||
|
||||
// Hard-delete the invoice row
|
||||
$del_q = "DELETE FROM {$table_prefix}billing_invoices WHERE invoice_id = " . intval($remove_id) . " AND user_id = " . intval($user_id) . " AND status = 'due' LIMIT 1";
|
||||
$del_q = "DELETE FROM {$table_prefix}billing_invoices WHERE invoice_id = " . intval($remove_id) . " AND user_id = " . intval($user_id) . " AND (status = 'due' OR status = '') AND (payment_status IS NULL OR payment_status NOT IN ('paid','cancelled','refunded')) LIMIT 1";
|
||||
$ok = mysqli_query($db, $del_q);
|
||||
if ($ok && mysqli_affected_rows($db) > 0) {
|
||||
echo json_encode(['success' => true]);
|
||||
|
|
@ -223,12 +225,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_invoice']) &&
|
|||
if (!$db) {
|
||||
$error_message = 'Unable to remove item: database unavailable.';
|
||||
} else {
|
||||
// Verify ownership and that invoice is still due
|
||||
$check_q = "SELECT invoice_id FROM {$table_prefix}billing_invoices WHERE invoice_id = " . intval($remove_id) . " AND user_id = " . intval($user_id) . " AND status = 'due' LIMIT 1";
|
||||
// Verify ownership and that invoice is still unpaid/due
|
||||
$check_q = "SELECT invoice_id FROM {$table_prefix}billing_invoices WHERE invoice_id = " . intval($remove_id) . " AND user_id = " . intval($user_id) . " AND (status = 'due' OR status = '') AND (payment_status IS NULL OR payment_status NOT IN ('paid','cancelled','refunded')) LIMIT 1";
|
||||
$check_r = mysqli_query($db, $check_q);
|
||||
if ($check_r && mysqli_num_rows($check_r) === 1) {
|
||||
// Hard-delete to remove from cart
|
||||
$del_q = "DELETE FROM {$table_prefix}billing_invoices WHERE invoice_id = " . intval($remove_id) . " AND user_id = " . intval($user_id) . " AND status = 'due' LIMIT 1";
|
||||
$del_q = "DELETE FROM {$table_prefix}billing_invoices WHERE invoice_id = " . intval($remove_id) . " AND user_id = " . intval($user_id) . " AND (status = 'due' OR status = '') AND (payment_status IS NULL OR payment_status NOT IN ('paid','cancelled','refunded')) LIMIT 1";
|
||||
if (mysqli_query($db, $del_q)) {
|
||||
// Reload to avoid form re-submission and refresh invoice list
|
||||
header('Location: /cart.php');
|
||||
|
|
@ -620,6 +622,25 @@ $siteBase = $protocol . $host;
|
|||
</div>
|
||||
|
||||
<!-- Checkout Section -->
|
||||
<?php if ($final_amount <= 0.00): ?>
|
||||
<!-- Zero-dollar checkout: coupon covers the full amount, no PayPal needed -->
|
||||
<div class="checkout-section">
|
||||
<h3>🎉 Complete Your Free Order</h3>
|
||||
<p>Your coupon covers the full amount. Click below to confirm and automatically provision your server(s).</p>
|
||||
<div id="status-message" class="status-message"></div>
|
||||
<form method="POST" action="/checkout_free.php" onsubmit="document.getElementById('free-submit-btn').disabled=true; document.getElementById('status-message').style.display='block'; document.getElementById('status-message').textContent='Processing…';">
|
||||
<input type="hidden" name="coupon_id" value="<?php echo intval($_SESSION['cart_coupon_id'] ?? 0); ?>">
|
||||
<input type="hidden" name="coupon_code" value="<?php echo htmlspecialchars($_SESSION['cart_coupon_code'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<button id="free-submit-btn" type="submit" class="btn" style="background:#28a745;">
|
||||
✓ Complete Free Order
|
||||
</button>
|
||||
</form>
|
||||
<div class="action-buttons" style="margin-top:15px;">
|
||||
<a href="/order.php" class="btn btn-secondary">Continue Shopping</a>
|
||||
<a href="/my_account.php" class="btn btn-secondary">My Account</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="checkout-section">
|
||||
<h3>Checkout with PayPal</h3>
|
||||
<p>Click the button below to complete your purchase securely through PayPal.</p>
|
||||
|
|
@ -632,7 +653,9 @@ $siteBase = $protocol . $host;
|
|||
<a href="/my_account.php" class="btn btn-secondary">My Account</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($final_amount > 0.00): ?>
|
||||
<script>
|
||||
function setStatus(msg) {
|
||||
const statusDiv = document.getElementById('status-message');
|
||||
|
|
@ -712,11 +735,12 @@ $siteBase = $protocol . $host;
|
|||
}
|
||||
}).render('#paypal-button-container');
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
<script>
|
||||
// Remove invoice via AJAX and perform a partial reload of the cart container
|
||||
function removeInvoice(invoiceId) {
|
||||
if (!confirm('Remove this item from your cart?')) return;
|
||||
setStatus('Removing item...');
|
||||
if (typeof setStatus === 'function') setStatus('Removing item...');
|
||||
|
||||
var body = 'remove_invoice_ajax=1&invoice_id=' + encodeURIComponent(invoiceId);
|
||||
|
||||
|
|
|
|||
175
modules/billing/checkout_free.php
Normal file
175
modules/billing/checkout_free.php
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
/**
|
||||
* Free Checkout Handler
|
||||
*
|
||||
* Processes a zero-dollar cart when a coupon reduces the total to $0.
|
||||
* Marks invoices paid (method=coupon, txid=free-<timestamp>),
|
||||
* creates billing_orders rows, and triggers automatic server provisioning.
|
||||
*
|
||||
* POST params: coupon_id, coupon_code
|
||||
*/
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_name('opengamepanel_web');
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/includes/login_required.php';
|
||||
|
||||
$userId = intval($_SESSION['website_user_id'] ?? $_SESSION['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Only accept POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: /cart.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// DB connection
|
||||
$db = mysqli_connect($db_host, $db_user, $db_pass, $db_name, isset($db_port) ? (int)$db_port : null);
|
||||
if (!$db) {
|
||||
die('Database connection failed: ' . htmlspecialchars(mysqli_connect_error()));
|
||||
}
|
||||
mysqli_set_charset($db, 'utf8mb4');
|
||||
|
||||
// Fetch unpaid invoices for this user
|
||||
$invoices = [];
|
||||
$q = mysqli_query($db, "SELECT * FROM {$table_prefix}billing_invoices
|
||||
WHERE user_id = " . intval($userId) . "
|
||||
AND (status = 'due' OR status = '')
|
||||
AND (payment_status IS NULL OR payment_status NOT IN ('paid','cancelled','refunded'))
|
||||
ORDER BY invoice_id ASC");
|
||||
if ($q) {
|
||||
while ($row = mysqli_fetch_assoc($q)) {
|
||||
$invoices[] = $row;
|
||||
}
|
||||
mysqli_free_result($q);
|
||||
}
|
||||
|
||||
if (empty($invoices)) {
|
||||
mysqli_close($db);
|
||||
header('Location: /cart.php?msg=empty');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate coupon from POST / session
|
||||
$couponId = intval($_POST['coupon_id'] ?? $_SESSION['cart_coupon_id'] ?? 0);
|
||||
$couponCode = trim($_POST['coupon_code'] ?? $_SESSION['cart_coupon_code'] ?? '');
|
||||
$discountPct = 0.0;
|
||||
|
||||
if ($couponCode !== '') {
|
||||
$safe = mysqli_real_escape_string($db, $couponCode);
|
||||
$cr = mysqli_query($db, "SELECT * FROM {$table_prefix}billing_coupons
|
||||
WHERE code = '$safe' AND is_active = 1 LIMIT 1");
|
||||
if ($cr && mysqli_num_rows($cr) === 1) {
|
||||
$coupon = mysqli_fetch_assoc($cr);
|
||||
$discountPct = (float)($coupon['discount_percent'] ?? 0);
|
||||
mysqli_free_result($cr);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total and verify it is $0 after discount
|
||||
$totalAmount = 0.0;
|
||||
foreach ($invoices as $inv) {
|
||||
$totalAmount += (float)($inv['amount'] ?? 0);
|
||||
}
|
||||
$discountAmount = $totalAmount * ($discountPct / 100.0);
|
||||
$finalAmount = round($totalAmount - $discountAmount, 2);
|
||||
|
||||
if ($finalAmount > 0.00) {
|
||||
// Coupon no longer covers the full amount — redirect to cart
|
||||
mysqli_close($db);
|
||||
header('Location: /cart.php?msg=coupon_insufficient');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Process the free checkout
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$txid = 'free-' . time() . '-' . $userId;
|
||||
|
||||
require_once __DIR__ . '/classes/BillingRepository.php';
|
||||
require_once __DIR__ . '/classes/BillingService.php';
|
||||
|
||||
$repo = new BillingRepository($db, $table_prefix);
|
||||
$svc = new BillingService($repo);
|
||||
$newOrderIds = [];
|
||||
|
||||
foreach ($invoices as $inv) {
|
||||
$invoiceId = intval($inv['invoice_id']);
|
||||
|
||||
// Mark invoice paid (zero-dollar, method=coupon)
|
||||
$repo->markInvoicePaid($invoiceId, $txid, 'coupon', $now);
|
||||
|
||||
// Log a $0 transaction for the audit trail
|
||||
$repo->logTransaction([
|
||||
'invoice_id' => $invoiceId,
|
||||
'user_id' => $userId,
|
||||
'home_id' => 0,
|
||||
'payment_method' => 'coupon',
|
||||
'transaction_external_id' => $txid,
|
||||
'amount' => 0.00,
|
||||
'currency' => 'USD',
|
||||
'status' => 'completed',
|
||||
'raw_response' => ['coupon_id' => $couponId, 'discount_pct' => $discountPct, 'original_amount' => (float)($inv['amount'] ?? 0)],
|
||||
]);
|
||||
|
||||
// Increment coupon use counter
|
||||
if ($couponId > 0) {
|
||||
mysqli_query($db, "UPDATE {$table_prefix}billing_coupons
|
||||
SET current_uses = current_uses + 1
|
||||
WHERE coupon_id = " . intval($couponId));
|
||||
}
|
||||
|
||||
// Create billing_orders row so the provisioner can run
|
||||
$durMap = ['daily'=>'+1 day','monthly'=>'+1 month','yearly'=>'+1 year','day'=>'+1 day','month'=>'+1 month','year'=>'+1 year'];
|
||||
$dur = strtolower($inv['invoice_duration'] ?? 'month');
|
||||
$endDate = date('Y-m-d H:i:s', strtotime($durMap[$dur] ?? '+1 month'));
|
||||
|
||||
$newOrderId = $repo->createOrder([
|
||||
'user_id' => intval($inv['user_id']),
|
||||
'service_id' => intval($inv['service_id']),
|
||||
'home_name' => $inv['home_name'] ?? '',
|
||||
'ip' => (string)($inv['ip'] ?? '0'),
|
||||
'qty' => intval($inv['qty'] ?? 1),
|
||||
'invoice_duration' => $inv['invoice_duration'] ?? 'month',
|
||||
'max_players' => intval($inv['max_players'] ?? 0),
|
||||
'price' => 0.00,
|
||||
'remote_control_password' => $inv['remote_control_password'] ?? '',
|
||||
'ftp_password' => $inv['ftp_password'] ?? '',
|
||||
'status' => 'Active',
|
||||
'end_date' => $endDate,
|
||||
'payment_txid' => $txid,
|
||||
'paid_ts' => $now,
|
||||
'coupon_id' => $couponId,
|
||||
]);
|
||||
|
||||
if ($newOrderId > 0) {
|
||||
$repo->updateInvoiceOrderId($invoiceId, $newOrderId);
|
||||
$newOrderIds[] = $newOrderId;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear coupon from session
|
||||
unset($_SESSION['cart_coupon_code'], $_SESSION['cart_coupon_id']);
|
||||
|
||||
// Attempt automatic provisioning via panel bridge
|
||||
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';
|
||||
billing_invoke_provision(['order_ids' => $newOrderIds, 'user_id' => $userId, 'is_admin' => true]);
|
||||
}
|
||||
// If panel bootstrap fails the order is Active and admins can provision via the orders panel.
|
||||
}
|
||||
|
||||
mysqli_close($db);
|
||||
|
||||
header('Location: /payment_success.php?order_id=' . urlencode($txid));
|
||||
exit;
|
||||
|
|
@ -47,12 +47,12 @@ class BillingRepository
|
|||
return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
/** Mark an invoice as paid. */
|
||||
/** Mark an invoice as paid. Also sets status='paid' so it disappears from cart queries. */
|
||||
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=?
|
||||
SET payment_status='paid', status='paid', payment_txid=?, payment_method=?, paid_date=?
|
||||
WHERE invoice_id = ? LIMIT 1"
|
||||
);
|
||||
if (!$stmt) return false;
|
||||
|
|
@ -62,6 +62,69 @@ class BillingRepository
|
|||
return $ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a billing_orders row from invoice/payment data.
|
||||
* Returns new order_id (0 on failure).
|
||||
*
|
||||
* @param array $data Keys: user_id, service_id, home_name, ip, qty, invoice_duration,
|
||||
* max_players, price, remote_control_password, ftp_password,
|
||||
* status, end_date, payment_txid, paid_ts, coupon_id
|
||||
*/
|
||||
public function createOrder(array $data): int
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$status = (string)($data['status'] ?? 'Active');
|
||||
$endDate = $data['end_date'] ?? null;
|
||||
$txid = (string)($data['payment_txid'] ?? '');
|
||||
$paidTs = (string)($data['paid_ts'] ?? $now);
|
||||
$couponId = intval($data['coupon_id'] ?? 0);
|
||||
$ip = (string)($data['ip'] ?? '0');
|
||||
$qty = intval($data['qty'] ?? 1);
|
||||
$maxPl = intval($data['max_players'] ?? 0);
|
||||
$price = (float)($data['price'] ?? 0);
|
||||
$userId = intval($data['user_id']);
|
||||
$svcId = intval($data['service_id']);
|
||||
$homeName = (string)($data['home_name'] ?? '');
|
||||
$invDur = (string)($data['invoice_duration'] ?? 'month');
|
||||
$rcp = (string)($data['remote_control_password'] ?? '');
|
||||
$ftp = (string)($data['ftp_password'] ?? '');
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO `{$this->prefix}billing_orders`
|
||||
(user_id, service_id, home_name, ip, qty, invoice_duration, max_players,
|
||||
price, remote_control_password, ftp_password, home_id, status,
|
||||
order_date, end_date, payment_txid, paid_ts, coupon_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '0', ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
if (!$stmt) return 0;
|
||||
$stmt->bind_param(
|
||||
'iissiisdsssssssi',
|
||||
$userId, $svcId, $homeName, $ip, $qty, $invDur, $maxPl,
|
||||
$price, $rcp, $ftp,
|
||||
$status, $now, $endDate, $txid, $paidTs, $couponId
|
||||
);
|
||||
if (!$stmt->execute()) { $stmt->close(); return 0; }
|
||||
$id = (int)$stmt->insert_id;
|
||||
$stmt->close();
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a billing_invoice row to its corresponding billing_orders row.
|
||||
* Called after createOrder() so the capture endpoint can be idempotent.
|
||||
*/
|
||||
public function updateInvoiceOrderId(int $invoiceId, int $orderId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE `{$this->prefix}billing_invoices` SET order_id = ? WHERE invoice_id = ? LIMIT 1"
|
||||
);
|
||||
if (!$stmt) return false;
|
||||
$stmt->bind_param('ii', $orderId, $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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ $total_paid = 0;
|
|||
|
||||
if ($db && $user_id > 0) {
|
||||
// Get recent orders for this user (just paid)
|
||||
$query = "SELECT o.*, s.game_name
|
||||
$query = "SELECT o.*, s.service_name
|
||||
FROM {$table_prefix}billing_orders o
|
||||
LEFT JOIN {$table_prefix}billing_services s ON o.service_id = s.service_id
|
||||
WHERE o.user_id = $user_id
|
||||
|
|
@ -200,7 +200,7 @@ if ($db && $user_id > 0) {
|
|||
<tr>
|
||||
<td>#<?php echo htmlspecialchars($order['order_id']); ?></td>
|
||||
<td><?php echo htmlspecialchars($order['home_name']); ?></td>
|
||||
<td><?php echo htmlspecialchars($order['game_name'] ?? 'Game Server'); ?></td>
|
||||
<td><?php echo htmlspecialchars($order['service_name'] ?? 'Game Server'); ?></td>
|
||||
<td><?php echo htmlspecialchars($order['qty']); ?>x <?php echo htmlspecialchars($order['invoice_duration']); ?></td>
|
||||
<td><span class="status-badge">PAID</span></td>
|
||||
<td style="text-align: right; font-weight: 600; color: #28a745;">
|
||||
|
|
|
|||
98
modules/billing/sql/002_billing_checkout_fixes.sql
Normal file
98
modules/billing/sql/002_billing_checkout_fixes.sql
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
-- =============================================================================
|
||||
-- 002_billing_checkout_fixes.sql
|
||||
-- Idempotent migration: adds columns required by the billing checkout fixes.
|
||||
-- Safe to run multiple times (uses IF-based prepared statements).
|
||||
-- Run against the panel database after deploying the updated PHP files.
|
||||
-- Table prefix is hardcoded to gsp_.
|
||||
-- =============================================================================
|
||||
|
||||
SET @db = DATABASE();
|
||||
SET @tbl = 'gsp_billing_invoices';
|
||||
|
||||
-- 1) coupon_id — tracks which coupon was applied to an invoice
|
||||
SET @cnt = 0;
|
||||
SELECT COUNT(*) INTO @cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = 'coupon_id';
|
||||
SET @sql = IF(@cnt = 0,
|
||||
'ALTER TABLE `gsp_billing_invoices` ADD COLUMN `coupon_id` INT(11) NOT NULL DEFAULT 0 AFTER `qty`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 2) discount_amount — records the monetary discount applied at checkout
|
||||
SET @cnt = 0;
|
||||
SELECT COUNT(*) INTO @cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = 'discount_amount';
|
||||
SET @sql = IF(@cnt = 0,
|
||||
'ALTER TABLE `gsp_billing_invoices` ADD COLUMN `discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER `amount`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 3) payment_status — ENUM used by BillingRepository for idempotency
|
||||
-- (present in module.php schema; older installs may be missing it)
|
||||
SET @cnt = 0;
|
||||
SELECT COUNT(*) INTO @cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = 'payment_status';
|
||||
SET @sql = IF(@cnt = 0,
|
||||
"ALTER TABLE `gsp_billing_invoices` ADD COLUMN `payment_status` ENUM('unpaid','paid','cancelled','refunded') NOT NULL DEFAULT 'unpaid' AFTER `currency`",
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Backfill payment_status for existing rows:
|
||||
-- 'paid' → payment_status = 'paid'
|
||||
-- anything else → payment_status = 'unpaid'
|
||||
UPDATE `gsp_billing_invoices`
|
||||
SET `payment_status` = 'paid'
|
||||
WHERE `status` = 'paid' AND `payment_status` <> 'paid';
|
||||
|
||||
-- 4) subtotal — needed by BillingRepository::createInvoice (extended schema)
|
||||
SET @cnt = 0;
|
||||
SELECT COUNT(*) INTO @cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = 'subtotal';
|
||||
SET @sql = IF(@cnt = 0,
|
||||
'ALTER TABLE `gsp_billing_invoices` ADD COLUMN `subtotal` DECIMAL(15,2) NOT NULL DEFAULT 0 AFTER `discount_amount`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 5) total_due — needed by BillingRepository::createInvoice (extended schema)
|
||||
SET @cnt = 0;
|
||||
SELECT COUNT(*) INTO @cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = 'total_due';
|
||||
SET @sql = IF(@cnt = 0,
|
||||
'ALTER TABLE `gsp_billing_invoices` ADD COLUMN `total_due` DECIMAL(15,2) NOT NULL DEFAULT 0 AFTER `subtotal`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 6) coupon_id index on billing_invoices
|
||||
SET @cnt = 0;
|
||||
SELECT COUNT(*) INTO @cnt FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND INDEX_NAME = 'coupon_id';
|
||||
SET @sql = IF(@cnt = 0,
|
||||
'ALTER TABLE `gsp_billing_invoices` ADD KEY `coupon_id` (`coupon_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- -------------------------
|
||||
-- billing_orders additions
|
||||
-- -------------------------
|
||||
SET @tbl = 'gsp_billing_orders';
|
||||
|
||||
-- 7) coupon_id on billing_orders (already in baseline schema but guard for older installs)
|
||||
SET @cnt = 0;
|
||||
SELECT COUNT(*) INTO @cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = 'coupon_id';
|
||||
SET @sql = IF(@cnt = 0,
|
||||
'ALTER TABLE `gsp_billing_orders` ADD COLUMN `coupon_id` INT(11) NOT NULL DEFAULT 0 AFTER `paid_ts`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 8) discount_amount on billing_orders (for permanent coupon records)
|
||||
SET @cnt = 0;
|
||||
SELECT COUNT(*) INTO @cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = 'discount_amount';
|
||||
SET @sql = IF(@cnt = 0,
|
||||
'ALTER TABLE `gsp_billing_orders` ADD COLUMN `discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER `price`',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Done
|
||||
SELECT 'billing_checkout_fixes migration complete' AS status;
|
||||
Loading…
Add table
Add a link
Reference in a new issue