diff --git a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php new file mode 100644 index 000000000..e64f86926 --- /dev/null +++ b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php @@ -0,0 +1,81 @@ +error('This command can only be run on Coolify Cloud.'); + + return 1; + } + + if (! isStripe()) { + $this->error('Stripe is not configured.'); + + return 1; + } + + $fix = $this->option('fix'); + + if ($fix) { + $this->warn('Running with --fix: discrepancies will be corrected.'); + } else { + $this->info('Running in check mode (no changes will be made). Use --fix to apply corrections.'); + } + + $this->newLine(); + + $job = new SyncStripeSubscriptionsJob($fix); + $result = $job->handle(); + + if (isset($result['error'])) { + $this->error($result['error']); + + return 1; + } + + $this->info("Total subscriptions checked: {$result['total_checked']}"); + $this->newLine(); + + if (count($result['discrepancies']) > 0) { + $this->warn('Discrepancies found: '.count($result['discrepancies'])); + $this->newLine(); + + foreach ($result['discrepancies'] as $discrepancy) { + $this->line(" - Subscription ID: {$discrepancy['subscription_id']}"); + $this->line(" Team ID: {$discrepancy['team_id']}"); + $this->line(" Stripe ID: {$discrepancy['stripe_subscription_id']}"); + $this->line(" Stripe Status: {$discrepancy['stripe_status']}"); + $this->newLine(); + } + + if ($fix) { + $this->info('All discrepancies have been fixed.'); + } else { + $this->comment('Run with --fix to correct these discrepancies.'); + } + } else { + $this->info('No discrepancies found. All subscriptions are in sync.'); + } + + if (count($result['errors']) > 0) { + $this->newLine(); + $this->error('Errors encountered: '.count($result['errors'])); + foreach ($result['errors'] as $error) { + $this->line(" - Subscription {$error['subscription_id']}: {$error['error']}"); + } + } + + return 0; + } +} diff --git a/app/Jobs/SyncStripeSubscriptionsJob.php b/app/Jobs/SyncStripeSubscriptionsJob.php new file mode 100644 index 000000000..9eb946e4d --- /dev/null +++ b/app/Jobs/SyncStripeSubscriptionsJob.php @@ -0,0 +1,92 @@ +onQueue('high'); + } + + public function handle(): array + { + if (! isCloud() || ! isStripe()) { + return ['error' => 'Not running on Cloud or Stripe not configured']; + } + + $subscriptions = Subscription::whereNotNull('stripe_subscription_id') + ->where('stripe_invoice_paid', true) + ->get(); + + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $discrepancies = []; + $errors = []; + + foreach ($subscriptions as $subscription) { + try { + $stripeSubscription = $stripe->subscriptions->retrieve( + $subscription->stripe_subscription_id + ); + + // Check if Stripe says cancelled but we think it's active + if (in_array($stripeSubscription->status, ['canceled', 'incomplete_expired', 'unpaid'])) { + $discrepancies[] = [ + 'subscription_id' => $subscription->id, + 'team_id' => $subscription->team_id, + 'stripe_subscription_id' => $subscription->stripe_subscription_id, + 'stripe_status' => $stripeSubscription->status, + ]; + + // Only fix if --fix flag is passed + if ($this->fix) { + $subscription->update([ + 'stripe_invoice_paid' => false, + 'stripe_past_due' => false, + ]); + + if ($stripeSubscription->status === 'canceled') { + $subscription->team?->subscriptionEnded(); + } + } + } + + // Small delay to avoid Stripe rate limits + usleep(100000); // 100ms + } catch (\Exception $e) { + $errors[] = [ + 'subscription_id' => $subscription->id, + 'error' => $e->getMessage(), + ]; + } + } + + // Only notify if discrepancies found and fixed + if ($this->fix && count($discrepancies) > 0) { + send_internal_notification( + 'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n". + json_encode($discrepancies, JSON_PRETTY_PRINT) + ); + } + + return [ + 'total_checked' => $subscriptions->count(), + 'discrepancies' => $discrepancies, + 'errors' => $errors, + 'fixed' => $this->fix, + ]; + } +}