Files
website/scripts/whmcs-migrate/src/WhmcsApi.php
Claude Dev b4ef90465c feat: complete pre-launch audit — frontend polish, churn prevention, login history, financial reports, configurable checkout
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>
2026-03-16 11:39:25 -04:00

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;
}
}