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>
325 lines
9.4 KiB
PHP
325 lines
9.4 KiB
PHP
<?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();
|
|
}
|
|
}
|