Includes all work from phases 6-9+ and frontend polish rounds 1 & 2: - Login history with device trust, new device notifications, session management - Churn prevention: cancellation surveys, winback campaigns with email sequences - Financial reports: revenue, P&L, tax, aging, refund, subscription reports with PDF/CSV/JSON export - Configurable checkout: plan config groups/options, build-your-own VPS - Frontend polish: fix broken legal links, add SEO meta tags, favicon, font display=swap, Head titles on all 14 marketing pages, mobile responsive fixes, AuthLayout legal footer, remove false 24/7 claims, hide empty stats, correct uptime SLA to 99.9%, GameServers notify buttons linked to /contact, 301 redirects for /terms and /privacy - WHMCS migration scripts - Update legal page effective dates to March 16, 2026 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
147 lines
4.4 KiB
PHP
147 lines
4.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace WhmcsMigrate;
|
|
|
|
use RuntimeException;
|
|
|
|
final class WhmcsApi
|
|
{
|
|
private string $apiUrl;
|
|
|
|
private string $identifier;
|
|
|
|
private string $secret;
|
|
|
|
private const int MAX_RETRIES = 3;
|
|
|
|
private const array BACKOFF_SECONDS = [1, 2, 4];
|
|
|
|
public function __construct(
|
|
private readonly Config $config,
|
|
private readonly Logger $logger,
|
|
) {
|
|
$this->apiUrl = $config->getRequired('WHMCS_API_URL');
|
|
$this->identifier = $config->getRequired('WHMCS_API_IDENTIFIER');
|
|
$this->secret = $config->getRequired('WHMCS_API_SECRET');
|
|
}
|
|
|
|
/**
|
|
* Call a WHMCS API action with optional parameters.
|
|
*
|
|
* Retries up to 3 times with exponential backoff on failure.
|
|
*
|
|
* @return array<string, mixed> The decoded JSON response.
|
|
*
|
|
* @throws RuntimeException On final failure after all retries.
|
|
*/
|
|
public function call(string $action, array $params = []): array
|
|
{
|
|
$postFields = array_merge($params, [
|
|
'identifier' => $this->identifier,
|
|
'secret' => $this->secret,
|
|
'action' => $action,
|
|
'responsetype' => 'json',
|
|
]);
|
|
|
|
$this->logger->debug("WHMCS API call: {$action}", [
|
|
'params' => array_diff_key($params, ['identifier' => 1, 'secret' => 1]),
|
|
]);
|
|
|
|
$lastException = null;
|
|
|
|
for ($attempt = 0; $attempt < self::MAX_RETRIES; $attempt++) {
|
|
try {
|
|
$result = $this->doRequest($postFields);
|
|
|
|
$this->logger->debug("WHMCS API response: {$action}", [
|
|
'result' => $result['result'] ?? 'unknown',
|
|
]);
|
|
|
|
return $result;
|
|
} catch (RuntimeException $e) {
|
|
$lastException = $e;
|
|
$backoff = self::BACKOFF_SECONDS[$attempt] ?? 4;
|
|
|
|
$retryMessage = sprintf(
|
|
'WHMCS API call failed (attempt %d/%d): %s, retrying in %ds',
|
|
$attempt + 1,
|
|
self::MAX_RETRIES,
|
|
$e->getMessage(),
|
|
$backoff,
|
|
);
|
|
$this->logger->warning($retryMessage);
|
|
|
|
if ($attempt < self::MAX_RETRIES - 1) {
|
|
sleep($backoff);
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new RuntimeException(
|
|
"WHMCS API call '{$action}' failed after " . self::MAX_RETRIES . " attempts: " . $lastException->getMessage(),
|
|
0,
|
|
$lastException,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Execute the cURL request to the WHMCS API.
|
|
*
|
|
* @return array<string, mixed>
|
|
*
|
|
* @throws RuntimeException On cURL or JSON decode error.
|
|
*/
|
|
private function doRequest(array $postFields): array
|
|
{
|
|
$ch = curl_init();
|
|
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_URL => $this->apiUrl,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => http_build_query($postFields),
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 60,
|
|
CURLOPT_CONNECTTIMEOUT => 15,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
'Accept: application/json',
|
|
],
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$curlError = curl_error($ch);
|
|
$curlErrno = curl_errno($ch);
|
|
|
|
curl_close($ch);
|
|
|
|
if ($curlErrno !== 0) {
|
|
throw new RuntimeException("cURL error ({$curlErrno}): {$curlError}");
|
|
}
|
|
|
|
if ($httpCode < 200 || $httpCode >= 300) {
|
|
throw new RuntimeException("HTTP {$httpCode} response from WHMCS API");
|
|
}
|
|
|
|
if (! is_string($response) || $response === '') {
|
|
throw new RuntimeException('Empty response from WHMCS API');
|
|
}
|
|
|
|
$decoded = json_decode($response, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw new RuntimeException('Failed to decode WHMCS API response: ' . json_last_error_msg());
|
|
}
|
|
|
|
if (isset($decoded['result']) && $decoded['result'] === 'error') {
|
|
throw new RuntimeException('WHMCS API error: ' . ($decoded['message'] ?? 'Unknown error'));
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
}
|