diff --git a/CHANGELOG.md b/CHANGELOG.md index dac065d3..bef9b644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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()`. diff --git a/modules/billing/api/capture_order.php b/modules/billing/api/capture_order.php index 61628245..175b27f1 100644 --- a/modules/billing/api/capture_order.php +++ b/modules/billing/api/capture_order.php @@ -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]); } } } diff --git a/modules/billing/cart.php b/modules/billing/cart.php index d84c5ca0..18024d5c 100644 --- a/modules/billing/cart.php +++ b/modules/billing/cart.php @@ -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; + + +
Your coupon covers the full amount. Click below to confirm and automatically provision your server(s).
+ + + +Click the button below to complete your purchase securely through PayPal.
@@ -632,7 +653,9 @@ $siteBase = $protocol . $host; My Account