refactor(cloud-commands): consolidate and enhance subscription management commands

- Deleted obsolete CloudCheckSubscription, CloudCleanupSubscriptions, and CloudDeleteUser commands to streamline the codebase.
- Introduced new CloudDeleteUser and CloudFixSubscription commands with improved functionality for user deletion and subscription management.
- Enhanced subscription handling with options for fixing canceled subscriptions and verifying active subscriptions against Stripe, improving overall command usability and control.
This commit is contained in:
Andras Bacsai
2025-09-23 14:56:58 +02:00
parent be9aff3cdc
commit 106682b5b8
4 changed files with 880 additions and 421 deletions

View File

@@ -0,0 +1,722 @@
<?php
namespace App\Console\Commands\Cloud;
use App\Actions\Stripe\CancelSubscription;
use App\Actions\User\DeleteUserResources;
use App\Actions\User\DeleteUserServers;
use App\Actions\User\DeleteUserTeams;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class CloudDeleteUser extends Command
{
protected $signature = 'cloud:delete-user {email}
{--dry-run : Preview what will be deleted without actually deleting}
{--skip-stripe : Skip Stripe subscription cancellation}
{--skip-resources : Skip resource deletion}';
protected $description = 'Delete a user from the cloud instance with phase-by-phase confirmation';
private bool $isDryRun = false;
private bool $skipStripe = false;
private bool $skipResources = false;
private User $user;
public function handle()
{
if (! isCloud()) {
$this->error('This command is only available on cloud instances.');
return 1;
}
$email = $this->argument('email');
$this->isDryRun = $this->option('dry-run');
$this->skipStripe = $this->option('skip-stripe');
$this->skipResources = $this->option('skip-resources');
if ($this->isDryRun) {
$this->info('🔍 DRY RUN MODE - No data will be deleted');
$this->newLine();
}
try {
$this->user = User::whereEmail($email)->firstOrFail();
} catch (\Exception $e) {
$this->error("User with email '{$email}' not found.");
return 1;
}
$this->logAction("Starting user deletion process for: {$email}");
// Phase 1: Show User Overview (outside transaction)
if (! $this->showUserOverview()) {
$this->info('User deletion cancelled.');
return 0;
}
// If not dry run, wrap everything in a transaction
if (! $this->isDryRun) {
try {
DB::beginTransaction();
// Phase 2: Delete Resources
if (! $this->skipResources) {
if (! $this->deleteResources()) {
DB::rollBack();
$this->error('User deletion failed at resource deletion phase. All changes rolled back.');
return 1;
}
}
// Phase 3: Delete Servers
if (! $this->deleteServers()) {
DB::rollBack();
$this->error('User deletion failed at server deletion phase. All changes rolled back.');
return 1;
}
// Phase 4: Handle Teams
if (! $this->handleTeams()) {
DB::rollBack();
$this->error('User deletion failed at team handling phase. All changes rolled back.');
return 1;
}
// Phase 5: Cancel Stripe Subscriptions
if (! $this->skipStripe && isCloud()) {
if (! $this->cancelStripeSubscriptions()) {
DB::rollBack();
$this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.');
return 1;
}
}
// Phase 6: Delete User Profile
if (! $this->deleteUserProfile()) {
DB::rollBack();
$this->error('User deletion failed at final phase. All changes rolled back.');
return 1;
}
// Commit the transaction
DB::commit();
$this->newLine();
$this->info('✅ User deletion completed successfully!');
$this->logAction("User deletion completed for: {$email}");
} catch (\Exception $e) {
DB::rollBack();
$this->error('An error occurred during user deletion: '.$e->getMessage());
$this->logAction("User deletion failed for {$email}: ".$e->getMessage());
return 1;
}
} else {
// Dry run mode - just run through the phases without transaction
// Phase 2: Delete Resources
if (! $this->skipResources) {
if (! $this->deleteResources()) {
$this->info('User deletion would be cancelled at resource deletion phase.');
return 0;
}
}
// Phase 3: Delete Servers
if (! $this->deleteServers()) {
$this->info('User deletion would be cancelled at server deletion phase.');
return 0;
}
// Phase 4: Handle Teams
if (! $this->handleTeams()) {
$this->info('User deletion would be cancelled at team handling phase.');
return 0;
}
// Phase 5: Cancel Stripe Subscriptions
if (! $this->skipStripe && isCloud()) {
if (! $this->cancelStripeSubscriptions()) {
$this->info('User deletion would be cancelled at Stripe cancellation phase.');
return 0;
}
}
// Phase 6: Delete User Profile
if (! $this->deleteUserProfile()) {
$this->info('User deletion would be cancelled at final phase.');
return 0;
}
$this->newLine();
$this->info('✅ DRY RUN completed successfully! No data was deleted.');
}
return 0;
}
private function showUserOverview(): bool
{
$this->info('═══════════════════════════════════════');
$this->info('PHASE 1: USER OVERVIEW');
$this->info('═══════════════════════════════════════');
$this->newLine();
$teams = $this->user->teams;
$ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner');
$memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner');
// Collect all servers from all teams
$allServers = collect();
$allApplications = collect();
$allDatabases = collect();
$allServices = collect();
$activeSubscriptions = collect();
foreach ($teams as $team) {
$servers = $team->servers;
$allServers = $allServers->merge($servers);
foreach ($servers as $server) {
$resources = $server->definedResources();
foreach ($resources as $resource) {
if ($resource instanceof \App\Models\Application) {
$allApplications->push($resource);
} elseif ($resource instanceof \App\Models\Service) {
$allServices->push($resource);
} else {
$allDatabases->push($resource);
}
}
}
if ($team->subscription && $team->subscription->stripe_subscription_id) {
$activeSubscriptions->push($team->subscription);
}
}
$this->table(
['Property', 'Value'],
[
['User', $this->user->email],
['User ID', $this->user->id],
['Created', $this->user->created_at->format('Y-m-d H:i:s')],
['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')],
['Teams (Total)', $teams->count()],
['Teams (Owner)', $ownedTeams->count()],
['Teams (Member)', $memberTeams->count()],
['Servers', $allServers->unique('id')->count()],
['Applications', $allApplications->count()],
['Databases', $allDatabases->count()],
['Services', $allServices->count()],
['Active Stripe Subscriptions', $activeSubscriptions->count()],
]
);
$this->newLine();
$this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!');
$this->newLine();
if (! $this->confirm('Do you want to continue with the deletion process?', false)) {
return false;
}
return true;
}
private function deleteResources(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 2: DELETE RESOURCES');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new DeleteUserResources($this->user, $this->isDryRun);
$resources = $action->getResourcesPreview();
if ($resources['applications']->isEmpty() &&
$resources['databases']->isEmpty() &&
$resources['services']->isEmpty()) {
$this->info('No resources to delete.');
return true;
}
$this->info('Resources to be deleted:');
$this->newLine();
if ($resources['applications']->isNotEmpty()) {
$this->warn("Applications to be deleted ({$resources['applications']->count()}):");
$this->table(
['Name', 'UUID', 'Server', 'Status'],
$resources['applications']->map(function ($app) {
return [
$app->name,
$app->uuid,
$app->destination->server->name,
$app->status ?? 'unknown',
];
})->toArray()
);
$this->newLine();
}
if ($resources['databases']->isNotEmpty()) {
$this->warn("Databases to be deleted ({$resources['databases']->count()}):");
$this->table(
['Name', 'Type', 'UUID', 'Server'],
$resources['databases']->map(function ($db) {
return [
$db->name,
class_basename($db),
$db->uuid,
$db->destination->server->name,
];
})->toArray()
);
$this->newLine();
}
if ($resources['services']->isNotEmpty()) {
$this->warn("Services to be deleted ({$resources['services']->count()}):");
$this->table(
['Name', 'UUID', 'Server'],
$resources['services']->map(function ($service) {
return [
$service->name,
$service->uuid,
$service->server->name,
];
})->toArray()
);
$this->newLine();
}
$this->error('⚠️ THIS ACTION CANNOT BE UNDONE!');
if (! $this->confirm('Are you sure you want to delete all these resources?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Deleting resources...');
$result = $action->execute();
$this->info("Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services");
$this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services");
}
return true;
}
private function deleteServers(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 3: DELETE SERVERS');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new DeleteUserServers($this->user, $this->isDryRun);
$servers = $action->getServersPreview();
if ($servers->isEmpty()) {
$this->info('No servers to delete.');
return true;
}
$this->warn("Servers to be deleted ({$servers->count()}):");
$this->table(
['ID', 'Name', 'IP', 'Description', 'Resources Count'],
$servers->map(function ($server) {
$resourceCount = $server->definedResources()->count();
return [
$server->id,
$server->name,
$server->ip,
$server->description ?? '-',
$resourceCount,
];
})->toArray()
);
$this->newLine();
$this->error('⚠️ WARNING: Deleting servers will remove all server configurations!');
if (! $this->confirm('Are you sure you want to delete all these servers?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Deleting servers...');
$result = $action->execute();
$this->info("Deleted {$result['servers']} servers");
$this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}");
}
return true;
}
private function handleTeams(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 4: HANDLE TEAMS');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new DeleteUserTeams($this->user, $this->isDryRun);
$preview = $action->getTeamsPreview();
// Check for edge cases first - EXIT IMMEDIATELY if found
if ($preview['edge_cases']->isNotEmpty()) {
$this->error('═══════════════════════════════════════');
$this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED');
$this->error('═══════════════════════════════════════');
$this->newLine();
foreach ($preview['edge_cases'] as $edgeCase) {
$team = $edgeCase['team'];
$reason = $edgeCase['reason'];
$this->error("Team: {$team->name} (ID: {$team->id})");
$this->error("Issue: {$reason}");
// Show team members for context
$this->info('Current members:');
foreach ($team->members as $member) {
$role = $member->pivot->role;
$this->line(" - {$member->name} ({$member->email}) - Role: {$role}");
}
// Check for active resources
$resourceCount = 0;
foreach ($team->servers as $server) {
$resources = $server->definedResources();
$resourceCount += $resources->count();
}
if ($resourceCount > 0) {
$this->warn(" ⚠️ This team has {$resourceCount} active resources!");
}
// Show subscription details if relevant
if ($team->subscription && $team->subscription->stripe_subscription_id) {
$this->warn(' ⚠️ Active Stripe subscription details:');
$this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}");
$this->warn(" Customer ID: {$team->subscription->stripe_customer_id}");
// Show other owners who could potentially take over
$otherOwners = $team->members
->where('id', '!=', $this->user->id)
->filter(function ($member) {
return $member->pivot->role === 'owner';
});
if ($otherOwners->isNotEmpty()) {
$this->info(' Other owners who could take over billing:');
foreach ($otherOwners as $owner) {
$this->line(" - {$owner->name} ({$owner->email})");
}
}
}
$this->newLine();
}
$this->error('Please resolve these issues manually before retrying:');
// Check if any edge case involves subscription payment issues
$hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) {
return str_contains($edgeCase['reason'], 'Stripe subscription');
});
if ($hasSubscriptionIssue) {
$this->info('For teams with subscription payment issues:');
$this->info('1. Cancel the subscription through Stripe dashboard, OR');
$this->info('2. Transfer the subscription to another owner\'s payment method, OR');
$this->info('3. Have the other owner create a new subscription after cancelling this one');
$this->newLine();
}
$hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) {
return str_contains($edgeCase['reason'], 'No suitable owner replacement');
});
if ($hasNoOwnerReplacement) {
$this->info('For teams with no suitable owner replacement:');
$this->info('1. Assign an admin role to a trusted member, OR');
$this->info('2. Transfer team resources to another team, OR');
$this->info('3. Delete the team manually if no longer needed');
$this->newLine();
}
$this->error('USER DELETION ABORTED DUE TO EDGE CASES');
$this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling");
// Exit immediately - don't proceed with deletion
if (! $this->isDryRun) {
DB::rollBack();
}
exit(1);
}
if ($preview['to_delete']->isEmpty() &&
$preview['to_transfer']->isEmpty() &&
$preview['to_leave']->isEmpty()) {
$this->info('No team changes needed.');
return true;
}
if ($preview['to_delete']->isNotEmpty()) {
$this->warn('Teams to be DELETED (user is the only member):');
$this->table(
['ID', 'Name', 'Resources', 'Subscription'],
$preview['to_delete']->map(function ($team) {
$resourceCount = 0;
foreach ($team->servers as $server) {
$resourceCount += $server->definedResources()->count();
}
$hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id
? '⚠️ YES - '.$team->subscription->stripe_subscription_id
: 'No';
return [
$team->id,
$team->name,
$resourceCount,
$hasSubscription,
];
})->toArray()
);
$this->newLine();
}
if ($preview['to_transfer']->isNotEmpty()) {
$this->warn('Teams where ownership will be TRANSFERRED:');
$this->table(
['Team ID', 'Team Name', 'New Owner', 'New Owner Email'],
$preview['to_transfer']->map(function ($item) {
return [
$item['team']->id,
$item['team']->name,
$item['new_owner']->name,
$item['new_owner']->email,
];
})->toArray()
);
$this->newLine();
}
if ($preview['to_leave']->isNotEmpty()) {
$this->warn('Teams where user will be REMOVED (other owners/admins exist):');
$userId = $this->user->id;
$this->table(
['ID', 'Name', 'User Role', 'Other Members'],
$preview['to_leave']->map(function ($team) use ($userId) {
$userRole = $team->members->where('id', $userId)->first()->pivot->role;
$otherMembers = $team->members->count() - 1;
return [
$team->id,
$team->name,
$userRole,
$otherMembers,
];
})->toArray()
);
$this->newLine();
}
$this->error('⚠️ WARNING: Team changes affect access control and ownership!');
if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Processing team changes...');
$result = $action->execute();
$this->info("Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}");
$this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}");
}
return true;
}
private function cancelStripeSubscriptions(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 5: CANCEL STRIPE SUBSCRIPTIONS');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new CancelSubscription($this->user, $this->isDryRun);
$subscriptions = $action->getSubscriptionsPreview();
if ($subscriptions->isEmpty()) {
$this->info('No Stripe subscriptions to cancel.');
return true;
}
$this->info('Stripe subscriptions to cancel:');
$this->newLine();
$totalMonthlyValue = 0;
foreach ($subscriptions as $subscription) {
$team = $subscription->team;
$planId = $subscription->stripe_plan_id;
// Try to get the price from config
$monthlyValue = $this->getSubscriptionMonthlyValue($planId);
$totalMonthlyValue += $monthlyValue;
$this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})");
if ($monthlyValue > 0) {
$this->line(" Monthly value: \${$monthlyValue}");
}
if ($subscription->stripe_cancel_at_period_end) {
$this->line(' ⚠️ Already set to cancel at period end');
}
}
if ($totalMonthlyValue > 0) {
$this->newLine();
$this->warn("Total monthly value: \${$totalMonthlyValue}");
}
$this->newLine();
$this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!');
if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Cancelling subscriptions...');
$result = $action->execute();
$this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed");
if ($result['failed'] > 0 && ! empty($result['errors'])) {
$this->error('Failed subscriptions:');
foreach ($result['errors'] as $error) {
$this->error(" - {$error}");
}
}
$this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}");
}
return true;
}
private function deleteUserProfile(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 6: DELETE USER PROFILE');
$this->info('═══════════════════════════════════════');
$this->newLine();
$this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!');
$this->newLine();
$this->info('User profile to be deleted:');
$this->table(
['Property', 'Value'],
[
['Email', $this->user->email],
['Name', $this->user->name],
['User ID', $this->user->id],
['Created', $this->user->created_at->format('Y-m-d H:i:s')],
['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'],
['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'],
]
);
$this->newLine();
$this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:");
$confirmation = $this->ask('Confirmation');
if ($confirmation !== "DELETE {$this->user->email}") {
$this->error('Confirmation text does not match. Deletion cancelled.');
return false;
}
if (! $this->isDryRun) {
$this->info('Deleting user profile...');
try {
$this->user->delete();
$this->info('User profile deleted successfully.');
$this->logAction("User profile deleted: {$this->user->email}");
} catch (\Exception $e) {
$this->error('Failed to delete user profile: '.$e->getMessage());
$this->logAction("Failed to delete user profile {$this->user->email}: ".$e->getMessage());
return false;
}
}
return true;
}
private function getSubscriptionMonthlyValue(string $planId): int
{
// Map plan IDs to monthly values based on config
$subscriptionConfigs = config('subscription');
foreach ($subscriptionConfigs as $key => $value) {
if ($value === $planId && str_contains($key, 'stripe_price_id_')) {
// Extract price from key pattern: stripe_price_id_basic_monthly -> basic
$planType = str($key)->after('stripe_price_id_')->before('_')->toString();
// Map to known prices (you may need to adjust these based on your actual pricing)
return match ($planType) {
'basic' => 29,
'pro' => 49,
'ultimate' => 99,
default => 0
};
}
}
return 0;
}
private function logAction(string $message): void
{
$logMessage = "[CloudDeleteUser] {$message}";
if ($this->isDryRun) {
$logMessage = "[DRY RUN] {$logMessage}";
}
Log::channel('single')->info($logMessage);
// Also log to a dedicated user deletion log file
$logFile = storage_path('logs/user-deletions.log');
$timestamp = now()->format('Y-m-d H:i:s');
file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX);
}
}

View File

@@ -0,0 +1,879 @@
<?php
namespace App\Console\Commands\Cloud;
use App\Models\Team;
use Illuminate\Console\Command;
class CloudFixSubscription extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cloud:fix-subscription
{--fix-canceled-subs : Fix canceled subscriptions in database}
{--verify-all : Verify all active subscriptions against Stripe}
{--fix-verified : Fix discrepancies found during verification}
{--dry-run : Show what would be fixed without making changes}
{--one : Only fix the first found subscription}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fix Cloud subscriptions';
/**
* Execute the console command.
*/
public function handle()
{
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
if ($this->option('verify-all')) {
return $this->verifyAllActiveSubscriptions($stripe);
}
if ($this->option('fix-canceled-subs') || $this->option('dry-run')) {
return $this->fixCanceledSubscriptions($stripe);
}
$activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
$out = fopen('php://output', 'w');
// CSV header
fputcsv($out, [
'team_id',
'invoice_status',
'stripe_customer_url',
'stripe_subscription_id',
'subscription_status',
'subscription_url',
'note',
]);
foreach ($activeSubscribers as $team) {
$stripeSubscriptionId = $team->subscription->stripe_subscription_id;
$stripeInvoicePaid = $team->subscription->stripe_invoice_paid;
$stripeCustomerId = $team->subscription->stripe_customer_id;
if (! $stripeSubscriptionId && str($stripeInvoicePaid)->lower() != 'past_due') {
fputcsv($out, [
$team->id,
$stripeInvoicePaid,
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
null,
null,
null,
'Missing subscription ID while invoice not past_due',
]);
continue;
}
if (! $stripeSubscriptionId) {
// No subscription ID and invoice is past_due, still record for visibility
fputcsv($out, [
$team->id,
$stripeInvoicePaid,
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
null,
null,
null,
'Missing subscription ID',
]);
continue;
}
$subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId);
if ($subscription->status === 'active') {
continue;
}
fputcsv($out, [
$team->id,
$stripeInvoicePaid,
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
$stripeSubscriptionId,
$subscription->status,
"https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}",
'Subscription not active',
]);
}
fclose($out);
}
/**
* Fix canceled subscriptions in the database
*/
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
{
$isDryRun = $this->option('dry-run');
$checkOne = $this->option('one');
if ($isDryRun) {
$this->info('DRY RUN MODE - No changes will be made');
if ($checkOne) {
$this->info('Checking only the first canceled subscription...');
} else {
$this->info('Checking for canceled subscriptions...');
}
} else {
if ($checkOne) {
$this->info('Checking and fixing only the first canceled subscription...');
} else {
$this->info('Checking and fixing canceled subscriptions...');
}
}
$teamsWithSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
$toFixCount = 0;
$fixedCount = 0;
$errors = [];
$canceledSubscriptions = [];
foreach ($teamsWithSubscriptions as $team) {
$subscription = $team->subscription;
if (! $subscription->stripe_subscription_id) {
continue;
}
try {
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
if ($stripeSubscription->status === 'canceled') {
$toFixCount++;
// Get team members' emails
$memberEmails = $team->members->pluck('email')->toArray();
$canceledSubscriptions[] = [
'team_id' => $team->id,
'team_name' => $team->name,
'customer_id' => $subscription->stripe_customer_id,
'subscription_id' => $subscription->stripe_subscription_id,
'status' => 'canceled',
'member_emails' => $memberEmails,
'subscription_model' => $subscription->toArray(),
];
if ($isDryRun) {
$this->warn('Would fix canceled subscription:');
$this->line(" Team ID: {$team->id}");
$this->line(" Team Name: {$team->name}");
$this->line(' Team Members: '.implode(', ', $memberEmails));
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
$this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}");
$this->line(' Current Subscription Data:');
foreach ($subscription->getAttributes() as $key => $value) {
if (is_null($value)) {
$this->line(" - {$key}: null");
} elseif (is_bool($value)) {
$this->line(" - {$key}: ".($value ? 'true' : 'false'));
} else {
$this->line(" - {$key}: {$value}");
}
}
$this->newLine();
} else {
$this->warn("Found canceled subscription for Team ID: {$team->id}");
// Send internal notification with all details before fixing
$notificationMessage = "Fixing canceled subscription:\n";
$notificationMessage .= "Team ID: {$team->id}\n";
$notificationMessage .= "Team Name: {$team->name}\n";
$notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n";
$notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n";
$notificationMessage .= "Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}\n";
$notificationMessage .= "Subscription Data:\n";
foreach ($subscription->getAttributes() as $key => $value) {
if (is_null($value)) {
$notificationMessage .= " - {$key}: null\n";
} elseif (is_bool($value)) {
$notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n";
} else {
$notificationMessage .= " - {$key}: {$value}\n";
}
}
send_internal_notification($notificationMessage);
// Apply the same logic as customer.subscription.deleted webhook
$team->subscriptionEnded();
$fixedCount++;
$this->info(" ✓ Fixed subscription for Team ID: {$team->id}");
$this->line(' Team Members: '.implode(', ', $memberEmails));
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
$this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}");
}
// Break if --one flag is set
if ($checkOne) {
break;
}
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
if ($e->getStripeCode() === 'resource_missing') {
$toFixCount++;
// Get team members' emails
$memberEmails = $team->members->pluck('email')->toArray();
$canceledSubscriptions[] = [
'team_id' => $team->id,
'team_name' => $team->name,
'customer_id' => $subscription->stripe_customer_id,
'subscription_id' => $subscription->stripe_subscription_id,
'status' => 'missing',
'member_emails' => $memberEmails,
'subscription_model' => $subscription->toArray(),
];
if ($isDryRun) {
$this->error('Would fix missing subscription (not found in Stripe):');
$this->line(" Team ID: {$team->id}");
$this->line(" Team Name: {$team->name}");
$this->line(' Team Members: '.implode(', ', $memberEmails));
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
$this->line(" Subscription ID (missing): {$subscription->stripe_subscription_id}");
$this->line(' Current Subscription Data:');
foreach ($subscription->getAttributes() as $key => $value) {
if (is_null($value)) {
$this->line(" - {$key}: null");
} elseif (is_bool($value)) {
$this->line(" - {$key}: ".($value ? 'true' : 'false'));
} else {
$this->line(" - {$key}: {$value}");
}
}
$this->newLine();
} else {
$this->error("Subscription not found in Stripe for Team ID: {$team->id}");
// Send internal notification with all details before fixing
$notificationMessage = "Fixing missing subscription (not found in Stripe):\n";
$notificationMessage .= "Team ID: {$team->id}\n";
$notificationMessage .= "Team Name: {$team->name}\n";
$notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n";
$notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n";
$notificationMessage .= "Subscription ID (missing): {$subscription->stripe_subscription_id}\n";
$notificationMessage .= "Subscription Data:\n";
foreach ($subscription->getAttributes() as $key => $value) {
if (is_null($value)) {
$notificationMessage .= " - {$key}: null\n";
} elseif (is_bool($value)) {
$notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n";
} else {
$notificationMessage .= " - {$key}: {$value}\n";
}
}
send_internal_notification($notificationMessage);
// Apply the same logic as customer.subscription.deleted webhook
$team->subscriptionEnded();
$fixedCount++;
$this->info(" ✓ Fixed missing subscription for Team ID: {$team->id}");
$this->line(' Team Members: '.implode(', ', $memberEmails));
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
}
// Break if --one flag is set
if ($checkOne) {
break;
}
} else {
$errors[] = "Team ID {$team->id}: ".$e->getMessage();
}
} catch (\Exception $e) {
$errors[] = "Team ID {$team->id}: ".$e->getMessage();
}
}
$this->newLine();
$this->info('Summary:');
if ($isDryRun) {
$this->info(" - Found {$toFixCount} canceled/missing subscriptions that would be fixed");
if ($toFixCount > 0) {
$this->newLine();
$this->comment('Run with --fix-canceled-subs to apply these changes');
}
} else {
$this->info(" - Fixed {$fixedCount} canceled/missing subscriptions");
}
if (! empty($errors)) {
$this->newLine();
$this->error('Errors encountered:');
foreach ($errors as $error) {
$this->error(" - {$error}");
}
}
return 0;
}
/**
* Verify all active subscriptions against Stripe API
*/
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
{
$isDryRun = $this->option('dry-run');
$shouldFix = $this->option('fix-verified');
$this->info('Verifying all active subscriptions against Stripe...');
if ($isDryRun) {
$this->info('DRY RUN MODE - No changes will be made');
}
if ($shouldFix && ! $isDryRun) {
$this->warn('FIX MODE - Discrepancies will be corrected');
}
// Get all teams with active subscriptions
$teamsWithActiveSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
$totalCount = $teamsWithActiveSubscriptions->count();
$this->info("Found {$totalCount} teams with active subscriptions in database");
$this->newLine();
$out = fopen('php://output', 'w');
// CSV header
fputcsv($out, [
'team_id',
'team_name',
'customer_id',
'subscription_id',
'db_status',
'stripe_status',
'action',
'member_emails',
'customer_url',
'subscription_url',
]);
$stats = [
'total' => $totalCount,
'valid_active' => 0,
'valid_past_due' => 0,
'canceled' => 0,
'missing' => 0,
'invalid' => 0,
'fixed' => 0,
'errors' => 0,
];
$processedCount = 0;
foreach ($teamsWithActiveSubscriptions as $team) {
$subscription = $team->subscription;
$memberEmails = $team->members->pluck('email')->toArray();
// Database state
$dbStatus = 'active';
if ($subscription->stripe_past_due) {
$dbStatus = 'past_due';
}
$stripeStatus = null;
$action = 'none';
if (! $subscription->stripe_subscription_id) {
$this->line("Team {$team->id}: Missing subscription ID, searching in Stripe...");
$foundResult = null;
$searchMethod = null;
// Search by customer ID
if ($subscription->stripe_customer_id) {
$this->line(" → Searching by customer ID: {$subscription->stripe_customer_id}");
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
if ($foundResult) {
$searchMethod = $foundResult['method'];
}
} else {
$this->line(' → No customer ID available');
}
// Search by emails if not found
if (! $foundResult && count($memberEmails) > 0) {
$foundResult = $this->searchSubscriptionsByEmails($stripe, $memberEmails);
if ($foundResult) {
$searchMethod = $foundResult['method'];
// Update customer ID if different
if (isset($foundResult['customer_id']) && $subscription->stripe_customer_id !== $foundResult['customer_id']) {
if ($isDryRun) {
$this->warn(" ⚠ Would update customer ID from {$subscription->stripe_customer_id} to {$foundResult['customer_id']}");
} elseif ($shouldFix) {
$subscription->update(['stripe_customer_id' => $foundResult['customer_id']]);
$this->info(" ✓ Updated customer ID to {$foundResult['customer_id']}");
}
}
}
}
if ($foundResult && isset($foundResult['subscription'])) {
// Check if it's an active/past_due subscription
if (in_array($foundResult['status'], ['active', 'past_due'])) {
// Found an active subscription, handle update
$result = $this->handleFoundSubscription(
$team,
$subscription,
$foundResult['subscription'],
$searchMethod,
$isDryRun,
$shouldFix,
$stats
);
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$result['id'],
$dbStatus,
$result['status'],
$result['action'],
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
$result['url'],
]);
} else {
// Found subscription but it's canceled/expired - needs to be deactivated
$this->warn(" → Found {$foundResult['status']} subscription {$foundResult['subscription']->id} - needs deactivation");
$result = $this->handleMissingSubscription($team, $subscription, $foundResult['status'], $isDryRun, $shouldFix, $stats);
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$foundResult['subscription']->id,
$dbStatus,
$foundResult['status'],
'needs_fix',
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
"https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}",
]);
}
} else {
// No subscription found at all
$this->line(' → No subscription found');
$stripeStatus = 'not_found';
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
'N/A',
$dbStatus,
$result['status'],
$result['action'],
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
'N/A',
]);
}
} else {
// First validate the subscription ID format
if (! str_starts_with($subscription->stripe_subscription_id, 'sub_')) {
$this->warn(" ⚠ Invalid subscription ID format (doesn't start with 'sub_')");
}
try {
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
$stripeStatus = $stripeSubscription->status;
// Determine if action is needed
switch ($stripeStatus) {
case 'active':
$stats['valid_active']++;
$action = 'valid';
break;
case 'past_due':
$stats['valid_past_due']++;
$action = 'valid';
// Ensure past_due flag is set
if (! $subscription->stripe_past_due) {
if ($isDryRun) {
$this->info("Would set stripe_past_due=true for Team {$team->id}");
} elseif ($shouldFix) {
$subscription->update(['stripe_past_due' => true]);
}
}
break;
case 'canceled':
case 'incomplete_expired':
case 'unpaid':
case 'incomplete':
$stats['canceled']++;
$action = 'needs_fix';
// Only output problematic subscriptions
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
$stripeStatus,
$action,
implode(', ', $memberEmails),
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
"https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}",
]);
if ($isDryRun) {
$this->info("Would deactivate subscription for Team {$team->id} - status: {$stripeStatus}");
} elseif ($shouldFix) {
$this->fixSubscription($team, $subscription, $stripeStatus);
$stats['fixed']++;
}
break;
default:
$stats['invalid']++;
$action = 'unknown';
// Only output problematic subscriptions
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
$stripeStatus,
$action,
implode(', ', $memberEmails),
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
"https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}",
]);
break;
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
$this->error(' → Error: '.$e->getMessage());
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
// Subscription doesn't exist, try to find by customer ID
$this->warn(" → Subscription not found, checking customer's subscriptions...");
$foundResult = null;
if ($subscription->stripe_customer_id) {
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
}
if ($foundResult && isset($foundResult['subscription']) && in_array($foundResult['status'], ['active', 'past_due'])) {
// Found an active subscription with different ID
$this->warn(" → ID mismatch! DB: {$subscription->stripe_subscription_id}, Stripe: {$foundResult['subscription']->id}");
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
"WRONG ID: {$subscription->stripe_subscription_id}{$foundResult['subscription']->id}",
$dbStatus,
$foundResult['status'],
'id_mismatch',
implode(', ', $memberEmails),
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
"https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}",
]);
if ($isDryRun) {
$this->warn(" → Would update subscription ID to {$foundResult['subscription']->id}");
} elseif ($shouldFix) {
$subscription->update([
'stripe_subscription_id' => $foundResult['subscription']->id,
'stripe_invoice_paid' => true,
'stripe_past_due' => $foundResult['status'] === 'past_due',
]);
$stats['fixed']++;
$this->info(' → Updated subscription ID');
}
$stats[$foundResult['status'] === 'active' ? 'valid_active' : 'valid_past_due']++;
} else {
// No active subscription found
$stripeStatus = $foundResult ? $foundResult['status'] : 'not_found';
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
$result['status'],
$result['action'],
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
$foundResult && isset($foundResult['subscription']) ? "https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}" : 'N/A',
]);
}
} else {
// Other API error
$stats['errors']++;
$this->error(' → API Error - not marking as deleted');
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
'error: '.$e->getStripeCode(),
'error',
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
$subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A',
]);
}
} catch (\Exception $e) {
$this->error(' → Unexpected error: '.$e->getMessage());
$stats['errors']++;
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
'error',
'error',
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
$subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A',
]);
}
}
$processedCount++;
if ($processedCount % 100 === 0) {
$this->info("Processed {$processedCount}/{$totalCount} subscriptions...");
}
}
fclose($out);
// Print summary
$this->newLine(2);
$this->info('=== Verification Summary ===');
$this->info("Total subscriptions checked: {$stats['total']}");
$this->newLine();
$this->info('Valid subscriptions in Stripe:');
$this->line(" - Active: {$stats['valid_active']}");
$this->line(" - Past Due: {$stats['valid_past_due']}");
$validTotal = $stats['valid_active'] + $stats['valid_past_due'];
$this->info(" Total valid: {$validTotal}");
$this->newLine();
$this->warn('Invalid subscriptions:');
$this->line(" - Canceled/Expired: {$stats['canceled']}");
$this->line(" - Missing/Not Found: {$stats['missing']}");
$this->line(" - Unknown status: {$stats['invalid']}");
$invalidTotal = $stats['canceled'] + $stats['missing'] + $stats['invalid'];
$this->warn(" Total invalid: {$invalidTotal}");
if ($stats['errors'] > 0) {
$this->newLine();
$this->error("Errors encountered: {$stats['errors']}");
}
if ($shouldFix && ! $isDryRun) {
$this->newLine();
$this->info("Fixed subscriptions: {$stats['fixed']}");
} elseif ($invalidTotal > 0 && ! $shouldFix) {
$this->newLine();
$this->comment('Run with --fix-verified to fix the discrepancies');
}
return 0;
}
/**
* Fix a subscription based on its status
*/
private function fixSubscription($team, $subscription, $status)
{
$message = "Fixing subscription for Team ID: {$team->id} (Status: {$status})\n";
$message .= "Team Name: {$team->name}\n";
$message .= "Customer ID: {$subscription->stripe_customer_id}\n";
$message .= "Subscription ID: {$subscription->stripe_subscription_id}\n";
send_internal_notification($message);
// Call the team's subscription ended method which properly cleans up
$team->subscriptionEnded();
}
/**
* Search for subscriptions by customer ID
*/
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false)
{
try {
$subscriptions = $stripe->subscriptions->all([
'customer' => $customerId,
'limit' => 10,
'status' => 'all',
]);
$this->line(' → Found '.count($subscriptions->data).' subscription(s) for customer');
// Look for active/past_due first
foreach ($subscriptions->data as $sub) {
$this->line(" - Subscription {$sub->id}: status={$sub->status}");
if (in_array($sub->status, ['active', 'past_due'])) {
$this->info(" ✓ Found active/past_due subscription: {$sub->id}");
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id'];
}
}
// If not requiring active and there are subscriptions, return first one
if (! $requireActive && count($subscriptions->data) > 0) {
$sub = $subscriptions->data[0];
$this->warn(" ⚠ Only found {$sub->status} subscription: {$sub->id}");
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id_first'];
}
return null;
} catch (\Exception $e) {
$this->error(' → Error searching by customer ID: '.$e->getMessage());
return null;
}
}
/**
* Search for subscriptions by team member emails
*/
private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails)
{
$this->line(' → Searching by team member emails...');
foreach ($emails as $email) {
$this->line(" → Checking email: {$email}");
try {
$customers = $stripe->customers->all([
'email' => $email,
'limit' => 5,
]);
if (count($customers->data) === 0) {
$this->line(' - No customers found');
continue;
}
$this->line(' - Found '.count($customers->data).' customer(s)');
foreach ($customers->data as $customer) {
$this->line(" - Checking customer {$customer->id}");
$result = $this->searchSubscriptionsByCustomer($stripe, $customer->id, true);
if ($result) {
$result['method'] = "email:{$email}";
$result['customer_id'] = $customer->id;
return $result;
}
}
} catch (\Exception $e) {
$this->error(" - Error searching for email {$email}: ".$e->getMessage());
}
}
return null;
}
/**
* Handle found subscription update (only for active/past_due subscriptions)
*/
private function handleFoundSubscription($team, $subscription, $foundSub, $searchMethod, $isDryRun, $shouldFix, &$stats)
{
$stripeStatus = $foundSub->status;
$this->info(" ✓ FOUND active/past_due subscription {$foundSub->id} (status: {$stripeStatus})");
// Only update if it's active or past_due
if (! in_array($stripeStatus, ['active', 'past_due'])) {
$this->error(" ERROR: handleFoundSubscription called with {$stripeStatus} subscription!");
return [
'id' => $foundSub->id,
'status' => $stripeStatus,
'action' => 'error',
'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}",
];
}
if ($isDryRun) {
$this->warn(" → Would update subscription ID to {$foundSub->id} (status: {$stripeStatus})");
} elseif ($shouldFix) {
$subscription->update([
'stripe_subscription_id' => $foundSub->id,
'stripe_invoice_paid' => true,
'stripe_past_due' => $stripeStatus === 'past_due',
]);
$stats['fixed']++;
$this->info(" → Updated subscription ID to {$foundSub->id}");
}
// Update stats
$stats[$stripeStatus === 'active' ? 'valid_active' : 'valid_past_due']++;
return [
'id' => "FOUND: {$foundSub->id}",
'status' => $stripeStatus,
'action' => "will_update (via {$searchMethod})",
'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}",
];
}
/**
* Handle missing subscription
*/
private function handleMissingSubscription($team, $subscription, $status, $isDryRun, $shouldFix, &$stats)
{
$stats['missing']++;
if ($isDryRun) {
$statusMsg = $status !== 'not_found' ? "status: {$status}" : 'no subscription found in Stripe';
$this->warn(" → Would deactivate subscription - {$statusMsg}");
} elseif ($shouldFix) {
$this->fixSubscription($team, $subscription, $status);
$stats['fixed']++;
$this->info(' → Deactivated subscription');
}
return [
'id' => 'N/A',
'status' => $status,
'action' => 'needs_fix',
'url' => 'N/A',
];
}
}