Notify users about expired bank connections (#404)
## Summary - mark expired EnableBanking connections during scheduled sync and queue reconnect email - add reconnect email/button route for expired connections - surface expired connections in shared Inertia props and show persistent reconnect toast ## Tests - vendor/bin/pint --dirty --format agent - php artisan test --compact tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php --filter='expired|scheduled sync includes active enablebanking' - php artisan test --compact tests/Feature/OpenBanking/AuthorizationControllerTest.php --filter='reauthorize|reconnect link' - php artisan test --compact tests/Feature/InertiaSharedDataTest.php --filter='expired banking' - npm run types (fails on existing unrelated TypeScript errors; no app.tsx/expiredBanking errors after Wayfinder generation)
This commit is contained in:
parent
5c74292448
commit
0f527d0f26
|
|
@ -43,15 +43,23 @@ class SyncBankingConnections extends Command
|
|||
$query = BankingConnection::query()
|
||||
->whereHas('user')
|
||||
->where(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Active)
|
||||
->orWhere(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Error)
|
||||
->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES);
|
||||
$query->where(function ($query) {
|
||||
$query->where(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Active)
|
||||
->orWhere(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Error)
|
||||
->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES);
|
||||
});
|
||||
})->where(function ($query) {
|
||||
$query->whereNull('valid_until')
|
||||
->orWhere('valid_until', '>', now());
|
||||
});
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNull('valid_until')
|
||||
->orWhere('valid_until', '>', now());
|
||||
})->orWhere(function ($query) {
|
||||
$query->where('provider', 'enablebanking')
|
||||
->where('status', BankingConnectionStatus::Active)
|
||||
->whereNotNull('valid_until')
|
||||
->where('valid_until', '<=', now());
|
||||
});
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNull('rate_limited_until')
|
||||
|
|
|
|||
|
|
@ -69,12 +69,52 @@ class AuthorizationController extends Controller
|
|||
return $this->subscribeJsonResponse();
|
||||
}
|
||||
|
||||
$result = $this->startReauthorization($connection, $provider);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return response()->json(['error' => $result['error']], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => $result['url'],
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start reconnect flow from email or UI links.
|
||||
*/
|
||||
public function reconnect(Request $request, BankingConnection $connection, BankingProviderInterface $provider): RedirectResponse
|
||||
{
|
||||
if ($connection->user_id !== auth()->id()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($this->shouldBlockOpenBankingAccess($request->user())) {
|
||||
return $this->subscribeRedirectResponse();
|
||||
}
|
||||
|
||||
$result = $this->startReauthorization($connection, $provider);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return redirect()->route('settings.connections.index')
|
||||
->with('error', $result['error']);
|
||||
}
|
||||
|
||||
return redirect()->away($result['url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{url?: string, authorization_id?: string, error?: string}
|
||||
*/
|
||||
private function startReauthorization(BankingConnection $connection, BankingProviderInterface $provider): array
|
||||
{
|
||||
if (! $connection->isEnableBanking()) {
|
||||
return response()->json(['error' => 'Only EnableBanking connections can be re-authorized.'], 422);
|
||||
return ['error' => 'Only EnableBanking connections can be re-authorized.'];
|
||||
}
|
||||
|
||||
if ($connection->status !== BankingConnectionStatus::Error && ! $connection->isExpired()) {
|
||||
return response()->json(['error' => 'Only connections with an error or expired status can be re-authorized.'], 422);
|
||||
return ['error' => 'Only connections with an error or expired status can be re-authorized.'];
|
||||
}
|
||||
|
||||
$redirectUrl = config('services.enablebanking.redirect_url');
|
||||
|
|
@ -91,10 +131,7 @@ class AuthorizationController extends Controller
|
|||
'error_message' => null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => $result['url'],
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Enums\AccountType;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Services\CurrencyOptions;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -92,6 +94,25 @@ class HandleInertiaRequests extends Middleware
|
|||
'includeRealEstateInNetWorthChart' => $user?->setting->include_real_estate_in_net_worth_chart ?? true,
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'features' => $this->resolveFeatureFlags(),
|
||||
'expiredBankingConnections' => fn () => $user ? $user->bankingConnections()
|
||||
->where('provider', 'enablebanking')
|
||||
->where(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Expired)
|
||||
->orWhere(function ($query) {
|
||||
$query->whereNotNull('valid_until')
|
||||
->where('valid_until', '<=', now());
|
||||
});
|
||||
})
|
||||
->orderBy('valid_until')
|
||||
->limit(5)
|
||||
->get(['id', 'aspsp_name', 'provider', 'valid_until'])
|
||||
->map(fn (BankingConnection $connection): array => [
|
||||
'id' => $connection->id,
|
||||
'aspsp_name' => $connection->aspsp_name,
|
||||
'provider' => $connection->provider,
|
||||
'valid_until' => $connection->valid_until?->toIso8601String(),
|
||||
'reconnect_url' => route('open-banking.reconnect', $connection),
|
||||
]) : [],
|
||||
'accounts' => fn () => $user ? $user->accounts()
|
||||
->with(['bank:id,name,logo', 'realEstateDetail:account_id,linked_loan_account_id'])
|
||||
->orderBy('name')
|
||||
|
|
|
|||
|
|
@ -23,15 +23,23 @@ class SyncAllBankingConnectionsJob implements ShouldQueue
|
|||
BankingConnection::query()
|
||||
->whereHas('user')
|
||||
->where(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Active)
|
||||
->orWhere(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Error)
|
||||
->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES);
|
||||
$query->where(function ($query) {
|
||||
$query->where(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Active)
|
||||
->orWhere(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Error)
|
||||
->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES);
|
||||
});
|
||||
})->where(function ($query) {
|
||||
$query->whereNull('valid_until')
|
||||
->orWhere('valid_until', '>', now());
|
||||
});
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNull('valid_until')
|
||||
->orWhere('valid_until', '>', now());
|
||||
})->orWhere(function ($query) {
|
||||
$query->where('provider', 'enablebanking')
|
||||
->where('status', BankingConnectionStatus::Active)
|
||||
->whereNotNull('valid_until')
|
||||
->where('valid_until', '<=', now());
|
||||
});
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNull('rate_limited_until')
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Enums\BankingConnectionStatus;
|
|||
use App\Enums\BankingSyncLogStatus;
|
||||
use App\Exceptions\Banking\TransientBankingProviderException;
|
||||
use App\Mail\BankingConnectionAuthFailedEmail;
|
||||
use App\Mail\BankingConnectionExpiredEmail;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\BankingSyncLog;
|
||||
use App\Services\Banking\BalanceSyncService;
|
||||
|
|
@ -76,9 +77,18 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
|
|||
}
|
||||
|
||||
if ($connection->isEnableBanking() && $connection->isExpired()) {
|
||||
$shouldNotify = $connection->status !== BankingConnectionStatus::Expired;
|
||||
|
||||
$connection->update(['status' => BankingConnectionStatus::Expired]);
|
||||
Log::info('Banking connection expired, skipping sync', ['connection_id' => $connection->id]);
|
||||
|
||||
if ($shouldNotify && $connection->user->canReceiveEmails()) {
|
||||
Mail::to($connection->user)->send(new BankingConnectionExpiredEmail(
|
||||
$connection->user,
|
||||
$connection,
|
||||
));
|
||||
}
|
||||
|
||||
$this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: ['reason' => 'expired']);
|
||||
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
<?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 BankingConnectionExpiredEmail 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: reconnect :provider', [
|
||||
'provider' => $this->bankingConnection->aspsp_name,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.banking-connection-expired',
|
||||
with: [
|
||||
'userName' => $this->user->name,
|
||||
'providerName' => $this->bankingConnection->aspsp_name,
|
||||
'reconnectUrl' => route('open-banking.reconnect', $this->bankingConnection),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the middleware the job should pass through.
|
||||
*
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new RateLimited('emails'))->releaseAfter(1)];
|
||||
}
|
||||
}
|
||||
|
|
@ -1047,6 +1047,7 @@
|
|||
"Ready to take control of your finances?": "¿Listo para tomar el control de tus finanzas?",
|
||||
"Real Estate": "Bienes Raíces",
|
||||
"Reconnect": "Reconectar",
|
||||
"Reconnect to resume automatic syncing.": "Reconecta para reanudar la sincronización automática.",
|
||||
"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.",
|
||||
|
|
@ -1620,6 +1621,7 @@
|
|||
"You can\\n request a copy of the personal data we hold\\n about you": "Puedes solicitar una copia de los datos personales que tenemos sobre ti",
|
||||
"You can\\n request deletion of your personal data\\n (right to be forgotten)": "Puedes solicitar la eliminación de tus datos personales (derecho al olvido)",
|
||||
"You get your personal referral link.": "Obtienes tu enlace personal de referidos.",
|
||||
"You have :count expired bank connections.": "Tienes :count conexiones bancarias caducadas.",
|
||||
"You may cancel your subscription at any time. Upon cancellation, you will retain access until the end of your current billing period, after which your subscription will not renew.": "Puedes cancelar tu suscripción en cualquier momento. Al cancelar, mantendrás acceso hasta el final de tu período de facturación actual, después del cual tu suscripción no se renovará.",
|
||||
"You may cancel your subscription at any time.\\n Upon cancellation, you will retain access until\\n the end of your current billing period, after\\n which your subscription will not renew.": "Puedes cancelar tu suscripción en cualquier momento. Tras la cancelación, mantendrás el acceso hasta el final de tu período de facturación actual, después del cual tu suscripción no se renovará.",
|
||||
"You may not use the service to engage in any illegal activity, transmit malicious code, attempt to gain unauthorized access to our systems, or interfere with the proper functioning of the service.": "No puedes usar el servicio para participar en ninguna actividad ilegal, transmitir código malicioso, intentar obtener acceso no autorizado a nuestros sistemas o interferir con el funcionamiento adecuado del servicio.",
|
||||
|
|
@ -1653,6 +1655,7 @@
|
|||
"You\\n can request correction of inaccurate or\\n incomplete data": "Puedes solicitar la corrección de datos inexactos o incompletos",
|
||||
"You\\n can request restriction of processing in\\n certain circumstances": "Puedes solicitar la restricción del procesamiento en determinadas circunstancias",
|
||||
"Your :provider connection needs attention": "Tu conexión con :provider necesita atención",
|
||||
"Your :provider connection has expired.": "Tu conexión con :provider ha caducado.",
|
||||
"Your Accounts": "Tus Cuentas",
|
||||
"Your Categories Include:": "Tus Categorías Incluyen:",
|
||||
"Your Data is Truly Private": "Tus Datos Son Verdaderamente Privados",
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ import {
|
|||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from 'lucide-react';
|
||||
import { StrictMode } from 'react';
|
||||
import { StrictMode, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Toaster } from 'sonner';
|
||||
import { toast, Toaster } from 'sonner';
|
||||
import { EncryptionKeyProvider } from './contexts/encryption-key-context';
|
||||
import { PrivacyModeProvider } from './contexts/privacy-mode-context';
|
||||
import { SyncProvider } from './contexts/sync-context';
|
||||
|
|
@ -26,8 +26,8 @@ import {
|
|||
isFacebookInAppBrowserJavaBridgeNoise,
|
||||
isPostMessageDataCloneNoise,
|
||||
} from './lib/sentry';
|
||||
import type { SharedData } from './types';
|
||||
import { setTranslations } from './utils/i18n';
|
||||
import type { ExpiredBankingConnectionNotification, SharedData } from './types';
|
||||
import { __, setTranslations } from './utils/i18n';
|
||||
|
||||
installChunkLoadRecovery();
|
||||
|
||||
|
|
@ -60,6 +60,67 @@ initializeChartColorScheme();
|
|||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
let hasAttemptedTimezoneBackfill = false;
|
||||
const notifiedExpiredConnectionIds = new Set<string>();
|
||||
|
||||
function showExpiredConnectionsToast(
|
||||
expiredConnections: ExpiredBankingConnectionNotification[] | undefined,
|
||||
): void {
|
||||
if (!expiredConnections || expiredConnections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newExpiredConnections = expiredConnections.filter(
|
||||
(connection) => !notifiedExpiredConnectionIds.has(connection.id),
|
||||
);
|
||||
|
||||
if (newExpiredConnections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
expiredConnections.forEach((connection) => {
|
||||
notifiedExpiredConnectionIds.add(connection.id);
|
||||
});
|
||||
|
||||
const firstConnection = expiredConnections[0];
|
||||
const count = expiredConnections.length;
|
||||
|
||||
toast.error(
|
||||
count === 1
|
||||
? __('Your :provider connection has expired.', {
|
||||
provider: firstConnection.aspsp_name,
|
||||
})
|
||||
: __('You have :count expired bank connections.', {
|
||||
count,
|
||||
}),
|
||||
{
|
||||
description: __('Reconnect to resume automatic syncing.'),
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: __('Reconnect'),
|
||||
onClick: () => {
|
||||
window.location.href = firstConnection.reconnect_url;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function ExpiredConnectionsToast({
|
||||
initialExpiredConnections,
|
||||
}: {
|
||||
initialExpiredConnections: ExpiredBankingConnectionNotification[];
|
||||
}) {
|
||||
useEffect(() => {
|
||||
showExpiredConnectionsToast(initialExpiredConnections);
|
||||
|
||||
return router.on('navigate', (event) => {
|
||||
const pageProps = event.detail.page.props as unknown as SharedData;
|
||||
showExpiredConnectionsToast(pageProps.expiredBankingConnections);
|
||||
});
|
||||
}, [initialExpiredConnections]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine progress bar color based on current theme
|
||||
const getProgressBarColor = () => {
|
||||
|
|
@ -87,6 +148,10 @@ createInertiaApp({
|
|||
(initialPageProps?.hasEncryptedAccounts as boolean) ?? false;
|
||||
const hasEncryptedTransactions =
|
||||
(initialPageProps?.hasEncryptedTransactions as boolean) ?? false;
|
||||
const initialExpiredConnections =
|
||||
(initialPageProps?.expiredBankingConnections as
|
||||
| ExpiredBankingConnectionNotification[]
|
||||
| undefined) ?? [];
|
||||
|
||||
const syncUserTimezone = async (pageProps?: Partial<SharedData>) => {
|
||||
const user = pageProps?.auth?.user ?? null;
|
||||
|
|
@ -144,6 +209,11 @@ createInertiaApp({
|
|||
initialUser={initialUser}
|
||||
>
|
||||
<App {...props} />
|
||||
<ExpiredConnectionsToast
|
||||
initialExpiredConnections={
|
||||
initialExpiredConnections
|
||||
}
|
||||
/>
|
||||
<Toaster
|
||||
richColors
|
||||
mobileOffset={{ bottom: '110px' }}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,14 @@ export interface Features {
|
|||
cashflow: boolean;
|
||||
}
|
||||
|
||||
export interface ExpiredBankingConnectionNotification {
|
||||
id: UUID;
|
||||
aspsp_name: string;
|
||||
provider: string;
|
||||
valid_until: string | null;
|
||||
reconnect_url: string;
|
||||
}
|
||||
|
||||
export interface Flash {
|
||||
success: string | null;
|
||||
error: string | null;
|
||||
|
|
@ -64,6 +72,7 @@ export interface SharedData {
|
|||
pricing: PricingConfig;
|
||||
sidebarOpen: boolean;
|
||||
features: Features;
|
||||
expiredBankingConnections: ExpiredBankingConnectionNotification[];
|
||||
hasEncryptedAccounts: boolean;
|
||||
hasEncryptedTransactions: boolean;
|
||||
hasEncryptionSetup: boolean;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<x-mail::message>
|
||||
# {{ __('Reconnect your :provider account', ['provider' => $providerName]) }}
|
||||
|
||||
{{ __('Hi :name,', ['name' => $userName]) }}
|
||||
|
||||
{{ __('Your :provider connection has expired, so we cannot sync new transactions until you reconnect it.', ['provider' => $providerName]) }}
|
||||
|
||||
<x-mail::button :url="$reconnectUrl">
|
||||
{{ __('Reconnect Account') }}
|
||||
</x-mail::button>
|
||||
|
||||
{{ __('If the button does not work, open your connection settings and reconnect :provider from there.', ['provider' => $providerName]) }}
|
||||
|
||||
{{ __('Best,') }}<br>
|
||||
{{ __('Álvaro & Víctor') }}<br>
|
||||
{{ __('Founders of Whisper Money') }}
|
||||
</x-mail::message>
|
||||
|
|
@ -128,6 +128,7 @@ Route::middleware(['auth', 'verified'])->prefix('open-banking')->group(function
|
|||
Route::get('institutions', [InstitutionController::class, 'index'])->name('open-banking.institutions');
|
||||
Route::post('authorize', [AuthorizationController::class, 'store'])->name('open-banking.authorize');
|
||||
Route::post('connections/{connection}/reauthorize', [AuthorizationController::class, 'reauthorize'])->name('open-banking.reauthorize');
|
||||
Route::get('connections/{connection}/reconnect', [AuthorizationController::class, 'reconnect'])->name('open-banking.reconnect');
|
||||
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::post('connections/{connection}/map-accounts', [AccountMappingController::class, 'store'])->name('open-banking.map-accounts.store');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
|
|
@ -43,6 +45,31 @@ test('shared feature flags do not include coinbase flag', function () {
|
|||
]);
|
||||
});
|
||||
|
||||
test('authenticated users receive expired banking connection reconnect links', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$expiredConnection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'aspsp_name' => 'Santander',
|
||||
'status' => BankingConnectionStatus::Active,
|
||||
'valid_until' => now()->subDay(),
|
||||
]);
|
||||
BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'aspsp_name' => 'Fresh Bank',
|
||||
'status' => BankingConnectionStatus::Active,
|
||||
'valid_until' => now()->addDay(),
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->withoutVite()->get(route('dashboard'));
|
||||
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->has('expiredBankingConnections', 1)
|
||||
->where('expiredBankingConnections.0.id', $expiredConnection->id)
|
||||
->where('expiredBankingConnections.0.aspsp_name', 'Santander')
|
||||
->where('expiredBankingConnections.0.reconnect_url', route('open-banking.reconnect', $expiredConnection))
|
||||
);
|
||||
});
|
||||
|
||||
test('shared currency options split profile and account currencies', function () {
|
||||
$response = $this->withoutVite()->get(route('home'));
|
||||
|
||||
|
|
|
|||
|
|
@ -324,6 +324,34 @@ test('reauthorize starts new authorization for expired connections', function ()
|
|||
expect($connection->authorization_id)->toBe('new-auth-id-789');
|
||||
});
|
||||
|
||||
test('reconnect link redirects expired connections to bank authorization', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->expired()->create([
|
||||
'user_id' => $user->id,
|
||||
'aspsp_name' => 'Santander',
|
||||
'aspsp_country' => 'ES',
|
||||
]);
|
||||
|
||||
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||
$mockProvider->shouldReceive('startAuthorization')
|
||||
->with('Santander', 'ES', config('services.enablebanking.redirect_url'))
|
||||
->once()
|
||||
->andReturn([
|
||||
'url' => 'https://bank.example.com/reauthorize',
|
||||
'authorization_id' => 'new-auth-id-987',
|
||||
]);
|
||||
|
||||
$this->app->instance(BankingProviderInterface::class, $mockProvider);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('open-banking.reconnect', $connection));
|
||||
|
||||
$response->assertRedirect('https://bank.example.com/reauthorize');
|
||||
|
||||
$connection->refresh();
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::Pending);
|
||||
expect($connection->authorization_id)->toBe('new-auth-id-987');
|
||||
});
|
||||
|
||||
test('free tier users cannot reauthorize after onboarding when subscriptions are enabled', function () {
|
||||
config(['subscriptions.enabled' => true]);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use App\Enums\BankingSyncLogStatus;
|
|||
use App\Exceptions\Banking\TransientBankingProviderException;
|
||||
use App\Jobs\SyncAllBankingConnectionsJob;
|
||||
use App\Jobs\SyncBankingConnectionJob;
|
||||
use App\Mail\BankingConnectionExpiredEmail;
|
||||
use App\Models\Account;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\BankingSyncLog;
|
||||
|
|
@ -15,6 +16,7 @@ use GuzzleHttp\Psr7\Response;
|
|||
use Illuminate\Contracts\Queue\Job;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
// --- Retry Behavior Tests ---
|
||||
|
|
@ -359,7 +361,9 @@ test('failed sync creates a failed log entry', function () {
|
|||
expect($log->error_class)->toBe(RequestException::class);
|
||||
});
|
||||
|
||||
test('skipped sync creates a skipped log entry for expired connection', function () {
|
||||
test('skipped sync creates a skipped log entry and emails user for newly expired connection', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
|
|
@ -372,10 +376,17 @@ test('skipped sync creates a skipped log entry for expired connection', function
|
|||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
|
||||
$connection->refresh();
|
||||
$log = BankingSyncLog::where('banking_connection_id', $connection->id)->first();
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::Expired);
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe(BankingSyncLogStatus::Skipped);
|
||||
expect($log->metadata)->toBe(['reason' => 'expired']);
|
||||
|
||||
Mail::assertQueued(BankingConnectionExpiredEmail::class, function (BankingConnectionExpiredEmail $mail) use ($user, $connection) {
|
||||
return $mail->user->is($user)
|
||||
&& $mail->bankingConnection->is($connection);
|
||||
});
|
||||
});
|
||||
|
||||
test('skipped sync creates a skipped log entry for non-syncable status', function () {
|
||||
|
|
@ -559,6 +570,23 @@ test('scheduled sync excludes error connections with expired valid_until', funct
|
|||
Queue::assertNotPushed(SyncBankingConnectionJob::class);
|
||||
});
|
||||
|
||||
test('scheduled sync includes active enablebanking connections whose valid_until expired', function () {
|
||||
Queue::fake(SyncBankingConnectionJob::class);
|
||||
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'status' => BankingConnectionStatus::Active,
|
||||
'valid_until' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$job = new SyncAllBankingConnectionsJob;
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(SyncBankingConnectionJob::class, 1);
|
||||
Queue::assertPushed(SyncBankingConnectionJob::class, fn ($job) => $job->bankingConnection->id === $connection->id);
|
||||
});
|
||||
|
||||
test('scheduled sync skips connections still within rate limit backoff window', function () {
|
||||
Queue::fake(SyncBankingConnectionJob::class);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
ROOT_PATH=$1
|
||||
|
||||
cp "$ROOT_PATH/.env" .env
|
||||
cp -r "$ROOT_PATH//storage/keys" ./storage/keys
|
||||
|
||||
bun i
|
||||
composer install
|
||||
|
|
|
|||
Loading…
Reference in New Issue