diff --git a/app/Console/Commands/CloudDeleteUser.php b/app/Console/Commands/Cloud/CloudDeleteUser.php similarity index 99% rename from app/Console/Commands/CloudDeleteUser.php rename to app/Console/Commands/Cloud/CloudDeleteUser.php index 6928eb97b..29580a95e 100644 --- a/app/Console/Commands/CloudDeleteUser.php +++ b/app/Console/Commands/Cloud/CloudDeleteUser.php @@ -1,6 +1,6 @@ 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', + ]; + } +} diff --git a/app/Console/Commands/CloudCheckSubscription.php b/app/Console/Commands/CloudCheckSubscription.php deleted file mode 100644 index e24918df5..000000000 --- a/app/Console/Commands/CloudCheckSubscription.php +++ /dev/null @@ -1,319 +0,0 @@ -option('fix') || $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 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; - } -} diff --git a/app/Console/Commands/CloudCleanupSubscriptions.php b/app/Console/Commands/CloudCleanupSubscriptions.php deleted file mode 100644 index ab676c927..000000000 --- a/app/Console/Commands/CloudCleanupSubscriptions.php +++ /dev/null @@ -1,101 +0,0 @@ -error('This command can only be run on cloud'); - - return; - } - $this->info('Cleaning up subcriptions teams'); - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); - - $teams = Team::all()->filter(function ($team) { - return $team->id !== 0; - })->sortBy('id'); - foreach ($teams as $team) { - if ($team) { - $this->info("Checking team {$team->id}"); - } - if (! data_get($team, 'subscription')) { - $this->disableServers($team); - - continue; - } - // If the team has no subscription id and the invoice is paid, we need to reset the invoice paid status - if (! (data_get($team, 'subscription.stripe_subscription_id'))) { - $this->info("Resetting invoice paid status for team {$team->id}"); - - $team->subscription->update([ - 'stripe_invoice_paid' => false, - 'stripe_trial_already_ended' => false, - 'stripe_subscription_id' => null, - ]); - $this->disableServers($team); - - continue; - } else { - $subscription = $stripe->subscriptions->retrieve(data_get($team, 'subscription.stripe_subscription_id'), []); - $status = data_get($subscription, 'status'); - if ($status === 'active') { - $team->subscription->update([ - 'stripe_invoice_paid' => true, - 'stripe_trial_already_ended' => false, - ]); - - continue; - } - $this->info('Subscription status: '.$status); - $this->info('Subscription id: '.data_get($team, 'subscription.stripe_subscription_id')); - $confirm = $this->confirm('Do you want to cancel the subscription?', true); - if (! $confirm) { - $this->info("Skipping team {$team->id}"); - } else { - $this->info("Cancelling subscription for team {$team->id}"); - $team->subscription->update([ - 'stripe_invoice_paid' => false, - 'stripe_trial_already_ended' => false, - 'stripe_subscription_id' => null, - ]); - $this->disableServers($team); - } - } - } - } catch (\Exception $e) { - $this->error($e->getMessage()); - - return; - } - } - - private function disableServers(Team $team) - { - foreach ($team->servers as $server) { - if ($server->settings->is_usable === true || $server->settings->is_reachable === true || $server->ip !== '1.2.3.4') { - $this->info("Disabling server {$server->id} {$server->name}"); - $server->settings()->update([ - 'is_usable' => false, - 'is_reachable' => false, - ]); - $server->update([ - 'ip' => '1.2.3.4', - ]); - - ServerReachabilityChanged::dispatch($server); - } - } - } -}