diff --git a/app/Jobs/SyncStripeSubscriptionsJob.php b/app/Actions/Stripe/SyncStripeSubscriptions.php similarity index 61% rename from app/Jobs/SyncStripeSubscriptionsJob.php rename to app/Actions/Stripe/SyncStripeSubscriptions.php index 572d6e78c..977007961 100644 --- a/app/Jobs/SyncStripeSubscriptionsJob.php +++ b/app/Actions/Stripe/SyncStripeSubscriptions.php @@ -1,30 +1,18 @@ onQueue('high'); - } - - public function handle(?\Closure $onProgress = null): array + public function handle(bool $fix = false, ?\Closure $onProgress = null): array { if (! isCloud() || ! isStripe()) { return ['error' => 'Not running on Cloud or Stripe not configured']; @@ -34,7 +22,9 @@ public function handle(?\Closure $onProgress = null): array ->where('stripe_invoice_paid', true) ->get(); - $stripe = app(StripeClient::class); + $stripe = app()->bound(StripeClient::class) + ? app(StripeClient::class) + : new StripeClient(config('subscription.stripe_api_key')); // Bulk fetch all valid subscription IDs from Stripe (active + past_due) $validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress); @@ -43,13 +33,20 @@ public function handle(?\Closure $onProgress = null): array $staleSubscriptions = $subscriptions->filter( fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds) ); + $staleSubscriptionCount = $staleSubscriptions->count(); + + $onProgress?->__invoke('checking', 0, $staleSubscriptionCount); // For each stale subscription, get the exact Stripe status and check for resubscriptions $discrepancies = []; $resubscribed = []; $errors = []; + $fixedCount = 0; + $manualReviewCount = 0; + + foreach ($staleSubscriptions->values() as $index => $subscription) { + $onProgress?->__invoke('checking', $index + 1, $staleSubscriptionCount); - foreach ($staleSubscriptions as $subscription) { try { $stripeSubscription = $stripe->subscriptions->retrieve( $subscription->stripe_subscription_id @@ -66,8 +63,18 @@ public function handle(?\Closure $onProgress = null): array continue; } - // Check if this user resubscribed under a different customer/subscription + if (in_array($stripeStatus, self::VALID_STRIPE_STATUSES, true)) { + continue; + } + $activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer); + $validReplacement = Subscription::query() + ->where('team_id', $subscription->team_id) + ->where('id', '!=', $subscription->id) + ->where('stripe_invoice_paid', true) + ->whereIn('stripe_subscription_id', $validStripeIds) + ->first(); + if ($activeSub) { $resubscribed[] = [ 'subscription_id' => $subscription->id, @@ -78,33 +85,69 @@ public function handle(?\Closure $onProgress = null): array 'new_stripe_subscription_id' => $activeSub['subscription_id'], 'new_stripe_customer_id' => $activeSub['customer_id'], 'new_status' => $activeSub['status'], + 'linked_to_team' => $validReplacement?->stripe_subscription_id === $activeSub['subscription_id'], ]; - - continue; } + $inactiveSubscription = null; + if (! $validReplacement && ! $activeSub) { + $inactiveSubscription = Subscription::query() + ->where('team_id', $subscription->team_id) + ->where('id', '!=', $subscription->id) + ->where('stripe_invoice_paid', false) + ->first(); + } + + $resolution = match (true) { + (bool) $validReplacement => 'delete_stale', + (bool) $activeSub => 'manual_review', + (bool) $inactiveSubscription => 'delete_stale', + default => 'end_subscription', + }; + $discrepancies[] = [ 'subscription_id' => $subscription->id, 'team_id' => $subscription->team_id, 'stripe_subscription_id' => $subscription->stripe_subscription_id, 'stripe_status' => $stripeStatus, + 'resolution' => $resolution, ]; - if ($this->fix) { - $subscription->update([ - 'stripe_invoice_paid' => false, - 'stripe_past_due' => false, - ]); + if ($fix) { + $team = $subscription->team; - if ($stripeStatus === 'canceled') { - $subscription->team?->subscriptionEnded(); + if ($resolution === 'manual_review') { + $manualReviewCount++; + + continue; } + + if ($resolution === 'delete_stale') { + if (! $validReplacement && $inactiveSubscription && $team) { + $team->subscriptionEnded($inactiveSubscription); + } + + $subscription->delete(); + $fixedCount++; + + continue; + } + + if ($team) { + $team->subscriptionEnded($subscription); + } else { + $subscription->update([ + 'stripe_invoice_paid' => false, + 'stripe_past_due' => false, + ]); + } + $fixedCount++; } } - if ($this->fix && count($discrepancies) > 0) { + if ($fix && $fixedCount > 0) { send_internal_notification( - 'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n". + "SyncStripeSubscriptions: Fixed {$fixedCount} discrepancies:\n". json_encode($discrepancies, JSON_PRETTY_PRINT) ); } @@ -114,7 +157,9 @@ public function handle(?\Closure $onProgress = null): array 'discrepancies' => $discrepancies, 'resubscribed' => $resubscribed, 'errors' => $errors, - 'fixed' => $this->fix, + 'fixed' => $fix, + 'fixed_count' => $fixedCount, + 'manual_review_count' => $manualReviewCount, ]; } @@ -183,13 +228,13 @@ private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $validIds = []; $fetched = 0; - foreach (['active', 'past_due'] as $status) { + foreach (self::VALID_STRIPE_STATUSES as $status) { foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) { $validIds[] = $sub->id; $fetched++; if ($onProgress) { - $onProgress($fetched); + $onProgress('fetching', $fetched, null); } } } diff --git a/app/Console/Commands/Cloud/CleanupUnverifiedUsers.php b/app/Console/Commands/Cloud/CleanupUnverifiedUsers.php new file mode 100644 index 000000000..bf63edce5 --- /dev/null +++ b/app/Console/Commands/Cloud/CleanupUnverifiedUsers.php @@ -0,0 +1,83 @@ +error('This command can only be run on Coolify Cloud.'); + + return self::FAILURE; + } + + $eligibleUsers = $this->eligibleUsers(); + $eligibleCount = $eligibleUsers->count(); + + $this->info("Found {$eligibleCount} ".Str::plural('unverified user', $eligibleCount).' eligible for deletion.'); + $shouldDelete = (bool) $this->option('yes'); + + if (! $shouldDelete) { + $this->warn('Dry run only. Use --yes to delete eligible users.'); + } + + $deletedCount = 0; + + if ($eligibleCount > 0) { + $progressAction = $shouldDelete ? 'Deleting' : 'Checking'; + $progressBar = $this->output->createProgressBar($eligibleCount); + $progressBar->setFormat("{$progressAction} eligible users: %current%/%max% [%bar%] %percent:3s%%"); + $progressBar->start(); + + foreach ($eligibleUsers->lazyById(100) as $user) { + if ($shouldDelete && $user->delete()) { + $deletedCount++; + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + } + + if ($shouldDelete) { + $this->info("Deleted {$deletedCount} ".Str::plural('unverified user', $deletedCount).'.'); + } + + return self::SUCCESS; + } + + private function eligibleUsers(): Builder + { + return User::query() + ->where('id', '!=', 0) + ->whereNull('email_verified_at') + ->whereDoesntHave('teams', fn (Builder $query) => $query->whereKey(0)) + ->whereDoesntHave('teams.subscription') + ->whereDoesntHave('teams.servers') + ->whereDoesntHave('teams', function (Builder $query) { + $query->whereHas('projects.applications') + ->orWhereHas('projects.postgresqls') + ->orWhereHas('projects.redis') + ->orWhereHas('projects.mongodbs') + ->orWhereHas('projects.mysqls') + ->orWhereHas('projects.mariadbs') + ->orWhereHas('projects.keydbs') + ->orWhereHas('projects.dragonflies') + ->orWhereHas('projects.clickhouses') + ->orWhereHas('projects.services'); + }); + } +} diff --git a/app/Console/Commands/Cloud/ExportUsers.php b/app/Console/Commands/Cloud/ExportUsers.php new file mode 100644 index 000000000..5e383c4ce --- /dev/null +++ b/app/Console/Commands/Cloud/ExportUsers.php @@ -0,0 +1,127 @@ +error('This command can only be run on Coolify Cloud.'); + + return self::FAILURE; + } + + $backups = Storage::disk('backups'); + $backups->delete('cloud-users.csv'); + + $subscribedPath = $backups->path('cloud-users-subscribed.csv'); + $unsubscribedPath = $backups->path('cloud-users-unsubscribed.csv'); + $subscribedOutput = fopen($subscribedPath, 'wb'); + + if ($subscribedOutput === false) { + $this->error("Unable to open {$subscribedPath} for writing."); + + return self::FAILURE; + } + + $unsubscribedOutput = fopen($unsubscribedPath, 'wb'); + + if ($unsubscribedOutput === false) { + fclose($subscribedOutput); + $this->error("Unable to open {$unsubscribedPath} for writing."); + + return self::FAILURE; + } + + $subscribedCount = 0; + $unsubscribedCount = 0; + + try { + $header = [ + 'email', + 'first_name', + 'last_name', + 'lifetime_value_currency', + 'lifetime_value_amount', + 'utm_campaign', + 'utm_source', + 'utm_medium', + 'utm_content', + 'utm_term', + 'phone', + ]; + + $this->writeCsvRow($subscribedOutput, $header); + $this->writeCsvRow($unsubscribedOutput, $header); + + foreach (User::query() + ->select(['id', 'email', 'name']) + ->where('id', '!=', 0) + ->whereNotNull('email_verified_at') + ->withExists([ + 'teams as is_subscribed' => fn ($query) => $query + ->whereRelation('subscription', 'stripe_invoice_paid', true), + ]) + ->lazyById(500) as $user) { + $nameParts = preg_split('/\s+/u', trim((string) $user->name), 2) ?: []; + [$firstName, $lastName] = array_pad($nameParts, 2, ''); + + $row = [ + $user->email, + $firstName, + $lastName, + '', + '', + '', + '', + '', + '', + '', + '', + ]; + + if ($user->is_subscribed) { + $this->writeCsvRow($subscribedOutput, $row); + $subscribedCount++; + } else { + $this->writeCsvRow($unsubscribedOutput, $row); + $unsubscribedCount++; + } + } + } catch (Throwable $exception) { + $this->error("Unable to export users: {$exception->getMessage()}"); + + return self::FAILURE; + } finally { + fclose($subscribedOutput); + fclose($unsubscribedOutput); + } + + $this->info("Exported {$subscribedCount} subscribed verified users to {$subscribedPath}"); + $this->info("Exported {$unsubscribedCount} unsubscribed verified users to {$unsubscribedPath}"); + + return self::SUCCESS; + } + + /** + * @param resource $output + * @param array $fields + */ + private function writeCsvRow($output, array $fields): void + { + if (fputcsv($output, $fields, ',', '"', '') === false) { + throw new RuntimeException('Unable to write the CSV file.'); + } + } +} diff --git a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php index 46f6b4edd..cedacfbeb 100644 --- a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php +++ b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php @@ -2,7 +2,7 @@ namespace App\Console\Commands\Cloud; -use App\Jobs\SyncStripeSubscriptionsJob; +use App\Actions\Stripe\SyncStripeSubscriptions as SyncStripeSubscriptionsAction; use Illuminate\Console\Command; class SyncStripeSubscriptions extends Command @@ -35,14 +35,18 @@ public function handle(): int $this->newLine(); - $job = new SyncStripeSubscriptionsJob($fix); - $fetched = 0; - $result = $job->handle(function (int $count) use (&$fetched): void { - $fetched = $count; - $this->output->write("\r Fetching subscriptions from Stripe... {$fetched}"); + $progressShown = false; + $result = SyncStripeSubscriptionsAction::run($fix, function (string $stage, int $current, ?int $total) use (&$progressShown): void { + $progressShown = true; + $message = match ($stage) { + 'checking' => " Checking stale subscriptions against Stripe... {$current}/{$total}", + default => " Fetching valid subscriptions from Stripe... {$current}", + }; + + $this->output->write("\r".str_pad($message, 80)); }); - if ($fetched > 0) { - $this->output->write("\r".str_repeat(' ', 60)."\r"); + if ($progressShown) { + $this->output->write("\r".str_repeat(' ', 80)."\r"); } if (isset($result['error'])) { @@ -63,13 +67,22 @@ public function handle(): int $this->line(" Team ID: {$discrepancy['team_id']}"); $this->line(" Stripe ID: {$discrepancy['stripe_subscription_id']}"); $this->line(" Stripe Status: {$discrepancy['stripe_status']}"); + $resolution = match ($discrepancy['resolution']) { + 'delete_stale' => 'Delete stale local row', + 'manual_review' => 'Manual review required', + default => 'End local subscription', + }; + $this->line(" Resolution: {$resolution}"); $this->newLine(); } if ($fix) { - $this->info('All discrepancies have been fixed.'); + $this->info("Automatic corrections applied: {$result['fixed_count']}"); + if ($result['manual_review_count'] > 0) { + $this->warn("Skipped for manual review: {$result['manual_review_count']}"); + } } else { - $this->comment('Run with --fix to correct these discrepancies.'); + $this->comment('Run with --fix to apply automatic corrections.'); } } else { $this->info('No discrepancies found. All subscriptions are in sync.'); @@ -84,6 +97,7 @@ public function handle(): int $this->line(" - Team ID: {$resub['team_id']} | Email: {$resub['email']}"); $this->line(" Old: {$resub['old_stripe_subscription_id']} (cus: {$resub['old_stripe_customer_id']})"); $this->line(" New: {$resub['new_stripe_subscription_id']} (cus: {$resub['new_stripe_customer_id']}) [{$resub['new_status']}]"); + $this->line(' Linked to this team: '.($resub['linked_to_team'] ? 'Yes' : 'No')); $this->newLine(); } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 3761effd7..d06e2eac5 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -850,6 +850,12 @@ public function create_backup(Request $request) $this->authorize('manageBackups', $database); + if (! $database->isBackupSolutionAvailable()) { + return response()->json([ + 'message' => 'Scheduled backups are not supported for this database type.', + ], 422); + } + // Validate frequency is a valid cron expression $isValid = validate_cron_expression($request->frequency); if (! $isValid) { @@ -915,6 +921,8 @@ public function create_backup(Request $request) $backupData['databases_to_backup'] = $database->mysql_database; } elseif ($database->type() === 'standalone-mariadb') { $backupData['databases_to_backup'] = $database->mariadb_database; + } elseif ($database->type() === 'standalone-clickhouse') { + $backupData['databases_to_backup'] = $database->clickhouse_db; } } @@ -3016,7 +3024,7 @@ public function list_backup_executions(Request $request) ), ] )] - public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + public function move_by_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index e9e1f9105..f25158d34 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -8,6 +8,7 @@ use App\Models\ScheduledDatabaseBackupExecution; use App\Models\Server; use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; use App\Models\StandaloneMariadb; use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; @@ -17,6 +18,7 @@ use App\Notifications\Database\BackupSuccess; use App\Notifications\Database\BackupSuccessWithS3Warning; use App\Rules\SafeWebhookUrl; +use App\Support\ClickhouseBackupCommand; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -39,7 +41,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public Server $server; - public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|ServiceDatabase $database; + public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneClickhouse|ServiceDatabase $database; public ?string $container_name = null; @@ -271,6 +273,8 @@ public function handle(): void $databasesToBackup = [$this->database->mysql_database]; } elseif (str($databaseType)->contains('mariadb')) { $databasesToBackup = [$this->database->mariadb_database]; + } elseif ($this->database instanceof StandaloneClickhouse) { + $databasesToBackup = [$this->database->clickhouse_db]; } else { return; } @@ -294,6 +298,10 @@ public function handle(): void // Format: db1,db2,db3 $databasesToBackup = explode(',', $databasesToBackup); $databasesToBackup = array_map('trim', $databasesToBackup); + } elseif ($this->database instanceof StandaloneClickhouse) { + // Format: db1,db2,db3 + $databasesToBackup = explode(',', $databasesToBackup); + $databasesToBackup = array_map('trim', $databasesToBackup); } else { return; } @@ -386,6 +394,17 @@ public function handle(): void 'local_storage_deleted' => false, ]); $this->backup_standalone_mariadb($database); + } elseif ($this->database instanceof StandaloneClickhouse) { + $this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip"; + $this->backup_location = $this->backup_dir.$this->backup_file; + $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $this->backup_log_uuid, + 'database_name' => $database, + 'filename' => $this->backup_location, + 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, + ]); + $this->backup_standalone_clickhouse($database); } else { throw new \Exception('Unsupported database type'); } @@ -400,6 +419,9 @@ public function handle(): void } } catch (Throwable $e) { // Local backup failed + if ($this->database instanceof StandaloneClickhouse) { + deleteBackupsLocally($this->backup_location, $this->server); + } if ($this->backup_log) { $this->backup_log->update([ 'status' => 'failed', @@ -642,6 +664,32 @@ private function backup_standalone_mariadb(string $database): void } } + private function backup_standalone_clickhouse(string $database): void + { + $archiveName = ltrim($this->backup_file, '/'); + + try { + $commands = ClickhouseBackupCommand::make( + containerName: $this->container_name, + database: $database, + archiveName: $archiveName, + backupDirectory: $this->backup_dir, + ); + + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = trim($this->backup_output); + if ($this->backup_output === '') { + $this->backup_output = null; + } + } catch (Throwable $e) { + $this->add_to_error_output($e->getMessage()); + throw $e; + } finally { + $cleanupCommand = ClickhouseBackupCommand::cleanup($this->container_name, $archiveName); + instant_remote_process([$cleanupCommand], $this->server, false, false, null, disableMultiplexing: true); + } + } + private function add_to_backup_output($output): void { if ($this->backup_output) { diff --git a/app/Livewire/Project/Database/Backup/Index.php b/app/Livewire/Project/Database/Backup/Index.php index 2df32ec7b..71380754f 100644 --- a/app/Livewire/Project/Database/Backup/Index.php +++ b/app/Livewire/Project/Database/Backup/Index.php @@ -22,13 +22,7 @@ public function mount() if (! $database) { return redirect()->route('dashboard'); } - // No backups - if ( - $database->getMorphClass() === \App\Models\StandaloneRedis::class || - $database->getMorphClass() === \App\Models\StandaloneKeydb::class || - $database->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $database->getMorphClass() === \App\Models\StandaloneClickhouse::class - ) { + if (! $database->isBackupSolutionAvailable()) { return redirect()->route('project.database.configuration', [ 'project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 7384adcff..49065711b 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -48,6 +48,12 @@ public function submit() try { $this->authorize('manageBackups', $this->database); + if (! $this->database->isBackupSolutionAvailable()) { + $this->dispatch('error', 'Scheduled backups are not supported for this database type.'); + + return; + } + $this->validate(); if ($this->saveToS3) { @@ -87,6 +93,8 @@ public function submit() $payload['databases_to_backup'] = $this->database->mysql_database; } elseif ($this->database->type() === 'standalone-mariadb') { $payload['databases_to_backup'] = $this->database->mariadb_database; + } elseif ($this->database->type() === 'standalone-clickhouse') { + $payload['databases_to_backup'] = $this->database->clickhouse_db; } $databaseBackup = ScheduledDatabaseBackup::create($payload); diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 9db5f21b7..7ca45cc3b 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -370,6 +370,6 @@ public function scheduledBackups() public function isBackupSolutionAvailable() { - return false; + return true; } } diff --git a/app/Models/Team.php b/app/Models/Team.php index a979b44fb..2b468bf4c 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -219,13 +219,15 @@ public function isAnyNotificationEnabled() $this->getNotificationSettings('webhook')?->isEnabled(); } - public function subscriptionEnded() + public function subscriptionEnded(?Subscription $subscription = null): void { - if (! $this->subscription) { + $subscription ??= $this->subscription; + + if (! $subscription) { return; } - $this->subscription->update([ + $subscription->update([ 'stripe_subscription_id' => null, 'stripe_cancel_at_period_end' => false, 'stripe_invoice_paid' => false, diff --git a/app/Support/ClickhouseBackupCommand.php b/app/Support/ClickhouseBackupCommand.php new file mode 100644 index 000000000..69e93d5c3 --- /dev/null +++ b/app/Support/ClickhouseBackupCommand.php @@ -0,0 +1,37 @@ + */ + public static function make( + string $containerName, + string $database, + string $archiveName, + string $backupDirectory, + ): array { + validateShellSafePath($database, 'database name'); + validateFilenameSafe($archiveName, 'ClickHouse backup archive'); + + $backupDirectory = rtrim($backupDirectory, '/'); + $containerBackupPath = '/var/lib/clickhouse/backups/'.$archiveName; + $backupLocation = $backupDirectory.'/'.$archiveName; + $query = "BACKUP DATABASE `{$database}` TO File('{$archiveName}')"; + + return [ + 'mkdir -p '.escapeshellarg($backupDirectory), + 'docker exec '.escapeshellarg($containerName).' clickhouse-client --query '.escapeshellarg($query), + 'docker cp '.escapeshellarg($containerName.':'.$containerBackupPath).' '.escapeshellarg($backupLocation), + ]; + } + + public static function cleanup(string $containerName, string $archiveName): string + { + validateFilenameSafe($archiveName, 'ClickHouse backup archive'); + + $containerBackupPath = '/var/lib/clickhouse/backups/'.$archiveName; + + return 'docker exec '.escapeshellarg($containerName).' rm -f '.escapeshellarg($containerBackupPath); + } +} diff --git a/resources/views/livewire/admin/index.blade.php b/resources/views/livewire/admin/index.blade.php index acba3acce..8abb92413 100644 --- a/resources/views/livewire/admin/index.blade.php +++ b/resources/views/livewire/admin/index.blade.php @@ -22,7 +22,7 @@
{{ $user->name }}
{{ $user->email }}
Active: - {{ $user->teams()->whereRelation('subscription', 'stripe_subscription_id', '!=', null)->exists() ? 'Yes' : 'No' }} + {{ $user->teams()->whereRelation('subscription', 'stripe_invoice_paid', true)->exists() ? 'Yes' : 'No' }}
diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index 4f810d755..515241364 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -97,6 +97,10 @@ helper="Comma separated list of databases to backup. Empty will include the default one." id="databasesToBackup" /> @endif + @elseif($backup->database_type === 'App\Models\StandaloneClickhouse') + @endif
diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index ca4fcf1ea..66af4895f 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -8,12 +8,7 @@ 'label' => 'Backups', 'route' => 'project.database.backup.index', 'active' => request()->routeIs('project.database.backup.index', 'project.database.backup.execution'), - 'visible' => in_array($database->getMorphClass(), [ - 'App\Models\StandalonePostgresql', - 'App\Models\StandaloneMongodb', - 'App\Models\StandaloneMysql', - 'App\Models\StandaloneMariadb', - ]), + 'visible' => $database->isBackupSolutionAvailable(), ], ]; @@ -200,11 +195,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow- Terminal @endcan - @if ( - $database->getMorphClass() === 'App\Models\StandalonePostgresql' || - $database->getMorphClass() === 'App\Models\StandaloneMongodb' || - $database->getMorphClass() === 'App\Models\StandaloneMysql' || - $database->getMorphClass() === 'App\Models\StandaloneMariadb') + @if ($database->isBackupSolutionAvailable()) Backups diff --git a/tests/Feature/AdminSubscriptionStatusTest.php b/tests/Feature/AdminSubscriptionStatusTest.php new file mode 100644 index 000000000..94e160935 --- /dev/null +++ b/tests/Feature/AdminSubscriptionStatusTest.php @@ -0,0 +1,47 @@ +set('cache.default', 'array'); + config()->set('constants.coolify.self_hosted', false); + + InstanceSettings::unguarded( + fn () => InstanceSettings::query()->firstOrCreate(['id' => 0]) + ); + + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + $rootUser = User::find(0) ?? User::factory()->create(['id' => 0]); + $rootTeam->members()->syncWithoutDetaching([ + $rootUser->id => ['role' => 'admin'], + ]); + + $inactiveUser = User::factory()->create(['email' => 'inactive@example.com']); + $inactiveTeam = Team::factory()->create(); + $inactiveTeam->members()->attach($inactiveUser->id, ['role' => 'owner']); + Subscription::create([ + 'team_id' => $inactiveTeam->id, + 'stripe_subscription_id' => 'sub_stale', + 'stripe_invoice_paid' => false, + ]); + + $this->actingAs($rootUser); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(AdminIndex::class) + ->set([ + 'foundUsers' => collect(), + 'search' => 'inactive@example.com', + ]) + ->call('submitSearch') + ->assertSee('No') + ->assertDontSee('Yes'); +}); diff --git a/tests/Feature/Api/DatabaseBackupCreationApiTest.php b/tests/Feature/Api/DatabaseBackupCreationApiTest.php index 8882fec39..cb0c5cf73 100644 --- a/tests/Feature/Api/DatabaseBackupCreationApiTest.php +++ b/tests/Feature/Api/DatabaseBackupCreationApiTest.php @@ -5,8 +5,10 @@ use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -15,7 +17,7 @@ uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::updateOrCreate(['id' => 0]); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); @@ -77,6 +79,56 @@ function backupHeaders(): array } describe('POST /api/v1/databases/{uuid}/backups', function () { + test('rejects backup configurations for unsupported database types', function () { + $database = StandaloneRedis::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Redis DB', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$database->uuid}/backups", [ + 'frequency' => 'daily', + ]); + + $response->assertUnprocessable() + ->assertJson([ + 'message' => 'Scheduled backups are not supported for this database type.', + ]); + + expect(ScheduledDatabaseBackup::count())->toBe(0); + }); + + test('defaults clickhouse backups to its configured database', function () { + $database = StandaloneClickhouse::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'ClickHouse DB', + 'clickhouse_admin_user' => 'default', + 'clickhouse_admin_password' => 'password', + 'clickhouse_db' => 'analytics', + 'image' => 'clickhouse/clickhouse-server:25.11', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$database->uuid}/backups", [ + 'frequency' => 'daily', + ]); + + $response->assertCreated(); + + $backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($backup->databases_to_backup)->toBe('analytics') + ->and($backup->database_type)->toBe(StandaloneClickhouse::class); + }); + test('creates backup configuration with valid frequency', function () { $response = $this->withHeaders(backupHeaders()) ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ diff --git a/tests/Feature/CleanupUnverifiedUsersCommandTest.php b/tests/Feature/CleanupUnverifiedUsersCommandTest.php new file mode 100644 index 000000000..39d5c67ba --- /dev/null +++ b/tests/Feature/CleanupUnverifiedUsersCommandTest.php @@ -0,0 +1,154 @@ +set('constants.coolify.self_hosted', false); +}); + +test('it previews eligible unverified users without deleting them', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'unverified@example.com', + ]); + + $this->artisan('cloud:cleanup-unverified-users') + ->expectsOutput('Found 1 unverified user eligible for deletion.') + ->expectsOutput('Dry run only. Use --yes to delete eligible users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it reports dry run progress', function () { + $users = User::factory()->unverified()->count(2)->create(); + + $exitCode = Artisan::call('cloud:cleanup-unverified-users'); + + expect($exitCode)->toBe(0) + ->and(Artisan::output())->toContain('Checking eligible users: 2/2'); + + $users->each(fn (User $user) => $this->assertModelExists($user)); +}); + +test('it deletes eligible unverified users with the yes option', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'unverified@example.com', + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 1 unverified user.') + ->assertSuccessful(); + + $this->assertModelMissing($user); +}); + +test('it reports deletion progress', function () { + User::factory()->unverified()->count(2)->create(); + + $exitCode = Artisan::call('cloud:cleanup-unverified-users', ['--yes' => true]); + + expect($exitCode)->toBe(0) + ->and(Artisan::output())->toContain('Deleting eligible users: 2/2'); +}); + +test('it keeps verified users', function () { + $user = User::factory()->create([ + 'email' => 'verified@example.com', + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it keeps unverified users with a Stripe subscription record', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'subscribed@example.com', + ]); + + Subscription::create([ + 'team_id' => $user->teams()->firstOrFail()->id, + 'stripe_invoice_paid' => false, + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it keeps unverified users with defined resources', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'resource-owner@example.com', + ]); + + $project = Project::factory()->create([ + 'team_id' => $user->teams()->firstOrFail()->id, + ]); + $environment = Environment::factory()->create([ + 'project_id' => $project->id, + ]); + Application::factory()->create([ + 'environment_id' => $environment->id, + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it keeps unverified users with servers', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'server-owner@example.com', + ]); + + Server::factory()->create([ + 'team_id' => $user->teams()->firstOrFail()->id, + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it keeps unverified root team members', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'root-member@example.com', + ]); + $rootTeam = Team::factory()->create([ + 'id' => 0, + 'name' => 'Root Team', + ]); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it only runs on Coolify Cloud', function () { + config()->set('constants.coolify.self_hosted', true); + + $this->artisan('cloud:cleanup-unverified-users') + ->expectsOutput('This command can only be run on Coolify Cloud.') + ->assertFailed(); +}); diff --git a/tests/Feature/CloudExportUsersCommandTest.php b/tests/Feature/CloudExportUsersCommandTest.php new file mode 100644 index 000000000..45dda4959 --- /dev/null +++ b/tests/Feature/CloudExportUsersCommandTest.php @@ -0,0 +1,101 @@ +set('constants.coolify.self_hosted', false); +}); + +test('it exports subscribed and unsubscribed verified users to separate files without tags', function () { + User::factory()->create([ + 'id' => 0, + 'name' => 'Root User', + 'email' => 'root@example.com', + ]); + + $subscribedUser = User::factory()->create([ + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + ]); + + Subscription::create([ + 'team_id' => $subscribedUser->teams()->firstOrFail()->id, + 'stripe_invoice_paid' => true, + 'stripe_subscription_id' => 'sub_active', + ]); + + User::factory()->create([ + 'name' => 'Grace', + 'email' => 'grace@example.com', + ]); + + User::factory()->unverified()->create([ + 'name' => 'Unverified User', + 'email' => 'unverified@example.com', + ]); + + Storage::disk('backups')->put('cloud-users.csv', 'old export'); + + $this->artisan('cloud:export-users')->assertSuccessful(); + + Storage::disk('backups')->assertExists([ + 'cloud-users-subscribed.csv', + 'cloud-users-unsubscribed.csv', + ]); + Storage::disk('backups')->assertMissing('cloud-users.csv'); + + $readCsv = function (string $filename): array { + $output = fopen(Storage::disk('backups')->path($filename), 'rb'); + $rows = []; + + while (($row = fgetcsv($output, null, ',', '"', '')) !== false) { + $rows[] = $row; + } + + fclose($output); + + return $rows; + }; + + $header = [ + 'email', + 'first_name', + 'last_name', + 'lifetime_value_currency', + 'lifetime_value_amount', + 'utm_campaign', + 'utm_source', + 'utm_medium', + 'utm_content', + 'utm_term', + 'phone', + ]; + + expect($readCsv('cloud-users-subscribed.csv'))->toBe([ + $header, + ['ada@example.com', 'Ada', 'Lovelace', '', '', '', '', '', '', '', ''], + ])->and($readCsv('cloud-users-unsubscribed.csv'))->toBe([ + $header, + ['grace@example.com', 'Grace', '', '', '', '', '', '', '', '', ''], + ]); +}); + +test('it only runs on Coolify Cloud', function () { + config()->set('constants.coolify.self_hosted', true); + + $this->artisan('cloud:export-users') + ->expectsOutput('This command can only be run on Coolify Cloud.') + ->assertFailed(); + + Storage::disk('backups')->assertMissing([ + 'cloud-users.csv', + 'cloud-users-subscribed.csv', + 'cloud-users-unsubscribed.csv', + ]); +}); diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php index 4b5eec24c..56a4cf479 100644 --- a/tests/Feature/CreateScheduledBackupValidationTest.php +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -6,8 +6,10 @@ use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -136,3 +138,49 @@ function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage expect($backup->save_s3)->toBeTruthy(); expect($backup->s3_storage_id)->toBe($s3->id); }); + +it('creates a clickhouse backup for its configured database', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = StandaloneClickhouse::create([ + 'name' => 'clickhouse-scheduled-backup', + 'clickhouse_admin_user' => 'default', + 'clickhouse_admin_password' => 'password', + 'clickhouse_db' => 'analytics', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', 'daily') + ->call('submit') + ->assertDispatched('refreshScheduledBackups'); + + $backup = ScheduledDatabaseBackup::firstOrFail(); + + expect($backup->database_type)->toBe(StandaloneClickhouse::class) + ->and($backup->databases_to_backup)->toBe('analytics'); +}); + +it('rejects scheduled backups for unsupported database types', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = StandaloneRedis::create([ + 'name' => 'redis-without-backups', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', 'daily') + ->call('submit') + ->assertDispatched('error'); + + expect(ScheduledDatabaseBackup::count())->toBe(0); +}); diff --git a/tests/Feature/ScheduleOnOneServerTest.php b/tests/Feature/ScheduleOnOneServerTest.php index 167d16bfa..49d592689 100644 --- a/tests/Feature/ScheduleOnOneServerTest.php +++ b/tests/Feature/ScheduleOnOneServerTest.php @@ -48,3 +48,13 @@ ); }); }); + +it('does not schedule Stripe subscription reconciliation automatically', function () { + $schedule = app(Schedule::class); + + $event = collect($schedule->events())->first( + fn ($event) => str_contains((string) $event->description, 'SyncStripeSubscriptions') + ); + + expect($event)->toBeNull(); +}); diff --git a/tests/Feature/Subscription/SyncStripeSubscriptionsActionTest.php b/tests/Feature/Subscription/SyncStripeSubscriptionsActionTest.php new file mode 100644 index 000000000..31070053d --- /dev/null +++ b/tests/Feature/Subscription/SyncStripeSubscriptionsActionTest.php @@ -0,0 +1,296 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + + $this->team = Team::factory()->create(); + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_stale', + 'stripe_customer_id' => 'cus_stale', + 'stripe_invoice_paid' => true, + ]); +}); + +afterEach(function () { + SyncStripeSubscriptions::clearFake(); +}); + +function stripeSubscriptionCollection(array $subscriptions = []): StripeCollection +{ + return StripeCollection::constructFrom([ + 'data' => $subscriptions, + 'has_more' => false, + 'url' => '/v1/subscriptions', + ]); +} + +test('fix mode fully ends a locally active subscription with an invalid Stripe status', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'unreachable_count' => 0, + 'unreachable_notification_sent' => false, + ]); + + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $customers = Mockery::mock(CustomerService::class); + $stripe->subscriptions = $subscriptions; + $stripe->customers = $customers; + + $subscriptions->shouldReceive('all')->twice()->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [ + 'status' => 'unpaid', + 'customer' => 'cus_stale', + ]); + $customers->shouldReceive('retrieve')->with('cus_stale')->andThrow(new RuntimeException('Customer unavailable')); + + $this->instance(StripeClient::class, $stripe); + + $progress = []; + + SyncStripeSubscriptions::run( + fix: true, + onProgress: function (string $stage, int $current, ?int $total) use (&$progress): void { + $progress[] = [$stage, $current, $total]; + }, + ); + + $this->subscription->refresh(); + $server->refresh(); + + expect($this->subscription->stripe_invoice_paid)->toBeFalsy() + ->and($this->subscription->stripe_subscription_id)->toBeNull() + ->and($server->unreachable_count)->toBe(3) + ->and($server->unreachable_notification_sent)->toBeTrue() + ->and($progress)->toContain(['checking', 0, 1]) + ->and($progress)->toContain(['checking', 1, 1]); +}); + +test('a same-email subscription not linked to the team is left for manual review', function () { + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $customers = Mockery::mock(CustomerService::class); + $stripe->subscriptions = $subscriptions; + $stripe->customers = $customers; + + $subscriptions->shouldReceive('all') + ->with(Mockery::on(fn (array $parameters) => isset($parameters['status']))) + ->twice() + ->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [ + 'status' => 'canceled', + 'customer' => 'cus_stale', + ]); + $customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) [ + 'email' => 'customer@example.com', + ]); + $customers->shouldReceive('all')->andReturn((object) [ + 'data' => [(object) ['id' => 'cus_active']], + ]); + $subscriptions->shouldReceive('all')->with([ + 'customer' => 'cus_active', + 'limit' => 10, + ])->andReturn((object) [ + 'data' => [(object) [ + 'id' => 'sub_active_elsewhere', + 'status' => 'active', + ]], + ]); + + $this->instance(StripeClient::class, $stripe); + + $result = SyncStripeSubscriptions::run(fix: true); + + $this->subscription->refresh(); + + expect($result['resubscribed'])->toHaveCount(1) + ->and($result['discrepancies'])->toHaveCount(1) + ->and($result['discrepancies'][0]['resolution'])->toBe('manual_review') + ->and($this->subscription->stripe_invoice_paid)->toBeTruthy() + ->and($this->subscription->stripe_subscription_id)->toBe('sub_stale'); +}); + +test('a stale local subscription is deleted when its team has a valid replacement', function () { + $replacement = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_active_elsewhere', + 'stripe_customer_id' => 'cus_active', + 'stripe_invoice_paid' => true, + ]); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $customers = Mockery::mock(CustomerService::class); + $stripe->subscriptions = $subscriptions; + $stripe->customers = $customers; + + $subscriptions->shouldReceive('all') + ->with(['status' => 'active', 'limit' => 100]) + ->andReturn(stripeSubscriptionCollection([ + ['id' => 'sub_active_elsewhere'], + ])); + $subscriptions->shouldReceive('all') + ->with(['status' => 'past_due', 'limit' => 100]) + ->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [ + 'status' => 'canceled', + 'customer' => 'cus_stale', + ]); + $customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) [ + 'email' => 'customer@example.com', + ]); + $customers->shouldReceive('all')->andReturn((object) [ + 'data' => [(object) ['id' => 'cus_active']], + ]); + $subscriptions->shouldReceive('all')->with([ + 'customer' => 'cus_active', + 'limit' => 10, + ])->andReturn((object) [ + 'data' => [(object) [ + 'id' => 'sub_active_elsewhere', + 'status' => 'active', + ]], + ]); + + $this->instance(StripeClient::class, $stripe); + + $result = SyncStripeSubscriptions::run(fix: true); + + expect($result['discrepancies'][0]['resolution'])->toBe('delete_stale') + ->and($this->subscription->fresh())->toBeNull() + ->and($replacement->fresh())->not->toBeNull() + ->and($replacement->fresh()->stripe_invoice_paid)->toBeTruthy() + ->and($server->settings->fresh()->is_reachable)->toBeTruthy() + ->and($server->settings->fresh()->is_usable)->toBeTruthy(); +}); + +test('a stale duplicate is deleted when its team already has an inactive subscription row', function () { + $inactiveSubscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_customer_id' => 'cus_stale', + 'stripe_invoice_paid' => false, + ]); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $customers = Mockery::mock(CustomerService::class); + $stripe->subscriptions = $subscriptions; + $stripe->customers = $customers; + + $subscriptions->shouldReceive('all')->twice()->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [ + 'status' => 'canceled', + 'customer' => 'cus_stale', + ]); + $customers->shouldReceive('retrieve')->with('cus_stale')->andThrow(new RuntimeException('Customer unavailable')); + + $this->instance(StripeClient::class, $stripe); + + $result = SyncStripeSubscriptions::run(fix: true); + + expect($result['discrepancies'][0]['resolution'])->toBe('delete_stale') + ->and($this->subscription->fresh())->toBeNull() + ->and($inactiveSubscription->fresh())->not->toBeNull() + ->and($server->settings->fresh()->is_reachable)->toBeFalsy() + ->and($server->settings->fresh()->is_usable)->toBeFalsy(); +}); + +test('valid Stripe subscriptions remain active', function (string $status) { + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $stripe->subscriptions = $subscriptions; + + $subscriptions->shouldReceive('all') + ->with(['status' => $status, 'limit' => 100]) + ->andReturn(stripeSubscriptionCollection([ + ['id' => 'sub_stale'], + ])); + $subscriptions->shouldReceive('all') + ->with(['status' => $status === 'active' ? 'past_due' : 'active', 'limit' => 100]) + ->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldNotReceive('retrieve'); + + $this->instance(StripeClient::class, $stripe); + + $result = SyncStripeSubscriptions::run(fix: true); + + $this->subscription->refresh(); + + expect($result['discrepancies'])->toBeEmpty() + ->and($this->subscription->stripe_invoice_paid)->toBeTruthy() + ->and($this->subscription->stripe_subscription_id)->toBe('sub_stale'); +})->with(['active', 'past_due']); + +test('the terminal command runs the reconciliation action synchronously', function () { + SyncStripeSubscriptions::shouldRun() + ->once() + ->withArgs(fn (bool $fix, ?Closure $onProgress) => $fix === false && $onProgress instanceof Closure) + ->andReturnUsing(function (bool $fix, Closure $onProgress): array { + $onProgress('checking', 2, 10); + + return [ + 'total_checked' => 0, + 'discrepancies' => [], + 'resubscribed' => [], + 'errors' => [], + 'fixed' => false, + ]; + }); + + $this->artisan('cloud:sync-stripe-subscriptions') + ->expectsOutputToContain('Checking stale subscriptions against Stripe... 2/10') + ->expectsOutput('Total subscriptions checked: 0') + ->assertSuccessful(); +}); + +test('the terminal command passes the fix option to the reconciliation action', function () { + SyncStripeSubscriptions::shouldRun() + ->once() + ->withArgs(fn (bool $fix, ?Closure $onProgress) => $fix === true && $onProgress instanceof Closure) + ->andReturn([ + 'total_checked' => 1, + 'discrepancies' => [[ + 'subscription_id' => 1, + 'team_id' => 2, + 'stripe_subscription_id' => 'sub_stale', + 'stripe_status' => 'canceled', + 'resolution' => 'manual_review', + ]], + 'resubscribed' => [], + 'errors' => [], + 'fixed' => true, + 'fixed_count' => 0, + 'manual_review_count' => 1, + ]); + + $this->artisan('cloud:sync-stripe-subscriptions', ['--fix' => true]) + ->expectsOutput('Total subscriptions checked: 1') + ->expectsOutput(' Resolution: Manual review required') + ->expectsOutput('Automatic corrections applied: 0') + ->expectsOutput('Skipped for manual review: 1') + ->assertSuccessful(); +}); diff --git a/tests/Feature/Subscription/TeamSubscriptionEndedTest.php b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php index 55d59e0e6..bc0f0a6f2 100644 --- a/tests/Feature/Subscription/TeamSubscriptionEndedTest.php +++ b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php @@ -1,5 +1,6 @@ toBeTrue(); }); + +test('subscriptionEnded updates the exact subscription when duplicate rows exist', function () { + $team = Team::factory()->create(); + $otherSubscription = Subscription::create([ + 'team_id' => $team->id, + 'stripe_subscription_id' => 'sub_other', + 'stripe_invoice_paid' => true, + ]); + $subscriptionToEnd = Subscription::create([ + 'team_id' => $team->id, + 'stripe_subscription_id' => 'sub_stale', + 'stripe_invoice_paid' => true, + ]); + + $team->subscriptionEnded($subscriptionToEnd); + + expect($subscriptionToEnd->fresh()->stripe_subscription_id)->toBeNull() + ->and($subscriptionToEnd->fresh()->stripe_invoice_paid)->toBeFalsy() + ->and($otherSubscription->fresh()->stripe_subscription_id)->toBe('sub_other') + ->and($otherSubscription->fresh()->stripe_invoice_paid)->toBeTruthy(); +}); diff --git a/tests/Unit/ClickhouseBackupCommandTest.php b/tests/Unit/ClickhouseBackupCommandTest.php new file mode 100644 index 000000000..0130a9dd6 --- /dev/null +++ b/tests/Unit/ClickhouseBackupCommandTest.php @@ -0,0 +1,36 @@ +isBackupSolutionAvailable())->toBeTrue(); +}); + +test('builds a native clickhouse backup command using container credentials', function () { + $commands = ClickhouseBackupCommand::make( + containerName: 'clickhouse-uuid', + database: 'analytics', + archiveName: 'backup-uuid.zip', + backupDirectory: '/data/coolify/backups/clickhouse', + ); + + expect($commands)->toHaveCount(3) + ->and($commands[0])->toBe("mkdir -p '/data/coolify/backups/clickhouse'") + ->and($commands[1])->toContain("docker exec 'clickhouse-uuid' clickhouse-client") + ->and($commands[1])->not->toContain('--password') + ->and($commands[1])->toContain("BACKUP DATABASE `analytics` TO File('") + ->and($commands[1])->toContain('backup-uuid.zip') + ->and($commands[2])->toBe("docker cp 'clickhouse-uuid:/var/lib/clickhouse/backups/backup-uuid.zip' '/data/coolify/backups/clickhouse/backup-uuid.zip'") + ->and(ClickhouseBackupCommand::cleanup('clickhouse-uuid', 'backup-uuid.zip')) + ->toBe("docker exec 'clickhouse-uuid' rm -f '/var/lib/clickhouse/backups/backup-uuid.zip'"); +}); + +test('rejects unsafe clickhouse database names', function () { + expect(fn () => ClickhouseBackupCommand::make( + containerName: 'clickhouse-uuid', + database: 'analytics`; DROP DATABASE default; --', + archiveName: 'backup-uuid.zip', + backupDirectory: '/data/coolify/backups/clickhouse', + ))->toThrow(Exception::class); +});