feat: add MigrateVpsPlans command for customer migration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
100
website/app/Console/Commands/MigrateVpsPlans.php
Normal file
100
website/app/Console/Commands/MigrateVpsPlans.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Plan;
|
||||||
|
use App\Models\Service;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Laravel\Cashier\Subscription;
|
||||||
|
|
||||||
|
class MigrateVpsPlans extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'plans:migrate-vps {--dry-run : Preview changes without applying}';
|
||||||
|
|
||||||
|
protected $description = 'Migrate existing subscriptions and services from old VPS plans to new plan IDs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps old plan slugs to new plan slugs.
|
||||||
|
* Only covers plans that exist in the billing system's plans table.
|
||||||
|
* VirtFusion-only packages (Dev Starter, VPS-3-Custom, Base Package, RAM Optimized)
|
||||||
|
* are handled manually via the VirtFusion admin panel.
|
||||||
|
*/
|
||||||
|
private const MIGRATION_MAP = [
|
||||||
|
'vps-nano' => 'vps-1',
|
||||||
|
'vps-micro' => 'vps-2',
|
||||||
|
'vps-mini' => 'vps-2',
|
||||||
|
'vps-standard' => 'vps-8',
|
||||||
|
'vps-plus' => 'vps-16',
|
||||||
|
'vps-pro' => 'vps-32',
|
||||||
|
'vps-storage-500' => 'stor-500',
|
||||||
|
'vps-storage-1tb' => 'stor-1tb',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->info('DRY RUN — no changes will be applied.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Migrating VPS subscriptions and services to new plan IDs...');
|
||||||
|
|
||||||
|
$migrated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach (self::MIGRATION_MAP as $oldSlug => $newSlug) {
|
||||||
|
$oldPlan = Plan::where('slug', $oldSlug)->first();
|
||||||
|
$newPlan = Plan::where('slug', $newSlug)->first();
|
||||||
|
|
||||||
|
if (! $oldPlan || ! $newPlan) {
|
||||||
|
$this->warn("Skipping {$oldSlug} → {$newSlug}: one or both plans not found.");
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate subscriptions
|
||||||
|
$subscriptions = Subscription::query()->where('plan_id', $oldPlan->id)->get();
|
||||||
|
|
||||||
|
foreach ($subscriptions as $subscription) {
|
||||||
|
$this->line(" Subscription #{$subscription->id} (user {$subscription->user_id}): {$oldSlug} → {$newSlug}");
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$subscription->update(['plan_id' => $newPlan->id]);
|
||||||
|
|
||||||
|
Log::info('Migrated subscription to new plan', [
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'user_id' => $subscription->user_id,
|
||||||
|
'old_plan' => $oldSlug,
|
||||||
|
'new_plan' => $newSlug,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate services
|
||||||
|
$services = Service::query()->where('plan_id', $oldPlan->id)->get();
|
||||||
|
|
||||||
|
foreach ($services as $service) {
|
||||||
|
$this->line(" Service #{$service->id}: {$oldSlug} → {$newSlug}");
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$service->update(['plan_id' => $newPlan->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$label = $dryRun ? 'would be migrated' : 'migrated';
|
||||||
|
$this->info("Migration complete: {$migrated} records {$label}, {$skipped} plan mappings skipped.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
website/tests/Feature/MigrateVpsPlansTest.php
Normal file
89
website/tests/Feature/MigrateVpsPlansTest.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Plan;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
it('migrates subscriptions from old plans to new plans', function () {
|
||||||
|
$this->seed(\Database\Seeders\PlanSeeder::class);
|
||||||
|
|
||||||
|
$oldPlan = Plan::create([
|
||||||
|
'name' => 'VPS Nano',
|
||||||
|
'slug' => 'vps-nano',
|
||||||
|
'description' => 'Old nano plan',
|
||||||
|
'service_type' => 'vps',
|
||||||
|
'price' => 3.50,
|
||||||
|
'billing_cycle' => 'monthly',
|
||||||
|
'features' => [],
|
||||||
|
'sort_order' => 99,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newPlan = Plan::where('slug', 'vps-1')->first();
|
||||||
|
|
||||||
|
if (! $newPlan) {
|
||||||
|
$this->markTestSkipped('New plan vps-1 not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
DB::table('subscriptions')->insert([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'type' => 'vps-nano',
|
||||||
|
'stripe_id' => 'sub_test_'.uniqid(),
|
||||||
|
'stripe_status' => 'active',
|
||||||
|
'plan_id' => $oldPlan->id,
|
||||||
|
'billing_cycle' => 'monthly',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('plans:migrate-vps')
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$subscription = DB::table('subscriptions')->where('user_id', $user->id)->first();
|
||||||
|
expect($subscription->plan_id)->toBe($newPlan->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dry run does not modify data', function () {
|
||||||
|
$this->seed(\Database\Seeders\PlanSeeder::class);
|
||||||
|
|
||||||
|
$oldPlan = Plan::create([
|
||||||
|
'name' => 'VPS Nano',
|
||||||
|
'slug' => 'vps-nano',
|
||||||
|
'description' => 'Old nano plan',
|
||||||
|
'service_type' => 'vps',
|
||||||
|
'price' => 3.50,
|
||||||
|
'billing_cycle' => 'monthly',
|
||||||
|
'features' => [],
|
||||||
|
'sort_order' => 99,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
DB::table('subscriptions')->insert([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'type' => 'vps-nano',
|
||||||
|
'stripe_id' => 'sub_dry_'.uniqid(),
|
||||||
|
'stripe_status' => 'active',
|
||||||
|
'plan_id' => $oldPlan->id,
|
||||||
|
'billing_cycle' => 'monthly',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('plans:migrate-vps', ['--dry-run' => true])
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$subscription = DB::table('subscriptions')->where('user_id', $user->id)->first();
|
||||||
|
expect($subscription->plan_id)->toBe($oldPlan->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips when old plan does not exist', function () {
|
||||||
|
$this->seed(\Database\Seeders\PlanSeeder::class);
|
||||||
|
|
||||||
|
// No old plans created, so all mappings should be skipped
|
||||||
|
$this->artisan('plans:migrate-vps')
|
||||||
|
->assertSuccessful();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user