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:
parent
e4243c2eaa
commit
690be20f21
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue