diff --git a/app/Http/Controllers/OpenBanking/ConnectionController.php b/app/Http/Controllers/OpenBanking/ConnectionController.php
index 41be2bbb..7380fab9 100644
--- a/app/Http/Controllers/OpenBanking/ConnectionController.php
+++ b/app/Http/Controllers/OpenBanking/ConnectionController.php
@@ -6,8 +6,12 @@ use App\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\OpenBanking\DestroyConnectionRequest;
+use App\Http\Requests\OpenBanking\UpdateConnectionCredentialsRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection;
+use App\Services\Banking\BinanceClient;
+use App\Services\Banking\BitpandaClient;
+use App\Services\Banking\IndexaCapitalClient;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
@@ -60,6 +64,64 @@ class ConnectionController extends Controller
return back()->with('success', 'Sync started. Transactions will be updated shortly.');
}
+ /**
+ * Update credentials for an API-key-based connection.
+ */
+ public function updateCredentials(UpdateConnectionCredentialsRequest $request, BankingConnection $connection): RedirectResponse
+ {
+ $validated = $request->validated();
+
+ $validationError = $this->validateProviderCredentials($connection, $validated);
+
+ if ($validationError) {
+ return back()->withErrors(['credentials' => $validationError]);
+ }
+
+ $updateData = match ($connection->provider) {
+ 'indexacapital' => ['api_token' => $validated['api_token']],
+ 'binance' => ['api_token' => $validated['api_key'], 'api_secret' => $validated['api_secret']],
+ 'bitpanda' => ['api_token' => $validated['api_key']],
+ default => [],
+ };
+
+ $connection->update([
+ ...$updateData,
+ 'status' => BankingConnectionStatus::Active,
+ 'error_message' => null,
+ ]);
+
+ SyncBankingConnectionJob::dispatch($connection);
+
+ return back()->with('success', __('Credentials updated. Sync started.'));
+ }
+
+ /**
+ * Validate credentials against the provider API.
+ */
+ private function validateProviderCredentials(BankingConnection $connection, array $validated): ?string
+ {
+ try {
+ match ($connection->provider) {
+ 'indexacapital' => (new IndexaCapitalClient($validated['api_token']))->getUser(),
+ 'binance' => (new BinanceClient($validated['api_key'], $validated['api_secret']))->getAccount(),
+ 'bitpanda' => (new BitpandaClient($validated['api_key']))->getCryptoWallets(),
+ default => throw new \InvalidArgumentException('Unsupported provider for credential update.'),
+ };
+ } catch (\InvalidArgumentException $e) {
+ return $e->getMessage();
+ } catch (\Throwable $e) {
+ Log::warning('Credential validation failed during update', [
+ 'connection_id' => $connection->id,
+ 'provider' => $connection->provider,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return __('Invalid credentials. Please check and try again.');
+ }
+
+ return null;
+ }
+
/**
* Revoke and delete a banking connection.
*/
diff --git a/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php b/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php
new file mode 100644
index 00000000..d6f566cf
--- /dev/null
+++ b/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php
@@ -0,0 +1,45 @@
+route('connection');
+
+ return Feature::for($this->user())->active('open-banking')
+ && $connection instanceof BankingConnection
+ && $connection->user_id === $this->user()->id;
+ }
+
+ /**
+ * @return array>
+ */
+ public function rules(): array
+ {
+ $connection = $this->route('connection');
+
+ if (! $connection instanceof BankingConnection) {
+ return [];
+ }
+
+ return match ($connection->provider) {
+ 'indexacapital' => [
+ 'api_token' => ['required', 'string', 'min:10'],
+ ],
+ 'binance' => [
+ 'api_key' => ['required', 'string', 'min:10'],
+ 'api_secret' => ['required', 'string', 'min:10'],
+ ],
+ 'bitpanda' => [
+ 'api_key' => ['required', 'string', 'min:10'],
+ ],
+ default => [],
+ };
+ }
+}
diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php
index 33e4d995..d3f5e3e4 100644
--- a/app/Jobs/SyncBankingConnectionJob.php
+++ b/app/Jobs/SyncBankingConnectionJob.php
@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Enums\BankingConnectionStatus;
+use App\Mail\BankingConnectionAuthFailedEmail;
use App\Mail\BankTransactionsSyncedEmail;
use App\Models\BankingConnection;
use App\Services\Banking\BalanceSyncService;
@@ -84,6 +85,13 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
'error_message' => $this->friendlyErrorMessage($e),
]);
+ if ($this->isAuthError($e) && $this->isApiKeyProvider($connection) && $this->attempts() >= $this->tries) {
+ Mail::to($connection->user)->send(new BankingConnectionAuthFailedEmail(
+ $connection->user,
+ $connection,
+ ));
+ }
+
throw $e;
}
}
@@ -194,4 +202,17 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
return __('An unexpected error occurred during sync. Please try again later.');
}
+
+ private function isAuthError(\Throwable $e): bool
+ {
+ return $e instanceof RequestException
+ && in_array($e->response->status(), [401, 403]);
+ }
+
+ private function isApiKeyProvider(BankingConnection $connection): bool
+ {
+ return $connection->isIndexaCapital()
+ || $connection->isBinance()
+ || $connection->isBitpanda();
+ }
}
diff --git a/app/Mail/BankingConnectionAuthFailedEmail.php b/app/Mail/BankingConnectionAuthFailedEmail.php
new file mode 100644
index 00000000..bdabf3d0
--- /dev/null
+++ b/app/Mail/BankingConnectionAuthFailedEmail.php
@@ -0,0 +1,69 @@
+
+ */
+ public $backoff = [2, 5, 10, 30];
+
+ public function __construct(
+ public User $user,
+ public BankingConnection $bankingConnection,
+ ) {
+ $this->onQueue('emails');
+ }
+
+ public function envelope(): Envelope
+ {
+ return new Envelope(
+ subject: __('Action required: :provider connection needs attention', [
+ 'provider' => $this->bankingConnection->aspsp_name,
+ ]),
+ )->from(config('mail.from.address', 'hello@example.com'), 'Victor');
+ }
+
+ public function content(): Content
+ {
+ return new Content(
+ markdown: 'mail.banking-connection-auth-failed',
+ with: [
+ 'userName' => $this->user->name,
+ 'providerName' => $this->bankingConnection->aspsp_name,
+ ],
+ );
+ }
+
+ /**
+ * Get the middleware the job should pass through.
+ *
+ * @return array
+ */
+ public function middleware(): array
+ {
+ return [(new RateLimited('emails'))->releaseAfter(1)];
+ }
+}
diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php
index abd79166..be8a2cc7 100644
--- a/app/Models/BankingConnection.php
+++ b/app/Models/BankingConnection.php
@@ -32,6 +32,14 @@ class BankingConnection extends Model
'api_secret',
];
+ protected $hidden = [
+ 'api_token',
+ 'api_secret',
+ 'pending_accounts_data',
+ 'authorization_id',
+ 'session_id',
+ ];
+
protected function casts(): array
{
return [
diff --git a/resources/js/components/open-banking/update-credentials-dialog.tsx b/resources/js/components/open-banking/update-credentials-dialog.tsx
new file mode 100644
index 00000000..dcee1825
--- /dev/null
+++ b/resources/js/components/open-banking/update-credentials-dialog.tsx
@@ -0,0 +1,242 @@
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import type { BankingConnection } from '@/types/banking';
+import { __ } from '@/utils/i18n';
+import { router } from '@inertiajs/react';
+import { useState } from 'react';
+
+interface UpdateCredentialsDialogProps {
+ connection: BankingConnection;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function UpdateCredentialsDialog({
+ connection,
+ open,
+ onOpenChange,
+}: UpdateCredentialsDialogProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [apiToken, setApiToken] = useState('');
+ const [apiKey, setApiKey] = useState('');
+ const [apiSecret, setApiSecret] = useState('');
+ const [error, setError] = useState(null);
+
+ const isIndexaCapital = connection.provider === 'indexacapital';
+ const isBinance = connection.provider === 'binance';
+ const isBitpanda = connection.provider === 'bitpanda';
+
+ const isValid = isIndexaCapital
+ ? apiToken.length > 0
+ : isBinance
+ ? apiKey.length > 0 && apiSecret.length > 0
+ : isBitpanda
+ ? apiKey.length > 0
+ : false;
+
+ function handleSubmit() {
+ setIsSubmitting(true);
+ setError(null);
+
+ const data = isIndexaCapital
+ ? { api_token: apiToken }
+ : isBinance
+ ? { api_key: apiKey, api_secret: apiSecret }
+ : { api_key: apiKey };
+
+ router.patch(
+ `/settings/connections/${connection.id}/credentials`,
+ data,
+ {
+ onSuccess: () => {
+ onOpenChange(false);
+ resetState();
+ },
+ onError: (errors) => {
+ setError(
+ errors.credentials ??
+ errors.api_token ??
+ errors.api_key ??
+ errors.api_secret ??
+ __(
+ 'Failed to update credentials. Please try again.',
+ ),
+ );
+ },
+ onFinish: () => {
+ setIsSubmitting(false);
+ },
+ },
+ );
+ }
+
+ function resetState() {
+ setApiToken('');
+ setApiKey('');
+ setApiSecret('');
+ setError(null);
+ }
+
+ function handleOpenChange(value: boolean) {
+ if (!value) {
+ resetState();
+ }
+ onOpenChange(value);
+ }
+
+ return (
+
+ );
+}
diff --git a/resources/js/pages/settings/connections.tsx b/resources/js/pages/settings/connections.tsx
index 33f7c563..00dbed40 100644
--- a/resources/js/pages/settings/connections.tsx
+++ b/resources/js/pages/settings/connections.tsx
@@ -1,6 +1,7 @@
import { ConnectAccountDialog } from '@/components/open-banking/connect-account-dialog';
import { ConnectionStatusBadge } from '@/components/open-banking/connection-status-badge';
import { DisconnectDialog } from '@/components/open-banking/disconnect-dialog';
+import { UpdateCredentialsDialog } from '@/components/open-banking/update-credentials-dialog';
import { Button } from '@/components/ui/button';
import {
Card,
@@ -26,6 +27,7 @@ import { Head, router, usePage, usePoll } from '@inertiajs/react';
import {
AlertCircle,
ArrowRight,
+ KeyRound,
MoreHorizontal,
RefreshCw,
Unplug,
@@ -43,6 +45,8 @@ export default function ConnectionsPage({ connections }: Props) {
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
const [disconnectConnection, setDisconnectConnection] =
useState(null);
+ const [updateCredentialsConnection, setUpdateCredentialsConnection] =
+ useState(null);
const hasSyncing = connections.some(
(c) => c.status === 'active' && !c.last_synced_at,
@@ -71,6 +75,21 @@ export default function ConnectionsPage({ connections }: Props) {
router.post(`/settings/connections/${connection.id}/sync`);
}
+ function isApiKeyProvider(connection: BankingConnection): boolean {
+ return ['indexacapital', 'binance', 'bitpanda'].includes(
+ connection.provider,
+ );
+ }
+
+ function hasAuthError(connection: BankingConnection): boolean {
+ return (
+ connection.status === 'error' &&
+ isApiKeyProvider(connection) &&
+ (connection.error_message?.includes('Authentication failed') ??
+ false)
+ );
+ }
+
function formatDate(dateString: string | null): string {
if (!dateString) return __('Never');
return new Date(dateString).toLocaleDateString(undefined, {
@@ -165,6 +184,22 @@ export default function ConnectionsPage({ connections }: Props) {
{__('Map Accounts')}
)}
+ {hasAuthError(
+ connection,
+ ) && (
+
+ setUpdateCredentialsConnection(
+ connection,
+ )
+ }
+ >
+
+ {__(
+ 'Update Credentials',
+ )}
+
+ )}
{(connection.status ===
'active' ||
connection.status ===
@@ -252,6 +287,25 @@ export default function ConnectionsPage({ connections }: Props) {
)}
+ {hasAuthError(
+ connection,
+ ) && (
+
+ )}
)}
+
+ {updateCredentialsConnection && (
+ {
+ if (!open) setUpdateCredentialsConnection(null);
+ }}
+ />
+ )}
);
diff --git a/resources/views/mail/banking-connection-auth-failed.blade.php b/resources/views/mail/banking-connection-auth-failed.blade.php
new file mode 100644
index 00000000..45bcf492
--- /dev/null
+++ b/resources/views/mail/banking-connection-auth-failed.blade.php
@@ -0,0 +1,17 @@
+
+# {{ __('Your :provider connection needs attention', ['provider' => $providerName]) }}
+
+{{ __('Hi :name,', ['name' => $userName]) }}
+
+{{ __('We were unable to sync your :provider connection because your credentials appear to have expired or been revoked.', ['provider' => $providerName]) }}
+
+{{ __('To fix this, generate a new API token from your :provider account and update it in your connection settings.', ['provider' => $providerName]) }}
+
+
+{{ __('Update Credentials') }}
+
+
+Best,
+VĂctor F,
+Founder of Whisper Money
+
diff --git a/routes/settings.php b/routes/settings.php
index 86166666..2cace206 100644
--- a/routes/settings.php
+++ b/routes/settings.php
@@ -69,6 +69,7 @@ Route::middleware('auth')->group(function () {
Route::middleware('open-banking')->group(function () {
Route::get('settings/connections', [ConnectionController::class, 'index'])->name('settings.connections.index');
Route::post('settings/connections/{connection}/sync', [ConnectionController::class, 'sync'])->name('settings.connections.sync');
+ Route::patch('settings/connections/{connection}/credentials', [ConnectionController::class, 'updateCredentials'])->name('settings.connections.update-credentials');
Route::delete('settings/connections/{connection}', [ConnectionController::class, 'destroy'])->name('settings.connections.destroy');
});
});
diff --git a/tests/Feature/OpenBanking/ConnectionControllerTest.php b/tests/Feature/OpenBanking/ConnectionControllerTest.php
index fb4bd8ea..02910076 100644
--- a/tests/Feature/OpenBanking/ConnectionControllerTest.php
+++ b/tests/Feature/OpenBanking/ConnectionControllerTest.php
@@ -221,3 +221,179 @@ test('users cannot sync expired connection', function () {
$response->assertRedirect();
$response->assertSessionHas('error');
});
+
+test('users can update indexa capital credentials with valid token', function () {
+ Queue::fake();
+
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ $connection = BankingConnection::factory()->indexaCapital()->error()->create([
+ 'user_id' => $user->id,
+ 'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/users/me' => Http::response([
+ 'accounts' => [['account_number' => 'IC-001', 'status' => 'active', 'type' => 'mutual']],
+ ]),
+ ]);
+
+ $response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
+ 'api_token' => 'new-valid-indexa-token-12345',
+ ]);
+
+ $response->assertRedirect();
+ $response->assertSessionHas('success');
+
+ $connection->refresh();
+ expect($connection->status)->toBe(BankingConnectionStatus::Active);
+ expect($connection->error_message)->toBeNull();
+ expect($connection->api_token)->toBe('new-valid-indexa-token-12345');
+});
+
+test('users can update binance credentials with valid api key and secret', function () {
+ Queue::fake();
+
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ $connection = BankingConnection::factory()->binance()->error()->create([
+ 'user_id' => $user->id,
+ 'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
+ ]);
+
+ Http::fake([
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [['asset' => 'BTC', 'free' => '1.0', 'locked' => '0.0']],
+ ]),
+ ]);
+
+ $response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
+ 'api_key' => 'new-valid-binance-key-12345',
+ 'api_secret' => 'new-valid-binance-secret-12345',
+ ]);
+
+ $response->assertRedirect();
+ $response->assertSessionHas('success');
+
+ $connection->refresh();
+ expect($connection->status)->toBe(BankingConnectionStatus::Active);
+ expect($connection->error_message)->toBeNull();
+ expect($connection->api_token)->toBe('new-valid-binance-key-12345');
+ expect($connection->api_secret)->toBe('new-valid-binance-secret-12345');
+});
+
+test('users can update bitpanda credentials with valid api key', function () {
+ Queue::fake();
+
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ $connection = BankingConnection::factory()->bitpanda()->error()->create([
+ 'user_id' => $user->id,
+ 'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
+ ]);
+
+ Http::fake([
+ 'api.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [],
+ ]),
+ ]);
+
+ $response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
+ 'api_key' => 'new-valid-bitpanda-key-12345',
+ ]);
+
+ $response->assertRedirect();
+ $response->assertSessionHas('success');
+
+ $connection->refresh();
+ expect($connection->status)->toBe(BankingConnectionStatus::Active);
+ expect($connection->error_message)->toBeNull();
+ expect($connection->api_token)->toBe('new-valid-bitpanda-key-12345');
+});
+
+test('updating credentials with invalid token returns validation error', function () {
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ $connection = BankingConnection::factory()->indexaCapital()->error()->create([
+ 'user_id' => $user->id,
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/users/me' => Http::response(['error' => 'Unauthorized'], 401),
+ ]);
+
+ $response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
+ 'api_token' => 'invalid-token-12345678',
+ ]);
+
+ $response->assertSessionHasErrors('credentials');
+
+ $connection->refresh();
+ expect($connection->status)->toBe(BankingConnectionStatus::Error);
+});
+
+test('cannot update credentials for enablebanking connection', function () {
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ $connection = BankingConnection::factory()->create([
+ 'user_id' => $user->id,
+ 'provider' => 'enablebanking',
+ ]);
+
+ $response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
+ 'api_token' => 'some-token-value-12345',
+ ]);
+
+ $response->assertSessionHasErrors();
+});
+
+test('cannot update credentials for another users connection', function () {
+ $user = User::factory()->onboarded()->create();
+ $otherUser = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $otherUser->id,
+ ]);
+
+ $response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
+ 'api_token' => 'some-token-value-12345',
+ ]);
+
+ $response->assertForbidden();
+});
+
+test('credential update requires feature flag', function () {
+ $user = User::factory()->onboarded()->create();
+
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $user->id,
+ ]);
+
+ $response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
+ 'api_token' => 'some-token-value-12345',
+ ]);
+
+ $response->assertNotFound();
+});
+
+test('credential update validates required fields for binance', function () {
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ $connection = BankingConnection::factory()->binance()->error()->create([
+ 'user_id' => $user->id,
+ ]);
+
+ $response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
+ 'api_key' => 'valid-key-12345678',
+ // missing api_secret
+ ]);
+
+ $response->assertSessionHasErrors('api_secret');
+});
diff --git a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
index 7dde25a6..2013a5c6 100644
--- a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
+++ b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
@@ -3,6 +3,7 @@
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob;
use App\Jobs\SyncBinanceHistoricalBalancesJob;
+use App\Mail\BankingConnectionAuthFailedEmail;
use App\Mail\BankTransactionsSyncedEmail;
use App\Models\Account;
use App\Models\Bank;
@@ -621,3 +622,209 @@ test('bitpanda sync does not send email', function () {
Mail::assertNothingQueued();
});
+
+test('sends auth failed email on final retry for indexa capital 401 error', function () {
+ Mail::fake();
+
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $user->id,
+ 'last_synced_at' => now()->subDay(),
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'IC-001',
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/*' => Http::response(['error' => 'Unauthorized'], 401),
+ ]);
+
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+
+ // Simulate final attempt (3 of 3) - SHOULD send email
+ $job = new SyncBankingConnectionJob($connection);
+ $mockQueueJob = Mockery::mock(\Illuminate\Contracts\Queue\Job::class);
+ $mockQueueJob->shouldReceive('attempts')->andReturn(3);
+ $mockQueueJob->shouldReceive('isReleased')->andReturn(false);
+ $mockQueueJob->shouldReceive('isDeletedOrReleased')->andReturn(false);
+ $mockQueueJob->shouldReceive('hasFailed')->andReturn(false);
+ $job->job = $mockQueueJob;
+
+ expect($connection->isActive())->toBeTrue();
+ expect($job->attempts())->toBe(3);
+
+ $threw = false;
+
+ try {
+ $job->handle($transactionSync, $balanceSync);
+ } catch (\Throwable $e) {
+ $threw = true;
+ expect($e)->toBeInstanceOf(\Illuminate\Http\Client\RequestException::class);
+ expect($e->response->status())->toBe(401);
+ }
+
+ expect($threw)->toBeTrue();
+
+ $connection->refresh();
+ expect($connection->status)->toBe(BankingConnectionStatus::Error);
+ expect($connection->error_message)->toContain('Authentication failed');
+
+ Mail::assertQueued(BankingConnectionAuthFailedEmail::class);
+});
+
+test('does not send auth failed email before final retry attempt', function () {
+ Mail::fake();
+
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $user->id,
+ 'last_synced_at' => now()->subDay(),
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'IC-001',
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/*' => Http::response(['error' => 'Unauthorized'], 401),
+ ]);
+
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+
+ // Simulate attempt 1 of 3 - should NOT send email
+ $job = new SyncBankingConnectionJob($connection);
+ $job->job = Mockery::mock(\Illuminate\Contracts\Queue\Job::class);
+ $job->job->shouldReceive('attempts')->andReturn(1);
+ $job->job->shouldReceive('isReleased')->andReturn(false);
+ $job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
+ $job->job->shouldReceive('hasFailed')->andReturn(false);
+
+ try {
+ $job->handle($transactionSync, $balanceSync);
+ } catch (\Throwable) {
+ // Expected
+ }
+
+ Mail::assertNotQueued(BankingConnectionAuthFailedEmail::class);
+});
+
+test('does not send auth failed email for non-auth errors', function () {
+ Mail::fake();
+
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $user->id,
+ 'last_synced_at' => now()->subDay(),
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'IC-001',
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/*' => Http::response(['error' => 'Server Error'], 500),
+ ]);
+
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+
+ $job = new SyncBankingConnectionJob($connection);
+ $job->job = Mockery::mock(\Illuminate\Contracts\Queue\Job::class);
+ $job->job->shouldReceive('attempts')->andReturn(3);
+ $job->job->shouldReceive('isReleased')->andReturn(false);
+ $job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
+ $job->job->shouldReceive('hasFailed')->andReturn(false);
+
+ try {
+ $job->handle($transactionSync, $balanceSync);
+ } catch (\Throwable) {
+ // Expected
+ }
+
+ Mail::assertNotQueued(BankingConnectionAuthFailedEmail::class);
+});
+
+test('does not send auth failed email for enablebanking connections', function () {
+ Mail::fake();
+
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->create([
+ 'user_id' => $user->id,
+ 'provider' => 'enablebanking',
+ 'last_synced_at' => now()->subDay(),
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'ext-123',
+ ]);
+
+ // For EnableBanking, even if the job throws, it should NOT send the auth failed email
+ // because isApiKeyProvider() returns false for enablebanking.
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $transactionSync->shouldReceive('sync')->andThrow(new \RuntimeException('Auth error'));
+
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+
+ $job = new SyncBankingConnectionJob($connection);
+ $job->job = Mockery::mock(\Illuminate\Contracts\Queue\Job::class);
+ $job->job->shouldReceive('attempts')->andReturn(3);
+ $job->job->shouldReceive('isReleased')->andReturn(false);
+ $job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
+ $job->job->shouldReceive('hasFailed')->andReturn(false);
+
+ try {
+ $job->handle($transactionSync, $balanceSync);
+ } catch (\RuntimeException) {
+ // Expected
+ }
+
+ Mail::assertNotQueued(BankingConnectionAuthFailedEmail::class);
+});
+
+test('sends auth failed email for binance 403 error on final attempt', function () {
+ Mail::fake();
+
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ 'last_synced_at' => now()->subDay(),
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.binance.com/*' => Http::response(['error' => 'Forbidden'], 403),
+ ]);
+
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+
+ $job = new SyncBankingConnectionJob($connection);
+ $job->job = Mockery::mock(\Illuminate\Contracts\Queue\Job::class);
+ $job->job->shouldReceive('attempts')->andReturn(3);
+ $job->job->shouldReceive('isReleased')->andReturn(false);
+ $job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
+ $job->job->shouldReceive('hasFailed')->andReturn(false);
+
+ try {
+ $job->handle($transactionSync, $balanceSync);
+ } catch (\Throwable) {
+ // Expected
+ }
+
+ Mail::assertQueued(BankingConnectionAuthFailedEmail::class, function ($mail) use ($user, $connection) {
+ return $mail->hasTo($user->email)
+ && $mail->bankingConnection->id === $connection->id;
+ });
+});