refactor(billing): clean architecture with payment gateway abstraction
- Add PaymentGatewayInterface contract for all payment providers - Add PayPalGateway (reads credentials from config, not hardcoded) - Add ManualGateway for admin-triggered payments - Add StripeGateway stub for future implementation - Add GatewayFactory for gateway instantiation by name - Add BillingRepository: parameterized-SQL data layer - Add BillingService: pricing, invoice creation, payment processing - Add gsp_billing_transactions table (DB version 2) for audit trail - Add new columns to gsp_billing_invoices (home_id, rate_type, players, period_start/end, subtotal, total_due, payment_status) - Add gsp_billing_service_remote_servers mapping table - Move PayPal credentials from api files into config.inc.php - Fix double session_start() bug in capture_order.php - Replace raw SQL with prepared statements throughout - Refactor admin_invoices.php to use billing_invoices + BillingRepository - Refactor admin_payments.php to read from gsp_billing_transactions - Update admin.php with links to Transaction Log and Manage Invoices Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
This commit is contained in:
parent
b0e00c9370
commit
986a4e53b4
14 changed files with 1227 additions and 749 deletions
279
modules/billing/classes/BillingRepository.php
Normal file
279
modules/billing/classes/BillingRepository.php
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
<?php
|
||||
/**
|
||||
* BillingRepository — data layer for the billing module.
|
||||
* All SQL lives here. Accepts a mysqli connection.
|
||||
*/
|
||||
class BillingRepository
|
||||
{
|
||||
private mysqli $db;
|
||||
private string $prefix;
|
||||
|
||||
public function __construct(mysqli $db, string $prefix = 'gsp_')
|
||||
{
|
||||
$this->db = $db;
|
||||
$this->prefix = $prefix;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Invoice helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/** Find a single 'unpaid' invoice by ID, owned by $userId. */
|
||||
public function getUnpaidInvoice(int $invoiceId, int $userId): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM `{$this->prefix}billing_invoices`
|
||||
WHERE invoice_id = ? AND user_id = ? AND payment_status IN ('unpaid','due') LIMIT 1"
|
||||
);
|
||||
if (!$stmt) return null;
|
||||
$stmt->bind_param('ii', $invoiceId, $userId);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/** Get all unpaid invoices for a user. */
|
||||
public function getUnpaidInvoicesForUser(int $userId): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM `{$this->prefix}billing_invoices`
|
||||
WHERE user_id = ? AND payment_status IN ('unpaid','due')
|
||||
ORDER BY invoice_id ASC"
|
||||
);
|
||||
if (!$stmt) return [];
|
||||
$stmt->bind_param('i', $userId);
|
||||
$stmt->execute();
|
||||
return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
/** Mark an invoice as paid. */
|
||||
public function markInvoicePaid(int $invoiceId, string $txid, string $method, string $paidAt): bool
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE `{$this->prefix}billing_invoices`
|
||||
SET payment_status='paid', payment_txid=?, payment_method=?, paid_date=?
|
||||
WHERE invoice_id = ? LIMIT 1"
|
||||
);
|
||||
if (!$stmt) return false;
|
||||
$stmt->bind_param('sssi', $txid, $method, $paidAt, $invoiceId);
|
||||
$ok = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/** Create a new invoice record. Returns new invoice_id or 0 on failure. */
|
||||
public function createInvoice(array $data): int
|
||||
{
|
||||
$fields = [
|
||||
'user_id', 'service_id', 'home_id', 'home_name',
|
||||
'customer_name', 'customer_email',
|
||||
'rate_type', 'rate_per_player', 'players',
|
||||
'period_start', 'period_end',
|
||||
'subtotal', 'total_due',
|
||||
'currency', 'payment_status', 'payment_method', 'description',
|
||||
];
|
||||
$cols = implode(',', array_map(fn($f) => "`$f`", $fields));
|
||||
$places = implode(',', array_fill(0, count($fields), '?'));
|
||||
$types = 'iiissssssiissssss';
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO `{$this->prefix}billing_invoices` ({$cols}) VALUES ({$places})"
|
||||
);
|
||||
if (!$stmt) return 0;
|
||||
|
||||
$vals = [];
|
||||
foreach ($fields as $f) {
|
||||
$vals[] = $data[$f] ?? null;
|
||||
}
|
||||
$stmt->bind_param($types, ...$vals);
|
||||
if (!$stmt->execute()) { $stmt->close(); return 0; }
|
||||
$id = (int)$stmt->insert_id;
|
||||
$stmt->close();
|
||||
return $id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Transaction (payment log) helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/** Insert a row into gsp_billing_transactions. Returns new transaction_id. */
|
||||
public function logTransaction(array $data): int
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO `{$this->prefix}billing_transactions`
|
||||
(invoice_id, user_id, home_id, payment_method, transaction_external_id,
|
||||
amount, currency, status, raw_response)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
if (!$stmt) return 0;
|
||||
$rawJson = is_array($data['raw_response']) ? json_encode($data['raw_response']) : (string)($data['raw_response'] ?? '');
|
||||
$stmt->bind_param(
|
||||
'iiissdss' . 's',
|
||||
$data['invoice_id'],
|
||||
$data['user_id'],
|
||||
$data['home_id'],
|
||||
$data['payment_method'],
|
||||
$data['transaction_external_id'],
|
||||
$data['amount'],
|
||||
$data['currency'],
|
||||
$data['status'],
|
||||
$rawJson
|
||||
);
|
||||
if (!$stmt->execute()) { $stmt->close(); return 0; }
|
||||
$id = (int)$stmt->insert_id;
|
||||
$stmt->close();
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** Get all transactions, optionally filtered. */
|
||||
public function getTransactions(array $filter = [], int $limit = 100, int $offset = 0): array
|
||||
{
|
||||
$where = '1=1';
|
||||
$params = [];
|
||||
$types = '';
|
||||
if (!empty($filter['user_id'])) {
|
||||
$where .= ' AND t.user_id = ?';
|
||||
$params[] = intval($filter['user_id']);
|
||||
$types .= 'i';
|
||||
}
|
||||
if (!empty($filter['home_id'])) {
|
||||
$where .= ' AND t.home_id = ?';
|
||||
$params[] = intval($filter['home_id']);
|
||||
$types .= 'i';
|
||||
}
|
||||
if (!empty($filter['payment_method'])) {
|
||||
$where .= ' AND t.payment_method = ?';
|
||||
$params[] = $filter['payment_method'];
|
||||
$types .= 's';
|
||||
}
|
||||
$sql = "SELECT t.*, u.users_login, u.users_email
|
||||
FROM `{$this->prefix}billing_transactions` t
|
||||
LEFT JOIN `{$this->prefix}users` u ON u.user_id = t.user_id
|
||||
WHERE {$where}
|
||||
ORDER BY t.transaction_id DESC
|
||||
LIMIT ? OFFSET ?";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
$types .= 'ii';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
if (!$stmt) return [];
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Server home (billing state) helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/** Get server home billing info by home_id. */
|
||||
public function getServerHomeBilling(int $homeId): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT home_id, home_name, user_id_main, billing_status, billing_expires_at,
|
||||
billing_price, billing_rate_type, billing_players, billing_enabled,
|
||||
next_invoice_date, server_expiration_date, billing_invoice_sent_at
|
||||
FROM `{$this->prefix}server_homes`
|
||||
WHERE home_id = ? LIMIT 1"
|
||||
);
|
||||
if (!$stmt) return null;
|
||||
$stmt->bind_param('i', $homeId);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/** Update billing state fields on server_homes. */
|
||||
public function updateServerHomeBilling(int $homeId, array $data): bool
|
||||
{
|
||||
$allowed = [
|
||||
'billing_status', 'billing_expires_at', 'billing_price',
|
||||
'billing_rate_type', 'billing_players', 'billing_enabled',
|
||||
'next_invoice_date', 'server_expiration_date', 'billing_invoice_sent_at',
|
||||
];
|
||||
$set = [];
|
||||
$params = [];
|
||||
$types = '';
|
||||
foreach ($allowed as $col) {
|
||||
if (array_key_exists($col, $data)) {
|
||||
$set[] = "`{$col}` = ?";
|
||||
$params[] = $data[$col];
|
||||
$types .= is_int($data[$col]) ? 'i' : 's';
|
||||
}
|
||||
}
|
||||
if (empty($set)) return false;
|
||||
$params[] = $homeId;
|
||||
$types .= 'i';
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE `{$this->prefix}server_homes` SET " . implode(', ', $set) . " WHERE home_id = ? LIMIT 1"
|
||||
);
|
||||
if (!$stmt) return false;
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$ok = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $ok;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Service helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/** Get a billing service by ID. Returns null if not found / disabled. */
|
||||
public function getService(int $serviceId, bool $mustBeEnabled = true): ?array
|
||||
{
|
||||
$extra = $mustBeEnabled ? ' AND enabled = 1' : '';
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM `{$this->prefix}billing_services` WHERE service_id = ?{$extra} LIMIT 1"
|
||||
);
|
||||
if (!$stmt) return null;
|
||||
$stmt->bind_param('i', $serviceId);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/** Get enabled services (for storefront listing). */
|
||||
public function getEnabledServices(): array
|
||||
{
|
||||
$res = $this->db->query(
|
||||
"SELECT * FROM `{$this->prefix}billing_services` WHERE enabled = 1 ORDER BY service_name"
|
||||
);
|
||||
return $res ? $res->fetch_all(MYSQLI_ASSOC) : [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Legacy billing_orders helpers (kept for backward compat during migration)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/** Get an active order by order_id. */
|
||||
public function getOrder(int $orderId): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM `{$this->prefix}billing_orders` WHERE order_id = ? LIMIT 1"
|
||||
);
|
||||
if (!$stmt) return null;
|
||||
$stmt->bind_param('i', $orderId);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/** Extend an existing order's end_date. */
|
||||
public function extendOrder(int $orderId, string $newEndDate, string $txid, string $now): bool
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE `{$this->prefix}billing_orders`
|
||||
SET status='Active', end_date=?, payment_txid=?, paid_ts=?
|
||||
WHERE order_id=? LIMIT 1"
|
||||
);
|
||||
if (!$stmt) return false;
|
||||
$stmt->bind_param('sssi', $newEndDate, $txid, $now, $orderId);
|
||||
$ok = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $ok;
|
||||
}
|
||||
}
|
||||
188
modules/billing/classes/BillingService.php
Normal file
188
modules/billing/classes/BillingService.php
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../classes/BillingRepository.php';
|
||||
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
|
||||
|
||||
/**
|
||||
* BillingService — core business logic for the billing module.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Calculate pricing
|
||||
* - Create invoices
|
||||
* - Process payment results (log transaction, mark invoice paid, update server home)
|
||||
* - Extend / reset server billing expiration
|
||||
*/
|
||||
class BillingService
|
||||
{
|
||||
private BillingRepository $repo;
|
||||
|
||||
public function __construct(BillingRepository $repo)
|
||||
{
|
||||
$this->repo = $repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate pricing for a new order.
|
||||
*
|
||||
* @param array $service Row from gsp_billing_services
|
||||
* @param string $rateType 'daily' | 'monthly' | 'yearly'
|
||||
* @param int $players Number of player slots
|
||||
* @param int $qty Duration quantity (e.g. 2 = 2 months)
|
||||
* @return array { rate_per_player, subtotal, total_due, period_days }
|
||||
*/
|
||||
public function calculatePrice(array $service, string $rateType, int $players, int $qty = 1): array
|
||||
{
|
||||
$qty = max(1, $qty);
|
||||
$players = max(1, $players);
|
||||
|
||||
switch ($rateType) {
|
||||
case 'daily':
|
||||
$basePrice = (float)($service['price_daily'] ?? 0);
|
||||
$periodDays = $qty;
|
||||
break;
|
||||
case 'yearly':
|
||||
$basePrice = (float)($service['price_year'] ?? 0);
|
||||
$periodDays = $qty * 365;
|
||||
break;
|
||||
case 'monthly':
|
||||
default:
|
||||
$rateType = 'monthly';
|
||||
$basePrice = (float)($service['price_monthly'] ?? 0);
|
||||
$periodDays = $qty * 31;
|
||||
break;
|
||||
}
|
||||
|
||||
// price_monthly etc is the per-player per-period rate
|
||||
$ratePerPlayer = $basePrice;
|
||||
$subtotal = round($ratePerPlayer * $players * $qty, 2);
|
||||
$totalDue = $subtotal;
|
||||
|
||||
return [
|
||||
'rate_type' => $rateType,
|
||||
'rate_per_player' => $ratePerPlayer,
|
||||
'players' => $players,
|
||||
'qty' => $qty,
|
||||
'subtotal' => $subtotal,
|
||||
'total_due' => $totalDue,
|
||||
'period_days' => $periodDays,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a billing invoice row.
|
||||
*
|
||||
* @param array $pricing Result from calculatePrice()
|
||||
* @param array $context { user_id, service_id, home_id, home_name, customer_name, customer_email, description }
|
||||
* @return int New invoice_id (0 on failure)
|
||||
*/
|
||||
public function createInvoice(array $pricing, array $context): int
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$periodStart = $now;
|
||||
$periodEnd = date('Y-m-d H:i:s', strtotime('+' . $pricing['period_days'] . ' days'));
|
||||
|
||||
return $this->repo->createInvoice([
|
||||
'user_id' => intval($context['user_id'] ?? 0),
|
||||
'service_id' => intval($context['service_id'] ?? 0),
|
||||
'home_id' => intval($context['home_id'] ?? 0),
|
||||
'home_name' => $context['home_name'] ?? '',
|
||||
'customer_name' => $context['customer_name'] ?? '',
|
||||
'customer_email' => $context['customer_email'] ?? '',
|
||||
'rate_type' => $pricing['rate_type'],
|
||||
'rate_per_player' => $pricing['rate_per_player'],
|
||||
'players' => $pricing['players'],
|
||||
'period_start' => $periodStart,
|
||||
'period_end' => $periodEnd,
|
||||
'subtotal' => $pricing['subtotal'],
|
||||
'total_due' => $pricing['total_due'],
|
||||
'currency' => $context['currency'] ?? 'USD',
|
||||
'payment_status' => 'unpaid',
|
||||
'payment_method' => '',
|
||||
'description' => $context['description'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a successful payment result from a gateway.
|
||||
*
|
||||
* 1. Log the transaction
|
||||
* 2. Mark invoice paid
|
||||
* 3. Update server home billing state (extend or reset expiration)
|
||||
*
|
||||
* @param array $captureResult Result from PaymentGatewayInterface::handleCallback()
|
||||
* @param int $invoiceId
|
||||
* @param int $userId
|
||||
* @param int $homeId
|
||||
* @param array $invoiceRow The invoice row (from DB) — needed for period/pricing
|
||||
* @return array { success: bool, transaction_id: string, error?: string }
|
||||
*/
|
||||
public function processPaymentSuccess(
|
||||
array $captureResult,
|
||||
int $invoiceId,
|
||||
int $userId,
|
||||
int $homeId,
|
||||
array $invoiceRow
|
||||
): array {
|
||||
$txid = $captureResult['transaction_id'] ?? null;
|
||||
$method = $captureResult['payment_method'] ?? 'paypal';
|
||||
$amount = (float)($captureResult['amount'] ?? $invoiceRow['total_due'] ?? 0);
|
||||
$currency = $captureResult['currency'] ?? $invoiceRow['currency'] ?? 'USD';
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
// 1. Log transaction
|
||||
$this->repo->logTransaction([
|
||||
'invoice_id' => $invoiceId,
|
||||
'user_id' => $userId,
|
||||
'home_id' => $homeId,
|
||||
'payment_method' => $method,
|
||||
'transaction_external_id' => $txid ?? '',
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'status' => 'completed',
|
||||
'raw_response' => $captureResult['raw_response'] ?? [],
|
||||
]);
|
||||
|
||||
// 2. Mark invoice paid
|
||||
if ($invoiceId > 0) {
|
||||
$this->repo->markInvoicePaid($invoiceId, $txid ?? '', $method, $now);
|
||||
}
|
||||
|
||||
// 3. Update server home billing state
|
||||
if ($homeId > 0) {
|
||||
$this->extendServerBilling($homeId, $invoiceRow, $now);
|
||||
}
|
||||
|
||||
return ['success' => true, 'transaction_id' => $txid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend or reset a server's billing expiration based on the invoice period.
|
||||
*/
|
||||
public function extendServerBilling(int $homeId, array $invoiceRow, string $now): void
|
||||
{
|
||||
$home = $this->repo->getServerHomeBilling($homeId);
|
||||
$periodEnd = $invoiceRow['period_end'] ?? null;
|
||||
|
||||
if (!$periodEnd) {
|
||||
$rateType = $invoiceRow['rate_type'] ?? 'monthly';
|
||||
$periodMap = ['daily' => '+1 day', 'monthly' => '+31 days', 'yearly' => '+365 days'];
|
||||
$periodEnd = date('Y-m-d H:i:s', strtotime($periodMap[$rateType] ?? '+31 days'));
|
||||
}
|
||||
|
||||
// If current expiry is in the future, extend from it; otherwise reset from period_end
|
||||
$currentExpiry = $home['billing_expires_at'] ?? null;
|
||||
if ($currentExpiry && strtotime($currentExpiry) > time()) {
|
||||
$currentPeriodSecs = strtotime($invoiceRow['period_end'] ?? $now) - strtotime($invoiceRow['period_start'] ?? $now);
|
||||
$newExpiry = date('Y-m-d H:i:s', strtotime($currentExpiry) + max(86400, $currentPeriodSecs));
|
||||
} else {
|
||||
$newExpiry = $periodEnd;
|
||||
}
|
||||
|
||||
$this->repo->updateServerHomeBilling($homeId, [
|
||||
'billing_status' => 'active',
|
||||
'billing_expires_at' => $newExpiry,
|
||||
'next_invoice_date' => $newExpiry,
|
||||
'server_expiration_date' => null,
|
||||
'billing_invoice_sent_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
modules/billing/classes/GatewayFactory.php
Normal file
30
modules/billing/classes/GatewayFactory.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
|
||||
require_once __DIR__ . '/../classes/PayPalGateway.php';
|
||||
require_once __DIR__ . '/../classes/ManualGateway.php';
|
||||
require_once __DIR__ . '/../classes/StripeGateway.php';
|
||||
|
||||
/**
|
||||
* Factory for instantiating payment gateways by name.
|
||||
*/
|
||||
class GatewayFactory
|
||||
{
|
||||
/**
|
||||
* @param string $name Gateway name: 'paypal', 'stripe', 'manual'
|
||||
* @return PaymentGatewayInterface
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public static function make(string $name): PaymentGatewayInterface
|
||||
{
|
||||
switch (strtolower($name)) {
|
||||
case 'paypal':
|
||||
return PayPalGateway::fromConfig();
|
||||
case 'manual':
|
||||
return new ManualGateway();
|
||||
case 'stripe':
|
||||
return new StripeGateway();
|
||||
default:
|
||||
throw new InvalidArgumentException("Unknown payment gateway: {$name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
36
modules/billing/classes/ManualGateway.php
Normal file
36
modules/billing/classes/ManualGateway.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
|
||||
|
||||
/**
|
||||
* Manual / offline payment gateway.
|
||||
* Used when an admin marks a payment as paid directly.
|
||||
*/
|
||||
class ManualGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public function getName(): string { return 'manual'; }
|
||||
|
||||
public function createPayment(array $params): array
|
||||
{
|
||||
return ['success' => true, 'provider_order_id' => 'MANUAL-' . uniqid(), 'raw_response' => []];
|
||||
}
|
||||
|
||||
public function handleCallback(array $params): array
|
||||
{
|
||||
$txid = $params['admin_txid'] ?? ('MANUAL-' . uniqid());
|
||||
return [
|
||||
'success' => true,
|
||||
'transaction_id' => $txid,
|
||||
'amount' => (float)($params['amount'] ?? 0),
|
||||
'currency' => $params['currency'] ?? 'USD',
|
||||
'status' => 'completed',
|
||||
'raw_response' => $params,
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyPayment(array $payload): bool { return true; }
|
||||
|
||||
public function getTransactionId(array $captureResult): ?string
|
||||
{
|
||||
return $captureResult['transaction_id'] ?? null;
|
||||
}
|
||||
}
|
||||
188
modules/billing/classes/PayPalGateway.php
Normal file
188
modules/billing/classes/PayPalGateway.php
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
|
||||
|
||||
class PayPalGateway implements PaymentGatewayInterface
|
||||
{
|
||||
private string $clientId;
|
||||
private string $clientSecret;
|
||||
private bool $sandbox;
|
||||
private string $apiBase;
|
||||
|
||||
public function __construct(string $clientId, string $clientSecret, bool $sandbox = true)
|
||||
{
|
||||
$this->clientId = $clientId;
|
||||
$this->clientSecret = $clientSecret;
|
||||
$this->sandbox = $sandbox;
|
||||
$this->apiBase = $sandbox
|
||||
? 'https://api-m.sandbox.paypal.com'
|
||||
: 'https://api-m.paypal.com';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PayPalGateway instance from global config variables.
|
||||
* Expects $paypal_client_id, $paypal_client_secret, $paypal_sandbox in scope.
|
||||
*/
|
||||
public static function fromConfig(): self
|
||||
{
|
||||
$clientId = $GLOBALS['paypal_client_id'] ?? '';
|
||||
$clientSecret = $GLOBALS['paypal_client_secret'] ?? '';
|
||||
$sandbox = (bool)($GLOBALS['paypal_sandbox'] ?? true);
|
||||
return new self($clientId, $clientSecret, $sandbox);
|
||||
}
|
||||
|
||||
public function getName(): string { return 'paypal'; }
|
||||
|
||||
/** Exchange client credentials for a Bearer token. Returns token or null. */
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
$ch = curl_init("{$this->apiBase}/v1/oauth2/token");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => 'grant_type=client_credentials',
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
CURLOPT_USERPWD => "{$this->clientId}:{$this->clientSecret}",
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($code !== 200 || !$body) return null;
|
||||
$data = json_decode($body, true);
|
||||
return $data['access_token'] ?? null;
|
||||
}
|
||||
|
||||
public function createPayment(array $params): array
|
||||
{
|
||||
$token = $this->getAccessToken();
|
||||
if (!$token) {
|
||||
return ['success' => false, 'error' => 'paypal_oauth_failed'];
|
||||
}
|
||||
|
||||
$amount = number_format((float)($params['amount'] ?? 0), 2, '.', '');
|
||||
$currency = $params['currency'] ?? 'USD';
|
||||
$invoiceId = $params['invoice_id'] ?? null;
|
||||
$description = $params['description'] ?? 'Game Server Order';
|
||||
$returnUrl = $params['return_url'] ?? '';
|
||||
$cancelUrl = $params['cancel_url'] ?? '';
|
||||
$items = $params['items'] ?? null;
|
||||
|
||||
$purchaseUnit = [
|
||||
'amount' => ['currency_code' => $currency, 'value' => $amount],
|
||||
'description' => $description,
|
||||
'custom_id' => (string)($params['custom_id'] ?? $invoiceId ?? ''),
|
||||
];
|
||||
if ($invoiceId) {
|
||||
$purchaseUnit['invoice_id'] = (string)$invoiceId;
|
||||
}
|
||||
if ($items) {
|
||||
$purchaseUnit['items'] = $items;
|
||||
$purchaseUnit['amount']['breakdown'] = [
|
||||
'item_total' => ['currency_code' => $currency, 'value' => $amount],
|
||||
];
|
||||
}
|
||||
|
||||
$body = [
|
||||
'intent' => 'CAPTURE',
|
||||
'purchase_units' => [$purchaseUnit],
|
||||
'application_context' => [
|
||||
'return_url' => $returnUrl,
|
||||
'cancel_url' => $cancelUrl,
|
||||
'user_action' => 'PAY_NOW',
|
||||
],
|
||||
];
|
||||
|
||||
$ch = curl_init("{$this->apiBase}/v2/checkout/orders");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($body),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
"Authorization: Bearer {$token}",
|
||||
],
|
||||
]);
|
||||
$res = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code !== 201 || !$res) {
|
||||
return ['success' => false, 'error' => 'paypal_create_order_failed', 'http_code' => $code];
|
||||
}
|
||||
$data = json_decode($res, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return ['success' => false, 'error' => 'paypal_invalid_response'];
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'provider_order_id' => $data['id'] ?? '',
|
||||
'raw_response' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
public function handleCallback(array $params): array
|
||||
{
|
||||
$providerOrderId = $params['order_id'] ?? null;
|
||||
if (!$providerOrderId) {
|
||||
return ['success' => false, 'error' => 'missing_order_id'];
|
||||
}
|
||||
|
||||
$token = $this->getAccessToken();
|
||||
if (!$token) {
|
||||
return ['success' => false, 'error' => 'paypal_oauth_failed'];
|
||||
}
|
||||
|
||||
$ch = curl_init("{$this->apiBase}/v2/checkout/orders/{$providerOrderId}/capture");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
"Authorization: Bearer {$token}",
|
||||
],
|
||||
]);
|
||||
$res = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if (($code !== 200 && $code !== 201) || !$res) {
|
||||
return ['success' => false, 'error' => 'paypal_capture_failed', 'http_code' => $code];
|
||||
}
|
||||
$data = json_decode($res, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return ['success' => false, 'error' => 'paypal_invalid_capture_response'];
|
||||
}
|
||||
|
||||
$status = $data['status'] ?? '';
|
||||
if ($status !== 'COMPLETED') {
|
||||
return ['success' => false, 'error' => 'payment_not_completed', 'status' => $status];
|
||||
}
|
||||
|
||||
$capture = $data['purchase_units'][0]['payments']['captures'][0] ?? [];
|
||||
$txid = $capture['id'] ?? null;
|
||||
$amount = (float)($capture['amount']['value'] ?? 0);
|
||||
$currency = $capture['amount']['currency_code'] ?? 'USD';
|
||||
$customId = $data['purchase_units'][0]['custom_id'] ?? null;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'transaction_id' => $txid,
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'status' => 'completed',
|
||||
'custom_id' => $customId,
|
||||
'raw_response' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyPayment(array $payload): bool
|
||||
{
|
||||
// For REST API flow (JS SDK capture), verification is done by the capture response itself.
|
||||
// Webhook signature verification would be implemented here for webhook events.
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getTransactionId(array $captureResult): ?string
|
||||
{
|
||||
return $captureResult['transaction_id'] ?? null;
|
||||
}
|
||||
}
|
||||
40
modules/billing/classes/PaymentGatewayInterface.php
Normal file
40
modules/billing/classes/PaymentGatewayInterface.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
/**
|
||||
* Payment Gateway Interface
|
||||
* All payment providers must implement this contract.
|
||||
*/
|
||||
interface PaymentGatewayInterface
|
||||
{
|
||||
/**
|
||||
* Create a payment/order on the provider side.
|
||||
* @param array $params { amount, currency, invoice_id, description, return_url, cancel_url, items? }
|
||||
* @return array { success: bool, provider_order_id: string, redirect_url?: string, error?: string }
|
||||
*/
|
||||
public function createPayment(array $params): array;
|
||||
|
||||
/**
|
||||
* Handle a provider callback/capture (webhook or return).
|
||||
* @param array $params Provider-specific parameters (e.g. { order_id } for PayPal)
|
||||
* @return array { success: bool, transaction_id: string, amount: float, status: string, raw_response: array, error?: string }
|
||||
*/
|
||||
public function handleCallback(array $params): array;
|
||||
|
||||
/**
|
||||
* Verify that a payment/webhook is authentic.
|
||||
* @param array $payload Raw request body / headers
|
||||
* @return bool
|
||||
*/
|
||||
public function verifyPayment(array $payload): bool;
|
||||
|
||||
/**
|
||||
* Get the provider's external transaction ID from a capture result.
|
||||
* @param array $captureResult Result from handleCallback()
|
||||
* @return string|null
|
||||
*/
|
||||
public function getTransactionId(array $captureResult): ?string;
|
||||
|
||||
/**
|
||||
* Return a short machine name for this gateway (e.g. 'paypal', 'stripe', 'manual').
|
||||
*/
|
||||
public function getName(): string;
|
||||
}
|
||||
25
modules/billing/classes/StripeGateway.php
Normal file
25
modules/billing/classes/StripeGateway.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../classes/PaymentGatewayInterface.php';
|
||||
|
||||
/**
|
||||
* Stripe payment gateway stub.
|
||||
* Implement this class when Stripe support is needed.
|
||||
*/
|
||||
class StripeGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public function getName(): string { return 'stripe'; }
|
||||
|
||||
public function createPayment(array $params): array
|
||||
{
|
||||
return ['success' => false, 'error' => 'stripe_not_implemented'];
|
||||
}
|
||||
|
||||
public function handleCallback(array $params): array
|
||||
{
|
||||
return ['success' => false, 'error' => 'stripe_not_implemented'];
|
||||
}
|
||||
|
||||
public function verifyPayment(array $payload): bool { return false; }
|
||||
|
||||
public function getTransactionId(array $captureResult): ?string { return null; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue