Panel/modules/billing/classes/BillingRepository.php
copilot-swe-agent[bot] 439e57b333
Fix billing provisioning and admin defaults
Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/1e47877f-c80e-4514-bdff-2bd022c84f13

Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
2026-05-06 22:44:53 +00:00

674 lines
26 KiB
PHP

<?php
/**
* BillingRepository — data layer for the billing module.
* All SQL lives here. Accepts a mysqli connection.
*/
class BillingRepository
{
private mysqli $db;
private string $prefix;
private array $columnCache = [];
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);
}
/** Get invoice rows for a specific user and invoice id list. */
public function getInvoicesForUserByIds(int $userId, array $invoiceIds, bool $onlyUnpaid = true): array
{
$invoiceIds = array_values(array_unique(array_filter(array_map('intval', $invoiceIds), static fn($id) => $id > 0)));
if (empty($invoiceIds)) {
return [];
}
$placeholders = implode(',', array_fill(0, count($invoiceIds), '?'));
$types = str_repeat('i', count($invoiceIds) + 1);
$params = array_merge([$userId], $invoiceIds);
$where = $onlyUnpaid ? " AND payment_status IN ('unpaid','due')" : '';
$sql = "SELECT * FROM `{$this->prefix}billing_invoices`
WHERE user_id = ? AND invoice_id IN ({$placeholders}){$where}
ORDER BY invoice_id ASC";
$stmt = $this->db->prepare($sql);
if (!$stmt) {
return [];
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
$rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
return $rows;
}
/** 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', 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 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);
$discount = (float)($data['discount_amount'] ?? 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'] ?? '');
$fields = [
'user_id' => $userId,
'service_id' => $svcId,
'home_name' => $homeName,
'ip' => $ip,
'qty' => $qty,
'invoice_duration' => $invDur,
'max_players' => $maxPl,
'price' => $price,
'discount_amount' => $discount,
'remote_control_password' => $rcp,
'ftp_password' => $ftp,
'home_id' => '0',
'status' => $status,
'order_date' => $now,
'end_date' => $endDate,
'payment_txid' => $txid,
'paid_ts' => $paidTs,
'coupon_id' => $couponId,
];
if ($this->hasColumn('billing_orders', 'paypal_data')) {
$fields['paypal_data'] = isset($data['paypal_data'])
? (is_array($data['paypal_data']) ? json_encode($data['paypal_data']) : (string)$data['paypal_data'])
: null;
}
return $this->insertAssoc('billing_orders', $fields);
}
/**
* 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
{
$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;
}
// ---------------------------------------------------------------
// Safe table-creation helpers (idempotent, check INFORMATION_SCHEMA first)
// ---------------------------------------------------------------
/**
* Ensure billing_transactions table exists.
* Safe to call on every request; uses INFORMATION_SCHEMA to skip if already present.
*/
public function ensureBillingTransactionsTable(): bool
{
$res = $this->db->query(
"SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$this->prefix}billing_transactions'"
);
if ($res && (int)$res->fetch_assoc()['cnt'] > 0) {
return true;
}
return (bool)$this->db->query(
"CREATE TABLE IF NOT EXISTS `{$this->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"
);
}
/**
* Ensure billing_paypal_errors table exists.
* Safe to call on every request; uses INFORMATION_SCHEMA to skip if already present.
*/
public function ensureBillingPaypalErrorsTable(): bool
{
$res = $this->db->query(
"SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$this->prefix}billing_paypal_errors'"
);
if ($res && (int)$res->fetch_assoc()['cnt'] > 0) {
return true;
}
return (bool)$this->db->query(
"CREATE TABLE IF NOT EXISTS `{$this->prefix}billing_paypal_errors` (
`id` INT NOT NULL AUTO_INCREMENT,
`context` VARCHAR(64) NOT NULL DEFAULT '',
`error_code` VARCHAR(128) NOT NULL DEFAULT '',
`message` TEXT NULL,
`paypal_debug_id` VARCHAR(128) NULL,
`order_id` VARCHAR(128) NULL,
`capture_id` VARCHAR(128) NULL,
`billing_order_id` INT NULL,
`user_id` INT NULL,
`raw_json` LONGTEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_context` (`context`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
// ---------------------------------------------------------------
// Transaction (payment log) helpers
// ---------------------------------------------------------------
/** Insert a row into billing_transactions. Returns new transaction_id. */
public function logTransaction(array $data): int
{
$this->ensureBillingTransactionsTable();
$invoiceId = intval($data['invoice_id'] ?? 0);
$extId = (string)($data['transaction_external_id'] ?? '');
if ($invoiceId > 0 && $extId !== '') {
$existing = $this->db->prepare(
"SELECT transaction_id FROM `{$this->prefix}billing_transactions`
WHERE invoice_id = ? AND transaction_external_id = ?
LIMIT 1"
);
if ($existing) {
$existing->bind_param('is', $invoiceId, $extId);
$existing->execute();
$row = $existing->get_result()->fetch_assoc();
$existing->close();
if (!empty($row['transaction_id'])) {
return (int)$row['transaction_id'];
}
}
}
$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'] ?? '');
$userId = intval($data['user_id'] ?? 0);
$homeId = intval($data['home_id'] ?? 0);
$method = (string)($data['payment_method'] ?? 'paypal');
$amount = (float)($data['amount'] ?? 0);
$currency = (string)($data['currency'] ?? 'USD');
$status = (string)($data['status'] ?? 'completed');
$stmt->bind_param(
'iiissdsss',
$invoiceId, $userId, $homeId, $method, $extId, $amount, $currency, $status, $rawJson
);
if (!$stmt->execute()) { $stmt->close(); return 0; }
$id = (int)$stmt->insert_id;
$stmt->close();
return $id;
}
/** Get all transactions, optionally filtered. Creates the table if missing. */
public function getTransactions(array $filter = [], int $limit = 100, int $offset = 0): array
{
if (!$this->ensureBillingTransactionsTable()) {
return [];
}
$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);
}
// ---------------------------------------------------------------
// PayPal error log helpers
// ---------------------------------------------------------------
/**
* Insert a row into billing_paypal_errors. Never logs client secrets.
* Returns new error log id (0 on failure).
*/
public function logPaypalError(array $data): int
{
$this->ensureBillingPaypalErrorsTable();
$stmt = $this->db->prepare(
"INSERT INTO `{$this->prefix}billing_paypal_errors`
(context, error_code, message, paypal_debug_id, order_id, capture_id,
billing_order_id, user_id, raw_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
if (!$stmt) return 0;
$context = substr((string)($data['context'] ?? ''), 0, 64);
$errorCode = substr((string)($data['error_code'] ?? ''), 0, 128);
$message = (string)($data['message'] ?? '');
$debugId = isset($data['paypal_debug_id']) ? substr((string)$data['paypal_debug_id'], 0, 128) : null;
$orderId = isset($data['order_id']) ? substr((string)$data['order_id'], 0, 128) : null;
$captureId = isset($data['capture_id']) ? substr((string)$data['capture_id'], 0, 128) : null;
$billingOrderId = isset($data['billing_order_id']) ? intval($data['billing_order_id']) : null;
$userId = isset($data['user_id']) ? intval($data['user_id']) : null;
$rawJson = isset($data['raw_json'])
? (is_array($data['raw_json']) ? json_encode($data['raw_json']) : (string)$data['raw_json'])
: null;
// Truncate large payloads to avoid LONGTEXT bloat
if ($rawJson !== null && strlen($rawJson) > 65536) {
$rawJson = substr($rawJson, 0, 65536) . '…[truncated]';
}
$stmt->bind_param(
'ssssssiis',
$context, $errorCode, $message, $debugId, $orderId, $captureId,
$billingOrderId, $userId, $rawJson
);
if (!$stmt->execute()) { $stmt->close(); return 0; }
$id = (int)$stmt->insert_id;
$stmt->close();
return $id;
}
/**
* Return the $limit most recent rows from billing_paypal_errors.
* Returns empty array if the table does not exist.
*/
public function getRecentPaypalErrors(int $limit = 10): array
{
if (!$this->ensureBillingPaypalErrorsTable()) {
return [];
}
$stmt = $this->db->prepare(
"SELECT id, created_at, context, error_code, message,
paypal_debug_id, order_id, capture_id, billing_order_id, user_id
FROM `{$this->prefix}billing_paypal_errors`
ORDER BY id DESC
LIMIT ?"
);
if (!$stmt) return [];
$stmt->bind_param('i', $limit);
$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];
$val = $data[$col];
if ($val === null) {
$types .= 's'; // NULL binds safely as string in mysqli
} elseif (is_int($val)) {
$types .= 'i';
} elseif (is_float($val)) {
$types .= 'd';
} else {
$types .= '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;
}
public function getCouponByCode(string $couponCode): ?array
{
$stmt = $this->db->prepare(
"SELECT * FROM `{$this->prefix}billing_coupons`
WHERE code = ? AND is_active = 1
LIMIT 1"
);
if (!$stmt) {
return null;
}
$stmt->bind_param('s', $couponCode);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
return $row ?: null;
}
public function updateInvoiceFields(int $invoiceId, array $data): bool
{
return $this->updateAssoc('billing_invoices', 'invoice_id', $invoiceId, $data);
}
public function updateOrderFields(int $orderId, array $data): bool
{
return $this->updateAssoc('billing_orders', 'order_id', $orderId, $data);
}
private function hasColumn(string $table, string $column): bool
{
$cacheKey = $table . '.' . $column;
if (array_key_exists($cacheKey, $this->columnCache)) {
return $this->columnCache[$cacheKey];
}
$tableName = $this->db->real_escape_string($this->prefix . $table);
$columnName = $this->db->real_escape_string($column);
$res = $this->db->query(
"SELECT COUNT(*) AS cnt
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$tableName}'
AND COLUMN_NAME = '{$columnName}'"
);
$exists = $res ? ((int)($res->fetch_assoc()['cnt'] ?? 0) > 0) : false;
$this->columnCache[$cacheKey] = $exists;
return $exists;
}
private function insertAssoc(string $table, array $data): int
{
if (empty($data)) {
return 0;
}
$columns = array_keys($data);
$placeholders = implode(',', array_fill(0, count($columns), '?'));
$sql = sprintf(
"INSERT INTO `%s%s` (%s) VALUES (%s)",
$this->prefix,
$table,
implode(',', array_map(static fn($field) => "`{$field}`", $columns)),
$placeholders
);
$stmt = $this->db->prepare($sql);
if (!$stmt) {
return 0;
}
[$types, $values] = $this->prepareBindValues($data);
$stmt->bind_param($types, ...$values);
if (!$stmt->execute()) {
$stmt->close();
return 0;
}
$id = (int)$stmt->insert_id;
$stmt->close();
return $id;
}
private function updateAssoc(string $table, string $idColumn, int $idValue, array $data): bool
{
$data = array_filter($data, static fn($value) => $value !== null);
if (empty($data)) {
return true;
}
$set = [];
foreach (array_keys($data) as $field) {
$set[] = "`{$field}` = ?";
}
$sql = sprintf(
"UPDATE `%s%s` SET %s WHERE `%s` = ? LIMIT 1",
$this->prefix,
$table,
implode(', ', $set),
$idColumn
);
$stmt = $this->db->prepare($sql);
if (!$stmt) {
return false;
}
[$types, $values] = $this->prepareBindValues($data);
$types .= 'i';
$values[] = $idValue;
$stmt->bind_param($types, ...$values);
$ok = $stmt->execute();
$stmt->close();
return $ok;
}
private function prepareBindValues(array $data): array
{
$types = '';
$values = [];
foreach ($data as $value) {
if (is_int($value)) {
$types .= 'i';
$values[] = $value;
} elseif (is_float($value)) {
$types .= 'd';
$values[] = $value;
} else {
$types .= 's';
$values[] = ($value === null) ? null : (string)$value;
}
}
return [$types, $values];
}
}