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>
This commit is contained in:
124
scripts/whmcs-migrate/src/Config.php
Normal file
124
scripts/whmcs-migrate/src/Config.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate;
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
use RuntimeException;
|
||||
|
||||
final class Config
|
||||
{
|
||||
private array $overrides = [];
|
||||
|
||||
public function __construct(string $basePath)
|
||||
{
|
||||
$dotenv = Dotenv::createImmutable($basePath);
|
||||
$dotenv->safeLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a runtime override (e.g., from CLI flags).
|
||||
*/
|
||||
public function override(string $key, mixed $value): void
|
||||
{
|
||||
$this->overrides[$key] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a config value with optional default.
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
if (array_key_exists($key, $this->overrides)) {
|
||||
return $this->overrides[$key];
|
||||
}
|
||||
|
||||
$value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
|
||||
|
||||
if ($value === false) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a required config value. Throws if missing.
|
||||
*/
|
||||
public function getRequired(string $key): string
|
||||
{
|
||||
$value = $this->get($key);
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
throw new RuntimeException("Required configuration key '{$key}' is not set.");
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the migration is running in dry-run mode.
|
||||
*/
|
||||
public function isDryRun(): bool
|
||||
{
|
||||
$value = $this->get('DRY_RUN', 'false');
|
||||
|
||||
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of records to process per batch.
|
||||
*
|
||||
* WHMCS API supports limitnum up to 250. Default raised from 100 to 250.
|
||||
*/
|
||||
public function getBatchSize(): int
|
||||
{
|
||||
return (int) $this->get('BATCH_SIZE', 250);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the migration is in export-only mode (fetch from API and save to JSONL files).
|
||||
*/
|
||||
public function isExportOnly(): bool
|
||||
{
|
||||
return filter_var($this->get('EXPORT_ONLY', 'false'), FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the migration should read from exported JSONL files instead of the API.
|
||||
*/
|
||||
public function isFromExport(): bool
|
||||
{
|
||||
return filter_var($this->get('FROM_EXPORT', 'false'), FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Monolog log level string.
|
||||
*/
|
||||
public function getLogLevel(): string
|
||||
{
|
||||
return (string) $this->get('LOG_LEVEL', 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Laravel APP_KEY as raw binary (decoded from base64).
|
||||
*/
|
||||
public function getLaravelAppKey(): string
|
||||
{
|
||||
$key = $this->getRequired('LARAVEL_APP_KEY');
|
||||
|
||||
// Strip the 'base64:' prefix if present
|
||||
if (str_starts_with($key, 'base64:')) {
|
||||
$key = substr($key, 7);
|
||||
}
|
||||
|
||||
$decoded = base64_decode($key, strict: true);
|
||||
|
||||
if ($decoded === false) {
|
||||
throw new RuntimeException('LARAVEL_APP_KEY is not valid base64.');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
202
scripts/whmcs-migrate/src/Database.php
Normal file
202
scripts/whmcs-migrate/src/Database.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate;
|
||||
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
|
||||
final class Database
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(Config $config)
|
||||
{
|
||||
$host = $config->getRequired('EZSCALE_DB_HOST');
|
||||
$port = $config->get('EZSCALE_DB_PORT', '3306');
|
||||
$database = $config->getRequired('EZSCALE_DB_DATABASE');
|
||||
$username = $config->getRequired('EZSCALE_DB_USERNAME');
|
||||
$password = $config->get('EZSCALE_DB_PASSWORD', '');
|
||||
|
||||
$dsn = "mysql:host={$host};port={$port};dbname={$database};charset=utf8mb4";
|
||||
|
||||
$this->pdo = new PDO($dsn, $username, (string) $password, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying PDO instance.
|
||||
*/
|
||||
public function getPdo(): PDO
|
||||
{
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a row and return the last insert ID.
|
||||
*/
|
||||
public function insert(string $table, array $data): int
|
||||
{
|
||||
$data = $this->ensureTimestamps($data);
|
||||
|
||||
$columns = implode(', ', array_map(fn (string $col): string => "`{$col}`", array_keys($data)));
|
||||
$placeholders = implode(', ', array_map(fn (string $col): string => ":{$col}", array_keys($data)));
|
||||
|
||||
$sql = "INSERT INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a row using INSERT IGNORE. Returns last insert ID (0 if ignored).
|
||||
*/
|
||||
public function insertIgnore(string $table, array $data): int
|
||||
{
|
||||
$data = $this->ensureTimestamps($data);
|
||||
|
||||
$columns = implode(', ', array_map(fn (string $col): string => "`{$col}`", array_keys($data)));
|
||||
$placeholders = implode(', ', array_map(fn (string $col): string => ":{$col}", array_keys($data)));
|
||||
|
||||
$sql = "INSERT IGNORE INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rows matching the where clause. Returns affected row count.
|
||||
*/
|
||||
public function update(string $table, array $data, array $where): int
|
||||
{
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($data as $column => $value) {
|
||||
$paramKey = "set_{$column}";
|
||||
$setParts[] = "`{$column}` = :{$paramKey}";
|
||||
$params[$paramKey] = $value;
|
||||
}
|
||||
|
||||
$whereParts = [];
|
||||
foreach ($where as $column => $value) {
|
||||
$paramKey = "where_{$column}";
|
||||
$whereParts[] = "`{$column}` = :{$paramKey}";
|
||||
$params[$paramKey] = $value;
|
||||
}
|
||||
|
||||
$sql = sprintf(
|
||||
'UPDATE `%s` SET %s WHERE %s',
|
||||
$table,
|
||||
implode(', ', $setParts),
|
||||
implode(' AND ', $whereParts),
|
||||
);
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a SELECT query and return all rows.
|
||||
*/
|
||||
public function query(string $sql, array $params = []): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a SELECT query and return a single row or null.
|
||||
*/
|
||||
public function queryOne(string $sql, array $params = []): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row !== false ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a raw SQL statement (UPDATE, DELETE, etc.) and return affected rows.
|
||||
*/
|
||||
public function execute(string $sql, array $params = []): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public function beginTransaction(): void
|
||||
{
|
||||
$this->pdo->beginTransaction();
|
||||
}
|
||||
|
||||
public function commit(): void
|
||||
{
|
||||
$this->pdo->commit();
|
||||
}
|
||||
|
||||
public function rollback(): void
|
||||
{
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a row exists matching the given conditions.
|
||||
*/
|
||||
public function tableHasRow(string $table, array $where): bool
|
||||
{
|
||||
$whereParts = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($where as $column => $value) {
|
||||
$whereParts[] = "`{$column}` = :{$column}";
|
||||
$params[$column] = $value;
|
||||
}
|
||||
|
||||
$sql = sprintf(
|
||||
'SELECT 1 FROM `%s` WHERE %s LIMIT 1',
|
||||
$table,
|
||||
implode(' AND ', $whereParts),
|
||||
);
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $stmt->fetch() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure created_at and updated_at are set if not already present.
|
||||
*/
|
||||
private function ensureTimestamps(array $data): array
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
if (! array_key_exists('created_at', $data)) {
|
||||
$data['created_at'] = $now;
|
||||
}
|
||||
|
||||
if (! array_key_exists('updated_at', $data)) {
|
||||
$data['updated_at'] = $now;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
64
scripts/whmcs-migrate/src/Encryptor.php
Normal file
64
scripts/whmcs-migrate/src/Encryptor.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class Encryptor
|
||||
{
|
||||
private const string CIPHER = 'aes-256-cbc';
|
||||
|
||||
private const int IV_LENGTH = 16;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $key,
|
||||
) {
|
||||
if (strlen($this->key) !== 32) {
|
||||
throw new RuntimeException(
|
||||
'Encryption key must be exactly 32 bytes for AES-256-CBC. Got ' . strlen($this->key) . ' bytes.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a value using Laravel-compatible AES-256-CBC encryption.
|
||||
*
|
||||
* The output format matches Laravel's Illuminate\Encryption\Encrypter exactly:
|
||||
* base64(json({ iv: base64, value: base64(encrypted), mac: hmac_hex, tag: '' }))
|
||||
*/
|
||||
public function encrypt(string $value): string
|
||||
{
|
||||
$iv = random_bytes(self::IV_LENGTH);
|
||||
|
||||
// openssl_encrypt with default options (no OPENSSL_RAW_DATA flag) returns base64-encoded ciphertext
|
||||
$encrypted = openssl_encrypt($value, self::CIPHER, $this->key, 0, $iv);
|
||||
|
||||
if ($encrypted === false) {
|
||||
throw new RuntimeException('Encryption failed: ' . openssl_error_string());
|
||||
}
|
||||
|
||||
$ivBase64 = base64_encode($iv);
|
||||
|
||||
// HMAC is computed over the base64-encoded IV concatenated with the base64-encoded ciphertext
|
||||
$mac = hash_hmac('sha256', $ivBase64 . $encrypted, $this->key);
|
||||
|
||||
$payload = json_encode([
|
||||
'iv' => $ivBase64,
|
||||
'value' => $encrypted,
|
||||
'mac' => $mac,
|
||||
'tag' => '',
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
return base64_encode($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt an array by JSON-encoding it first.
|
||||
*/
|
||||
public function encryptArray(array $data): string
|
||||
{
|
||||
return $this->encrypt(json_encode($data, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
318
scripts/whmcs-migrate/src/ExportManager.php
Normal file
318
scripts/whmcs-migrate/src/ExportManager.php
Normal file
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Manages JSONL export files for offline/fast re-imports.
|
||||
*
|
||||
* Each entity type (clients, products, invoices, etc.) gets its own JSONL file.
|
||||
* Each line is a self-contained JSON object representing one API response batch.
|
||||
*/
|
||||
final class ExportManager
|
||||
{
|
||||
private string $exportDir;
|
||||
|
||||
/** @var array<string, resource> Open file handles for writing */
|
||||
private array $writeHandles = [];
|
||||
|
||||
/** @var array<string, resource> Open file handles for reading */
|
||||
private array $readHandles = [];
|
||||
|
||||
/** @var array<string, int> Number of records written per entity */
|
||||
private array $writeCounts = [];
|
||||
|
||||
public function __construct(string $basePath)
|
||||
{
|
||||
$this->exportDir = $basePath . '/exports';
|
||||
|
||||
if (! is_dir($this->exportDir)) {
|
||||
mkdir($this->exportDir, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the export directory path.
|
||||
*/
|
||||
public function getExportDir(): string
|
||||
{
|
||||
return $this->exportDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a single record to a JSONL export file.
|
||||
*
|
||||
* @param string $entity Entity type (e.g., 'clients', 'products', 'invoices')
|
||||
* @param array<string, mixed> $record The record data to write
|
||||
*/
|
||||
public function writeRecord(string $entity, array $record): void
|
||||
{
|
||||
$handle = $this->getWriteHandle($entity);
|
||||
$line = json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) . "\n";
|
||||
|
||||
$written = fwrite($handle, $line);
|
||||
|
||||
if ($written === false) {
|
||||
throw new RuntimeException("Failed to write to export file for entity '{$entity}'");
|
||||
}
|
||||
|
||||
$this->writeCounts[$entity] = ($this->writeCounts[$entity] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a batch of records to a JSONL export file.
|
||||
*
|
||||
* @param string $entity Entity type
|
||||
* @param array<int, array<string, mixed>> $records The records to write
|
||||
*/
|
||||
public function writeBatch(string $entity, array $records): void
|
||||
{
|
||||
foreach ($records as $record) {
|
||||
$this->writeRecord($entity, $record);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all records from a JSONL export file as a generator.
|
||||
*
|
||||
* This uses a generator to avoid loading the entire file into memory.
|
||||
*
|
||||
* @param string $entity Entity type
|
||||
* @return \Generator<int, array<string, mixed>>
|
||||
*/
|
||||
public function readRecords(string $entity): \Generator
|
||||
{
|
||||
$path = $this->getFilePath($entity);
|
||||
|
||||
if (! file_exists($path)) {
|
||||
throw new RuntimeException("Export file not found: {$path}. Run --export-only first.");
|
||||
}
|
||||
|
||||
$handle = fopen($path, 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException("Cannot open export file: {$path}");
|
||||
}
|
||||
|
||||
$lineNumber = 0;
|
||||
|
||||
try {
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$lineNumber++;
|
||||
$line = trim($line);
|
||||
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded = json_decode($line, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new RuntimeException(
|
||||
"Invalid JSON on line {$lineNumber} of {$path}: " . json_last_error_msg()
|
||||
);
|
||||
}
|
||||
|
||||
yield $decoded;
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all records from a JSONL export file into an array.
|
||||
*
|
||||
* Use this only for small datasets (e.g., products, promotions).
|
||||
* For large datasets, use readRecords() generator instead.
|
||||
*
|
||||
* @param string $entity Entity type
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function readAll(string $entity): array
|
||||
{
|
||||
$records = [];
|
||||
|
||||
foreach ($this->readRecords($entity) as $record) {
|
||||
$records[] = $record;
|
||||
}
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of records in an export file without loading them all.
|
||||
*/
|
||||
public function countRecords(string $entity): int
|
||||
{
|
||||
$path = $this->getFilePath($entity);
|
||||
|
||||
if (! file_exists($path)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$handle = fopen($path, 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$line = trim($line);
|
||||
|
||||
if ($line !== '') {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an export file exists for a given entity.
|
||||
*/
|
||||
public function hasExport(string $entity): bool
|
||||
{
|
||||
$path = $this->getFilePath($entity);
|
||||
|
||||
return file_exists($path) && filesize($path) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of records written so far for an entity.
|
||||
*/
|
||||
public function getWriteCount(string $entity): int
|
||||
{
|
||||
return $this->writeCounts[$entity] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of all export files.
|
||||
*
|
||||
* @return array<string, array{file: string, records: int, size: string}>
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
$entities = ['clients', 'client_details', 'products', 'client_services', 'invoices', 'invoice_details', 'transactions', 'promotions', 'orders'];
|
||||
$summary = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$path = $this->getFilePath($entity);
|
||||
|
||||
if (file_exists($path)) {
|
||||
$size = filesize($path);
|
||||
$records = $this->countRecords($entity);
|
||||
$summary[$entity] = [
|
||||
'file' => basename($path),
|
||||
'records' => $records,
|
||||
'size' => $this->formatBytes($size),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset (delete) the export file for a given entity.
|
||||
*/
|
||||
public function resetExport(string $entity): void
|
||||
{
|
||||
$this->closeWriteHandle($entity);
|
||||
|
||||
$path = $this->getFilePath($entity);
|
||||
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
unset($this->writeCounts[$entity]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all export files.
|
||||
*/
|
||||
public function resetAll(): void
|
||||
{
|
||||
$this->closeAllHandles();
|
||||
|
||||
$files = glob($this->exportDir . '/*.jsonl');
|
||||
|
||||
if (is_array($files)) {
|
||||
foreach ($files as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
|
||||
$this->writeCounts = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush and close all open file handles.
|
||||
*/
|
||||
public function closeAllHandles(): void
|
||||
{
|
||||
foreach (array_keys($this->writeHandles) as $entity) {
|
||||
$this->closeWriteHandle($entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the JSONL file path for a given entity type.
|
||||
*/
|
||||
public function getFilePath(string $entity): string
|
||||
{
|
||||
return $this->exportDir . '/' . $entity . '.jsonl';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a write handle for the given entity.
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
private function getWriteHandle(string $entity)
|
||||
{
|
||||
if (! isset($this->writeHandles[$entity])) {
|
||||
$path = $this->getFilePath($entity);
|
||||
$handle = fopen($path, 'a');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException("Cannot open export file for writing: {$path}");
|
||||
}
|
||||
|
||||
$this->writeHandles[$entity] = $handle;
|
||||
}
|
||||
|
||||
return $this->writeHandles[$entity];
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a write handle for the given entity.
|
||||
*/
|
||||
private function closeWriteHandle(string $entity): void
|
||||
{
|
||||
if (isset($this->writeHandles[$entity])) {
|
||||
fflush($this->writeHandles[$entity]);
|
||||
fclose($this->writeHandles[$entity]);
|
||||
unset($this->writeHandles[$entity]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes into a human-readable string.
|
||||
*/
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
|
||||
$power = min($power, count($units) - 1);
|
||||
|
||||
return round($bytes / pow(1024, $power), 2) . ' ' . $units[(int) $power];
|
||||
}
|
||||
}
|
||||
79
scripts/whmcs-migrate/src/Logger.php
Normal file
79
scripts/whmcs-migrate/src/Logger.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate;
|
||||
|
||||
use Monolog\Formatter\LineFormatter;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Level;
|
||||
use Monolog\Logger as MonologLogger;
|
||||
|
||||
final class Logger
|
||||
{
|
||||
private MonologLogger $logger;
|
||||
|
||||
public function __construct(string $logDir, string $level = 'info')
|
||||
{
|
||||
if (! is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
$this->logger = new MonologLogger('whmcs-migrate');
|
||||
|
||||
$monologLevel = Level::fromName($level);
|
||||
|
||||
// File handler — detailed format
|
||||
$fileFormatter = new LineFormatter(
|
||||
format: "[%datetime%] %level_name%: %message% %context%\n",
|
||||
dateFormat: 'Y-m-d H:i:s',
|
||||
allowInlineLineBreaks: true,
|
||||
ignoreEmptyContextAndExtra: true,
|
||||
);
|
||||
|
||||
$fileHandler = new StreamHandler($logDir . '/migration.log', $monologLevel);
|
||||
$fileHandler->setFormatter($fileFormatter);
|
||||
$this->logger->pushHandler($fileHandler);
|
||||
|
||||
// Console handler — colorized
|
||||
$consoleFormatter = new LineFormatter(
|
||||
format: "%level_name%: %message% %context%\n",
|
||||
dateFormat: null,
|
||||
allowInlineLineBreaks: true,
|
||||
ignoreEmptyContextAndExtra: true,
|
||||
);
|
||||
|
||||
$consoleHandler = new StreamHandler('php://stdout', $monologLevel);
|
||||
$consoleHandler->setFormatter($consoleFormatter);
|
||||
$this->logger->pushHandler($consoleHandler);
|
||||
}
|
||||
|
||||
public function info(string $message, array $context = []): void
|
||||
{
|
||||
$this->logger->info($message, $context);
|
||||
}
|
||||
|
||||
public function warning(string $message, array $context = []): void
|
||||
{
|
||||
$this->logger->warning($message, $context);
|
||||
}
|
||||
|
||||
public function error(string $message, array $context = []): void
|
||||
{
|
||||
$this->logger->error($message, $context);
|
||||
}
|
||||
|
||||
public function debug(string $message, array $context = []): void
|
||||
{
|
||||
$this->logger->debug($message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a visual section separator.
|
||||
*/
|
||||
public function section(string $title): void
|
||||
{
|
||||
$separator = str_repeat('=', 60);
|
||||
$this->info("\n{$separator}\n{$title}\n{$separator}");
|
||||
}
|
||||
}
|
||||
277
scripts/whmcs-migrate/src/MigrationRunner.php
Normal file
277
scripts/whmcs-migrate/src/MigrationRunner.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WhmcsMigrate\Phases\Phase1Clients;
|
||||
use WhmcsMigrate\Phases\Phase2Products;
|
||||
use WhmcsMigrate\Phases\Phase3Services;
|
||||
use WhmcsMigrate\Phases\Phase4Invoices;
|
||||
use WhmcsMigrate\Phases\Phase5Transactions;
|
||||
use WhmcsMigrate\Phases\Phase6Coupons;
|
||||
use WhmcsMigrate\Phases\Phase7Orders;
|
||||
use WhmcsMigrate\Phases\PhaseInterface;
|
||||
|
||||
final class MigrationRunner
|
||||
{
|
||||
private Config $config;
|
||||
|
||||
private Logger $logger;
|
||||
|
||||
private Database $db;
|
||||
|
||||
private WhmcsApi $api;
|
||||
|
||||
private StateManager $state;
|
||||
|
||||
private Encryptor $encryptor;
|
||||
|
||||
private Validator $validator;
|
||||
|
||||
private ExportManager $exportManager;
|
||||
|
||||
/** @var list<PhaseInterface> */
|
||||
private array $phases = [];
|
||||
|
||||
public function __construct(string $basePath)
|
||||
{
|
||||
$this->config = new Config($basePath);
|
||||
$this->logger = new Logger(
|
||||
$basePath . '/logs',
|
||||
$this->config->getLogLevel(),
|
||||
);
|
||||
$this->db = new Database($this->config);
|
||||
$this->api = new WhmcsApi($this->config, $this->logger);
|
||||
$this->state = new StateManager($basePath . '/state');
|
||||
$this->encryptor = new Encryptor($this->config->getLaravelAppKey());
|
||||
$this->validator = new Validator($this->db, $this->logger);
|
||||
$this->exportManager = new ExportManager($basePath);
|
||||
|
||||
$this->registerPhases();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow overriding config values (e.g., --dry-run from CLI).
|
||||
*/
|
||||
public function getConfig(): Config
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all phases or a specific phase by number.
|
||||
*/
|
||||
public function run(?int $phaseNumber = null): void
|
||||
{
|
||||
$modeLabel = '';
|
||||
|
||||
if ($this->config->isDryRun()) {
|
||||
$modeLabel = ' [DRY RUN]';
|
||||
} elseif ($this->config->isExportOnly()) {
|
||||
$modeLabel = ' [EXPORT ONLY]';
|
||||
} elseif ($this->config->isFromExport()) {
|
||||
$modeLabel = ' [FROM EXPORT]';
|
||||
}
|
||||
|
||||
$this->logger->section("WHMCS to EZSCALE Migration{$modeLabel}");
|
||||
$this->logger->info('Started at ' . date('Y-m-d H:i:s'));
|
||||
$this->logger->info('Batch size: ' . $this->config->getBatchSize());
|
||||
|
||||
$phasesToRun = $this->phases;
|
||||
|
||||
if ($phaseNumber !== null) {
|
||||
$phasesToRun = array_filter(
|
||||
$this->phases,
|
||||
fn (PhaseInterface $phase): bool => $phase->getPhaseNumber() === $phaseNumber,
|
||||
);
|
||||
|
||||
if (empty($phasesToRun)) {
|
||||
throw new RuntimeException("Phase {$phaseNumber} is not registered.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($phasesToRun as $phase) {
|
||||
$name = $phase->getName();
|
||||
$number = $phase->getPhaseNumber();
|
||||
|
||||
// In export-only mode, skip the "already complete" check
|
||||
if (! $this->config->isExportOnly() && $phase->shouldSkip()) {
|
||||
$this->logger->info("Phase {$number} ({$name}): already complete, skipping.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->logger->info("Phase {$number} ({$name}): validating...");
|
||||
|
||||
if (! $phase->validate()) {
|
||||
$this->logger->error("Phase {$number} ({$name}): validation failed. Stopping.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->info("Phase {$number} ({$name}): starting...");
|
||||
|
||||
try {
|
||||
$phase->run();
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error("Phase {$number} ({$name}): FAILED — {$e->getMessage()}", [
|
||||
'exception' => $e::class,
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
$this->state->save();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// Close all export file handles
|
||||
$this->exportManager->closeAllHandles();
|
||||
|
||||
if ($this->config->isExportOnly()) {
|
||||
$this->logger->section('Export Complete');
|
||||
$this->showExportSummary();
|
||||
} else {
|
||||
$this->logger->section('Migration Complete');
|
||||
$this->showStatus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the current progress table for all phases.
|
||||
*/
|
||||
public function showStatus(): void
|
||||
{
|
||||
$this->logger->info('');
|
||||
$this->logger->info(str_pad('Phase', 8) . str_pad('Name', 40) . str_pad('Status', 14) . str_pad('Count', 10) . str_pad('Errors', 10));
|
||||
$this->logger->info(str_repeat('-', 82));
|
||||
|
||||
foreach ($this->phases as $phase) {
|
||||
$number = $phase->getPhaseNumber();
|
||||
$name = $phase->getName();
|
||||
$progress = $this->state->getProgress($phase->getPhaseKey());
|
||||
|
||||
$this->logger->info(
|
||||
str_pad((string) $number, 8)
|
||||
. str_pad($name, 40)
|
||||
. str_pad($progress['status'], 14)
|
||||
. str_pad((string) $progress['count'], 10)
|
||||
. str_pad((string) $progress['errors'], 10),
|
||||
);
|
||||
}
|
||||
|
||||
$this->logger->info('');
|
||||
|
||||
// Show mapping counts
|
||||
$entityTypes = ['clients', 'products', 'services', 'subscriptions', 'invoices', 'transactions', 'orders', 'promotions'];
|
||||
|
||||
foreach ($entityTypes as $entity) {
|
||||
$mappingCount = $this->state->getMappingCount($entity);
|
||||
|
||||
if ($mappingCount > 0) {
|
||||
$this->logger->info(" {$entity} mappings: {$mappingCount}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a summary of exported JSONL files.
|
||||
*/
|
||||
public function showExportSummary(): void
|
||||
{
|
||||
$summary = $this->exportManager->getSummary();
|
||||
|
||||
if (empty($summary)) {
|
||||
$this->logger->info('No export files found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->info('');
|
||||
$this->logger->info(str_pad('Entity', 25) . str_pad('Records', 12) . str_pad('Size', 15) . 'File');
|
||||
$this->logger->info(str_repeat('-', 72));
|
||||
|
||||
$totalRecords = 0;
|
||||
|
||||
foreach ($summary as $entity => $info) {
|
||||
$totalRecords += $info['records'];
|
||||
$this->logger->info(
|
||||
str_pad($entity, 25)
|
||||
. str_pad(number_format($info['records']), 12)
|
||||
. str_pad($info['size'], 15)
|
||||
. $info['file'],
|
||||
);
|
||||
}
|
||||
|
||||
$this->logger->info(str_repeat('-', 72));
|
||||
$this->logger->info(str_pad('Total', 25) . number_format($totalRecords));
|
||||
$this->logger->info('');
|
||||
$this->logger->info("Export directory: {$this->exportManager->getExportDir()}");
|
||||
$this->logger->info('To import from these files, run: php migrate.php --from-export');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all migration state (mappings and progress).
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->logger->warning('Resetting all migration state...');
|
||||
$this->state->reset();
|
||||
$this->logger->info('Migration state has been reset.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all exported JSONL files.
|
||||
*/
|
||||
public function resetExports(): void
|
||||
{
|
||||
$this->logger->warning('Resetting all export files...');
|
||||
$this->exportManager->resetAll();
|
||||
$this->logger->info('Export files have been deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run pre-flight validation only (no data changes).
|
||||
*/
|
||||
public function validateOnly(): void
|
||||
{
|
||||
$this->logger->section('Validation Only');
|
||||
|
||||
$ok = $this->validator->validateAll($this->api);
|
||||
|
||||
if ($ok) {
|
||||
$this->logger->info('All validations passed. Ready to migrate.');
|
||||
} else {
|
||||
$this->logger->error('Validation failed. Fix the issues above before running the migration.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all migration phases in execution order.
|
||||
*/
|
||||
private function registerPhases(): void
|
||||
{
|
||||
$deps = [
|
||||
$this->db,
|
||||
$this->api,
|
||||
$this->state,
|
||||
$this->logger,
|
||||
$this->config,
|
||||
$this->encryptor,
|
||||
$this->exportManager,
|
||||
];
|
||||
|
||||
$this->phases = [
|
||||
new Phase1Clients(...$deps),
|
||||
new Phase2Products(...$deps),
|
||||
new Phase3Services(...$deps),
|
||||
new Phase4Invoices(...$deps),
|
||||
new Phase5Transactions(...$deps),
|
||||
new Phase6Coupons(...$deps),
|
||||
new Phase7Orders(...$deps),
|
||||
];
|
||||
}
|
||||
}
|
||||
98
scripts/whmcs-migrate/src/Phases/AbstractPhase.php
Normal file
98
scripts/whmcs-migrate/src/Phases/AbstractPhase.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate\Phases;
|
||||
|
||||
use WhmcsMigrate\Config;
|
||||
use WhmcsMigrate\Database;
|
||||
use WhmcsMigrate\Encryptor;
|
||||
use WhmcsMigrate\ExportManager;
|
||||
use WhmcsMigrate\Logger;
|
||||
use WhmcsMigrate\StateManager;
|
||||
use WhmcsMigrate\WhmcsApi;
|
||||
|
||||
abstract class AbstractPhase implements PhaseInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly Database $db,
|
||||
protected readonly WhmcsApi $api,
|
||||
protected readonly StateManager $state,
|
||||
protected readonly Logger $logger,
|
||||
protected readonly Config $config,
|
||||
protected readonly Encryptor $encryptor,
|
||||
protected readonly ExportManager $exportManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Whether the migration is running in dry-run mode.
|
||||
*/
|
||||
protected function isDryRun(): bool
|
||||
{
|
||||
return $this->config->isDryRun();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we are exporting data from the API only (no DB writes).
|
||||
*/
|
||||
protected function isExportOnly(): bool
|
||||
{
|
||||
return $this->config->isExportOnly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we should read from exported JSONL files instead of the API.
|
||||
*/
|
||||
protected function isFromExport(): bool
|
||||
{
|
||||
return $this->config->isFromExport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of records to process per batch.
|
||||
*/
|
||||
protected function getBatchSize(): int
|
||||
{
|
||||
return $this->config->getBatchSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the state key for this phase (e.g., "phase_1").
|
||||
*/
|
||||
public function getPhaseKey(): string
|
||||
{
|
||||
return 'phase_' . $this->getPhaseNumber();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this phase has already been completed and should be skipped.
|
||||
*/
|
||||
public function shouldSkip(): bool
|
||||
{
|
||||
return $this->state->isPhaseComplete($this->getPhaseKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this phase as started in the state manager.
|
||||
*/
|
||||
protected function markStarted(): void
|
||||
{
|
||||
$this->state->setProgress($this->getPhaseKey(), 'in_progress');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this phase as complete in the state manager.
|
||||
*/
|
||||
protected function markComplete(int $count, int $errors): void
|
||||
{
|
||||
$this->state->setProgress($this->getPhaseKey(), 'complete', 0, $count, $errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log that an entity was skipped during migration.
|
||||
*/
|
||||
protected function logSkipped(string $entity, string $reason): void
|
||||
{
|
||||
$this->logger->warning("Skipped {$entity}: {$reason}");
|
||||
}
|
||||
}
|
||||
554
scripts/whmcs-migrate/src/Phases/Phase1Clients.php
Normal file
554
scripts/whmcs-migrate/src/Phases/Phase1Clients.php
Normal file
@@ -0,0 +1,554 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate\Phases;
|
||||
|
||||
use Throwable;
|
||||
use WhmcsMigrate\ProgressTracker;
|
||||
use WhmcsMigrate\StatusMapper;
|
||||
|
||||
final class Phase1Clients extends AbstractPhase
|
||||
{
|
||||
public function getPhaseNumber(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Clients → Users + UserProfiles';
|
||||
}
|
||||
|
||||
public function validate(): bool
|
||||
{
|
||||
// In export-only mode, skip DB validation
|
||||
if ($this->isExportOnly()) {
|
||||
return $this->validateApiConnectivity();
|
||||
}
|
||||
|
||||
// In from-export mode, skip API validation
|
||||
if ($this->isFromExport()) {
|
||||
return $this->validateDbPrerequisites();
|
||||
}
|
||||
|
||||
// Normal mode: validate both
|
||||
return $this->validateDbPrerequisites() && $this->validateApiConnectivity();
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
if (! $this->isExportOnly() && $this->shouldSkip()) {
|
||||
$this->logger->info('Phase 1 already complete, skipping');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->section('Phase 1: ' . $this->getName());
|
||||
|
||||
if ($this->isExportOnly()) {
|
||||
$this->runExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isFromExport()) {
|
||||
$this->runFromExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runFromApi();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all client data from WHMCS API to JSONL files.
|
||||
*/
|
||||
private function runExport(): void
|
||||
{
|
||||
$this->logger->info('Exporting clients from WHMCS API...');
|
||||
|
||||
// Reset the export files for this entity
|
||||
$this->exportManager->resetExport('clients');
|
||||
$this->exportManager->resetExport('client_details');
|
||||
|
||||
// Get total client count
|
||||
$probeResponse = $this->api->call('GetClients', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
$totalClients = (int) ($probeResponse['totalresults'] ?? 0);
|
||||
|
||||
$this->logger->info("Found {$totalClients} clients in WHMCS");
|
||||
|
||||
if ($totalClients === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$batchSize = $this->getBatchSize();
|
||||
$offset = 0;
|
||||
$exported = 0;
|
||||
$progress = new ProgressTracker('Exporting clients', $totalClients);
|
||||
|
||||
while ($offset < $totalClients) {
|
||||
$response = $this->api->call('GetClients', [
|
||||
'limitstart' => $offset,
|
||||
'limitnum' => $batchSize,
|
||||
]);
|
||||
|
||||
$clients = $response['clients']['client'] ?? [];
|
||||
|
||||
if (empty($clients)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Export each client's basic info from the list
|
||||
$this->exportManager->writeBatch('clients', $clients);
|
||||
|
||||
// Fetch and export full details for each client
|
||||
foreach ($clients as $client) {
|
||||
$whmcsId = (int) ($client['id'] ?? 0);
|
||||
|
||||
if ($whmcsId === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$details = $this->api->call('GetClientsDetails', ['clientid' => $whmcsId]);
|
||||
|
||||
if (($details['result'] ?? '') === 'success') {
|
||||
// Merge the datecreated from the list response
|
||||
$details['_datecreated_from_list'] = (string) ($client['datecreated'] ?? '');
|
||||
$this->exportManager->writeRecord('client_details', $details);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning("Failed to fetch details for client {$whmcsId}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$exported++;
|
||||
$progress->advance();
|
||||
}
|
||||
|
||||
$offset += count($clients);
|
||||
}
|
||||
|
||||
$progress->finish();
|
||||
$this->logger->info("Exported {$exported} client records to JSONL");
|
||||
}
|
||||
|
||||
/**
|
||||
* Import clients from previously exported JSONL files.
|
||||
*/
|
||||
private function runFromExport(): void
|
||||
{
|
||||
if (! $this->exportManager->hasExport('client_details')) {
|
||||
$this->logger->error('No client_details export found. Run --export-only first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markStarted();
|
||||
|
||||
$totalClients = $this->exportManager->countRecords('client_details');
|
||||
$this->logger->info("Found {$totalClients} client records in export");
|
||||
|
||||
if ($totalClients === 0) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the customer role_id once
|
||||
$role = $this->db->queryOne(
|
||||
"SELECT `id` FROM `roles` WHERE `name` = :name AND `guard_name` = :guard",
|
||||
['name' => 'customer', 'guard' => 'web'],
|
||||
);
|
||||
|
||||
$customerRoleId = (int) $role['id'];
|
||||
|
||||
$progress = $this->state->getProgress($this->getPhaseKey());
|
||||
$processedCount = (int) ($progress['offset'] ?? 0);
|
||||
$count = (int) ($progress['count'] ?? 0);
|
||||
$errors = (int) ($progress['errors'] ?? 0);
|
||||
|
||||
if ($processedCount > 0) {
|
||||
$this->logger->info("Resuming from record {$processedCount} ({$count} migrated, {$errors} errors so far)");
|
||||
}
|
||||
|
||||
$tracker = new ProgressTracker('Importing clients', $totalClients);
|
||||
$tracker->setCurrent($processedCount);
|
||||
|
||||
$currentIndex = 0;
|
||||
|
||||
foreach ($this->exportManager->readRecords('client_details') as $details) {
|
||||
$currentIndex++;
|
||||
|
||||
// Skip already-processed records
|
||||
if ($currentIndex <= $processedCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$whmcsId = (int) ($details['id'] ?? $details['userid'] ?? $details['client_id'] ?? 0);
|
||||
|
||||
if ($whmcsId === 0) {
|
||||
$errors++;
|
||||
$processedCount++;
|
||||
$tracker->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$dateCreatedFromList = (string) ($details['_datecreated_from_list'] ?? '');
|
||||
$result = $this->migrateClientFromDetails($whmcsId, $customerRoleId, $details, $dateCreatedFromList);
|
||||
|
||||
if ($result) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate client {$whmcsId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$processedCount++;
|
||||
$tracker->advance();
|
||||
|
||||
// Save progress every 100 records
|
||||
if ($processedCount % 100 === 0) {
|
||||
$this->state->setProgress($this->getPhaseKey(), 'running', $processedCount, $count, $errors);
|
||||
$this->state->save();
|
||||
}
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 1 complete: {$count} clients migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Original behavior: fetch from API and import directly.
|
||||
*/
|
||||
private function runFromApi(): void
|
||||
{
|
||||
$this->markStarted();
|
||||
|
||||
// Look up the customer role_id once
|
||||
$role = $this->db->queryOne(
|
||||
"SELECT `id` FROM `roles` WHERE `name` = :name AND `guard_name` = :guard",
|
||||
['name' => 'customer', 'guard' => 'web'],
|
||||
);
|
||||
|
||||
$customerRoleId = (int) $role['id'];
|
||||
|
||||
// Get total client count from WHMCS
|
||||
$probeResponse = $this->api->call('GetClients', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
$totalClients = (int) ($probeResponse['totalresults'] ?? 0);
|
||||
|
||||
$this->logger->info("Found {$totalClients} clients in WHMCS");
|
||||
|
||||
if ($totalClients === 0) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Resume from saved offset if applicable
|
||||
$progress = $this->state->getProgress($this->getPhaseKey());
|
||||
$offset = (int) ($progress['offset'] ?? 0);
|
||||
$count = (int) ($progress['count'] ?? 0);
|
||||
$errors = (int) ($progress['errors'] ?? 0);
|
||||
$batchSize = $this->getBatchSize();
|
||||
|
||||
if ($offset > 0) {
|
||||
$this->logger->info("Resuming from offset {$offset} ({$count} migrated, {$errors} errors so far)");
|
||||
}
|
||||
|
||||
$tracker = new ProgressTracker('Migrating clients', $totalClients);
|
||||
$tracker->setCurrent($offset);
|
||||
|
||||
while ($offset < $totalClients) {
|
||||
$this->logger->debug("Fetching clients batch: offset={$offset}, limit={$batchSize}");
|
||||
|
||||
$response = $this->api->call('GetClients', [
|
||||
'limitstart' => $offset,
|
||||
'limitnum' => $batchSize,
|
||||
]);
|
||||
|
||||
$clients = $response['clients']['client'] ?? [];
|
||||
|
||||
if (empty($clients)) {
|
||||
$this->logger->debug('No more clients returned, ending pagination');
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($clients as $client) {
|
||||
$whmcsId = (int) ($client['id'] ?? 0);
|
||||
|
||||
if ($whmcsId === 0) {
|
||||
$this->logger->warning('Skipping client with no ID', ['client' => $client]);
|
||||
$errors++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// datecreated is in GetClients response, not GetClientsDetails
|
||||
$dateCreatedFromList = (string) ($client['datecreated'] ?? '');
|
||||
$result = $this->migrateClient($whmcsId, $customerRoleId, $dateCreatedFromList);
|
||||
|
||||
if ($result) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate client {$whmcsId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$tracker->advance();
|
||||
}
|
||||
|
||||
$offset += count($clients);
|
||||
$this->state->setProgress($this->getPhaseKey(), 'running', $offset, $count, $errors);
|
||||
$this->state->save();
|
||||
|
||||
$this->logger->info("Batch complete: {$count} migrated, {$errors} errors, offset now {$offset}/{$totalClients}");
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 1 complete: {$count} clients migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single client from API (fetches GetClientsDetails).
|
||||
*/
|
||||
private function migrateClient(int $whmcsId, int $customerRoleId, string $dateCreatedFromList = ''): bool
|
||||
{
|
||||
if ($this->state->getMapping('clients', $whmcsId) !== null) {
|
||||
$this->logger->debug("Client {$whmcsId} already mapped, skipping");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$details = $this->api->call('GetClientsDetails', ['clientid' => $whmcsId]);
|
||||
|
||||
if (($details['result'] ?? '') !== 'success') {
|
||||
$this->logger->warning("GetClientsDetails failed for client {$whmcsId}", [
|
||||
'response' => $details,
|
||||
]);
|
||||
$this->logSkipped('client', "API returned non-success for client {$whmcsId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->migrateClientFromDetails($whmcsId, $customerRoleId, $details, $dateCreatedFromList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single client from already-fetched detail data.
|
||||
* Used by both API and from-export modes.
|
||||
*/
|
||||
private function migrateClientFromDetails(int $whmcsId, int $customerRoleId, array $details, string $dateCreatedFromList = ''): bool
|
||||
{
|
||||
if ($this->state->getMapping('clients', $whmcsId) !== null) {
|
||||
$this->logger->debug("Client {$whmcsId} already mapped, skipping");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$email = trim(strtolower($details['email'] ?? ''));
|
||||
|
||||
if ($email === '') {
|
||||
$this->logger->warning("Client {$whmcsId} has no email, skipping");
|
||||
$this->logSkipped('client', "No email for client {$whmcsId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$existingUser = $this->db->queryOne(
|
||||
"SELECT `id` FROM `users` WHERE `email` = :email LIMIT 1",
|
||||
['email' => $email],
|
||||
);
|
||||
|
||||
if ($existingUser !== null) {
|
||||
$existingUserId = (int) $existingUser['id'];
|
||||
$this->logger->warning("Client {$whmcsId} email '{$email}' already exists as user {$existingUserId}, mapping to existing user");
|
||||
$this->state->setMapping('clients', $whmcsId, $existingUserId);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$firstname = trim((string) ($details['firstname'] ?? ''));
|
||||
$lastname = trim((string) ($details['lastname'] ?? ''));
|
||||
$name = trim("{$firstname} {$lastname}");
|
||||
|
||||
if ($name === '') {
|
||||
$name = strstr($email, '@', true) ?: $email;
|
||||
}
|
||||
|
||||
$status = StatusMapper::mapClientStatus((string) ($details['status'] ?? 'Active'));
|
||||
$phone = $this->nullIfEmpty((string) ($details['phonenumber'] ?? ''));
|
||||
$company = $this->nullIfEmpty((string) ($details['companyname'] ?? ''));
|
||||
$dateCreated = $this->parseDate($dateCreatedFromList) ?? $this->parseDate((string) ($details['datecreated'] ?? ''));
|
||||
$credit = (string) ($details['credit'] ?? '0.00');
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$stripeCustomerId = null;
|
||||
$pmType = null;
|
||||
$pmLastFour = null;
|
||||
$gatewayIdRaw = (string) ($details['gatewayid'] ?? '');
|
||||
|
||||
if ($gatewayIdRaw !== '') {
|
||||
$gatewayData = json_decode($gatewayIdRaw, true);
|
||||
|
||||
if (is_array($gatewayData) && isset($gatewayData['customer']) && str_starts_with($gatewayData['customer'], 'cus_')) {
|
||||
$stripeCustomerId = $gatewayData['customer'];
|
||||
}
|
||||
}
|
||||
|
||||
$cctype = $this->nullIfEmpty((string) ($details['cctype'] ?? ''));
|
||||
$cclastfour = $this->nullIfEmpty((string) ($details['cclastfour'] ?? ''));
|
||||
|
||||
if ($cctype !== null) {
|
||||
$pmType = strtolower($cctype);
|
||||
}
|
||||
|
||||
if ($cclastfour !== null && strlen($cclastfour) <= 4) {
|
||||
$pmLastFour = $cclastfour;
|
||||
}
|
||||
|
||||
$adminNotes = "Imported from WHMCS. Client ID: {$whmcsId}";
|
||||
|
||||
if ((float) $credit > 0) {
|
||||
$adminNotes .= ". Credit balance: \${$credit}";
|
||||
}
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$stripeInfo = $stripeCustomerId ? " (Stripe: {$stripeCustomerId})" : '';
|
||||
$this->logger->info("[DRY RUN] Would create user for client {$whmcsId}: {$name} <{$email}>{$stripeInfo}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->db->beginTransaction();
|
||||
|
||||
try {
|
||||
$userId = $this->db->insert('users', [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => password_hash(bin2hex(random_bytes(32)), PASSWORD_BCRYPT),
|
||||
'status' => $status,
|
||||
'phone' => $phone,
|
||||
'company' => $company,
|
||||
'admin_notes' => $adminNotes,
|
||||
'stripe_id' => $stripeCustomerId,
|
||||
'pm_type' => $pmType,
|
||||
'pm_last_four' => $pmLastFour,
|
||||
'email_verified_at' => $now,
|
||||
'created_at' => $dateCreated ?? $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->db->insert('user_profiles', [
|
||||
'user_id' => $userId,
|
||||
'billing_address_line1' => $this->nullIfEmpty((string) ($details['address1'] ?? '')),
|
||||
'billing_address_line2' => $this->nullIfEmpty((string) ($details['address2'] ?? '')),
|
||||
'billing_city' => $this->nullIfEmpty((string) ($details['city'] ?? '')),
|
||||
'billing_state' => $this->nullIfEmpty((string) ($details['state'] ?? '')),
|
||||
'billing_zip' => $this->nullIfEmpty((string) ($details['postcode'] ?? '')),
|
||||
'billing_country' => $this->nullIfEmpty((string) ($details['country'] ?? '')),
|
||||
'company_name' => $company,
|
||||
]);
|
||||
|
||||
$this->db->execute(
|
||||
"INSERT IGNORE INTO `model_has_roles` (`role_id`, `model_type`, `model_id`) VALUES (:role_id, :model_type, :model_id)",
|
||||
['role_id' => $customerRoleId, 'model_type' => 'App\\Models\\User', 'model_id' => $userId],
|
||||
);
|
||||
|
||||
$this->db->commit();
|
||||
|
||||
$this->state->setMapping('clients', $whmcsId, $userId);
|
||||
|
||||
$this->logger->debug("Migrated client {$whmcsId} → user {$userId} ({$name} <{$email}>)");
|
||||
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
$this->db->rollback();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function validateDbPrerequisites(): bool
|
||||
{
|
||||
$role = $this->db->queryOne(
|
||||
"SELECT `id` FROM `roles` WHERE `name` = :name AND `guard_name` = :guard",
|
||||
['name' => 'customer', 'guard' => 'web'],
|
||||
);
|
||||
|
||||
if ($role === null) {
|
||||
$this->logger->error('Validation failed: no "customer" role found in roles table (guard_name=web)');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function validateApiConnectivity(): bool
|
||||
{
|
||||
try {
|
||||
$response = $this->api->call('GetClients', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
|
||||
if (($response['result'] ?? '') !== 'success') {
|
||||
$this->logger->error('Validation failed: WHMCS GetClients API returned non-success', [
|
||||
'response' => $response,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Validation failed: cannot reach WHMCS API', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function nullIfEmpty(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
|
||||
private function parseDate(string $date): ?string
|
||||
{
|
||||
$date = trim($date);
|
||||
|
||||
if ($date === '' || str_starts_with($date, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $date . ' 00:00:00';
|
||||
}
|
||||
|
||||
$timestamp = strtotime($date);
|
||||
|
||||
if ($timestamp === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
}
|
||||
479
scripts/whmcs-migrate/src/Phases/Phase2Products.php
Normal file
479
scripts/whmcs-migrate/src/Phases/Phase2Products.php
Normal file
@@ -0,0 +1,479 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate\Phases;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WhmcsMigrate\ProgressTracker;
|
||||
use WhmcsMigrate\StatusMapper;
|
||||
|
||||
final class Phase2Products extends AbstractPhase
|
||||
{
|
||||
/**
|
||||
* WHMCS billing cycle keys mapped to EZSCALE plan_prices billing_cycle values.
|
||||
*/
|
||||
private const array CYCLE_MAP = [
|
||||
'monthly' => 'monthly',
|
||||
'quarterly' => 'quarterly',
|
||||
'semiannually' => 'semi_annual',
|
||||
'annually' => 'annual',
|
||||
];
|
||||
|
||||
public function getPhaseNumber(): int
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Products → Plans + PlanPrices';
|
||||
}
|
||||
|
||||
public function validate(): bool
|
||||
{
|
||||
if ($this->isExportOnly()) {
|
||||
return $this->validateApi();
|
||||
}
|
||||
|
||||
if ($this->isFromExport()) {
|
||||
return $this->validatePlanMapping();
|
||||
}
|
||||
|
||||
return $this->validatePlanMapping() && $this->validateApi();
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
if (! $this->isExportOnly() && $this->shouldSkip()) {
|
||||
$this->logger->info('Phase 2 already complete, skipping');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->section('Phase 2: ' . $this->getName());
|
||||
|
||||
if ($this->isExportOnly()) {
|
||||
$this->runExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isFromExport()) {
|
||||
$this->runFromExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runFromApi();
|
||||
}
|
||||
|
||||
private function runExport(): void
|
||||
{
|
||||
$this->logger->info('Exporting products from WHMCS API...');
|
||||
$this->exportManager->resetExport('products');
|
||||
|
||||
$response = $this->api->call('GetProducts');
|
||||
$products = $response['products']['product'] ?? [];
|
||||
$totalProducts = (int) ($response['totalresults'] ?? count($products));
|
||||
|
||||
$this->logger->info("Found {$totalProducts} products in WHMCS");
|
||||
|
||||
if (empty($products)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->exportManager->writeBatch('products', $products);
|
||||
$this->logger->info("Exported {$totalProducts} product records to JSONL");
|
||||
}
|
||||
|
||||
private function runFromExport(): void
|
||||
{
|
||||
if (! $this->exportManager->hasExport('products')) {
|
||||
$this->logger->error('No products export found. Run --export-only first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markStarted();
|
||||
|
||||
$planData = $this->loadPlanMapping();
|
||||
$planMapping = $planData['mappings'];
|
||||
$skipList = $planData['skip'];
|
||||
$this->logger->info('Plan mapping loaded', ['mapped_products' => count($planMapping), 'skipped_products' => count($skipList)]);
|
||||
|
||||
$products = $this->exportManager->readAll('products');
|
||||
$totalProducts = count($products);
|
||||
|
||||
$this->logger->info("Found {$totalProducts} products in export");
|
||||
|
||||
if ($totalProducts === 0) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$errors = 0;
|
||||
$tracker = new ProgressTracker('Importing products', $totalProducts);
|
||||
|
||||
foreach ($products as $product) {
|
||||
$pid = (int) ($product['pid'] ?? 0);
|
||||
|
||||
if ($pid === 0) {
|
||||
$this->logger->warning('Skipping product with no pid', ['product' => $product]);
|
||||
$errors++;
|
||||
$tracker->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migrateProduct($pid, $product, $planMapping, $skipList);
|
||||
|
||||
if ($result) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate product {$pid}", [
|
||||
'error' => $e->getMessage(),
|
||||
'name' => $product['name'] ?? 'unknown',
|
||||
]);
|
||||
}
|
||||
|
||||
$tracker->advance();
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 2 complete: {$count} products migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
private function runFromApi(): void
|
||||
{
|
||||
$this->markStarted();
|
||||
|
||||
$planData = $this->loadPlanMapping();
|
||||
$planMapping = $planData['mappings'];
|
||||
$skipList = $planData['skip'];
|
||||
$this->logger->info('Plan mapping loaded', ['mapped_products' => count($planMapping), 'skipped_products' => count($skipList)]);
|
||||
|
||||
$response = $this->api->call('GetProducts');
|
||||
$products = $response['products']['product'] ?? [];
|
||||
$totalProducts = (int) ($response['totalresults'] ?? count($products));
|
||||
|
||||
$this->logger->info("Found {$totalProducts} products in WHMCS");
|
||||
|
||||
if (empty($products)) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$errors = 0;
|
||||
$tracker = new ProgressTracker('Migrating products', $totalProducts);
|
||||
|
||||
foreach ($products as $product) {
|
||||
$pid = (int) ($product['pid'] ?? 0);
|
||||
|
||||
if ($pid === 0) {
|
||||
$this->logger->warning('Skipping product with no pid', ['product' => $product]);
|
||||
$errors++;
|
||||
$tracker->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migrateProduct($pid, $product, $planMapping, $skipList);
|
||||
|
||||
if ($result) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate product {$pid}", [
|
||||
'error' => $e->getMessage(),
|
||||
'name' => $product['name'] ?? 'unknown',
|
||||
]);
|
||||
}
|
||||
|
||||
$tracker->advance();
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 2 complete: {$count} products migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single WHMCS product to an EZSCALE plan.
|
||||
*
|
||||
* @param array<string, string> $planMapping WHMCS pid => EZSCALE slug
|
||||
* @param array<int, string> $skipList WHMCS pids to skip entirely
|
||||
*/
|
||||
private function migrateProduct(int $pid, array $product, array $planMapping, array $skipList): bool
|
||||
{
|
||||
// Already mapped — skip
|
||||
if ($this->state->getMapping('products', $pid) !== null) {
|
||||
$this->logger->debug("Product {$pid} already mapped, skipping");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$productName = (string) ($product['name'] ?? "Product {$pid}");
|
||||
|
||||
// Check if this product is in the explicit skip list
|
||||
if (in_array((string) $pid, $skipList, true)) {
|
||||
$this->logger->info("Skipping product {$pid} ({$productName}) — in skip list");
|
||||
$this->logSkipped('product', "Product {$pid} ({$productName}) is in skip list");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this product has an explicit mapping to an existing EZSCALE plan
|
||||
if (array_key_exists((string) $pid, $planMapping)) {
|
||||
return $this->mapToExistingPlan($pid, $productName, $planMapping[(string) $pid]);
|
||||
}
|
||||
|
||||
// Unmapped and not skipped — log warning and skip (no auto-creation)
|
||||
$this->logger->warning("Product {$pid} ({$productName}) has no mapping and is not in skip list — skipping. Add it to plan_mapping.json mappings or skip list.");
|
||||
$this->logSkipped('product', "Product {$pid} ({$productName}) unmapped — not in mappings or skip list");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a WHMCS product to an existing EZSCALE plan by slug.
|
||||
*/
|
||||
private function mapToExistingPlan(int $pid, string $productName, string $ezscaleSlug): bool
|
||||
{
|
||||
$plan = $this->db->queryOne(
|
||||
"SELECT `id` FROM `plans` WHERE `slug` = :slug LIMIT 1",
|
||||
['slug' => $ezscaleSlug],
|
||||
);
|
||||
|
||||
if ($plan === null) {
|
||||
$this->logger->error("Mapped plan slug '{$ezscaleSlug}' not found in plans table for product {$pid} ({$productName})");
|
||||
$this->logSkipped('product', "Plan slug '{$ezscaleSlug}' not found for WHMCS product {$pid}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$planId = (int) $plan['id'];
|
||||
$this->state->setMapping('products', $pid, $planId);
|
||||
$this->logger->info("Mapped WHMCS product {$pid} ({$productName}) → existing plan {$planId} (slug: {$ezscaleSlug})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-create a new EZSCALE plan from WHMCS product data.
|
||||
*/
|
||||
private function autoCreatePlan(int $pid, array $product): bool
|
||||
{
|
||||
$productName = (string) ($product['name'] ?? "Product {$pid}");
|
||||
$groupName = (string) ($product['groupname'] ?? '');
|
||||
$description = strip_tags((string) ($product['description'] ?? ''));
|
||||
$serviceType = StatusMapper::mapServiceType($groupName, $productName);
|
||||
|
||||
// Extract USD pricing
|
||||
$pricing = $product['pricing']['USD'] ?? [];
|
||||
$monthlyPrice = $this->parsePrice((string) ($pricing['monthly'] ?? '0.00'));
|
||||
|
||||
// Generate unique slug
|
||||
$slug = $this->generateUniqueSlug($productName);
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->logger->info("[DRY RUN] Would create plan for product {$pid}: {$productName} (slug: {$slug}, type: {$serviceType}, monthly: {$monthlyPrice})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Insert the plan
|
||||
$planId = $this->db->insert('plans', [
|
||||
'name' => $productName,
|
||||
'slug' => $slug,
|
||||
'description' => $description !== '' ? $description : null,
|
||||
'service_type' => $serviceType,
|
||||
'price' => $monthlyPrice,
|
||||
'currency' => 'USD',
|
||||
'billing_cycle' => 'monthly',
|
||||
'status' => 'active',
|
||||
'features' => null,
|
||||
'provisioning_config' => null,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
// Insert plan_prices for each valid billing cycle
|
||||
$pricesInserted = $this->insertPlanPrices($planId, $pricing);
|
||||
|
||||
$this->state->setMapping('products', $pid, $planId);
|
||||
$this->state->save();
|
||||
|
||||
$this->logger->info("Created plan {$planId} for WHMCS product {$pid}: {$productName} (slug: {$slug}, {$pricesInserted} price tiers)");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert plan_prices rows for each valid billing cycle.
|
||||
*
|
||||
* @return int Number of price rows inserted
|
||||
*/
|
||||
private function insertPlanPrices(int $planId, array $pricing): int
|
||||
{
|
||||
$inserted = 0;
|
||||
|
||||
foreach (self::CYCLE_MAP as $whmcsCycle => $ezscaleCycle) {
|
||||
$priceStr = (string) ($pricing[$whmcsCycle] ?? '');
|
||||
$price = $this->parsePrice($priceStr);
|
||||
|
||||
// WHMCS uses -1.00 for disabled cycles, skip zero and negative
|
||||
if ($price <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->db->insert('plan_prices', [
|
||||
'plan_id' => $planId,
|
||||
'billing_cycle' => $ezscaleCycle,
|
||||
'price' => $price,
|
||||
]);
|
||||
|
||||
$inserted++;
|
||||
}
|
||||
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
private function parsePrice(string $price): float
|
||||
{
|
||||
$price = trim($price);
|
||||
|
||||
if ($price === '' || $price === '-1.00' || $price === '-1') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$parsed = (float) $price;
|
||||
|
||||
return $parsed > 0 ? round($parsed, 2) : 0.0;
|
||||
}
|
||||
|
||||
private function generateUniqueSlug(string $name): string
|
||||
{
|
||||
$baseSlug = strtolower(trim($name));
|
||||
$baseSlug = (string) preg_replace('/[^a-z0-9\s-]/', '', $baseSlug);
|
||||
$baseSlug = (string) preg_replace('/[\s-]+/', '-', $baseSlug);
|
||||
$baseSlug = trim($baseSlug, '-');
|
||||
|
||||
if ($baseSlug === '') {
|
||||
$baseSlug = 'plan';
|
||||
}
|
||||
|
||||
$slug = $baseSlug;
|
||||
$suffix = 2;
|
||||
|
||||
while ($this->db->tableHasRow('plans', ['slug' => $slug])) {
|
||||
$slug = "{$baseSlug}-{$suffix}";
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plan mapping and skip list from plan_mapping.json.
|
||||
*
|
||||
* @return array{mappings: array<string, string>, skip: array<int, string>}
|
||||
*/
|
||||
private function loadPlanMapping(): array
|
||||
{
|
||||
$path = $this->getPlanMappingPath();
|
||||
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
if ($contents === false) {
|
||||
throw new RuntimeException("Cannot read plan_mapping.json at {$path}");
|
||||
}
|
||||
|
||||
$decoded = json_decode($contents, true);
|
||||
|
||||
if (! is_array($decoded) || ! array_key_exists('mappings', $decoded)) {
|
||||
throw new RuntimeException('plan_mapping.json must contain a "mappings" key');
|
||||
}
|
||||
|
||||
$mappings = [];
|
||||
|
||||
foreach ($decoded['mappings'] as $whmcsId => $slug) {
|
||||
$mappings[(string) $whmcsId] = (string) $slug;
|
||||
}
|
||||
|
||||
$skip = [];
|
||||
|
||||
if (array_key_exists('skip', $decoded) && is_array($decoded['skip'])) {
|
||||
$skip = array_map('strval', $decoded['skip']);
|
||||
}
|
||||
|
||||
return ['mappings' => $mappings, 'skip' => $skip];
|
||||
}
|
||||
|
||||
private function getPlanMappingPath(): string
|
||||
{
|
||||
return dirname(__DIR__, 2) . '/plan_mapping.json';
|
||||
}
|
||||
|
||||
private function validatePlanMapping(): bool
|
||||
{
|
||||
$mappingPath = $this->getPlanMappingPath();
|
||||
|
||||
if (! file_exists($mappingPath)) {
|
||||
$this->logger->error("Validation failed: plan_mapping.json not found at {$mappingPath}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($mappingPath);
|
||||
|
||||
if ($contents === false) {
|
||||
$this->logger->error("Validation failed: cannot read plan_mapping.json");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$decoded = json_decode($contents, true);
|
||||
|
||||
if (! is_array($decoded) || ! array_key_exists('mappings', $decoded)) {
|
||||
$this->logger->error('Validation failed: plan_mapping.json must contain a "mappings" key');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function validateApi(): bool
|
||||
{
|
||||
try {
|
||||
$response = $this->api->call('GetProducts');
|
||||
|
||||
if (($response['result'] ?? '') !== 'success') {
|
||||
$this->logger->error('Validation failed: WHMCS GetProducts API returned non-success', [
|
||||
'response' => $response,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Validation failed: cannot fetch products from WHMCS API', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
494
scripts/whmcs-migrate/src/Phases/Phase3Services.php
Normal file
494
scripts/whmcs-migrate/src/Phases/Phase3Services.php
Normal file
@@ -0,0 +1,494 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate\Phases;
|
||||
|
||||
use Throwable;
|
||||
use WhmcsMigrate\ProgressTracker;
|
||||
use WhmcsMigrate\StatusMapper;
|
||||
|
||||
final class Phase3Services extends AbstractPhase
|
||||
{
|
||||
public function getPhaseNumber(): int
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Services → Subscriptions + Services';
|
||||
}
|
||||
|
||||
public function validate(): bool
|
||||
{
|
||||
if ($this->isExportOnly()) {
|
||||
// Export needs client mappings to know which clients to fetch services for
|
||||
$clientCount = $this->state->getMappingCount('clients');
|
||||
|
||||
if ($clientCount === 0) {
|
||||
// In export-only mode, we can export by iterating all clients from the clients export
|
||||
if (! $this->exportManager->hasExport('clients')) {
|
||||
$this->logger->error('Validation failed: no client export or mappings found — run Phase 1 export first');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Import modes need client + product mappings
|
||||
$clientCount = $this->state->getMappingCount('clients');
|
||||
|
||||
if ($clientCount === 0) {
|
||||
$this->logger->error('Validation failed: no client mappings found — run Phase 1 first');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$productCount = $this->state->getMappingCount('products');
|
||||
|
||||
if ($productCount === 0) {
|
||||
$this->logger->error('Validation failed: no product mappings found — run Phase 2 first');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->logger->info("Validation passed: {$clientCount} client mappings, {$productCount} product mappings");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
if (! $this->isExportOnly() && $this->shouldSkip()) {
|
||||
$this->logger->info('Phase 3 already complete, skipping');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->section('Phase 3: ' . $this->getName());
|
||||
|
||||
if ($this->isExportOnly()) {
|
||||
$this->runExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isFromExport()) {
|
||||
$this->runFromExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runFromApi();
|
||||
}
|
||||
|
||||
private function runExport(): void
|
||||
{
|
||||
$this->logger->info('Exporting client services from WHMCS API...');
|
||||
$this->exportManager->resetExport('client_services');
|
||||
|
||||
// Get client IDs from existing mappings or from the clients export
|
||||
$clientIds = [];
|
||||
|
||||
$clientMappings = $this->state->getAllMappings('clients');
|
||||
|
||||
if (! empty($clientMappings)) {
|
||||
$clientIds = array_map('intval', array_keys($clientMappings));
|
||||
} else {
|
||||
// Read from clients export
|
||||
foreach ($this->exportManager->readRecords('clients') as $client) {
|
||||
$id = (int) ($client['id'] ?? 0);
|
||||
|
||||
if ($id > 0) {
|
||||
$clientIds[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$totalClients = count($clientIds);
|
||||
$this->logger->info("Exporting services for {$totalClients} clients");
|
||||
|
||||
if ($totalClients === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tracker = new ProgressTracker('Exporting services', $totalClients);
|
||||
$exported = 0;
|
||||
|
||||
foreach ($clientIds as $whmcsClientId) {
|
||||
try {
|
||||
$response = $this->api->call('GetClientsProducts', ['clientid' => $whmcsClientId]);
|
||||
|
||||
if (($response['result'] ?? '') === 'success') {
|
||||
$products = $response['products']['product'] ?? [];
|
||||
|
||||
foreach ($products as $product) {
|
||||
$product['_whmcs_client_id'] = $whmcsClientId;
|
||||
$this->exportManager->writeRecord('client_services', $product);
|
||||
$exported++;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning("Failed to fetch services for client {$whmcsClientId}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$tracker->advance();
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->logger->info("Exported {$exported} service records to JSONL");
|
||||
}
|
||||
|
||||
private function runFromExport(): void
|
||||
{
|
||||
if (! $this->exportManager->hasExport('client_services')) {
|
||||
$this->logger->error('No client_services export found. Run --export-only first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markStarted();
|
||||
|
||||
$totalServices = $this->exportManager->countRecords('client_services');
|
||||
$this->logger->info("Found {$totalServices} service records in export");
|
||||
|
||||
if ($totalServices === 0) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$progress = $this->state->getProgress($this->getPhaseKey());
|
||||
$processedCount = (int) ($progress['offset'] ?? 0);
|
||||
$count = (int) ($progress['count'] ?? 0);
|
||||
$errors = (int) ($progress['errors'] ?? 0);
|
||||
|
||||
if ($processedCount > 0) {
|
||||
$this->logger->info("Resuming from record {$processedCount} ({$count} migrated, {$errors} errors so far)");
|
||||
}
|
||||
|
||||
$tracker = new ProgressTracker('Importing services', $totalServices);
|
||||
$tracker->setCurrent($processedCount);
|
||||
|
||||
$currentIndex = 0;
|
||||
|
||||
foreach ($this->exportManager->readRecords('client_services') as $product) {
|
||||
$currentIndex++;
|
||||
|
||||
if ($currentIndex <= $processedCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$whmcsServiceId = (int) ($product['id'] ?? 0);
|
||||
$whmcsClientId = (int) ($product['_whmcs_client_id'] ?? 0);
|
||||
$ezUserId = $this->state->getMapping('clients', $whmcsClientId);
|
||||
|
||||
if ($whmcsServiceId === 0 || $whmcsClientId === 0 || $ezUserId === null) {
|
||||
$errors++;
|
||||
$processedCount++;
|
||||
$tracker->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->migrateService($whmcsServiceId, $whmcsClientId, $ezUserId, $product)) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate service {$whmcsServiceId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$processedCount++;
|
||||
$tracker->advance();
|
||||
|
||||
if ($processedCount % 100 === 0) {
|
||||
$this->state->setProgress($this->getPhaseKey(), 'running', $processedCount, $count, $errors);
|
||||
$this->state->save();
|
||||
}
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 3 complete: {$count} services migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
private function runFromApi(): void
|
||||
{
|
||||
$this->markStarted();
|
||||
|
||||
$clientMappings = $this->state->getAllMappings('clients');
|
||||
$totalClients = count($clientMappings);
|
||||
|
||||
$this->logger->info("Processing services for {$totalClients} mapped clients");
|
||||
|
||||
if ($totalClients === 0) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$progress = $this->state->getProgress($this->getPhaseKey());
|
||||
$startOffset = (int) ($progress['offset'] ?? 0);
|
||||
$count = (int) ($progress['count'] ?? 0);
|
||||
$errors = (int) ($progress['errors'] ?? 0);
|
||||
|
||||
if ($startOffset > 0) {
|
||||
$this->logger->info("Resuming from client index {$startOffset} ({$count} services migrated, {$errors} errors so far)");
|
||||
}
|
||||
|
||||
$clientEntries = array_values(
|
||||
array_map(
|
||||
fn (string $whmcsId, int $ezUserId): array => [
|
||||
'whmcs_id' => (int) $whmcsId,
|
||||
'ez_user_id' => $ezUserId,
|
||||
],
|
||||
array_keys($clientMappings),
|
||||
array_values($clientMappings),
|
||||
),
|
||||
);
|
||||
|
||||
$tracker = new ProgressTracker('Migrating services', $totalClients);
|
||||
$tracker->setCurrent($startOffset);
|
||||
|
||||
for ($i = $startOffset; $i < $totalClients; $i++) {
|
||||
$whmcsClientId = $clientEntries[$i]['whmcs_id'];
|
||||
$ezUserId = $clientEntries[$i]['ez_user_id'];
|
||||
|
||||
try {
|
||||
$result = $this->migrateClientServices($whmcsClientId, $ezUserId);
|
||||
$count += $result['migrated'];
|
||||
$errors += $result['errors'];
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to process services for WHMCS client {$whmcsClientId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->state->setProgress($this->getPhaseKey(), 'running', $i + 1, $count, $errors);
|
||||
$this->state->save();
|
||||
$tracker->advance();
|
||||
|
||||
if (($i + 1) % 50 === 0 || $i + 1 === $totalClients) {
|
||||
$this->logger->info("Progress: client " . ($i + 1) . "/{$totalClients}, {$count} services migrated, {$errors} errors");
|
||||
}
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 3 complete: {$count} services migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate all services/products for a single WHMCS client.
|
||||
*
|
||||
* @return array{migrated: int, errors: int}
|
||||
*/
|
||||
private function migrateClientServices(int $whmcsClientId, int $ezUserId): array
|
||||
{
|
||||
$response = $this->api->call('GetClientsProducts', ['clientid' => $whmcsClientId]);
|
||||
|
||||
if (($response['result'] ?? '') !== 'success') {
|
||||
$this->logger->warning("GetClientsProducts failed for client {$whmcsClientId}", [
|
||||
'response' => $response,
|
||||
]);
|
||||
|
||||
return ['migrated' => 0, 'errors' => 1];
|
||||
}
|
||||
|
||||
$products = $response['products']['product'] ?? [];
|
||||
$migrated = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($products as $product) {
|
||||
$whmcsServiceId = (int) ($product['id'] ?? 0);
|
||||
|
||||
if ($whmcsServiceId === 0) {
|
||||
$this->logger->warning('Skipping service with no ID for client ' . $whmcsClientId);
|
||||
$errors++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->migrateService($whmcsServiceId, $whmcsClientId, $ezUserId, $product)) {
|
||||
$migrated++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate service {$whmcsServiceId} for client {$whmcsClientId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return ['migrated' => $migrated, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single WHMCS service/product to EZSCALE subscription + service.
|
||||
*/
|
||||
private function migrateService(int $whmcsServiceId, int $whmcsClientId, int $ezUserId, array $product): bool
|
||||
{
|
||||
if ($this->state->getMapping('services', $whmcsServiceId) !== null) {
|
||||
$this->logger->debug("Service {$whmcsServiceId} already mapped, skipping");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$pid = (int) ($product['pid'] ?? 0);
|
||||
$planId = $this->state->getMapping('products', $pid);
|
||||
|
||||
if ($planId === null) {
|
||||
$this->logger->warning("No plan mapping for WHMCS product {$pid}, skipping service {$whmcsServiceId}");
|
||||
$this->logSkipped('service', "No plan mapping for product {$pid} (service {$whmcsServiceId})");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$plan = $this->db->queryOne(
|
||||
"SELECT `slug`, `service_type` FROM `plans` WHERE `id` = :id LIMIT 1",
|
||||
['id' => $planId],
|
||||
);
|
||||
|
||||
if ($plan === null) {
|
||||
$this->logger->warning("Plan {$planId} not found in database, skipping service {$whmcsServiceId}");
|
||||
$this->logSkipped('service', "Plan {$planId} not found in DB (service {$whmcsServiceId})");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$planSlug = (string) $plan['slug'];
|
||||
$serviceType = (string) $plan['service_type'];
|
||||
$platform = StatusMapper::mapPlatform($serviceType); // null for mysql, backups
|
||||
|
||||
$stripeId = "whmcs_import_{$whmcsServiceId}";
|
||||
|
||||
if ($this->db->tableHasRow('subscriptions', ['stripe_id' => $stripeId])) {
|
||||
$this->logger->warning("Subscription with stripe_id '{$stripeId}' already exists, skipping service {$whmcsServiceId}");
|
||||
$this->logSkipped('service', "Duplicate stripe_id {$stripeId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$whmcsStatus = (string) ($product['status'] ?? 'Active');
|
||||
$billingCycle = StatusMapper::mapBillingCycle((string) ($product['billingcycle'] ?? 'Monthly'));
|
||||
$gateway = StatusMapper::mapGateway((string) ($product['paymentmethod'] ?? 'stripe'));
|
||||
$stripeStatus = StatusMapper::mapStripeStatus($whmcsStatus);
|
||||
$serviceStatus = StatusMapper::mapServiceStatus($whmcsStatus);
|
||||
|
||||
$regDate = $this->parseDate((string) ($product['regdate'] ?? ''));
|
||||
$nextDueDate = $this->parseDate((string) ($product['nextduedate'] ?? ''));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$isActive = strtolower($whmcsStatus) === 'active';
|
||||
$isSuspended = strtolower($whmcsStatus) === 'suspended';
|
||||
$isTerminated = in_array(strtolower($whmcsStatus), ['terminated', 'cancelled'], true);
|
||||
|
||||
$dedicatedIp = $this->nullIfEmpty((string) ($product['dedicatedip'] ?? ''));
|
||||
$domain = $this->nullIfEmpty((string) ($product['domain'] ?? ''));
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->logger->info("[DRY RUN] Would create subscription + service for WHMCS service {$whmcsServiceId} (client {$whmcsClientId}, plan {$planSlug}, status {$whmcsStatus})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->db->beginTransaction();
|
||||
|
||||
try {
|
||||
$subscriptionId = $this->db->insert('subscriptions', [
|
||||
'user_id' => $ezUserId,
|
||||
'type' => $planSlug,
|
||||
'plan_id' => $planId,
|
||||
'gateway' => $gateway,
|
||||
'stripe_id' => $stripeId,
|
||||
'stripe_status' => $stripeStatus,
|
||||
'stripe_price' => null,
|
||||
'quantity' => 1,
|
||||
'billing_cycle' => $billingCycle,
|
||||
'current_period_end' => $nextDueDate,
|
||||
'created_at' => $regDate ?? $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->db->insert('subscription_items', [
|
||||
'subscription_id' => $subscriptionId,
|
||||
'stripe_id' => "whmcs_si_{$whmcsServiceId}",
|
||||
'stripe_product' => "whmcs_prod_{$pid}",
|
||||
'stripe_price' => "whmcs_price_{$pid}",
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$serviceId = $this->db->insert('services', [
|
||||
'user_id' => $ezUserId,
|
||||
'subscription_id' => $subscriptionId,
|
||||
'plan_id' => $planId,
|
||||
'service_type' => $serviceType,
|
||||
'platform' => $platform,
|
||||
'status' => $serviceStatus,
|
||||
'ipv4_address' => $dedicatedIp,
|
||||
'domain' => $domain,
|
||||
'credentials' => $this->encryptor->encryptArray([]),
|
||||
'provisioned_at' => $isActive ? ($regDate ?? $now) : null,
|
||||
'suspended_at' => $isSuspended ? $now : null,
|
||||
'terminated_at' => $isTerminated ? $now : null,
|
||||
'auto_renew' => $isActive ? 1 : 0,
|
||||
'created_at' => $regDate ?? $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->db->commit();
|
||||
|
||||
$this->state->setMapping('services', $whmcsServiceId, $serviceId);
|
||||
$this->state->setMapping('subscriptions', $whmcsServiceId, $subscriptionId);
|
||||
|
||||
$this->logger->debug("Migrated service {$whmcsServiceId} → subscription {$subscriptionId} + service {$serviceId} (plan: {$planSlug}, status: {$serviceStatus})");
|
||||
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
$this->db->rollback();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function nullIfEmpty(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
|
||||
private function parseDate(string $date): ?string
|
||||
{
|
||||
$date = trim($date);
|
||||
|
||||
if ($date === '' || str_starts_with($date, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $date . ' 00:00:00';
|
||||
}
|
||||
|
||||
$timestamp = strtotime($date);
|
||||
|
||||
if ($timestamp === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
}
|
||||
553
scripts/whmcs-migrate/src/Phases/Phase4Invoices.php
Normal file
553
scripts/whmcs-migrate/src/Phases/Phase4Invoices.php
Normal file
@@ -0,0 +1,553 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate\Phases;
|
||||
|
||||
use Throwable;
|
||||
use WhmcsMigrate\ProgressTracker;
|
||||
use WhmcsMigrate\StatusMapper;
|
||||
|
||||
final class Phase4Invoices extends AbstractPhase
|
||||
{
|
||||
public function getPhaseNumber(): int
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Invoices → Invoices + InvoiceItems';
|
||||
}
|
||||
|
||||
public function validate(): bool
|
||||
{
|
||||
if ($this->isExportOnly()) {
|
||||
return $this->validateApi();
|
||||
}
|
||||
|
||||
if ($this->isFromExport()) {
|
||||
return $this->validateDb();
|
||||
}
|
||||
|
||||
return $this->validateDb() && $this->validateApi();
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
if (! $this->isExportOnly() && $this->shouldSkip()) {
|
||||
$this->logger->info('Phase 4 already complete, skipping');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->section('Phase 4: ' . $this->getName());
|
||||
|
||||
if ($this->isExportOnly()) {
|
||||
$this->runExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isFromExport()) {
|
||||
$this->runFromExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runFromApi();
|
||||
}
|
||||
|
||||
private function runExport(): void
|
||||
{
|
||||
$this->logger->info('Exporting invoices from WHMCS API...');
|
||||
$this->exportManager->resetExport('invoices');
|
||||
$this->exportManager->resetExport('invoice_details');
|
||||
|
||||
// Get total invoice count
|
||||
$probeResponse = $this->api->call('GetInvoices', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
$totalInvoices = (int) ($probeResponse['totalresults'] ?? 0);
|
||||
|
||||
$this->logger->info("Found {$totalInvoices} invoices in WHMCS");
|
||||
|
||||
if ($totalInvoices === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$batchSize = $this->getBatchSize();
|
||||
$offset = 0;
|
||||
$exported = 0;
|
||||
$tracker = new ProgressTracker('Exporting invoices', $totalInvoices);
|
||||
|
||||
while ($offset < $totalInvoices) {
|
||||
$response = $this->api->call('GetInvoices', [
|
||||
'limitstart' => $offset,
|
||||
'limitnum' => $batchSize,
|
||||
]);
|
||||
|
||||
$invoices = $response['invoices']['invoice'] ?? [];
|
||||
|
||||
if (empty($invoices)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Export summary data
|
||||
$this->exportManager->writeBatch('invoices', $invoices);
|
||||
|
||||
// Fetch and export detail data (line items) for each invoice
|
||||
foreach ($invoices as $invoice) {
|
||||
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||||
|
||||
if ($invoiceId === 0) {
|
||||
$tracker->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$detailResponse = $this->api->call('GetInvoice', ['invoiceid' => $invoiceId]);
|
||||
|
||||
if (($detailResponse['result'] ?? '') === 'success') {
|
||||
// Merge summary fields into detail for a complete record
|
||||
$detailResponse['_summary'] = $invoice;
|
||||
$this->exportManager->writeRecord('invoice_details', $detailResponse);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning("Failed to fetch details for invoice {$invoiceId}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$exported++;
|
||||
$tracker->advance();
|
||||
}
|
||||
|
||||
$offset += count($invoices);
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->logger->info("Exported {$exported} invoice records to JSONL");
|
||||
}
|
||||
|
||||
private function runFromExport(): void
|
||||
{
|
||||
if (! $this->exportManager->hasExport('invoices')) {
|
||||
$this->logger->error('No invoices export found. Run --export-only first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markStarted();
|
||||
|
||||
// We need both invoices (summary) and invoice_details (line items)
|
||||
// If invoice_details exists, use it (has line items). Otherwise fall back to invoices.
|
||||
$hasDetails = $this->exportManager->hasExport('invoice_details');
|
||||
$entityName = $hasDetails ? 'invoice_details' : 'invoices';
|
||||
|
||||
$totalInvoices = $this->exportManager->countRecords($entityName);
|
||||
$this->logger->info("Found {$totalInvoices} invoice records in export" . ($hasDetails ? ' (with details)' : ' (summary only)'));
|
||||
|
||||
if ($totalInvoices === 0) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$progress = $this->state->getProgress($this->getPhaseKey());
|
||||
$processedCount = (int) ($progress['offset'] ?? 0);
|
||||
$count = (int) ($progress['count'] ?? 0);
|
||||
$errors = (int) ($progress['errors'] ?? 0);
|
||||
|
||||
if ($processedCount > 0) {
|
||||
$this->logger->info("Resuming from record {$processedCount} ({$count} migrated, {$errors} errors so far)");
|
||||
}
|
||||
|
||||
$tracker = new ProgressTracker('Importing invoices', $totalInvoices);
|
||||
$tracker->setCurrent($processedCount);
|
||||
|
||||
$currentIndex = 0;
|
||||
|
||||
foreach ($this->exportManager->readRecords($entityName) as $record) {
|
||||
$currentIndex++;
|
||||
|
||||
if ($currentIndex <= $processedCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($hasDetails) {
|
||||
// invoice_details record has full detail + _summary
|
||||
$invoiceData = $record['_summary'] ?? $record;
|
||||
$detailData = $record;
|
||||
$whmcsInvoiceId = (int) ($invoiceData['id'] ?? $record['invoiceid'] ?? 0);
|
||||
} else {
|
||||
$invoiceData = $record;
|
||||
$detailData = null;
|
||||
$whmcsInvoiceId = (int) ($invoiceData['id'] ?? 0);
|
||||
}
|
||||
|
||||
if ($whmcsInvoiceId === 0) {
|
||||
$errors++;
|
||||
$processedCount++;
|
||||
$tracker->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migrateInvoiceFromData($whmcsInvoiceId, $invoiceData, $detailData);
|
||||
|
||||
if ($result) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate invoice {$whmcsInvoiceId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$processedCount++;
|
||||
$tracker->advance();
|
||||
|
||||
if ($processedCount % 100 === 0) {
|
||||
$this->state->setProgress($this->getPhaseKey(), 'running', $processedCount, $count, $errors);
|
||||
$this->state->save();
|
||||
}
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 4 complete: {$count} invoices migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
private function runFromApi(): void
|
||||
{
|
||||
$this->markStarted();
|
||||
|
||||
// Get total invoice count from WHMCS
|
||||
$probeResponse = $this->api->call('GetInvoices', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
$totalInvoices = (int) ($probeResponse['totalresults'] ?? 0);
|
||||
|
||||
$this->logger->info("Found {$totalInvoices} invoices in WHMCS");
|
||||
|
||||
if ($totalInvoices === 0) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$progress = $this->state->getProgress($this->getPhaseKey());
|
||||
$offset = (int) ($progress['offset'] ?? 0);
|
||||
$count = (int) ($progress['count'] ?? 0);
|
||||
$errors = (int) ($progress['errors'] ?? 0);
|
||||
$batchSize = $this->getBatchSize();
|
||||
|
||||
if ($offset > 0) {
|
||||
$this->logger->info("Resuming from offset {$offset} ({$count} migrated, {$errors} errors so far)");
|
||||
}
|
||||
|
||||
$tracker = new ProgressTracker('Migrating invoices', $totalInvoices);
|
||||
$tracker->setCurrent($offset);
|
||||
|
||||
while ($offset < $totalInvoices) {
|
||||
$this->logger->debug("Fetching invoices batch: offset={$offset}, limit={$batchSize}");
|
||||
|
||||
$response = $this->api->call('GetInvoices', [
|
||||
'limitstart' => $offset,
|
||||
'limitnum' => $batchSize,
|
||||
]);
|
||||
|
||||
$invoices = $response['invoices']['invoice'] ?? [];
|
||||
|
||||
if (empty($invoices)) {
|
||||
$this->logger->debug('No more invoices returned, ending pagination');
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
$whmcsInvoiceId = (int) ($invoice['id'] ?? 0);
|
||||
|
||||
if ($whmcsInvoiceId === 0) {
|
||||
$this->logger->warning('Skipping invoice with no ID', ['invoice' => $invoice]);
|
||||
$errors++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migrateInvoice($whmcsInvoiceId, $invoice);
|
||||
|
||||
if ($result) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate invoice {$whmcsInvoiceId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$tracker->advance();
|
||||
}
|
||||
|
||||
$offset += count($invoices);
|
||||
$this->state->setProgress($this->getPhaseKey(), 'running', $offset, $count, $errors);
|
||||
$this->state->save();
|
||||
|
||||
$this->logger->info("Batch complete: {$count} migrated, {$errors} errors, offset now {$offset}/{$totalInvoices}");
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 4 complete: {$count} invoices migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from API — fetches invoice detail on the fly.
|
||||
*/
|
||||
private function migrateInvoice(int $whmcsInvoiceId, array $invoiceData): bool
|
||||
{
|
||||
if ($this->state->getMapping('invoices', $whmcsInvoiceId) !== null) {
|
||||
$this->logger->debug("Invoice {$whmcsInvoiceId} already mapped, skipping");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch line items from WHMCS
|
||||
$detailResponse = $this->api->call('GetInvoice', ['invoiceid' => $whmcsInvoiceId]);
|
||||
|
||||
if (($detailResponse['result'] ?? '') !== 'success') {
|
||||
$this->logger->warning("GetInvoice failed for invoice {$whmcsInvoiceId}", [
|
||||
'response' => $detailResponse,
|
||||
]);
|
||||
$this->logSkipped('invoice', "GetInvoice API failed for invoice {$whmcsInvoiceId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->migrateInvoiceFromData($whmcsInvoiceId, $invoiceData, $detailResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from already-loaded data (used by both API and export modes).
|
||||
*/
|
||||
private function migrateInvoiceFromData(int $whmcsInvoiceId, array $invoiceData, ?array $detailData): bool
|
||||
{
|
||||
if ($this->state->getMapping('invoices', $whmcsInvoiceId) !== null) {
|
||||
$this->logger->debug("Invoice {$whmcsInvoiceId} already mapped, skipping");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$whmcsUserId = (int) ($invoiceData['userid'] ?? 0);
|
||||
|
||||
if ($whmcsUserId === 0) {
|
||||
$this->logger->warning("Invoice {$whmcsInvoiceId} has no userid, skipping");
|
||||
$this->logSkipped('invoice', "No userid for invoice {$whmcsInvoiceId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$ezscaleUserId = $this->state->getMapping('clients', $whmcsUserId);
|
||||
|
||||
if ($ezscaleUserId === null) {
|
||||
$this->logger->warning("Invoice {$whmcsInvoiceId} belongs to unmapped client {$whmcsUserId}, skipping");
|
||||
$this->logSkipped('invoice', "Unmapped client {$whmcsUserId} for invoice {$whmcsInvoiceId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$gatewayInvoiceId = "whmcs_{$whmcsInvoiceId}";
|
||||
|
||||
if ($this->db->tableHasRow('invoices', ['gateway_invoice_id' => $gatewayInvoiceId])) {
|
||||
$this->logger->warning("Invoice with gateway_invoice_id '{$gatewayInvoiceId}' already exists in DB, skipping");
|
||||
$this->logSkipped('invoice', "Duplicate gateway_invoice_id {$gatewayInvoiceId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$items = [];
|
||||
|
||||
if ($detailData !== null) {
|
||||
$items = $detailData['items']['item'] ?? [];
|
||||
}
|
||||
|
||||
$status = StatusMapper::mapInvoiceStatus((string) ($invoiceData['status'] ?? 'Unpaid'));
|
||||
$gateway = StatusMapper::mapGateway((string) ($invoiceData['paymentmethod'] ?? ''));
|
||||
$total = (string) ($invoiceData['total'] ?? '0.00');
|
||||
$tax = bcadd(
|
||||
(string) ($invoiceData['tax'] ?? '0.00'),
|
||||
(string) ($invoiceData['tax2'] ?? '0.00'),
|
||||
2,
|
||||
);
|
||||
$currency = $this->nullIfEmpty((string) ($invoiceData['currencycode'] ?? '')) ?? 'USD';
|
||||
$dueDate = $this->parseDate((string) ($invoiceData['duedate'] ?? ''));
|
||||
$paidAt = $this->parsePaidAt($invoiceData);
|
||||
$createdAt = $this->parseDate((string) ($invoiceData['date'] ?? ''));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->logger->info("[DRY RUN] Would create invoice for WHMCS invoice {$whmcsInvoiceId}: user={$ezscaleUserId}, total={$total}, status={$status}, items=" . count($items));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->db->beginTransaction();
|
||||
|
||||
try {
|
||||
$newInvoiceId = $this->db->insert('invoices', [
|
||||
'user_id' => $ezscaleUserId,
|
||||
'subscription_id' => null,
|
||||
'gateway' => $gateway,
|
||||
'gateway_invoice_id' => $gatewayInvoiceId,
|
||||
'number' => "WHMCS-{$whmcsInvoiceId}",
|
||||
'total' => $total,
|
||||
'tax' => $tax,
|
||||
'currency' => $currency,
|
||||
'status' => $status,
|
||||
'due_date' => $dueDate,
|
||||
'paid_at' => $paidAt,
|
||||
'notes' => 'Imported from WHMCS',
|
||||
'created_at' => $createdAt ?? $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$description = trim((string) ($item['description'] ?? ''));
|
||||
|
||||
if ($description === '') {
|
||||
$description = 'Invoice item';
|
||||
}
|
||||
|
||||
if (mb_strlen($description) > 255) {
|
||||
$description = mb_substr($description, 0, 252) . '...';
|
||||
}
|
||||
|
||||
$this->db->insert('invoice_items', [
|
||||
'invoice_id' => $newInvoiceId,
|
||||
'description' => $description,
|
||||
'amount' => (string) ($item['amount'] ?? '0.00'),
|
||||
'quantity' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->db->commit();
|
||||
|
||||
$this->state->setMapping('invoices', $whmcsInvoiceId, $newInvoiceId);
|
||||
|
||||
$this->logger->debug("Migrated invoice {$whmcsInvoiceId} → invoice {$newInvoiceId} (user={$ezscaleUserId}, total={$total}, items=" . count($items) . ')');
|
||||
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
$this->db->rollback();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function parsePaidAt(array $invoiceData): ?string
|
||||
{
|
||||
$status = (string) ($invoiceData['status'] ?? '');
|
||||
|
||||
if ($status !== 'Paid') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$datePaid = trim((string) ($invoiceData['datepaid'] ?? ''));
|
||||
|
||||
if ($datePaid === '' || str_starts_with($datePaid, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->parseDate($datePaid);
|
||||
}
|
||||
|
||||
private function validateDb(): bool
|
||||
{
|
||||
$result = $this->db->queryOne(
|
||||
"SELECT COUNT(*) AS cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table",
|
||||
['table' => 'invoices'],
|
||||
);
|
||||
|
||||
if ($result === null || (int) $result['cnt'] === 0) {
|
||||
$this->logger->error('Validation failed: "invoices" table does not exist');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $this->db->queryOne(
|
||||
"SELECT COUNT(*) AS cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table",
|
||||
['table' => 'invoice_items'],
|
||||
);
|
||||
|
||||
if ($result === null || (int) $result['cnt'] === 0) {
|
||||
$this->logger->error('Validation failed: "invoice_items" table does not exist');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$clientCount = $this->state->getMappingCount('clients');
|
||||
|
||||
if ($clientCount === 0) {
|
||||
$this->logger->error('Validation failed: no client mappings found — Phase 1 must run first');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function validateApi(): bool
|
||||
{
|
||||
try {
|
||||
$response = $this->api->call('GetInvoices', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
|
||||
if (($response['result'] ?? '') !== 'success') {
|
||||
$this->logger->error('Validation failed: WHMCS GetInvoices API returned non-success', [
|
||||
'response' => $response,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Validation failed: cannot reach WHMCS API', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function nullIfEmpty(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
|
||||
private function parseDate(string $date): ?string
|
||||
{
|
||||
$date = trim($date);
|
||||
|
||||
if ($date === '' || str_starts_with($date, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $date . ' 00:00:00';
|
||||
}
|
||||
|
||||
$timestamp = strtotime($date);
|
||||
|
||||
if ($timestamp === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
}
|
||||
438
scripts/whmcs-migrate/src/Phases/Phase5Transactions.php
Normal file
438
scripts/whmcs-migrate/src/Phases/Phase5Transactions.php
Normal file
@@ -0,0 +1,438 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate\Phases;
|
||||
|
||||
use Throwable;
|
||||
use WhmcsMigrate\ProgressTracker;
|
||||
use WhmcsMigrate\StatusMapper;
|
||||
|
||||
final class Phase5Transactions extends AbstractPhase
|
||||
{
|
||||
public function getPhaseNumber(): int
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Transactions → PaymentTransactions';
|
||||
}
|
||||
|
||||
public function validate(): bool
|
||||
{
|
||||
if ($this->isExportOnly()) {
|
||||
return $this->validateApi();
|
||||
}
|
||||
|
||||
if ($this->isFromExport()) {
|
||||
return $this->validateDb();
|
||||
}
|
||||
|
||||
return $this->validateDb() && $this->validateApi();
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
if (! $this->isExportOnly() && $this->shouldSkip()) {
|
||||
$this->logger->info('Phase 5 already complete, skipping');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->section('Phase 5: ' . $this->getName());
|
||||
|
||||
if ($this->isExportOnly()) {
|
||||
$this->runExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isFromExport()) {
|
||||
$this->runFromExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runFromApi();
|
||||
}
|
||||
|
||||
private function runExport(): void
|
||||
{
|
||||
$this->logger->info('Exporting transactions from WHMCS API...');
|
||||
$this->exportManager->resetExport('transactions');
|
||||
|
||||
$probeResponse = $this->api->call('GetTransactions', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
$totalTransactions = (int) ($probeResponse['totalresults'] ?? 0);
|
||||
|
||||
$this->logger->info("Found {$totalTransactions} transactions in WHMCS");
|
||||
|
||||
if ($totalTransactions === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$batchSize = $this->getBatchSize();
|
||||
$offset = 0;
|
||||
$exported = 0;
|
||||
$tracker = new ProgressTracker('Exporting txns', $totalTransactions);
|
||||
|
||||
while ($offset < $totalTransactions) {
|
||||
$response = $this->api->call('GetTransactions', [
|
||||
'limitstart' => $offset,
|
||||
'limitnum' => $batchSize,
|
||||
]);
|
||||
|
||||
$transactions = $response['transactions']['transaction'] ?? [];
|
||||
|
||||
if (empty($transactions)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$this->exportManager->writeBatch('transactions', $transactions);
|
||||
$exported += count($transactions);
|
||||
$offset += count($transactions);
|
||||
|
||||
$tracker->setCurrent($offset);
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->logger->info("Exported {$exported} transaction records to JSONL");
|
||||
}
|
||||
|
||||
private function runFromExport(): void
|
||||
{
|
||||
if (! $this->exportManager->hasExport('transactions')) {
|
||||
$this->logger->error('No transactions export found. Run --export-only first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markStarted();
|
||||
|
||||
$totalTransactions = $this->exportManager->countRecords('transactions');
|
||||
$this->logger->info("Found {$totalTransactions} transaction records in export");
|
||||
|
||||
if ($totalTransactions === 0) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$progress = $this->state->getProgress($this->getPhaseKey());
|
||||
$processedCount = (int) ($progress['offset'] ?? 0);
|
||||
$count = (int) ($progress['count'] ?? 0);
|
||||
$errors = (int) ($progress['errors'] ?? 0);
|
||||
|
||||
if ($processedCount > 0) {
|
||||
$this->logger->info("Resuming from record {$processedCount} ({$count} migrated, {$errors} errors so far)");
|
||||
}
|
||||
|
||||
$tracker = new ProgressTracker('Importing txns', $totalTransactions);
|
||||
$tracker->setCurrent($processedCount);
|
||||
|
||||
$currentIndex = 0;
|
||||
|
||||
foreach ($this->exportManager->readRecords('transactions') as $transaction) {
|
||||
$currentIndex++;
|
||||
|
||||
if ($currentIndex <= $processedCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$whmcsTxId = (int) ($transaction['id'] ?? 0);
|
||||
|
||||
if ($whmcsTxId === 0) {
|
||||
$errors++;
|
||||
$processedCount++;
|
||||
$tracker->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migrateTransaction($whmcsTxId, $transaction);
|
||||
|
||||
if ($result) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate transaction {$whmcsTxId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$processedCount++;
|
||||
$tracker->advance();
|
||||
|
||||
if ($processedCount % 100 === 0) {
|
||||
$this->state->setProgress($this->getPhaseKey(), 'running', $processedCount, $count, $errors);
|
||||
$this->state->save();
|
||||
}
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 5 complete: {$count} transactions migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
private function runFromApi(): void
|
||||
{
|
||||
$this->markStarted();
|
||||
|
||||
$probeResponse = $this->api->call('GetTransactions', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
$totalTransactions = (int) ($probeResponse['totalresults'] ?? 0);
|
||||
|
||||
$this->logger->info("Found {$totalTransactions} transactions in WHMCS");
|
||||
|
||||
if ($totalTransactions === 0) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$progress = $this->state->getProgress($this->getPhaseKey());
|
||||
$offset = (int) ($progress['offset'] ?? 0);
|
||||
$count = (int) ($progress['count'] ?? 0);
|
||||
$errors = (int) ($progress['errors'] ?? 0);
|
||||
$batchSize = $this->getBatchSize();
|
||||
|
||||
if ($offset > 0) {
|
||||
$this->logger->info("Resuming from offset {$offset} ({$count} migrated, {$errors} errors so far)");
|
||||
}
|
||||
|
||||
$tracker = new ProgressTracker('Migrating txns', $totalTransactions);
|
||||
$tracker->setCurrent($offset);
|
||||
|
||||
while ($offset < $totalTransactions) {
|
||||
$this->logger->debug("Fetching transactions batch: offset={$offset}, limit={$batchSize}");
|
||||
|
||||
$response = $this->api->call('GetTransactions', [
|
||||
'limitstart' => $offset,
|
||||
'limitnum' => $batchSize,
|
||||
]);
|
||||
|
||||
$transactions = $response['transactions']['transaction'] ?? [];
|
||||
|
||||
if (empty($transactions)) {
|
||||
$this->logger->debug('No more transactions returned, ending pagination');
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$whmcsTxId = (int) ($transaction['id'] ?? 0);
|
||||
|
||||
if ($whmcsTxId === 0) {
|
||||
$this->logger->warning('Skipping transaction with no ID', ['transaction' => $transaction]);
|
||||
$errors++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migrateTransaction($whmcsTxId, $transaction);
|
||||
|
||||
if ($result) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate transaction {$whmcsTxId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$tracker->advance();
|
||||
}
|
||||
|
||||
$offset += count($transactions);
|
||||
$this->state->setProgress($this->getPhaseKey(), 'running', $offset, $count, $errors);
|
||||
$this->state->save();
|
||||
|
||||
$this->logger->info("Batch complete: {$count} migrated, {$errors} errors, offset now {$offset}/{$totalTransactions}");
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 5 complete: {$count} transactions migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single WHMCS transaction to EZSCALE.
|
||||
*/
|
||||
private function migrateTransaction(int $whmcsTxId, array $txData): bool
|
||||
{
|
||||
if ($this->state->getMapping('transactions', $whmcsTxId) !== null) {
|
||||
$this->logger->debug("Transaction {$whmcsTxId} already mapped, skipping");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$whmcsUserId = (int) ($txData['userid'] ?? 0);
|
||||
|
||||
if ($whmcsUserId === 0) {
|
||||
$this->logger->warning("Transaction {$whmcsTxId} has no userid (userid=0), skipping");
|
||||
$this->logSkipped('transaction', "No userid for transaction {$whmcsTxId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$ezscaleUserId = $this->state->getMapping('clients', $whmcsUserId);
|
||||
|
||||
if ($ezscaleUserId === null) {
|
||||
$this->logger->warning("Transaction {$whmcsTxId} belongs to unmapped client {$whmcsUserId}, skipping");
|
||||
$this->logSkipped('transaction', "Unmapped client {$whmcsUserId} for transaction {$whmcsTxId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$amountIn = (float) ($txData['amountin'] ?? '0.00');
|
||||
$amountOut = (float) ($txData['amountout'] ?? '0.00');
|
||||
|
||||
if ($amountIn == 0.0 && $amountOut == 0.0) {
|
||||
$this->logger->debug("Transaction {$whmcsTxId} has zero amount, skipping");
|
||||
$this->logSkipped('transaction', "Zero amount for transaction {$whmcsTxId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$transId = trim((string) ($txData['transid'] ?? ''));
|
||||
$gatewayTransactionId = $transId !== '' ? $transId : "whmcs_tx_{$whmcsTxId}";
|
||||
|
||||
if ($transId !== '' && $this->db->tableHasRow('payment_transactions', ['gateway_transaction_id' => $gatewayTransactionId])) {
|
||||
$this->logger->warning("Transaction with gateway_transaction_id '{$gatewayTransactionId}' already exists in DB, skipping");
|
||||
$this->logSkipped('transaction', "Duplicate gateway_transaction_id {$gatewayTransactionId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$amount = round($amountIn - $amountOut, 2);
|
||||
$status = $amount >= 0 ? 'succeeded' : 'refunded';
|
||||
|
||||
$whmcsInvoiceId = (int) ($txData['invoiceid'] ?? 0);
|
||||
$ezscaleInvoiceId = $whmcsInvoiceId > 0
|
||||
? $this->state->getMapping('invoices', $whmcsInvoiceId)
|
||||
: null;
|
||||
|
||||
$gateway = StatusMapper::mapGateway((string) ($txData['gateway'] ?? ''));
|
||||
$description = $this->nullIfEmpty((string) ($txData['description'] ?? ''));
|
||||
$createdAt = $this->parseDate((string) ($txData['date'] ?? ''));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$metadata = json_encode([
|
||||
'whmcs_id' => $whmcsTxId,
|
||||
'whmcs_invoiceid' => $whmcsInvoiceId,
|
||||
'amountin' => (string) ($txData['amountin'] ?? '0.00'),
|
||||
'amountout' => (string) ($txData['amountout'] ?? '0.00'),
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->logger->info("[DRY RUN] Would create transaction for WHMCS tx {$whmcsTxId}: user={$ezscaleUserId}, amount={$amount}, status={$status}, gateway={$gateway}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$newTxId = $this->db->insert('payment_transactions', [
|
||||
'user_id' => $ezscaleUserId,
|
||||
'subscription_id' => null,
|
||||
'invoice_id' => $ezscaleInvoiceId,
|
||||
'gateway' => $gateway,
|
||||
'gateway_transaction_id' => $gatewayTransactionId,
|
||||
'amount' => (string) $amount,
|
||||
'currency' => 'USD',
|
||||
'status' => $status,
|
||||
'payment_method' => null,
|
||||
'description' => $description,
|
||||
'metadata' => $metadata,
|
||||
'created_at' => $createdAt ?? $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->state->setMapping('transactions', $whmcsTxId, $newTxId);
|
||||
|
||||
$this->logger->debug("Migrated transaction {$whmcsTxId} → payment_transaction {$newTxId} (user={$ezscaleUserId}, amount={$amount}, status={$status})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function validateDb(): bool
|
||||
{
|
||||
$result = $this->db->queryOne(
|
||||
"SELECT COUNT(*) AS cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table",
|
||||
['table' => 'payment_transactions'],
|
||||
);
|
||||
|
||||
if ($result === null || (int) $result['cnt'] === 0) {
|
||||
$this->logger->error('Validation failed: "payment_transactions" table does not exist');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$clientCount = $this->state->getMappingCount('clients');
|
||||
|
||||
if ($clientCount === 0) {
|
||||
$this->logger->error('Validation failed: no client mappings found — Phase 1 must run first');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function validateApi(): bool
|
||||
{
|
||||
try {
|
||||
$response = $this->api->call('GetTransactions', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
|
||||
if (($response['result'] ?? '') !== 'success') {
|
||||
$this->logger->error('Validation failed: WHMCS GetTransactions API returned non-success', [
|
||||
'response' => $response,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Validation failed: cannot reach WHMCS API', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function nullIfEmpty(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
|
||||
private function parseDate(string $date): ?string
|
||||
{
|
||||
$date = trim($date);
|
||||
|
||||
if ($date === '' || str_starts_with($date, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $date . ' 00:00:00';
|
||||
}
|
||||
|
||||
$timestamp = strtotime($date);
|
||||
|
||||
if ($timestamp === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
}
|
||||
324
scripts/whmcs-migrate/src/Phases/Phase6Coupons.php
Normal file
324
scripts/whmcs-migrate/src/Phases/Phase6Coupons.php
Normal file
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate\Phases;
|
||||
|
||||
use Throwable;
|
||||
use WhmcsMigrate\ProgressTracker;
|
||||
|
||||
final class Phase6Coupons extends AbstractPhase
|
||||
{
|
||||
public function getPhaseNumber(): int
|
||||
{
|
||||
return 6;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Promotions → Coupons';
|
||||
}
|
||||
|
||||
public function validate(): bool
|
||||
{
|
||||
// Promotions might not be available in all WHMCS versions,
|
||||
// so validation always passes — the run() method handles failures gracefully.
|
||||
return true;
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
if (! $this->isExportOnly() && $this->shouldSkip()) {
|
||||
$this->logger->info('Phase 6 already complete, skipping');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->section('Phase 6: ' . $this->getName());
|
||||
|
||||
if ($this->isExportOnly()) {
|
||||
$this->runExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isFromExport()) {
|
||||
$this->runFromExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runFromApi();
|
||||
}
|
||||
|
||||
private function runExport(): void
|
||||
{
|
||||
$this->logger->info('Exporting promotions from WHMCS API...');
|
||||
$this->exportManager->resetExport('promotions');
|
||||
|
||||
try {
|
||||
$response = $this->api->call('GetPromotions');
|
||||
|
||||
if (($response['result'] ?? '') !== 'success') {
|
||||
$this->logger->warning('GetPromotions API returned non-success, skipping');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$promotions = $response['promotions']['promotion'] ?? [];
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning('GetPromotions API call failed, skipping', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$totalPromotions = (int) ($response['totalresults'] ?? count($promotions));
|
||||
$this->logger->info("Found {$totalPromotions} promotions in WHMCS");
|
||||
|
||||
if (empty($promotions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->exportManager->writeBatch('promotions', $promotions);
|
||||
$this->logger->info("Exported {$totalPromotions} promotion records to JSONL");
|
||||
}
|
||||
|
||||
private function runFromExport(): void
|
||||
{
|
||||
if (! $this->exportManager->hasExport('promotions')) {
|
||||
$this->logger->warning('No promotions export found. Skipping coupon migration.');
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markStarted();
|
||||
|
||||
$promotions = $this->exportManager->readAll('promotions');
|
||||
$totalPromotions = count($promotions);
|
||||
|
||||
$this->logger->info("Found {$totalPromotions} promotions in export");
|
||||
|
||||
if ($totalPromotions === 0) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$errors = 0;
|
||||
$tracker = new ProgressTracker('Importing coupons', $totalPromotions);
|
||||
|
||||
foreach ($promotions as $promotion) {
|
||||
$whmcsPromoId = (int) ($promotion['id'] ?? 0);
|
||||
|
||||
if ($whmcsPromoId === 0) {
|
||||
$this->logger->warning('Skipping promotion with no ID', ['promotion' => $promotion]);
|
||||
$errors++;
|
||||
$tracker->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migratePromotion($whmcsPromoId, $promotion);
|
||||
|
||||
if ($result) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate promotion {$whmcsPromoId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$tracker->advance();
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 6 complete: {$count} coupons migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
private function runFromApi(): void
|
||||
{
|
||||
$this->markStarted();
|
||||
|
||||
$promotions = [];
|
||||
|
||||
try {
|
||||
$response = $this->api->call('GetPromotions');
|
||||
|
||||
if (($response['result'] ?? '') !== 'success') {
|
||||
$this->logger->warning('GetPromotions API returned non-success, skipping coupon migration', [
|
||||
'response' => $response,
|
||||
]);
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$promotions = $response['promotions']['promotion'] ?? [];
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning('GetPromotions API call failed, skipping coupon migration', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$totalPromotions = (int) ($response['totalresults'] ?? count($promotions));
|
||||
|
||||
$this->logger->info("Found {$totalPromotions} promotions in WHMCS");
|
||||
|
||||
if (empty($promotions)) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$errors = 0;
|
||||
$tracker = new ProgressTracker('Migrating coupons', $totalPromotions);
|
||||
|
||||
foreach ($promotions as $promotion) {
|
||||
$whmcsPromoId = (int) ($promotion['id'] ?? 0);
|
||||
|
||||
if ($whmcsPromoId === 0) {
|
||||
$this->logger->warning('Skipping promotion with no ID', ['promotion' => $promotion]);
|
||||
$errors++;
|
||||
$tracker->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migratePromotion($whmcsPromoId, $promotion);
|
||||
|
||||
if ($result) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate promotion {$whmcsPromoId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$tracker->advance();
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 6 complete: {$count} coupons migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single WHMCS promotion to an EZSCALE coupon.
|
||||
*/
|
||||
private function migratePromotion(int $whmcsPromoId, array $promoData): bool
|
||||
{
|
||||
if ($this->state->getMapping('promotions', $whmcsPromoId) !== null) {
|
||||
$this->logger->debug("Promotion {$whmcsPromoId} already mapped, skipping");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$code = strtoupper(trim((string) ($promoData['code'] ?? '')));
|
||||
|
||||
if ($code === '') {
|
||||
$this->logger->warning("Promotion {$whmcsPromoId} has no code, skipping");
|
||||
$this->logSkipped('promotion', "No code for promotion {$whmcsPromoId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->db->tableHasRow('coupons', ['code' => $code])) {
|
||||
$this->logger->warning("Coupon with code '{$code}' already exists in DB, skipping");
|
||||
$this->logSkipped('promotion', "Duplicate code {$code} for promotion {$whmcsPromoId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$whmcsType = trim((string) ($promoData['type'] ?? ''));
|
||||
$type = ($whmcsType === 'Percentage') ? 'percentage' : 'fixed';
|
||||
$value = (string) ($promoData['value'] ?? '0.00');
|
||||
$currency = ($type === 'fixed') ? 'USD' : null;
|
||||
$maxUses = (int) ($promoData['maxuses'] ?? 0);
|
||||
$maxUsesValue = ($maxUses > 0) ? $maxUses : null;
|
||||
$timesUsed = (int) ($promoData['uses'] ?? 0);
|
||||
$expirationDate = $this->parseExpirationDate((string) ($promoData['expirationdate'] ?? ''));
|
||||
$active = $this->isPromotionActive($expirationDate);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->logger->info("[DRY RUN] Would create coupon for WHMCS promotion {$whmcsPromoId}: code={$code}, type={$type}, value={$value}, active=" . ($active ? 'yes' : 'no'));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$newCouponId = $this->db->insert('coupons', [
|
||||
'code' => $code,
|
||||
'type' => $type,
|
||||
'value' => $value,
|
||||
'currency' => $currency,
|
||||
'applies_to' => null,
|
||||
'max_uses' => $maxUsesValue,
|
||||
'times_used' => $timesUsed,
|
||||
'active' => $active ? 1 : 0,
|
||||
'expires_at' => $expirationDate,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->state->setMapping('promotions', $whmcsPromoId, $newCouponId);
|
||||
|
||||
$this->logger->debug("Migrated promotion {$whmcsPromoId} → coupon {$newCouponId} (code={$code}, type={$type}, value={$value})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function parseExpirationDate(string $date): ?string
|
||||
{
|
||||
$date = trim($date);
|
||||
|
||||
if ($date === '' || str_starts_with($date, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $date . ' 23:59:59';
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
$timestamp = strtotime($date);
|
||||
|
||||
if ($timestamp === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
|
||||
private function isPromotionActive(?string $expirationDate): bool
|
||||
{
|
||||
if ($expirationDate === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$expirationTimestamp = strtotime($expirationDate);
|
||||
|
||||
if ($expirationTimestamp === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $expirationTimestamp > time();
|
||||
}
|
||||
}
|
||||
464
scripts/whmcs-migrate/src/Phases/Phase7Orders.php
Normal file
464
scripts/whmcs-migrate/src/Phases/Phase7Orders.php
Normal file
@@ -0,0 +1,464 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate\Phases;
|
||||
|
||||
use Throwable;
|
||||
use WhmcsMigrate\ProgressTracker;
|
||||
use WhmcsMigrate\StatusMapper;
|
||||
|
||||
final class Phase7Orders extends AbstractPhase
|
||||
{
|
||||
/**
|
||||
* WHMCS order status -> EZSCALE order status mapping.
|
||||
*/
|
||||
private const array STATUS_MAP = [
|
||||
'Active' => 'completed',
|
||||
'Pending' => 'pending',
|
||||
'Fraud' => 'cancelled',
|
||||
'Cancelled' => 'cancelled',
|
||||
'Suspended' => 'cancelled',
|
||||
];
|
||||
|
||||
public function getPhaseNumber(): int
|
||||
{
|
||||
return 7;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Orders → Orders';
|
||||
}
|
||||
|
||||
public function validate(): bool
|
||||
{
|
||||
if ($this->isExportOnly()) {
|
||||
return $this->validateApi();
|
||||
}
|
||||
|
||||
if ($this->isFromExport()) {
|
||||
return $this->validateDb();
|
||||
}
|
||||
|
||||
return $this->validateDb() && $this->validateApi();
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
if (! $this->isExportOnly() && $this->shouldSkip()) {
|
||||
$this->logger->info('Phase 7 already complete, skipping');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->section('Phase 7: ' . $this->getName());
|
||||
|
||||
if ($this->isExportOnly()) {
|
||||
$this->runExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isFromExport()) {
|
||||
$this->runFromExport();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runFromApi();
|
||||
}
|
||||
|
||||
private function runExport(): void
|
||||
{
|
||||
$this->logger->info('Exporting orders from WHMCS API...');
|
||||
$this->exportManager->resetExport('orders');
|
||||
|
||||
$probeResponse = $this->api->call('GetOrders', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
$totalOrders = (int) ($probeResponse['totalresults'] ?? 0);
|
||||
|
||||
$this->logger->info("Found {$totalOrders} orders in WHMCS");
|
||||
|
||||
if ($totalOrders === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$batchSize = $this->getBatchSize();
|
||||
$offset = 0;
|
||||
$exported = 0;
|
||||
$tracker = new ProgressTracker('Exporting orders', $totalOrders);
|
||||
|
||||
while ($offset < $totalOrders) {
|
||||
$response = $this->api->call('GetOrders', [
|
||||
'limitstart' => $offset,
|
||||
'limitnum' => $batchSize,
|
||||
]);
|
||||
|
||||
$orders = $response['orders']['order'] ?? [];
|
||||
|
||||
if (empty($orders)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$this->exportManager->writeBatch('orders', $orders);
|
||||
$exported += count($orders);
|
||||
$offset += count($orders);
|
||||
|
||||
$tracker->setCurrent($offset);
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->logger->info("Exported {$exported} order records to JSONL");
|
||||
}
|
||||
|
||||
private function runFromExport(): void
|
||||
{
|
||||
if (! $this->exportManager->hasExport('orders')) {
|
||||
$this->logger->error('No orders export found. Run --export-only first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markStarted();
|
||||
|
||||
$totalOrders = $this->exportManager->countRecords('orders');
|
||||
$this->logger->info("Found {$totalOrders} order records in export");
|
||||
|
||||
if ($totalOrders === 0) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$progress = $this->state->getProgress($this->getPhaseKey());
|
||||
$processedCount = (int) ($progress['offset'] ?? 0);
|
||||
$count = (int) ($progress['count'] ?? 0);
|
||||
$errors = (int) ($progress['errors'] ?? 0);
|
||||
|
||||
if ($processedCount > 0) {
|
||||
$this->logger->info("Resuming from record {$processedCount} ({$count} migrated, {$errors} errors so far)");
|
||||
}
|
||||
|
||||
$tracker = new ProgressTracker('Importing orders', $totalOrders);
|
||||
$tracker->setCurrent($processedCount);
|
||||
|
||||
$currentIndex = 0;
|
||||
|
||||
foreach ($this->exportManager->readRecords('orders') as $order) {
|
||||
$currentIndex++;
|
||||
|
||||
if ($currentIndex <= $processedCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$whmcsOrderId = (int) ($order['id'] ?? 0);
|
||||
|
||||
if ($whmcsOrderId === 0) {
|
||||
$errors++;
|
||||
$processedCount++;
|
||||
$tracker->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migrateOrder($whmcsOrderId, $order);
|
||||
|
||||
if ($result) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate order {$whmcsOrderId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$processedCount++;
|
||||
$tracker->advance();
|
||||
|
||||
if ($processedCount % 100 === 0) {
|
||||
$this->state->setProgress($this->getPhaseKey(), 'running', $processedCount, $count, $errors);
|
||||
$this->state->save();
|
||||
}
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 7 complete: {$count} orders migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
private function runFromApi(): void
|
||||
{
|
||||
$this->markStarted();
|
||||
|
||||
$probeResponse = $this->api->call('GetOrders', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
$totalOrders = (int) ($probeResponse['totalresults'] ?? 0);
|
||||
|
||||
$this->logger->info("Found {$totalOrders} orders in WHMCS");
|
||||
|
||||
if ($totalOrders === 0) {
|
||||
$this->markComplete(0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$progress = $this->state->getProgress($this->getPhaseKey());
|
||||
$offset = (int) ($progress['offset'] ?? 0);
|
||||
$count = (int) ($progress['count'] ?? 0);
|
||||
$errors = (int) ($progress['errors'] ?? 0);
|
||||
$batchSize = $this->getBatchSize();
|
||||
|
||||
if ($offset > 0) {
|
||||
$this->logger->info("Resuming from offset {$offset} ({$count} migrated, {$errors} errors so far)");
|
||||
}
|
||||
|
||||
$tracker = new ProgressTracker('Migrating orders', $totalOrders);
|
||||
$tracker->setCurrent($offset);
|
||||
|
||||
while ($offset < $totalOrders) {
|
||||
$this->logger->debug("Fetching orders batch: offset={$offset}, limit={$batchSize}");
|
||||
|
||||
$response = $this->api->call('GetOrders', [
|
||||
'limitstart' => $offset,
|
||||
'limitnum' => $batchSize,
|
||||
]);
|
||||
|
||||
$orders = $response['orders']['order'] ?? [];
|
||||
|
||||
if (empty($orders)) {
|
||||
$this->logger->debug('No more orders returned, ending pagination');
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$whmcsOrderId = (int) ($order['id'] ?? 0);
|
||||
|
||||
if ($whmcsOrderId === 0) {
|
||||
$this->logger->warning('Skipping order with no ID', ['order' => $order]);
|
||||
$errors++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migrateOrder($whmcsOrderId, $order);
|
||||
|
||||
if ($result) {
|
||||
$count++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->logger->error("Failed to migrate order {$whmcsOrderId}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$tracker->advance();
|
||||
}
|
||||
|
||||
$offset += count($orders);
|
||||
$this->state->setProgress($this->getPhaseKey(), 'running', $offset, $count, $errors);
|
||||
$this->state->save();
|
||||
|
||||
$this->logger->info("Batch complete: {$count} migrated, {$errors} errors, offset now {$offset}/{$totalOrders}");
|
||||
}
|
||||
|
||||
$tracker->finish();
|
||||
$this->markComplete($count, $errors);
|
||||
$this->logger->info("Phase 7 complete: {$count} orders migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single WHMCS order.
|
||||
*/
|
||||
private function migrateOrder(int $whmcsOrderId, array $orderData): bool
|
||||
{
|
||||
if ($this->state->getMapping('orders', $whmcsOrderId) !== null) {
|
||||
$this->logger->debug("Order {$whmcsOrderId} already mapped, skipping");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$whmcsUserId = (int) ($orderData['userid'] ?? 0);
|
||||
|
||||
if ($whmcsUserId === 0) {
|
||||
$this->logger->warning("Order {$whmcsOrderId} has no userid, skipping");
|
||||
$this->logSkipped('order', "No userid for order {$whmcsOrderId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$ezscaleUserId = $this->state->getMapping('clients', $whmcsUserId);
|
||||
|
||||
if ($ezscaleUserId === null) {
|
||||
$this->logger->warning("Order {$whmcsOrderId} belongs to unmapped client {$whmcsUserId}, skipping");
|
||||
$this->logSkipped('order', "Unmapped client {$whmcsUserId} for order {$whmcsOrderId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$orderNum = trim((string) ($orderData['ordernum'] ?? ''));
|
||||
$orderNumber = $orderNum !== '' ? "WHMCS-{$orderNum}" : "WHMCS-ORD-{$whmcsOrderId}";
|
||||
|
||||
if ($this->db->tableHasRow('orders', ['order_number' => $orderNumber])) {
|
||||
$this->logger->warning("Order with order_number '{$orderNumber}' already exists in DB, skipping");
|
||||
$this->logSkipped('order', "Duplicate order_number {$orderNumber}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$planId = $this->resolvePlanId($orderData);
|
||||
|
||||
$whmcsInvoiceId = (int) ($orderData['invoiceid'] ?? 0);
|
||||
$ezscaleInvoiceId = $whmcsInvoiceId > 0
|
||||
? $this->state->getMapping('invoices', $whmcsInvoiceId)
|
||||
: null;
|
||||
|
||||
$whmcsStatus = (string) ($orderData['status'] ?? 'Pending');
|
||||
$status = self::STATUS_MAP[$whmcsStatus] ?? 'pending';
|
||||
|
||||
$total = (string) ($orderData['amount'] ?? '0.00');
|
||||
$gateway = StatusMapper::mapGateway((string) ($orderData['paymentmethod'] ?? ''));
|
||||
$notes = $this->nullIfEmpty((string) ($orderData['notes'] ?? ''));
|
||||
$adminNotes = "Imported from WHMCS. Order ID: {$whmcsOrderId}" . ($notes !== null ? ". Notes: {$notes}" : '');
|
||||
$createdAt = $this->parseDate((string) ($orderData['date'] ?? ''));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$isCompleted = $status === 'completed';
|
||||
$isCancelled = $status === 'cancelled';
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->logger->info("[DRY RUN] Would create order for WHMCS order {$whmcsOrderId}: user={$ezscaleUserId}, total={$total}, status={$status}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$newOrderId = $this->db->insert('orders', [
|
||||
'user_id' => $ezscaleUserId,
|
||||
'plan_id' => $planId,
|
||||
'invoice_id' => $ezscaleInvoiceId,
|
||||
'service_id' => null,
|
||||
'order_number' => $orderNumber,
|
||||
'status' => $status,
|
||||
'total' => $total,
|
||||
'currency' => 'USD',
|
||||
'payment_gateway' => $gateway,
|
||||
'configuration' => null,
|
||||
'admin_notes' => mb_strlen($adminNotes) > 65535 ? mb_substr($adminNotes, 0, 65532) . '...' : $adminNotes,
|
||||
'completed_at' => $isCompleted ? ($createdAt ?? $now) : null,
|
||||
'cancelled_at' => $isCancelled ? $now : null,
|
||||
'created_at' => $createdAt ?? $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->state->setMapping('orders', $whmcsOrderId, $newOrderId);
|
||||
|
||||
$this->logger->debug("Migrated order {$whmcsOrderId} → order {$newOrderId} (user={$ezscaleUserId}, total={$total}, status={$status})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function resolvePlanId(array $orderData): int
|
||||
{
|
||||
$lineItems = $orderData['lineitems']['lineitem'] ?? [];
|
||||
|
||||
if (is_array($lineItems)) {
|
||||
foreach ($lineItems as $item) {
|
||||
$pid = (int) ($item['productid'] ?? $item['pid'] ?? 0);
|
||||
|
||||
if ($pid > 0) {
|
||||
$planId = $this->state->getMapping('products', $pid);
|
||||
|
||||
if ($planId !== null) {
|
||||
return $planId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fallback = $this->db->queryOne("SELECT `id` FROM `plans` LIMIT 1");
|
||||
|
||||
return $fallback !== null ? (int) $fallback['id'] : 1;
|
||||
}
|
||||
|
||||
private function validateDb(): bool
|
||||
{
|
||||
$result = $this->db->queryOne(
|
||||
"SELECT COUNT(*) AS cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table",
|
||||
['table' => 'orders'],
|
||||
);
|
||||
|
||||
if ($result === null || (int) $result['cnt'] === 0) {
|
||||
$this->logger->error('Validation failed: "orders" table does not exist');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$clientCount = $this->state->getMappingCount('clients');
|
||||
|
||||
if ($clientCount === 0) {
|
||||
$this->logger->error('Validation failed: no client mappings found — Phase 1 must run first');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function validateApi(): bool
|
||||
{
|
||||
try {
|
||||
$response = $this->api->call('GetOrders', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
|
||||
if (($response['result'] ?? '') !== 'success') {
|
||||
$this->logger->error('Validation failed: WHMCS GetOrders API returned non-success', [
|
||||
'response' => $response,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Validation failed: cannot reach WHMCS GetOrders API', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function nullIfEmpty(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
|
||||
private function parseDate(string $date): ?string
|
||||
{
|
||||
$date = trim($date);
|
||||
|
||||
if ($date === '' || str_starts_with($date, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $date . ' 00:00:00';
|
||||
}
|
||||
|
||||
$timestamp = strtotime($date);
|
||||
|
||||
if ($timestamp === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
}
|
||||
28
scripts/whmcs-migrate/src/Phases/PhaseInterface.php
Normal file
28
scripts/whmcs-migrate/src/Phases/PhaseInterface.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate\Phases;
|
||||
|
||||
interface PhaseInterface
|
||||
{
|
||||
/**
|
||||
* Execute the migration phase.
|
||||
*/
|
||||
public function run(): void;
|
||||
|
||||
/**
|
||||
* Validate prerequisites before running.
|
||||
*/
|
||||
public function validate(): bool;
|
||||
|
||||
/**
|
||||
* Human-readable name for this phase.
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Numeric order of this phase (1-6).
|
||||
*/
|
||||
public function getPhaseNumber(): int;
|
||||
}
|
||||
162
scripts/whmcs-migrate/src/ProgressTracker.php
Normal file
162
scripts/whmcs-migrate/src/ProgressTracker.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate;
|
||||
|
||||
/**
|
||||
* Tracks progress of long-running operations and renders a progress bar to the console.
|
||||
*/
|
||||
final class ProgressTracker
|
||||
{
|
||||
private int $total;
|
||||
|
||||
private int $current = 0;
|
||||
|
||||
private float $startTime;
|
||||
|
||||
private string $label;
|
||||
|
||||
private int $barWidth = 40;
|
||||
|
||||
private int $lastRenderedPercent = -1;
|
||||
|
||||
public function __construct(string $label, int $total)
|
||||
{
|
||||
$this->label = $label;
|
||||
$this->total = max($total, 1);
|
||||
$this->startTime = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance progress by the given amount.
|
||||
*/
|
||||
public function advance(int $amount = 1): void
|
||||
{
|
||||
$this->current += $amount;
|
||||
$this->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current progress value.
|
||||
*/
|
||||
public function setCurrent(int $current): void
|
||||
{
|
||||
$this->current = $current;
|
||||
$this->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the progress as complete and print a final summary line.
|
||||
*/
|
||||
public function finish(): void
|
||||
{
|
||||
$this->current = $this->total;
|
||||
$this->render(force: true);
|
||||
fprintf(STDOUT, "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the progress bar to STDOUT.
|
||||
*
|
||||
* Only re-renders when the percentage changes (to avoid excessive terminal writes).
|
||||
*/
|
||||
private function render(bool $force = false): void
|
||||
{
|
||||
$percent = min(100, (int) round(($this->current / $this->total) * 100));
|
||||
|
||||
// Only re-render when the percentage changes or forced
|
||||
if (! $force && $percent === $this->lastRenderedPercent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->lastRenderedPercent = $percent;
|
||||
|
||||
$filled = (int) round(($percent / 100) * $this->barWidth);
|
||||
$empty = $this->barWidth - $filled;
|
||||
|
||||
$bar = str_repeat('=', max(0, $filled - 1));
|
||||
|
||||
if ($filled > 0) {
|
||||
$bar .= '>';
|
||||
}
|
||||
|
||||
$bar .= str_repeat(' ', $empty);
|
||||
|
||||
// Calculate ETA
|
||||
$elapsed = microtime(true) - $this->startTime;
|
||||
$eta = '';
|
||||
$rate = '';
|
||||
|
||||
if ($this->current > 0 && $elapsed > 0) {
|
||||
$recordsPerSecond = $this->current / $elapsed;
|
||||
$remaining = $this->total - $this->current;
|
||||
$etaSeconds = $recordsPerSecond > 0 ? $remaining / $recordsPerSecond : 0;
|
||||
|
||||
$rate = sprintf('%.0f/s', $recordsPerSecond);
|
||||
$eta = $this->formatDuration((int) $etaSeconds);
|
||||
}
|
||||
|
||||
$line = sprintf(
|
||||
"\r %s [%s] %3d%% %s/%s %s%s",
|
||||
str_pad($this->label, 20),
|
||||
$bar,
|
||||
$percent,
|
||||
number_format($this->current),
|
||||
number_format($this->total),
|
||||
$rate !== '' ? "({$rate})" : '',
|
||||
$eta !== '' ? " ETA: {$eta}" : '',
|
||||
);
|
||||
|
||||
// Pad to clear previous line remnants
|
||||
$line = str_pad($line, 120);
|
||||
|
||||
fprintf(STDOUT, "%s", $line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in seconds to a human-readable string.
|
||||
*/
|
||||
private function formatDuration(int $seconds): string
|
||||
{
|
||||
if ($seconds <= 0) {
|
||||
return '< 1s';
|
||||
}
|
||||
|
||||
if ($seconds < 60) {
|
||||
return "{$seconds}s";
|
||||
}
|
||||
|
||||
$minutes = (int) floor($seconds / 60);
|
||||
$remainingSeconds = $seconds % 60;
|
||||
|
||||
if ($minutes < 60) {
|
||||
return "{$minutes}m {$remainingSeconds}s";
|
||||
}
|
||||
|
||||
$hours = (int) floor($minutes / 60);
|
||||
$remainingMinutes = $minutes % 60;
|
||||
|
||||
return "{$hours}h {$remainingMinutes}m";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the elapsed time since the tracker was started.
|
||||
*/
|
||||
public function getElapsed(): string
|
||||
{
|
||||
$elapsed = (int) (microtime(true) - $this->startTime);
|
||||
|
||||
return $this->formatDuration($elapsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the records per second rate.
|
||||
*/
|
||||
public function getRate(): float
|
||||
{
|
||||
$elapsed = microtime(true) - $this->startTime;
|
||||
|
||||
return $elapsed > 0 ? $this->current / $elapsed : 0;
|
||||
}
|
||||
}
|
||||
160
scripts/whmcs-migrate/src/StateManager.php
Normal file
160
scripts/whmcs-migrate/src/StateManager.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate;
|
||||
|
||||
final class StateManager
|
||||
{
|
||||
private string $stateDir;
|
||||
|
||||
/** @var array<string, array<string|int, int>> Entity type => [whmcsId => ezscaleId] */
|
||||
private array $mappings = [];
|
||||
|
||||
/** @var array<string, array{status: string, offset: int, count: int, errors: int}> */
|
||||
private array $progress = [];
|
||||
|
||||
public function __construct(string $stateDir)
|
||||
{
|
||||
$this->stateDir = $stateDir;
|
||||
|
||||
if (! is_dir($stateDir)) {
|
||||
mkdir($stateDir, 0755, true);
|
||||
}
|
||||
|
||||
$this->load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the EZSCALE ID mapped to a WHMCS ID for a given entity type.
|
||||
*/
|
||||
public function getMapping(string $entity, int|string $whmcsId): ?int
|
||||
{
|
||||
$key = (string) $whmcsId;
|
||||
|
||||
return $this->mappings[$entity][$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a WHMCS-to-EZSCALE ID mapping.
|
||||
*/
|
||||
public function setMapping(string $entity, int|string $whmcsId, int $ezscaleId): void
|
||||
{
|
||||
$key = (string) $whmcsId;
|
||||
$this->mappings[$entity][$key] = $ezscaleId;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mappings for a given entity type.
|
||||
*
|
||||
* @return array<string|int, int>
|
||||
*/
|
||||
public function getAllMappings(string $entity): array
|
||||
{
|
||||
return $this->mappings[$entity] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of mappings for a given entity type.
|
||||
*/
|
||||
public function getMappingCount(string $entity): int
|
||||
{
|
||||
return count($this->mappings[$entity] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the progress record for a phase.
|
||||
*
|
||||
* @return array{status: string, offset: int, count: int, errors: int}
|
||||
*/
|
||||
public function getProgress(string $phase): array
|
||||
{
|
||||
return $this->progress[$phase] ?? [
|
||||
'status' => 'pending',
|
||||
'offset' => 0,
|
||||
'count' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the progress record for a phase.
|
||||
*/
|
||||
public function setProgress(string $phase, string $status, int $offset = 0, int $count = 0, int $errors = 0): void
|
||||
{
|
||||
$this->progress[$phase] = [
|
||||
'status' => $status,
|
||||
'offset' => $offset,
|
||||
'count' => $count,
|
||||
'errors' => $errors,
|
||||
];
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a phase has been marked complete.
|
||||
*/
|
||||
public function isPhaseComplete(string $phase): bool
|
||||
{
|
||||
return ($this->progress[$phase]['status'] ?? 'pending') === 'complete';
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all state files and reset in-memory state.
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->mappings = [];
|
||||
$this->progress = [];
|
||||
|
||||
$mappingsFile = $this->stateDir . '/id_mappings.json';
|
||||
$progressFile = $this->stateDir . '/progress.json';
|
||||
|
||||
if (file_exists($mappingsFile)) {
|
||||
unlink($mappingsFile);
|
||||
}
|
||||
|
||||
if (file_exists($progressFile)) {
|
||||
unlink($progressFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist current state to disk (JSON files).
|
||||
*/
|
||||
public function save(): void
|
||||
{
|
||||
file_put_contents(
|
||||
$this->stateDir . '/id_mappings.json',
|
||||
json_encode($this->mappings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
|
||||
);
|
||||
|
||||
file_put_contents(
|
||||
$this->stateDir . '/progress.json',
|
||||
json_encode($this->progress, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load state from disk if files exist.
|
||||
*/
|
||||
private function load(): void
|
||||
{
|
||||
$mappingsFile = $this->stateDir . '/id_mappings.json';
|
||||
if (file_exists($mappingsFile)) {
|
||||
$data = json_decode(file_get_contents($mappingsFile), true);
|
||||
if (is_array($data)) {
|
||||
$this->mappings = $data;
|
||||
}
|
||||
}
|
||||
|
||||
$progressFile = $this->stateDir . '/progress.json';
|
||||
if (file_exists($progressFile)) {
|
||||
$data = json_decode(file_get_contents($progressFile), true);
|
||||
if (is_array($data)) {
|
||||
$this->progress = $data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
163
scripts/whmcs-migrate/src/StatusMapper.php
Normal file
163
scripts/whmcs-migrate/src/StatusMapper.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate;
|
||||
|
||||
final class StatusMapper
|
||||
{
|
||||
/**
|
||||
* Map WHMCS client status to EZSCALE user status.
|
||||
*/
|
||||
public static function mapClientStatus(string $whmcsStatus): string
|
||||
{
|
||||
return match ($whmcsStatus) {
|
||||
'Active' => 'active',
|
||||
'Inactive' => 'suspended',
|
||||
'Closed' => 'banned',
|
||||
default => 'suspended',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map WHMCS service/hosting status to EZSCALE service status.
|
||||
*/
|
||||
public static function mapServiceStatus(string $whmcsStatus): string
|
||||
{
|
||||
return match ($whmcsStatus) {
|
||||
'Active' => 'active',
|
||||
'Suspended' => 'suspended',
|
||||
'Terminated' => 'terminated',
|
||||
'Cancelled' => 'terminated',
|
||||
'Pending' => 'pending',
|
||||
default => 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map WHMCS invoice status to EZSCALE invoice status.
|
||||
*/
|
||||
public static function mapInvoiceStatus(string $whmcsStatus): string
|
||||
{
|
||||
return match ($whmcsStatus) {
|
||||
'Paid' => 'paid',
|
||||
'Unpaid' => 'pending',
|
||||
'Overdue' => 'overdue',
|
||||
'Cancelled' => 'void',
|
||||
'Refunded' => 'refunded',
|
||||
'Draft' => 'draft',
|
||||
default => 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map WHMCS billing cycle to EZSCALE billing cycle.
|
||||
*/
|
||||
public static function mapBillingCycle(string $whmcsCycle): string
|
||||
{
|
||||
return match ($whmcsCycle) {
|
||||
'Monthly' => 'monthly',
|
||||
'Quarterly' => 'quarterly',
|
||||
'Semi-Annually' => 'semi_annual',
|
||||
'Annually' => 'annual',
|
||||
'Biennially' => 'annual',
|
||||
'Triennially' => 'annual',
|
||||
'Free Account' => 'monthly',
|
||||
'One Time' => 'monthly',
|
||||
default => 'monthly',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map WHMCS service status to Stripe subscription status.
|
||||
*/
|
||||
public static function mapStripeStatus(string $whmcsServiceStatus): string
|
||||
{
|
||||
return match ($whmcsServiceStatus) {
|
||||
'Active' => 'active',
|
||||
'Suspended' => 'past_due',
|
||||
'Terminated' => 'canceled',
|
||||
'Cancelled' => 'canceled',
|
||||
'Pending' => 'incomplete',
|
||||
default => 'incomplete',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map WHMCS payment gateway module name to EZSCALE gateway identifier.
|
||||
*/
|
||||
public static function mapGateway(string $whmcsGateway): string
|
||||
{
|
||||
$normalized = strtolower(trim($whmcsGateway));
|
||||
|
||||
return match (true) {
|
||||
str_contains($normalized, 'paypal') => 'paypal',
|
||||
str_contains($normalized, 'stripe') => 'stripe',
|
||||
str_contains($normalized, 'bank') => 'stripe',
|
||||
str_contains($normalized, 'transfer') => 'stripe',
|
||||
str_contains($normalized, 'credit') => 'stripe',
|
||||
$normalized === '' => 'stripe',
|
||||
default => 'stripe',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristically map a WHMCS product group name (and optionally product name) to an EZSCALE service type.
|
||||
*/
|
||||
public static function mapServiceType(string $groupName, string $productName = ''): string
|
||||
{
|
||||
// Try group name first
|
||||
$normalized = strtolower(trim($groupName));
|
||||
|
||||
if ($normalized !== '' && $normalized !== 'none') {
|
||||
return match (true) {
|
||||
str_contains($normalized, 'mysql') => 'mysql',
|
||||
str_contains($normalized, 'veeam') || str_contains($normalized, 'backup') => 'backups',
|
||||
str_contains($normalized, 'vps') || str_contains($normalized, 'cloud') || str_contains($normalized, 'virtual') => 'vps',
|
||||
str_contains($normalized, 'dedicated') => 'dedicated',
|
||||
str_contains($normalized, 'web') || str_contains($normalized, 'hosting') || str_contains($normalized, 'cpanel') => 'hosting',
|
||||
str_contains($normalized, 'game') || str_contains($normalized, 'minecraft') || str_contains($normalized, 'rust') => 'game_server',
|
||||
default => 'vps',
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to product name heuristics
|
||||
$name = strtolower(trim($productName));
|
||||
|
||||
return match (true) {
|
||||
// MySQL hosting
|
||||
str_contains($name, 'mysql') => 'mysql',
|
||||
// Backup services (Veeam, etc.)
|
||||
str_contains($name, 'veeam') || str_contains($name, 'backup') => 'backups',
|
||||
// Dedicated servers: Dell, HP, Storage Server with bay counts
|
||||
str_contains($name, 'dell r') || str_contains($name, 'hp dl') || str_contains($name, 'storage server') => 'dedicated',
|
||||
// VPS products
|
||||
str_contains($name, 'vps') || str_contains($name, 'cloud') || str_contains($name, 'virtual') => 'vps',
|
||||
// Game server related
|
||||
str_contains($name, 'battlefield') || str_contains($name, 'procon') || str_contains($name, 'game') || str_contains($name, 'minecraft') => 'game_server',
|
||||
// Managed dedicated
|
||||
str_contains($name, 'managed dedicated') => 'dedicated',
|
||||
// Web hosting tiers
|
||||
str_contains($name, 'hosting') || str_contains($name, 'cpanel') => 'hosting',
|
||||
default => 'other',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an EZSCALE service type to its provisioning platform.
|
||||
*
|
||||
* Returns null for service types that have no auto-provisioning platform.
|
||||
*/
|
||||
public static function mapPlatform(string $serviceType): ?string
|
||||
{
|
||||
return match ($serviceType) {
|
||||
'vps' => 'virtfusion',
|
||||
'dedicated' => 'synergycp',
|
||||
'hosting' => 'enhance',
|
||||
'game_server' => 'pterodactyl',
|
||||
'mysql' => null,
|
||||
'backups' => null,
|
||||
default => 'virtfusion',
|
||||
};
|
||||
}
|
||||
}
|
||||
182
scripts/whmcs-migrate/src/Validator.php
Normal file
182
scripts/whmcs-migrate/src/Validator.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WhmcsMigrate;
|
||||
|
||||
final class Validator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Database $db,
|
||||
private readonly Logger $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate an array of client records from WHMCS.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $clients
|
||||
*
|
||||
* @return array{valid: int, skipped: int, errors: list<string>}
|
||||
*/
|
||||
public function validateClientsData(array $clients): array
|
||||
{
|
||||
$valid = 0;
|
||||
$skipped = 0;
|
||||
$errors = [];
|
||||
$seenEmails = [];
|
||||
|
||||
foreach ($clients as $index => $client) {
|
||||
$clientId = $client['id'] ?? $client['userid'] ?? "index:{$index}";
|
||||
|
||||
// Check required fields
|
||||
if (empty($client['email'])) {
|
||||
$errors[] = "Client #{$clientId}: missing email address.";
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($client['firstname'])) {
|
||||
$errors[] = "Client #{$clientId}: missing first name.";
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for duplicate emails in source data
|
||||
$email = strtolower(trim($client['email']));
|
||||
if (isset($seenEmails[$email])) {
|
||||
$errors[] = "Client #{$clientId}: duplicate email '{$email}' (first seen in client #{$seenEmails[$email]}).";
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$seenEmails[$email] = $clientId;
|
||||
$valid++;
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $valid,
|
||||
'skipped' => $skipped,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an array of product records from WHMCS.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $products
|
||||
*
|
||||
* @return array{valid: int, skipped: int, errors: list<string>}
|
||||
*/
|
||||
public function validateProductsData(array $products): array
|
||||
{
|
||||
$valid = 0;
|
||||
$skipped = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($products as $index => $product) {
|
||||
$productId = $product['pid'] ?? $product['id'] ?? "index:{$index}";
|
||||
|
||||
if (empty($product['name'])) {
|
||||
$errors[] = "Product #{$productId}: missing name.";
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for pricing data — at least one pricing field should be present
|
||||
$hasPricing = ! empty($product['pricing'])
|
||||
|| isset($product['monthly'])
|
||||
|| isset($product['quarterly'])
|
||||
|| isset($product['semiannually'])
|
||||
|| isset($product['annually']);
|
||||
|
||||
if (! $hasPricing) {
|
||||
$errors[] = "Product #{$productId} ({$product['name']}): no pricing data found.";
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$valid++;
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $valid,
|
||||
'skipped' => $skipped,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a full pre-flight validation by testing WHMCS API connectivity
|
||||
* and fetching basic counts.
|
||||
*/
|
||||
public function validateAll(WhmcsApi $api): bool
|
||||
{
|
||||
$this->logger->section('Pre-flight Validation');
|
||||
|
||||
$allOk = true;
|
||||
|
||||
// Test WHMCS API connectivity
|
||||
$this->logger->info('Testing WHMCS API connectivity...');
|
||||
|
||||
try {
|
||||
$result = $api->call('GetClients', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
$totalClients = (int) ($result['totalresults'] ?? 0);
|
||||
$this->logger->info("WHMCS API connected. Total clients: {$totalClients}");
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->logger->error('WHMCS API connection failed: ' . $e->getMessage());
|
||||
$allOk = false;
|
||||
}
|
||||
|
||||
// Test product listing
|
||||
try {
|
||||
$result = $api->call('GetProducts', ['limitstart' => 0, 'limitnum' => 1]);
|
||||
$totalProducts = (int) ($result['totalresults'] ?? 0);
|
||||
$this->logger->info("WHMCS products found: {$totalProducts}");
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->logger->error('WHMCS GetProducts failed: ' . $e->getMessage());
|
||||
$allOk = false;
|
||||
}
|
||||
|
||||
// Test EZSCALE database connectivity
|
||||
$this->logger->info('Testing EZSCALE database connectivity...');
|
||||
|
||||
try {
|
||||
$row = $this->db->queryOne('SELECT 1 AS ok');
|
||||
|
||||
if ($row !== null && ($row['ok'] ?? 0) === 1) {
|
||||
$this->logger->info('EZSCALE database connected.');
|
||||
} else {
|
||||
$this->logger->error('EZSCALE database query returned unexpected result.');
|
||||
$allOk = false;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('EZSCALE database connection failed: ' . $e->getMessage());
|
||||
$allOk = false;
|
||||
}
|
||||
|
||||
// Verify essential tables exist
|
||||
$requiredTables = ['users', 'user_profiles', 'plans', 'plan_prices', 'subscriptions', 'subscription_items', 'services', 'invoices', 'invoice_items', 'payment_transactions', 'coupons', 'roles', 'model_has_roles'];
|
||||
foreach ($requiredTables as $table) {
|
||||
try {
|
||||
$this->db->query("SELECT 1 FROM `{$table}` LIMIT 0");
|
||||
$this->logger->debug("Table '{$table}' exists.");
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error("Required table '{$table}' is missing or inaccessible.");
|
||||
$allOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($allOk) {
|
||||
$this->logger->info('All pre-flight checks passed.');
|
||||
} else {
|
||||
$this->logger->error('Some pre-flight checks failed. Resolve issues before running the migration.');
|
||||
}
|
||||
|
||||
return $allOk;
|
||||
}
|
||||
}
|
||||
146
scripts/whmcs-migrate/src/WhmcsApi.php
Normal file
146
scripts/whmcs-migrate/src/WhmcsApi.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user