feat(commands): delete transactions and balances of a user's non-connected accounts (#728)
## What Adds an artisan command that deletes all transactions and balances of a specific user's **non-connected** (manual) accounts, identified by email. ```bash php artisan user:delete-manual-account-data user@example.com ``` ## Behavior - Looks up the user by email (`withTrashed`, matching `user:delete`). - Selects accounts with `banking_connection_id IS NULL` (manual accounts). - Shows a confirmation prompt with the transaction/balance counts and the number of affected accounts. - On confirm, deletes inside a `DB::transaction`: - Transactions via `forceDelete()` (they use `SoftDeletes`, so this also purges already soft-deleted rows). - Balances via `delete()` (`AccountBalance` has no `SoftDeletes`, so it's a hard delete). - The accounts themselves are **not** deleted — only their transactions and balances. Related `label_transaction`, `budget_transactions` and `category_corrections` rows are cleaned up by existing `cascadeOnDelete()` foreign keys. ## Tests Pest feature test covering: manual-only deletion (connected accounts untouched), cross-user isolation, soft-deleted transactions counted and purged, connected-only user reports zero, cancellation, and user-not-found. QA skipped per request (backend maintenance command, covered by feature tests).
This commit is contained in:
parent
bd1ad633f8
commit
ca084ce5d1
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteManualAccountDataCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'user:delete-manual-account-data {email : The email address of the user}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete all transactions and balances of a user\'s non-connected accounts';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$email = $this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
|
||||
test('deletes transactions and balances of non-connected accounts only', function () {
|
||||
$user = User::factory()->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();
|
||||
});
|
||||
Loading…
Reference in New Issue