feat(open-banking): add update credentials flow for API-key connections (#154)

## Why

### Problem

When API tokens for Indexa Capital, Binance, or Bitpanda expire or are
revoked, syncs fail silently with 401/403 errors. Users have no way to
replace their credentials without disconnecting and recreating the
entire connection (losing account mappings and history).

## What

### Changes

- **Auth failure email notification**: on the final retry attempt (3rd
of 3), if a sync job fails with 401/403 for an API-key provider, an
email is sent to the user with a link to the connections settings page
- **Update credentials endpoint**: `PATCH
/settings/connections/{connection}/credentials` validates new
credentials against the provider API before saving, then triggers a sync
- **Update credentials dialog**: provider-specific form (API token for
Indexa/Bitpanda, API key + secret for Binance) shown via an "Update
Credentials" button in the error banner and dropdown menu
- **Mailable**: `BankingConnectionAuthFailedEmail` follows existing
patterns (queued, rate-limited, Markdown template)
- **Form request**: `UpdateConnectionCredentialsRequest` with dynamic
validation rules per provider and authorization check

### Files changed

| File | Change |
|------|--------|
| `app/Jobs/SyncBankingConnectionJob.php` | Send auth failed email on
final attempt for auth errors on API-key providers |
| `app/Mail/BankingConnectionAuthFailedEmail.php` | New queued mailable
|
| `resources/views/mail/banking-connection-auth-failed.blade.php` |
Email template |
| `app/Http/Controllers/OpenBanking/ConnectionController.php` |
`updateCredentials()` action with provider credential validation |
| `app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php`
| Dynamic validation per provider |
| `routes/settings.php` | PATCH route for credential updates |
| `resources/js/components/open-banking/update-credentials-dialog.tsx` |
Dialog with provider-specific fields |
| `resources/js/pages/settings/connections.tsx` | Update Credentials
button in error state and dropdown |

## Verification

### Tests

- **12 new tests** across 2 test files, all passing:
- `SyncBankingConnectionJobTest`: 5 tests covering email sent on final
retry (Indexa 401, Binance 403), not sent before final retry, not sent
for non-auth errors, not sent for EnableBanking
- `ConnectionControllerTest`: 7 tests covering valid credential update
for each provider, invalid credentials, EnableBanking rejection,
authorization, feature flag, required field validation
- Full OpenBanking test suite: **145 tests, 473 assertions** passing
This commit is contained in:
Víctor Falcón 2026-02-25 13:41:24 +01:00 committed by GitHub
parent e4243c2eaa
commit 690be20f21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 912 additions and 0 deletions

View File

@ -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.
*/

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\OpenBanking;
use App\Models\BankingConnection;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class UpdateConnectionCredentialsRequest extends FormRequest
{
public function authorize(): bool
{
$connection = $this->route('connection');
return Feature::for($this->user())->active('open-banking')
&& $connection instanceof BankingConnection
&& $connection->user_id === $this->user()->id;
}
/**
* @return array<string, array<mixed>>
*/
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 => [],
};
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Mail;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class BankingConnectionAuthFailedEmail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
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<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -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 [

View File

@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{__('Update Credentials')}</DialogTitle>
<DialogDescription>
{__('Enter your new API credentials for :provider.', {
provider: connection.aspsp_name,
})}
</DialogDescription>
</DialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="space-y-4">
{isIndexaCapital && (
<div className="space-y-2">
<Label htmlFor="update-api-token">
{__('API Token')}
</Label>
<Input
id="update-api-token"
type="password"
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
placeholder={__(
'Paste your Indexa Capital API token',
)}
/>
<p className="text-xs text-muted-foreground">
{__(
'You can generate your API token from your Indexa Capital dashboard under',
)}{' '}
<a
href="https://indexacapital.com/es/u/user#settings-apps"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('Settings > Applications')}
</a>
.
</p>
</div>
)}
{isBinance && (
<>
<div className="space-y-2">
<Label htmlFor="update-api-key">
{__('API Key')}
</Label>
<Input
id="update-api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={__(
'Paste your Binance API Key',
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="update-api-secret">
{__('API Secret')}
</Label>
<Input
id="update-api-secret"
type="password"
value={apiSecret}
onChange={(e) =>
setApiSecret(e.target.value)
}
placeholder={__(
'Paste your Binance API Secret',
)}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Binance account under',
)}{' '}
<a
href="https://www.binance.com/es/my/settings/api-management"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Management')}
</a>
.
</p>
</>
)}
{isBitpanda && (
<div className="space-y-2">
<Label htmlFor="update-bitpanda-api-key">
{__('API Key')}
</Label>
<Input
id="update-bitpanda-api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={__('Paste your Bitpanda API Key')}
/>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Bitpanda account under',
)}{' '}
<a
href="https://web.bitpanda.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Key Management')}
</a>
.
</p>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
{__('Cancel')}
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isValid}
>
{isSubmitting
? __('Updating...')
: __('Update Credentials')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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<BankingConnection | null>(null);
const [updateCredentialsConnection, setUpdateCredentialsConnection] =
useState<BankingConnection | null>(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')}
</DropdownMenuItem>
)}
{hasAuthError(
connection,
) && (
<DropdownMenuItem
onClick={() =>
setUpdateCredentialsConnection(
connection,
)
}
>
<KeyRound className="mr-2 h-4 w-4" />
{__(
'Update Credentials',
)}
</DropdownMenuItem>
)}
{(connection.status ===
'active' ||
connection.status ===
@ -252,6 +287,25 @@ export default function ConnectionsPage({ connections }: Props) {
)}
</p>
<div className="flex flex-wrap items-center gap-3">
{hasAuthError(
connection,
) && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() =>
setUpdateCredentialsConnection(
connection,
)
}
>
<KeyRound className="mr-1.5 h-3 w-3" />
{__(
'Update Credentials',
)}
</Button>
)}
<Button
variant="outline"
size="sm"
@ -301,6 +355,16 @@ export default function ConnectionsPage({ connections }: Props) {
}}
/>
)}
{updateCredentialsConnection && (
<UpdateCredentialsDialog
connection={updateCredentialsConnection}
open={!!updateCredentialsConnection}
onOpenChange={(open) => {
if (!open) setUpdateCredentialsConnection(null);
}}
/>
)}
</SettingsLayout>
</AppLayout>
);

View File

@ -0,0 +1,17 @@
<x-mail::message>
# {{ __('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]) }}
<x-mail::button :url="route('settings.connections.index')">
{{ __('Update Credentials') }}
</x-mail::button>
Best,<br>
Víctor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -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');
});
});

View File

@ -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');
});

View File

@ -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;
});
});