moved website outside of panel folder

This commit is contained in:
Frank Harris 2026-05-13 20:00:40 -04:00
parent 92ac778956
commit 08f07dca97
10328 changed files with 90 additions and 501 deletions

View file

@ -0,0 +1,674 @@
<?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];
}
}

View file

@ -0,0 +1,179 @@
<?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);
$rateType = 'monthly';
$basePrice = (float)($service['price_monthly'] ?? 0);
$periodDays = $qty * 31;
// 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) {
$periodEnd = date('Y-m-d H:i:s', strtotime('+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()) {
// Calculate the period length from the invoice; fall back to rate_type if dates are missing
$periodStart = $invoiceRow['period_start'] ?? null;
$periodEndVal = $invoiceRow['period_end'] ?? null;
if ($periodStart && $periodEndVal) {
$currentPeriodSecs = strtotime($periodEndVal) - strtotime($periodStart);
} else {
$currentPeriodSecs = 31 * 86400;
}
$newExpiry = date('Y-m-d H:i:s', strtotime($currentExpiry) + max(86400, $currentPeriodSecs));
} else {
$newExpiry = $periodEnd;
}
$this->repo->updateServerHomeBilling($homeId, [
'billing_status' => 'active',
'billing_expires_at' => $newExpiry,
'next_invoice_date' => $newExpiry,
'server_expiration_date' => null,
'billing_invoice_sent_at' => null,
]);
}
}

View file

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

View file

@ -0,0 +1,36 @@
<?php
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
/**
* Manual / offline payment gateway.
* Used when an admin marks a payment as paid directly.
*/
class ManualGateway implements PaymentGatewayInterface
{
public function getName(): string { return 'manual'; }
public function createPayment(array $params): array
{
return ['success' => true, 'provider_order_id' => 'MANUAL-' . uniqid(), 'raw_response' => []];
}
public function handleCallback(array $params): array
{
$txid = $params['admin_txid'] ?? ('MANUAL-' . uniqid());
return [
'success' => true,
'transaction_id' => $txid,
'amount' => (float)($params['amount'] ?? 0),
'currency' => $params['currency'] ?? 'USD',
'status' => 'completed',
'raw_response' => $params,
];
}
public function verifyPayment(array $payload): bool { return true; }
public function getTransactionId(array $captureResult): ?string
{
return $captureResult['transaction_id'] ?? null;
}
}

View file

@ -0,0 +1,194 @@
<?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.
* Prefers the new gsp_paypal_* helper functions; falls back to legacy globals.
*/
public static function fromConfig(): self
{
if (function_exists('gsp_paypal_get_client_id')) {
$clientId = gsp_paypal_get_client_id();
$clientSecret = gsp_paypal_get_client_secret();
$sandbox = gsp_paypal_is_sandbox();
} else {
$clientId = $GLOBALS['paypal_client_id'] ?? '';
$clientSecret = $GLOBALS['paypal_client_secret'] ?? '';
$sandbox = (bool)($GLOBALS['paypal_sandbox'] ?? true);
}
return new self($clientId, $clientSecret, $sandbox);
}
public function getName(): string { return 'paypal'; }
/** Exchange client credentials for a Bearer token. Returns token or null. */
private function getAccessToken(): ?string
{
$ch = curl_init("{$this->apiBase}/v1/oauth2/token");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => 'grant_type=client_credentials',
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_USERPWD => "{$this->clientId}:{$this->clientSecret}",
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || !$body) return null;
$data = json_decode($body, true);
return $data['access_token'] ?? null;
}
public function createPayment(array $params): array
{
$token = $this->getAccessToken();
if (!$token) {
return ['success' => false, 'error' => 'paypal_oauth_failed'];
}
$amount = number_format((float)($params['amount'] ?? 0), 2, '.', '');
$currency = $params['currency'] ?? 'USD';
$invoiceId = $params['invoice_id'] ?? null;
$description = $params['description'] ?? 'Game Server Order';
$returnUrl = $params['return_url'] ?? '';
$cancelUrl = $params['cancel_url'] ?? '';
$items = $params['items'] ?? null;
$purchaseUnit = [
'amount' => ['currency_code' => $currency, 'value' => $amount],
'description' => $description,
'custom_id' => (string)($params['custom_id'] ?? $invoiceId ?? ''),
];
if ($invoiceId) {
$purchaseUnit['invoice_id'] = (string)$invoiceId;
}
if ($items) {
$purchaseUnit['items'] = $items;
$purchaseUnit['amount']['breakdown'] = [
'item_total' => ['currency_code' => $currency, 'value' => $amount],
];
}
$body = [
'intent' => 'CAPTURE',
'purchase_units' => [$purchaseUnit],
'application_context' => [
'return_url' => $returnUrl,
'cancel_url' => $cancelUrl,
'user_action' => 'PAY_NOW',
],
];
$ch = curl_init("{$this->apiBase}/v2/checkout/orders");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"Authorization: Bearer {$token}",
],
]);
$res = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 201 || !$res) {
return ['success' => false, 'error' => 'paypal_create_order_failed', 'http_code' => $code];
}
$data = json_decode($res, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['success' => false, 'error' => 'paypal_invalid_response'];
}
return [
'success' => true,
'provider_order_id' => $data['id'] ?? '',
'raw_response' => $data,
];
}
public function handleCallback(array $params): array
{
$providerOrderId = $params['order_id'] ?? null;
if (!$providerOrderId) {
return ['success' => false, 'error' => 'missing_order_id'];
}
$token = $this->getAccessToken();
if (!$token) {
return ['success' => false, 'error' => 'paypal_oauth_failed'];
}
$ch = curl_init("{$this->apiBase}/v2/checkout/orders/{$providerOrderId}/capture");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"Authorization: Bearer {$token}",
],
]);
$res = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (($code !== 200 && $code !== 201) || !$res) {
return ['success' => false, 'error' => 'paypal_capture_failed', 'http_code' => $code];
}
$data = json_decode($res, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['success' => false, 'error' => 'paypal_invalid_capture_response'];
}
$status = $data['status'] ?? '';
if ($status !== 'COMPLETED') {
return ['success' => false, 'error' => 'payment_not_completed', 'status' => $status];
}
$capture = $data['purchase_units'][0]['payments']['captures'][0] ?? [];
$txid = $capture['id'] ?? null;
$amount = (float)($capture['amount']['value'] ?? 0);
$currency = $capture['amount']['currency_code'] ?? 'USD';
$customId = $data['purchase_units'][0]['custom_id'] ?? null;
return [
'success' => true,
'transaction_id' => $txid,
'amount' => $amount,
'currency' => $currency,
'status' => 'completed',
'custom_id' => $customId,
'raw_response' => $data,
];
}
public function verifyPayment(array $payload): bool
{
// For REST API flow (JS SDK capture), verification is done by the capture response itself.
// Webhook signature verification would be implemented here for webhook events.
return true;
}
public function getTransactionId(array $captureResult): ?string
{
return $captureResult['transaction_id'] ?? null;
}
}

View file

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

View file

@ -0,0 +1,25 @@
<?php
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
/**
* Stripe payment gateway stub.
* Implement this class when Stripe support is needed.
*/
class StripeGateway implements PaymentGatewayInterface
{
public function getName(): string { return 'stripe'; }
public function createPayment(array $params): array
{
return ['success' => false, 'error' => 'stripe_not_implemented'];
}
public function handleCallback(array $params): array
{
return ['success' => false, 'error' => 'stripe_not_implemented'];
}
public function verifyPayment(array $payload): bool { return false; }
public function getTransactionId(array $captureResult): ?string { return null; }
}