From e6ed3130b586b8a6b8b07fa136ae69a23999c3eb Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:34:39 +0100 Subject: [PATCH] feat(stripe): Add manual subscription sync command with dry-run support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cloud:sync-stripe-subscriptions command to manually check all subscriptions against Stripe. By default it only reports discrepancies without making changes. Use --fix flag to actually apply corrections. This addresses race conditions where subscriptions can be cancelled in Stripe but remain marked as active in Coolify's database. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 --- .../Cloud/SyncStripeSubscriptions.php | 81 ++++++++++++++++ app/Jobs/SyncStripeSubscriptionsJob.php | 92 +++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 app/Console/Commands/Cloud/SyncStripeSubscriptions.php create mode 100644 app/Jobs/SyncStripeSubscriptionsJob.php 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, + ]; + } +}