Files
website/scripts/whmcs-migrate/src/Phases/Phase6Coupons.php
Claude Dev b4ef90465c feat: complete pre-launch audit — frontend polish, churn prevention, login history, financial reports, configurable checkout
Includes all work from phases 6-9+ and frontend polish rounds 1 & 2:

- Login history with device trust, new device notifications, session management
- Churn prevention: cancellation surveys, winback campaigns with email sequences
- Financial reports: revenue, P&L, tax, aging, refund, subscription reports with PDF/CSV/JSON export
- Configurable checkout: plan config groups/options, build-your-own VPS
- Frontend polish: fix broken legal links, add SEO meta tags, favicon, font display=swap,
  Head titles on all 14 marketing pages, mobile responsive fixes, AuthLayout legal footer,
  remove false 24/7 claims, hide empty stats, correct uptime SLA to 99.9%,
  GameServers notify buttons linked to /contact, 301 redirects for /terms and /privacy
- WHMCS migration scripts
- Update legal page effective dates to March 16, 2026

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:39:25 -04:00

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