diff --git a/app/Console/Commands/DeleteManualAccountDataCommand.php b/app/Console/Commands/DeleteManualAccountDataCommand.php new file mode 100644 index 00000000..418c450c --- /dev/null +++ b/app/Console/Commands/DeleteManualAccountDataCommand.php @@ -0,0 +1,69 @@ +argument('email'); + + $user = User::withTrashed()->where('email', $email)->first(); + + if (! $user) { + $this->error("User with email '{$email}' not found."); + + return self::FAILURE; + } + + $accountIds = $user->accounts()->whereNull('banking_connection_id')->pluck('id'); + + if ($accountIds->isEmpty()) { + $this->info("User '{$email}' has no non-connected accounts."); + + return self::SUCCESS; + } + + $transactionCount = Transaction::withTrashed()->whereIn('account_id', $accountIds)->count(); + $balanceCount = AccountBalance::query()->whereIn('account_id', $accountIds)->count(); + + if (! $this->confirm("Delete {$transactionCount} transaction(s) and {$balanceCount} balance(s) across {$accountIds->count()} non-connected account(s) of '{$user->email}'?")) { + $this->info('Deletion cancelled.'); + + return self::SUCCESS; + } + + DB::transaction(function () use ($accountIds): void { + // forceDelete purges soft-deleted transactions too; balances have no SoftDeletes, so delete() is already a hard delete. + Transaction::query()->whereIn('account_id', $accountIds)->forceDelete(); + AccountBalance::query()->whereIn('account_id', $accountIds)->delete(); + }); + + $this->info("Deleted {$transactionCount} transaction(s) and {$balanceCount} balance(s) for '{$user->email}'."); + + return self::SUCCESS; + } +} diff --git a/tests/Feature/Console/DeleteManualAccountDataCommandTest.php b/tests/Feature/Console/DeleteManualAccountDataCommandTest.php new file mode 100644 index 00000000..9de187f0 --- /dev/null +++ b/tests/Feature/Console/DeleteManualAccountDataCommandTest.php @@ -0,0 +1,91 @@ +onboarded()->create(['email' => 'test@example.com']); + + $manual = Account::factory()->for($user)->create(); + $connected = Account::factory()->connected()->for($user)->create(); + + Transaction::factory()->count(3)->create(['user_id' => $user->id, 'account_id' => $manual->id]); + AccountBalance::factory()->count(2)->create(['account_id' => $manual->id]); + Transaction::factory()->count(4)->create(['user_id' => $user->id, 'account_id' => $connected->id]); + AccountBalance::factory()->count(5)->create(['account_id' => $connected->id]); + + $this->artisan('user:delete-manual-account-data', ['email' => 'test@example.com']) + ->expectsConfirmation("Delete 3 transaction(s) and 2 balance(s) across 1 non-connected account(s) of 'test@example.com'?", 'yes') + ->assertSuccessful(); + + expect(Transaction::withTrashed()->where('account_id', $manual->id)->exists())->toBeFalse(); + expect(AccountBalance::query()->where('account_id', $manual->id)->exists())->toBeFalse(); + expect(Transaction::query()->where('account_id', $connected->id)->count())->toBe(4); + expect(AccountBalance::query()->where('account_id', $connected->id)->count())->toBe(5); +}); + +test('does not touch other users data', function () { + $user = User::factory()->onboarded()->create(['email' => 'test@example.com']); + $other = User::factory()->onboarded()->create(['email' => 'keep@example.com']); + + $account = Account::factory()->for($user)->create(); + Transaction::factory()->count(3)->create(['user_id' => $user->id, 'account_id' => $account->id]); + AccountBalance::factory()->count(2)->create(['account_id' => $account->id]); + + $otherAccount = Account::factory()->for($other)->create(); + Transaction::factory()->count(2)->create(['user_id' => $other->id, 'account_id' => $otherAccount->id]); + AccountBalance::factory()->count(2)->create(['account_id' => $otherAccount->id]); + + $this->artisan('user:delete-manual-account-data', ['email' => 'test@example.com']) + ->expectsConfirmation("Delete 3 transaction(s) and 2 balance(s) across 1 non-connected account(s) of 'test@example.com'?", 'yes') + ->assertSuccessful(); + + expect(Transaction::query()->where('account_id', $account->id)->count())->toBe(0); + expect(AccountBalance::query()->where('account_id', $account->id)->count())->toBe(0); + expect(Transaction::query()->where('account_id', $otherAccount->id)->count())->toBe(2); + expect(AccountBalance::query()->where('account_id', $otherAccount->id)->count())->toBe(2); +}); + +test('reports zero non-connected accounts when the user has only connected ones', function () { + $user = User::factory()->onboarded()->create(['email' => 'test@example.com']); + Account::factory()->connected()->for($user)->create(); + + $this->artisan('user:delete-manual-account-data', ['email' => 'test@example.com']) + ->expectsOutput("User 'test@example.com' has no non-connected accounts.") + ->assertSuccessful(); +}); + +test('cancels when not confirmed', function () { + $user = User::factory()->onboarded()->create(['email' => 'test@example.com']); + $manual = Account::factory()->for($user)->create(); + Transaction::factory()->count(3)->create(['user_id' => $user->id, 'account_id' => $manual->id]); + + $this->artisan('user:delete-manual-account-data', ['email' => 'test@example.com']) + ->expectsConfirmation("Delete 3 transaction(s) and 0 balance(s) across 1 non-connected account(s) of 'test@example.com'?", 'no') + ->expectsOutput('Deletion cancelled.') + ->assertSuccessful(); + + expect(Transaction::query()->where('account_id', $manual->id)->count())->toBe(3); +}); + +test('counts and purges soft-deleted transactions', function () { + $user = User::factory()->onboarded()->create(['email' => 'test@example.com']); + $manual = Account::factory()->for($user)->create(); + + Transaction::factory()->count(2)->create(['user_id' => $user->id, 'account_id' => $manual->id]); + Transaction::factory()->count(1)->create(['user_id' => $user->id, 'account_id' => $manual->id])->each->delete(); + + $this->artisan('user:delete-manual-account-data', ['email' => 'test@example.com']) + ->expectsConfirmation("Delete 3 transaction(s) and 0 balance(s) across 1 non-connected account(s) of 'test@example.com'?", 'yes') + ->assertSuccessful(); + + expect(Transaction::withTrashed()->where('account_id', $manual->id)->count())->toBe(0); +}); + +test('shows error when user not found', function () { + $this->artisan('user:delete-manual-account-data', ['email' => 'nobody@example.com']) + ->expectsOutput("User with email 'nobody@example.com' not found.") + ->assertFailed(); +});