Skip to content

Commit 5ce61ff

Browse files
committed
Merge remote-tracking branch 'origin/next' into compose-settings-memory-crash
2 parents 60129c2 + 7d69981 commit 5ce61ff

38 files changed

Lines changed: 1626 additions & 133 deletions

app/Jobs/SyncStripeSubscriptionsJob.php renamed to app/Actions/Stripe/SyncStripeSubscriptions.php

Lines changed: 79 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,18 @@
11
<?php
22

3-
namespace App\Jobs;
3+
namespace App\Actions\Stripe;
44

55
use App\Models\Subscription;
6-
use Illuminate\Bus\Queueable;
7-
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
8-
use Illuminate\Contracts\Queue\ShouldQueue;
9-
use Illuminate\Foundation\Bus\Dispatchable;
10-
use Illuminate\Queue\InteractsWithQueue;
11-
use Illuminate\Queue\SerializesModels;
6+
use Lorisleiva\Actions\Concerns\AsAction;
127
use Stripe\StripeClient;
138

14-
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
9+
class SyncStripeSubscriptions
1510
{
16-
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
11+
use AsAction;
1712

18-
public int $tries = 1;
13+
private const VALID_STRIPE_STATUSES = ['active', 'past_due'];
1914

20-
public int $timeout = 1800; // 30 minutes max
21-
22-
public function __construct(public bool $fix = false)
23-
{
24-
$this->onQueue('high');
25-
}
26-
27-
public function handle(?\Closure $onProgress = null): array
15+
public function handle(bool $fix = false, ?\Closure $onProgress = null): array
2816
{
2917
if (! isCloud() || ! isStripe()) {
3018
return ['error' => 'Not running on Cloud or Stripe not configured'];
@@ -34,7 +22,9 @@ public function handle(?\Closure $onProgress = null): array
3422
->where('stripe_invoice_paid', true)
3523
->get();
3624

37-
$stripe = app(StripeClient::class);
25+
$stripe = app()->bound(StripeClient::class)
26+
? app(StripeClient::class)
27+
: new StripeClient(config('subscription.stripe_api_key'));
3828

3929
// Bulk fetch all valid subscription IDs from Stripe (active + past_due)
4030
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
@@ -43,13 +33,20 @@ public function handle(?\Closure $onProgress = null): array
4333
$staleSubscriptions = $subscriptions->filter(
4434
fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds)
4535
);
36+
$staleSubscriptionCount = $staleSubscriptions->count();
37+
38+
$onProgress?->__invoke('checking', 0, $staleSubscriptionCount);
4639

4740
// For each stale subscription, get the exact Stripe status and check for resubscriptions
4841
$discrepancies = [];
4942
$resubscribed = [];
5043
$errors = [];
44+
$fixedCount = 0;
45+
$manualReviewCount = 0;
46+
47+
foreach ($staleSubscriptions->values() as $index => $subscription) {
48+
$onProgress?->__invoke('checking', $index + 1, $staleSubscriptionCount);
5149

52-
foreach ($staleSubscriptions as $subscription) {
5350
try {
5451
$stripeSubscription = $stripe->subscriptions->retrieve(
5552
$subscription->stripe_subscription_id
@@ -66,8 +63,18 @@ public function handle(?\Closure $onProgress = null): array
6663
continue;
6764
}
6865

69-
// Check if this user resubscribed under a different customer/subscription
66+
if (in_array($stripeStatus, self::VALID_STRIPE_STATUSES, true)) {
67+
continue;
68+
}
69+
7070
$activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer);
71+
$validReplacement = Subscription::query()
72+
->where('team_id', $subscription->team_id)
73+
->where('id', '!=', $subscription->id)
74+
->where('stripe_invoice_paid', true)
75+
->whereIn('stripe_subscription_id', $validStripeIds)
76+
->first();
77+
7178
if ($activeSub) {
7279
$resubscribed[] = [
7380
'subscription_id' => $subscription->id,
@@ -78,33 +85,69 @@ public function handle(?\Closure $onProgress = null): array
7885
'new_stripe_subscription_id' => $activeSub['subscription_id'],
7986
'new_stripe_customer_id' => $activeSub['customer_id'],
8087
'new_status' => $activeSub['status'],
88+
'linked_to_team' => $validReplacement?->stripe_subscription_id === $activeSub['subscription_id'],
8189
];
90+
}
8291

83-
continue;
92+
$inactiveSubscription = null;
93+
if (! $validReplacement && ! $activeSub) {
94+
$inactiveSubscription = Subscription::query()
95+
->where('team_id', $subscription->team_id)
96+
->where('id', '!=', $subscription->id)
97+
->where('stripe_invoice_paid', false)
98+
->first();
8499
}
85100

101+
$resolution = match (true) {
102+
(bool) $validReplacement => 'delete_stale',
103+
(bool) $activeSub => 'manual_review',
104+
(bool) $inactiveSubscription => 'delete_stale',
105+
default => 'end_subscription',
106+
};
107+
86108
$discrepancies[] = [
87109
'subscription_id' => $subscription->id,
88110
'team_id' => $subscription->team_id,
89111
'stripe_subscription_id' => $subscription->stripe_subscription_id,
90112
'stripe_status' => $stripeStatus,
113+
'resolution' => $resolution,
91114
];
92115

93-
if ($this->fix) {
94-
$subscription->update([
95-
'stripe_invoice_paid' => false,
96-
'stripe_past_due' => false,
97-
]);
116+
if ($fix) {
117+
$team = $subscription->team;
118+
119+
if ($resolution === 'manual_review') {
120+
$manualReviewCount++;
121+
122+
continue;
123+
}
124+
125+
if ($resolution === 'delete_stale') {
126+
if (! $validReplacement && $inactiveSubscription && $team) {
127+
$team->subscriptionEnded($inactiveSubscription);
128+
}
129+
130+
$subscription->delete();
131+
$fixedCount++;
132+
133+
continue;
134+
}
98135

99-
if ($stripeStatus === 'canceled') {
100-
$subscription->team?->subscriptionEnded();
136+
if ($team) {
137+
$team->subscriptionEnded($subscription);
138+
} else {
139+
$subscription->update([
140+
'stripe_invoice_paid' => false,
141+
'stripe_past_due' => false,
142+
]);
101143
}
144+
$fixedCount++;
102145
}
103146
}
104147

105-
if ($this->fix && count($discrepancies) > 0) {
148+
if ($fix && $fixedCount > 0) {
106149
send_internal_notification(
107-
'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n".
150+
"SyncStripeSubscriptions: Fixed {$fixedCount} discrepancies:\n".
108151
json_encode($discrepancies, JSON_PRETTY_PRINT)
109152
);
110153
}
@@ -114,7 +157,9 @@ public function handle(?\Closure $onProgress = null): array
114157
'discrepancies' => $discrepancies,
115158
'resubscribed' => $resubscribed,
116159
'errors' => $errors,
117-
'fixed' => $this->fix,
160+
'fixed' => $fix,
161+
'fixed_count' => $fixedCount,
162+
'manual_review_count' => $manualReviewCount,
118163
];
119164
}
120165

@@ -183,13 +228,13 @@ private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure
183228
$validIds = [];
184229
$fetched = 0;
185230

186-
foreach (['active', 'past_due'] as $status) {
231+
foreach (self::VALID_STRIPE_STATUSES as $status) {
187232
foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) {
188233
$validIds[] = $sub->id;
189234
$fetched++;
190235

191236
if ($onProgress) {
192-
$onProgress($fetched);
237+
$onProgress('fetching', $fetched, null);
193238
}
194239
}
195240
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
namespace App\Console\Commands\Cloud;
4+
5+
use App\Models\User;
6+
use Illuminate\Console\Command;
7+
use Illuminate\Database\Eloquent\Builder;
8+
use Illuminate\Support\Str;
9+
10+
class CleanupUnverifiedUsers extends Command
11+
{
12+
protected $signature = 'cloud:cleanup-unverified-users
13+
{--yes : Delete eligible users instead of running a dry run}';
14+
15+
protected $description = 'Delete unverified users without Stripe subscriptions or defined resources';
16+
17+
public function handle(): int
18+
{
19+
if (! isCloud()) {
20+
$this->error('This command can only be run on Coolify Cloud.');
21+
22+
return self::FAILURE;
23+
}
24+
25+
$eligibleUsers = $this->eligibleUsers();
26+
$eligibleCount = $eligibleUsers->count();
27+
28+
$this->info("Found {$eligibleCount} ".Str::plural('unverified user', $eligibleCount).' eligible for deletion.');
29+
$shouldDelete = (bool) $this->option('yes');
30+
31+
if (! $shouldDelete) {
32+
$this->warn('Dry run only. Use --yes to delete eligible users.');
33+
}
34+
35+
$deletedCount = 0;
36+
37+
if ($eligibleCount > 0) {
38+
$progressAction = $shouldDelete ? 'Deleting' : 'Checking';
39+
$progressBar = $this->output->createProgressBar($eligibleCount);
40+
$progressBar->setFormat("{$progressAction} eligible users: %current%/%max% [%bar%] %percent:3s%%");
41+
$progressBar->start();
42+
43+
foreach ($eligibleUsers->lazyById(100) as $user) {
44+
if ($shouldDelete && $user->delete()) {
45+
$deletedCount++;
46+
}
47+
48+
$progressBar->advance();
49+
}
50+
51+
$progressBar->finish();
52+
$this->newLine(2);
53+
}
54+
55+
if ($shouldDelete) {
56+
$this->info("Deleted {$deletedCount} ".Str::plural('unverified user', $deletedCount).'.');
57+
}
58+
59+
return self::SUCCESS;
60+
}
61+
62+
private function eligibleUsers(): Builder
63+
{
64+
return User::query()
65+
->where('id', '!=', 0)
66+
->whereNull('email_verified_at')
67+
->whereDoesntHave('teams', fn (Builder $query) => $query->whereKey(0))
68+
->whereDoesntHave('teams.subscription')
69+
->whereDoesntHave('teams.servers')
70+
->whereDoesntHave('teams', function (Builder $query) {
71+
$query->whereHas('projects.applications')
72+
->orWhereHas('projects.postgresqls')
73+
->orWhereHas('projects.redis')
74+
->orWhereHas('projects.mongodbs')
75+
->orWhereHas('projects.mysqls')
76+
->orWhereHas('projects.mariadbs')
77+
->orWhereHas('projects.keydbs')
78+
->orWhereHas('projects.dragonflies')
79+
->orWhereHas('projects.clickhouses')
80+
->orWhereHas('projects.services');
81+
});
82+
}
83+
}

0 commit comments

Comments
 (0)