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:
Claude Dev
2026-03-16 11:39:25 -04:00
parent 5be235d35e
commit b4ef90465c
187 changed files with 27317 additions and 1840 deletions

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

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

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

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

View 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}");
}
}

View 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),
];
}
}

View 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}");
}
}

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

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

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

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

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

View 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();
}
}

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

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

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

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

View 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',
};
}
}

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

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