feat(connections): add EnableBanking reconnect flow (#218)
## Summary - Add `reauthorize` endpoint and reconnect detection in OAuth `callback()` so users can re-authorize a revoked EnableBanking session without losing their accounts or transaction history - Replace "Retry" with "Reconnect" button (dropdown + error panel) for EnableBanking authentication errors; catch-up sync of missed transactions is handled automatically by the existing `SyncBankingConnectionJob` via `last_synced_at` - Add 5 missing Spanish translations (`Reconectar`, `Autenticación fallida...`, `Error al iniciar la reautorización.`, `Error al reconectar...`, `Cuenta bancaria reconectada exitosamente.`) and wrap the reconnect flash message in `__()` - 7 new Pest tests covering all reauthorize scenarios and the reconnect callback path (15 total passing)
This commit is contained in:
parent
1058904b14
commit
1f5e6ac450
|
|
@ -54,6 +54,43 @@ class AuthorizationController extends Controller
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-authorize an existing EnableBanking connection whose session has been revoked.
|
||||||
|
*/
|
||||||
|
public function reauthorize(Request $request, BankingConnection $connection, BankingProviderInterface $provider): JsonResponse
|
||||||
|
{
|
||||||
|
if ($connection->user_id !== auth()->id()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $connection->isEnableBanking()) {
|
||||||
|
return response()->json(['error' => 'Only EnableBanking connections can be re-authorized.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($connection->status !== BankingConnectionStatus::Error && ! $connection->isExpired()) {
|
||||||
|
return response()->json(['error' => 'Only connections with an error or expired status can be re-authorized.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$redirectUrl = config('services.enablebanking.redirect_url');
|
||||||
|
|
||||||
|
$result = $provider->startAuthorization(
|
||||||
|
$connection->aspsp_name,
|
||||||
|
$connection->aspsp_country,
|
||||||
|
$redirectUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
$connection->update([
|
||||||
|
'authorization_id' => $result['authorization_id'],
|
||||||
|
'status' => BankingConnectionStatus::Pending,
|
||||||
|
'error_message' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'redirect_url' => $result['url'],
|
||||||
|
'connection_id' => $connection->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the callback from bank authorization.
|
* Handle the callback from bank authorization.
|
||||||
*/
|
*/
|
||||||
|
|
@ -104,6 +141,22 @@ class AuthorizationController extends Controller
|
||||||
->with('error', 'No pending connection found.');
|
->with('error', 'No pending connection found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$isReconnect = $connection->accounts()->exists();
|
||||||
|
|
||||||
|
if ($isReconnect) {
|
||||||
|
$connection->update([
|
||||||
|
'session_id' => $sessionData['session_id'],
|
||||||
|
'status' => BankingConnectionStatus::Active,
|
||||||
|
'valid_until' => $sessionData['access']['valid_until'] ?? null,
|
||||||
|
'error_message' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SyncBankingConnectionJob::dispatch($connection);
|
||||||
|
|
||||||
|
return redirect()->route('settings.connections.index')
|
||||||
|
->with('success', __('Bank account reconnected successfully.'));
|
||||||
|
}
|
||||||
|
|
||||||
if (Feature::for($user)->active('account-mapping')) {
|
if (Feature::for($user)->active('account-mapping')) {
|
||||||
$connection->update([
|
$connection->update([
|
||||||
'session_id' => $sessionData['session_id'],
|
'session_id' => $sessionData['session_id'],
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,7 @@
|
||||||
"As the founders, your feedback directly shapes what we build next. We personally read every response and take your suggestions seriously. When you subscribe, you're not just supporting some big corporation - you're helping us continue building something we're passionate about.": "Como fundadores, tus comentarios moldean directamente lo que construimos a continuación. Leemos personalmente cada respuesta y tomamos tus sugerencias en serio. Cuando te suscribes, no estás apoyando a una gran corporación, estás ayudándonos a seguir construyendo algo que nos apasiona.",
|
"As the founders, your feedback directly shapes what we build next. We personally read every response and take your suggestions seriously. When you subscribe, you're not just supporting some big corporation - you're helping us continue building something we're passionate about.": "Como fundadores, tus comentarios moldean directamente lo que construimos a continuación. Leemos personalmente cada respuesta y tomamos tus sugerencias en serio. Cuando te suscribes, no estás apoyando a una gran corporación, estás ayudándonos a seguir construyendo algo que nos apasiona.",
|
||||||
"Assign a new category": "Asignar una nueva categoría",
|
"Assign a new category": "Asignar una nueva categoría",
|
||||||
"At least one action is required": "Se requiere al menos una acción",
|
"At least one action is required": "Se requiere al menos una acción",
|
||||||
|
"Authentication failed. Your credentials may have expired or been revoked.": "Autenticación fallida. Tus credenciales pueden haber expirado o sido revocadas.",
|
||||||
"Auto-detect": "Detectar automáticamente",
|
"Auto-detect": "Detectar automáticamente",
|
||||||
"Auto-sync transactions directly from your bank": "Sincroniza automáticamente las transacciones desde tu banco",
|
"Auto-sync transactions directly from your bank": "Sincroniza automáticamente las transacciones desde tu banco",
|
||||||
"Automate over time": "Automatiza con el tiempo",
|
"Automate over time": "Automatiza con el tiempo",
|
||||||
|
|
@ -198,6 +199,7 @@
|
||||||
"Balance evolution": "Evolución del balance",
|
"Balance evolution": "Evolución del balance",
|
||||||
"Bank": "Banco",
|
"Bank": "Banco",
|
||||||
"Bank Account": "Cuenta Bancaria",
|
"Bank Account": "Cuenta Bancaria",
|
||||||
|
"Bank account reconnected successfully.": "Cuenta bancaria reconectada exitosamente.",
|
||||||
"Bank Connections": "Conexiones Bancarias",
|
"Bank Connections": "Conexiones Bancarias",
|
||||||
"Bank Name": "Nombre del Banco",
|
"Bank Name": "Nombre del Banco",
|
||||||
"Bank accounts": "Cuentas bancarias",
|
"Bank accounts": "Cuentas bancarias",
|
||||||
|
|
@ -496,8 +498,10 @@
|
||||||
"Failed to create transaction": "Error al crear la transacción",
|
"Failed to create transaction": "Error al crear la transacción",
|
||||||
"Failed to decrypt message. Please check your password and try again.": "Error al descifrar el mensaje. Por favor verifica tu contraseña e inténtalo de nuevo.",
|
"Failed to decrypt message. Please check your password and try again.": "Error al descifrar el mensaje. Por favor verifica tu contraseña e inténtalo de nuevo.",
|
||||||
"Failed to load banks. Please try again.": "Error al cargar los bancos. Por favor, inténtalo de nuevo.",
|
"Failed to load banks. Please try again.": "Error al cargar los bancos. Por favor, inténtalo de nuevo.",
|
||||||
|
"Failed to reconnect. Please try again.": "Error al reconectar. Por favor, inténtalo de nuevo.",
|
||||||
"Failed to set balance. Please try again.": "Error al establecer el balance. Por favor, inténtalo de nuevo.",
|
"Failed to set balance. Please try again.": "Error al establecer el balance. Por favor, inténtalo de nuevo.",
|
||||||
"Failed to setup encryption. Please try again.": "Error al configurar la encriptación. Por favor, inténtalo de nuevo.",
|
"Failed to setup encryption. Please try again.": "Error al configurar la encriptación. Por favor, inténtalo de nuevo.",
|
||||||
|
"Failed to start re-authorization.": "Error al iniciar la reautorización.",
|
||||||
"Failed to update credentials. Please try again.": "Error al actualizar las credenciales. Por favor, inténtalo de nuevo.",
|
"Failed to update credentials. Please try again.": "Error al actualizar las credenciales. Por favor, inténtalo de nuevo.",
|
||||||
"Failed to update transaction": "Error al actualizar la transacción",
|
"Failed to update transaction": "Error al actualizar la transacción",
|
||||||
"Fair": "Aceptable",
|
"Fair": "Aceptable",
|
||||||
|
|
@ -902,6 +906,7 @@
|
||||||
"Re-evaluate rules": "Reevaluar reglas",
|
"Re-evaluate rules": "Reevaluar reglas",
|
||||||
"Reactivate Your Subscription": "Reactiva Tu Suscripción",
|
"Reactivate Your Subscription": "Reactiva Tu Suscripción",
|
||||||
"Ready to take control of your finances?": "¿Listo para tomar el control de tus finanzas?",
|
"Ready to take control of your finances?": "¿Listo para tomar el control de tus finanzas?",
|
||||||
|
"Reconnect": "Reconectar",
|
||||||
"Recovery Codes": "Códigos de Recuperación",
|
"Recovery Codes": "Códigos de Recuperación",
|
||||||
"Recovery codes": "Códigos de recuperación",
|
"Recovery codes": "Códigos de recuperación",
|
||||||
"Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "Los códigos de recuperación te permiten recuperar acceso si pierdes tu dispositivo 2FA. Guárdalos en un administrador de contraseñas seguro.",
|
"Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "Los códigos de recuperación te permiten recuperar acceso si pierdes tu dispositivo 2FA. Guárdalos en un administrador de contraseñas seguro.",
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,17 @@ import {
|
||||||
KeyRound,
|
KeyRound,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
Unplug,
|
Unplug,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
function getCsrfToken(): string {
|
||||||
|
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
connections: BankingConnection[];
|
connections: BankingConnection[];
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +56,7 @@ export default function ConnectionsPage({ connections }: Props) {
|
||||||
useState<BankingConnection | null>(null);
|
useState<BankingConnection | null>(null);
|
||||||
const [updateCredentialsConnection, setUpdateCredentialsConnection] =
|
const [updateCredentialsConnection, setUpdateCredentialsConnection] =
|
||||||
useState<BankingConnection | null>(null);
|
useState<BankingConnection | null>(null);
|
||||||
|
const [reconnectingId, setReconnectingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const hasSyncing = connections.some(
|
const hasSyncing = connections.some(
|
||||||
(c) => c.status === 'active' && !c.last_synced_at,
|
(c) => c.status === 'active' && !c.last_synced_at,
|
||||||
|
|
@ -78,6 +85,40 @@ export default function ConnectionsPage({ connections }: Props) {
|
||||||
router.post(`/settings/connections/${connection.id}/sync`);
|
router.post(`/settings/connections/${connection.id}/sync`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleReconnect(connection: BankingConnection) {
|
||||||
|
setReconnectingId(connection.id);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/open-banking/connections/${connection.id}/reauthorize`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-XSRF-TOKEN': getCsrfToken(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
data.error || __('Failed to start re-authorization.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
window.location.href = data.redirect_url;
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: __('Failed to reconnect. Please try again.'),
|
||||||
|
);
|
||||||
|
setReconnectingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isApiKeyProvider(connection: BankingConnection): boolean {
|
function isApiKeyProvider(connection: BankingConnection): boolean {
|
||||||
return ['indexacapital', 'binance', 'bitpanda'].includes(
|
return ['indexacapital', 'binance', 'bitpanda'].includes(
|
||||||
connection.provider,
|
connection.provider,
|
||||||
|
|
@ -93,6 +134,15 @@ export default function ConnectionsPage({ connections }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEnableBankingAuthError(connection: BankingConnection): boolean {
|
||||||
|
return (
|
||||||
|
connection.status === 'error' &&
|
||||||
|
connection.provider === 'enablebanking' &&
|
||||||
|
(connection.error_message?.includes('Authentication failed') ??
|
||||||
|
false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string | null): string {
|
function formatDate(dateString: string | null): string {
|
||||||
if (!dateString) return __('Never');
|
if (!dateString) return __('Never');
|
||||||
return new Date(dateString).toLocaleDateString(undefined, {
|
return new Date(dateString).toLocaleDateString(undefined, {
|
||||||
|
|
@ -207,10 +257,31 @@ export default function ConnectionsPage({ connections }: Props) {
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isEnableBankingAuthError(
|
||||||
|
connection,
|
||||||
|
) && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
handleReconnect(
|
||||||
|
connection,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
reconnectingId ===
|
||||||
|
connection.id
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
{__('Reconnect')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{(connection.status ===
|
{(connection.status ===
|
||||||
'active' ||
|
'active' ||
|
||||||
connection.status ===
|
(connection.status ===
|
||||||
'error') && (
|
'error' &&
|
||||||
|
!isEnableBankingAuthError(
|
||||||
|
connection,
|
||||||
|
))) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleSync(
|
handleSync(
|
||||||
|
|
@ -313,19 +384,50 @@ export default function ConnectionsPage({ connections }: Props) {
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{isEnableBankingAuthError(
|
||||||
variant="secondary"
|
connection,
|
||||||
size="sm"
|
) ? (
|
||||||
className="h-7 text-xs"
|
<Button
|
||||||
onClick={() =>
|
variant="secondary"
|
||||||
handleSync(
|
size="sm"
|
||||||
connection,
|
className="h-7 text-xs"
|
||||||
)
|
disabled={
|
||||||
}
|
reconnectingId ===
|
||||||
>
|
connection.id
|
||||||
<RefreshCw className="mr-1.5 h-3 w-3" />
|
}
|
||||||
{__('Retry')}
|
onClick={() =>
|
||||||
</Button>
|
handleReconnect(
|
||||||
|
connection,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{reconnectingId ===
|
||||||
|
connection.id ? (
|
||||||
|
<Spinner className="mr-1.5 size-3" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{__(
|
||||||
|
'Reconnect',
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
handleSync(
|
||||||
|
connection,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1.5 h-3 w-3" />
|
||||||
|
{__(
|
||||||
|
'Retry',
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<a
|
<a
|
||||||
href="https://discord.gg/2WZmDW9QZ8"
|
href="https://discord.gg/2WZmDW9QZ8"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi
|
||||||
Route::middleware(['auth', 'verified', 'open-banking'])->prefix('open-banking')->group(function () {
|
Route::middleware(['auth', 'verified', 'open-banking'])->prefix('open-banking')->group(function () {
|
||||||
Route::get('institutions', [InstitutionController::class, 'index'])->name('open-banking.institutions');
|
Route::get('institutions', [InstitutionController::class, 'index'])->name('open-banking.institutions');
|
||||||
Route::post('authorize', [AuthorizationController::class, 'store'])->name('open-banking.authorize');
|
Route::post('authorize', [AuthorizationController::class, 'store'])->name('open-banking.authorize');
|
||||||
|
Route::post('connections/{connection}/reauthorize', [AuthorizationController::class, 'reauthorize'])->name('open-banking.reauthorize');
|
||||||
Route::get('callback', [AuthorizationController::class, 'callback'])->name('open-banking.callback');
|
Route::get('callback', [AuthorizationController::class, 'callback'])->name('open-banking.callback');
|
||||||
Route::get('connections/{connection}/map-accounts', [AccountMappingController::class, 'show'])->name('open-banking.map-accounts');
|
Route::get('connections/{connection}/map-accounts', [AccountMappingController::class, 'show'])->name('open-banking.map-accounts');
|
||||||
Route::post('connections/{connection}/map-accounts', [AccountMappingController::class, 'store'])->name('open-banking.map-accounts.store');
|
Route::post('connections/{connection}/map-accounts', [AccountMappingController::class, 'store'])->name('open-banking.map-accounts.store');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
use App\Contracts\BankingProviderInterface;
|
use App\Contracts\BankingProviderInterface;
|
||||||
use App\Enums\BankingConnectionStatus;
|
use App\Enums\BankingConnectionStatus;
|
||||||
use App\Jobs\SyncBankingConnectionJob;
|
use App\Jobs\SyncBankingConnectionJob;
|
||||||
|
use App\Models\Account;
|
||||||
use App\Models\BankingConnection;
|
use App\Models\BankingConnection;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
@ -236,3 +237,214 @@ test('callback with valid code stores pending accounts and redirects to mapping
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reauthorize tests
|
||||||
|
|
||||||
|
test('reauthorize returns 403 when user does not own the connection', function () {
|
||||||
|
$owner = User::factory()->onboarded()->create();
|
||||||
|
$other = User::factory()->onboarded()->create();
|
||||||
|
Feature::for($other)->activate('open-banking');
|
||||||
|
|
||||||
|
$connection = BankingConnection::factory()->error()->create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($other)->postJson("/open-banking/connections/{$connection->id}/reauthorize");
|
||||||
|
|
||||||
|
$response->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reauthorize returns 422 for non-EnableBanking connections', function () {
|
||||||
|
$user = User::factory()->onboarded()->create();
|
||||||
|
Feature::for($user)->activate('open-banking');
|
||||||
|
|
||||||
|
$connection = BankingConnection::factory()->indexaCapital()->error()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->postJson("/open-banking/connections/{$connection->id}/reauthorize");
|
||||||
|
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
$response->assertJson(['error' => 'Only EnableBanking connections can be re-authorized.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reauthorize returns 422 for active connections', function () {
|
||||||
|
$user = User::factory()->onboarded()->create();
|
||||||
|
Feature::for($user)->activate('open-banking');
|
||||||
|
|
||||||
|
$connection = BankingConnection::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'status' => BankingConnectionStatus::Active,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->postJson("/open-banking/connections/{$connection->id}/reauthorize");
|
||||||
|
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
$response->assertJson(['error' => 'Only connections with an error or expired status can be re-authorized.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reauthorize starts new authorization and sets connection to pending for error connections', function () {
|
||||||
|
$user = User::factory()->onboarded()->create();
|
||||||
|
Feature::for($user)->activate('open-banking');
|
||||||
|
|
||||||
|
$connection = BankingConnection::factory()->error()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'aspsp_name' => 'CaixaBank',
|
||||||
|
'aspsp_country' => 'ES',
|
||||||
|
'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$originalAuthorizationId = $connection->authorization_id;
|
||||||
|
|
||||||
|
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||||
|
$mockProvider->shouldReceive('startAuthorization')
|
||||||
|
->with('CaixaBank', 'ES', config('services.enablebanking.redirect_url'))
|
||||||
|
->once()
|
||||||
|
->andReturn([
|
||||||
|
'url' => 'https://bank.example.com/reauthorize',
|
||||||
|
'authorization_id' => 'new-auth-id-456',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->app->instance(BankingProviderInterface::class, $mockProvider);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->postJson("/open-banking/connections/{$connection->id}/reauthorize");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonStructure(['redirect_url', 'connection_id']);
|
||||||
|
$response->assertJson([
|
||||||
|
'redirect_url' => 'https://bank.example.com/reauthorize',
|
||||||
|
'connection_id' => $connection->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$connection->refresh();
|
||||||
|
expect($connection->status)->toBe(BankingConnectionStatus::Pending);
|
||||||
|
expect($connection->authorization_id)->toBe('new-auth-id-456');
|
||||||
|
expect($connection->authorization_id)->not->toBe($originalAuthorizationId);
|
||||||
|
expect($connection->error_message)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reauthorize starts new authorization for expired connections', function () {
|
||||||
|
$user = User::factory()->onboarded()->create();
|
||||||
|
Feature::for($user)->activate('open-banking');
|
||||||
|
|
||||||
|
$connection = BankingConnection::factory()->expired()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'aspsp_name' => 'Santander',
|
||||||
|
'aspsp_country' => 'ES',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||||
|
$mockProvider->shouldReceive('startAuthorization')
|
||||||
|
->once()
|
||||||
|
->andReturn([
|
||||||
|
'url' => 'https://bank.example.com/reauthorize',
|
||||||
|
'authorization_id' => 'new-auth-id-789',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->app->instance(BankingProviderInterface::class, $mockProvider);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->postJson("/open-banking/connections/{$connection->id}/reauthorize");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$connection->refresh();
|
||||||
|
expect($connection->status)->toBe(BankingConnectionStatus::Pending);
|
||||||
|
expect($connection->authorization_id)->toBe('new-auth-id-789');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reconnect callback tests
|
||||||
|
|
||||||
|
test('callback with existing accounts updates session without creating new accounts', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->onboarded()->create();
|
||||||
|
Feature::for($user)->activate('open-banking');
|
||||||
|
|
||||||
|
$connection = BankingConnection::factory()->pending()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'aspsp_name' => 'CaixaBank',
|
||||||
|
'aspsp_country' => 'ES',
|
||||||
|
'last_synced_at' => now()->subWeek(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Account::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'banking_connection_id' => $connection->id,
|
||||||
|
'external_account_id' => 'existing-ext-account-1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||||
|
$mockProvider->shouldReceive('createSession')
|
||||||
|
->with('test-code')
|
||||||
|
->once()
|
||||||
|
->andReturn([
|
||||||
|
'session_id' => 'new-session-456',
|
||||||
|
'accounts' => [
|
||||||
|
[
|
||||||
|
'uid' => 'existing-ext-account-1',
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'name' => 'CaixaBank Account',
|
||||||
|
'account_id' => ['iban' => 'ES1234567890123456789012'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'access' => ['valid_until' => now()->addDays(90)->toIso8601String()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->app->instance(BankingProviderInterface::class, $mockProvider);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get('/open-banking/callback?code=test-code');
|
||||||
|
|
||||||
|
$response->assertRedirect(route('settings.connections.index'));
|
||||||
|
$response->assertSessionHas('success', 'Bank account reconnected successfully.');
|
||||||
|
|
||||||
|
$connection->refresh();
|
||||||
|
expect($connection->status)->toBe(BankingConnectionStatus::Active);
|
||||||
|
expect($connection->session_id)->toBe('new-session-456');
|
||||||
|
expect($connection->error_message)->toBeNull();
|
||||||
|
|
||||||
|
// No duplicate accounts should have been created
|
||||||
|
$this->assertDatabaseCount('accounts', 1);
|
||||||
|
|
||||||
|
Queue::assertPushed(SyncBankingConnectionJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback with existing accounts skips account-mapping even when feature flag is enabled', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->onboarded()->create();
|
||||||
|
Feature::for($user)->activate('open-banking');
|
||||||
|
Feature::for($user)->activate('account-mapping');
|
||||||
|
|
||||||
|
$connection = BankingConnection::factory()->pending()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'aspsp_name' => 'CaixaBank',
|
||||||
|
'aspsp_country' => 'ES',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Account::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'banking_connection_id' => $connection->id,
|
||||||
|
'external_account_id' => 'existing-ext-account-1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||||
|
$mockProvider->shouldReceive('createSession')
|
||||||
|
->once()
|
||||||
|
->andReturn([
|
||||||
|
'session_id' => 'new-session-789',
|
||||||
|
'accounts' => [],
|
||||||
|
'access' => ['valid_until' => now()->addDays(90)->toIso8601String()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->app->instance(BankingProviderInterface::class, $mockProvider);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get('/open-banking/callback?code=test-code');
|
||||||
|
|
||||||
|
// Must redirect to connections page, NOT to the account-mapping route
|
||||||
|
$response->assertRedirect(route('settings.connections.index'));
|
||||||
|
|
||||||
|
$connection->refresh();
|
||||||
|
expect($connection->status)->toBe(BankingConnectionStatus::Active);
|
||||||
|
|
||||||
|
Queue::assertPushed(SyncBankingConnectionJob::class);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue