diff --git a/app/Console/Commands/Concerns/FindsUsersWithLegacyEncryption.php b/app/Console/Commands/Concerns/FindsUsersWithLegacyEncryption.php index fce90ce2..2c931a9d 100644 --- a/app/Console/Commands/Concerns/FindsUsersWithLegacyEncryption.php +++ b/app/Console/Commands/Concerns/FindsUsersWithLegacyEncryption.php @@ -4,6 +4,7 @@ namespace App\Console\Commands\Concerns; use App\Models\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; trait FindsUsersWithLegacyEncryption { @@ -11,11 +12,14 @@ trait FindsUsersWithLegacyEncryption * Users who still have at least one client-side encrypted transaction or account * (a non-null `*_iv` column is the source of truth, not the stale `accounts.encrypted` flag). * + * Subscriptions are eager-loaded so callers can filter with {@see excludeBilledUsers()} + * without an N+1 query. + * * @return Builder */ protected function usersWithLegacyEncryption(): Builder { - return User::query()->where(function (Builder $query): void { + return User::query()->with('subscriptions')->where(function (Builder $query): void { $query->whereHas('transactions', function (Builder $transactions): void { $transactions->whereNotNull('description_iv') ->orWhereNotNull('notes_iv'); @@ -24,4 +28,16 @@ trait FindsUsersWithLegacyEncryption }); }); } + + /** + * Drop users who are still being billed. These accounts must never be emailed a + * deletion warning nor deleted while a subscription or trial is active. + * + * @param Collection $users + * @return Collection + */ + protected function excludeBilledUsers(Collection $users): Collection + { + return $users->reject(fn (User $user): bool => $user->hasActiveSubscriptionOrTrial())->values(); + } } diff --git a/app/Console/Commands/DeleteEncryptedDataAccountsCommand.php b/app/Console/Commands/DeleteEncryptedDataAccountsCommand.php index 1b45a774..1e1a76af 100644 --- a/app/Console/Commands/DeleteEncryptedDataAccountsCommand.php +++ b/app/Console/Commands/DeleteEncryptedDataAccountsCommand.php @@ -36,13 +36,15 @@ class DeleteEncryptedDataAccountsCommand extends Command $days = (int) $this->option('days'); $cutoff = now()->subDays($days); - $users = $this->usersWithLegacyEncryption() - ->where('email', '!=', config('app.demo.email')) - ->where(function (Builder $query) use ($cutoff): void { - $query->whereNull('last_active_at') - ->orWhere('last_active_at', '<', $cutoff); - }) - ->get(); + $users = $this->excludeBilledUsers( + $this->usersWithLegacyEncryption() + ->where('email', '!=', config('app.demo.email')) + ->where(function (Builder $query) use ($cutoff): void { + $query->whereNull('last_active_at') + ->orWhere('last_active_at', '<', $cutoff); + }) + ->get() + ); if ($users->isEmpty()) { $this->info('No accounts to delete.'); diff --git a/app/Console/Commands/NotifyEncryptedDataRemovalCommand.php b/app/Console/Commands/NotifyEncryptedDataRemovalCommand.php index fe14f651..4ebb8f82 100644 --- a/app/Console/Commands/NotifyEncryptedDataRemovalCommand.php +++ b/app/Console/Commands/NotifyEncryptedDataRemovalCommand.php @@ -39,7 +39,7 @@ class NotifyEncryptedDataRemovalCommand extends Command */ public function handle(): int { - $users = $this->usersWithLegacyEncryption()->get(); + $users = $this->excludeBilledUsers($this->usersWithLegacyEncryption()->get()); if ($users->isEmpty()) { $this->info('No users with encrypted data found.'); diff --git a/tests/Feature/Console/DeleteEncryptedDataAccountsCommandTest.php b/tests/Feature/Console/DeleteEncryptedDataAccountsCommandTest.php index 8489411f..cf1b2c03 100644 --- a/tests/Feature/Console/DeleteEncryptedDataAccountsCommandTest.php +++ b/tests/Feature/Console/DeleteEncryptedDataAccountsCommandTest.php @@ -28,6 +28,23 @@ test('it soft-deletes users with encrypted data who did not sign in within the g expect(User::whereKey($clean->id)->exists())->toBeTrue(); }); +test('it never deletes a subscribed user', function () { + config(['subscriptions.enabled' => true]); + + $subscribed = User::factory()->create(['last_active_at' => now()->subDays(30)]); + Transaction::factory()->for($subscribed)->create(); + $subscribed->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_test123', + 'stripe_status' => 'active', + 'stripe_price' => 'price_test123', + ]); + + artisan('encryption:delete-accounts', ['--force' => true])->assertSuccessful(); + + expect(User::whereKey($subscribed->id)->exists())->toBeTrue(); +}); + test('it never deletes the demo account', function () { $demo = User::factory()->create([ 'email' => config('app.demo.email'), diff --git a/tests/Feature/Console/NotifyEncryptedDataRemovalCommandTest.php b/tests/Feature/Console/NotifyEncryptedDataRemovalCommandTest.php index 5077fac7..80f34244 100644 --- a/tests/Feature/Console/NotifyEncryptedDataRemovalCommandTest.php +++ b/tests/Feature/Console/NotifyEncryptedDataRemovalCommandTest.php @@ -28,6 +28,23 @@ test('it queues a warning email for users with encrypted transactions or account Queue::assertNotPushed(SendUpdateEmailJob::class, fn (SendUpdateEmailJob $job) => $job->user->is($clean)); }); +test('it never warns a subscribed user', function () { + config(['subscriptions.enabled' => true]); + + $subscribed = User::factory()->create(); + Transaction::factory()->for($subscribed)->create(); + $subscribed->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_test123', + 'stripe_status' => 'active', + 'stripe_price' => 'price_test123', + ]); + + artisan('encryption:notify-removal', ['--force' => true])->assertSuccessful(); + + Queue::assertNotPushed(SendUpdateEmailJob::class); +}); + test('it queues on the emails queue', function () { $user = User::factory()->create(); Transaction::factory()->for($user)->create();