fix(encryption): never email or delete users who are still being billed
The `encryption:delete-accounts` and `encryption:notify-removal` commands target users who still hold legacy client-side encrypted data. They must never warn or delete an account that is still being billed. Add `excludeBilledUsers()` to the shared trait, filtering out users where `hasActiveSubscriptionOrTrial()` is true (valid subscription outside its grace period, or an active generic trial). Eager-load `subscriptions` to avoid an N+1 when applying the filter.
This commit is contained in:
parent
9b7632f585
commit
4241a06457
|
|
@ -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<User>
|
||||
*/
|
||||
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<int, User> $users
|
||||
* @return Collection<int, User>
|
||||
*/
|
||||
protected function excludeBilledUsers(Collection $users): Collection
|
||||
{
|
||||
return $users->reject(fn (User $user): bool => $user->hasActiveSubscriptionOrTrial())->values();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue