'use client' import { useState, useEffect, useMemo, useRef } from 'react' import Link from 'next/link' import { useSearchParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { CheckCircle, Loader2, Upload } from 'lucide-react' import { createClient } from '@/lib/supabase/client' import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal' import { useCompany, useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import { UpgradeNote } from '@/components/billing/UpgradeNote' import { SettingsGroup, SettingsRow, SettingsRowEnd, SettingsRowNote, SettingsSeg, } from '@/components/settings/SettingsRows' import { BankSelector, type Bank } from './BankSelector' import { BankConnectionStatus } from './BankConnectionStatus' import { AccountPickerDialog } from './AccountPickerDialog' import type { BankConnection } from '@/types' import type { StoredAccount } from '../types' /** One "reuse an existing connection" offer, as returned by /reusable-sessions. */ interface ReusableSessionOffer { connection_id: string company_id: string company_name: string | null bank_name: string | null consent_expires: string | null available_account_count: number } /** * Self-contained banking settings panel for the enable-banking extension. * Loaded dynamically by the settings panel registry. */ export default function BankingSettingsPanel() { const { toast } = useToast() // Stable across renders so effects can list it as a dependency without // re-firing on every parent render (same reason as AccountPickerDialog). const supabase = useMemo(() => createClient(), []) // The OAuth callback lands here with ?select_accounts= once a bank is // successfully connected. Read via useSearchParams (SSR/hydration-safe) so // the first-load spinner can say "bank connected, fetching accounts" // instead of an anonymous spinner. The param itself is consumed and // stripped by the auto-open effect below. const searchParams = useSearchParams() const arrivedFromBankCallback = !!searchParams?.get('select_accounts') const { dialogProps, confirm } = useDestructiveConfirm() const { company, companies } = useCompany() const hasBankSync = useCapability(CAPABILITY.bank_sync) const [bankConnections, setBankConnections] = useState([]) const [syncingConnectionId, setSyncingConnectionId] = useState(null) const [isConnecting, setIsConnecting] = useState(false) const [connectingBankName, setConnectingBankName] = useState(null) const connectingRef = useRef(false) const releaseTimerRef = useRef | null>(null) const [isLoading, setIsLoading] = useState(true) const [loadError, setLoadError] = useState(false) const hasLoadedRef = useRef(false) const [showCsvFallback, setShowCsvFallback] = useState(false) const [psuType, setPsuType] = useState<'personal' | 'business'>('business') const [pickerConnectionId, setPickerConnectionId] = useState(null) // Live connections the same user holds at the same banks in OTHER companies. // Several ASPSPs allow only one active AIS session per PSU, so authorizing // company B silently kills company A's connection. RLS scopes SELECT to // user_company_ids(), so this read stays within the user's own companies. // Live sessions in the user's OTHER companies that still have unclaimed // accounts. Reusing one connects this company without a second BankID and // without revoking the first, which is what kills feeds at one-session banks. const [reusableSessions, setReusableSessions] = useState([]) const [attachingConnectionId, setAttachingConnectionId] = useState(null) const [otherCompanyConnections, setOtherCompanyConnections] = useState< { bank_name: string; company_id: string }[] >([]) // Set when the OAuth callback pointed at a connection that belongs to a // different company than the active one: without this the picker simply // never opens and the connection looks like it vanished. const [pickerCompanyMismatch, setPickerCompanyMismatch] = useState(null) // Must match STALE_THRESHOLD_MS in extensions/general/enable-banking/index.ts const PENDING_LOCK_MS = 30 * 1000 useEffect(() => { fetchConnections() return () => { if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current) } }, []) // Latest-ref so the visibility listener below (subscribed once) always // calls the current render's fetchConnections, which closes over company // context that may resolve after mount. const fetchConnectionsRef = useRef(fetchConnections) useEffect(() => { fetchConnectionsRef.current = fetchConnections }) // Safety net for completion signals that never reach this tab: a mobile // BankID app-switch can land the bank's redirect in a different browser // tab, the user can close the finalize page before its redirect, and a // bfcache-restored page shows a pre-connection snapshot. Refetch when the // tab regains visibility (background refresh, no spinner: fetchConnections // only blanks the panel on first load), throttled so rapid tab toggling // doesn't hammer the API. const lastVisibilityFetchRef = useRef(0) useEffect(() => { function onVisible() { if (document.visibilityState !== 'visible') return const now = Date.now() if (now - lastVisibilityFetchRef.current < 5_000) return lastVisibilityFetchRef.current = now void fetchConnectionsRef.current() } document.addEventListener('visibilitychange', onVisible) return () => document.removeEventListener('visibilitychange', onVisible) }, []) // Auto-open the picker when the user lands here from the OAuth callback // (URL: /settings/banking?select_accounts=). The query param is stripped // afterwards so a refresh doesn't keep reopening it. useEffect(() => { if (isLoading) return if (typeof window === 'undefined') return const params = new URLSearchParams(window.location.search) const targetId = params.get('select_accounts') if (!targetId) return const match = bankConnections.find(c => c.id === targetId) if (match) { setPickerConnectionId(targetId) setPickerCompanyMismatch(null) } else { // The callback finished, but the connection belongs to a company that // isn't the active one (the user switched company during the bank // round-trip, or authorized while another company was active). Name the // owner instead of dropping the user on a panel that looks unchanged. void (async () => { const { data } = await supabase .from('bank_connections') .select('company_id') .eq('id', targetId) .maybeSingle() const ownerId = (data as { company_id?: string } | null)?.company_id if (!ownerId) return const owner = companies.find((c) => c.company.id === ownerId) setPickerCompanyMismatch(owner?.company.name ?? 'ett annat bolag') })() } params.delete('select_accounts') const newQuery = params.toString() const newUrl = `${window.location.pathname}${newQuery ? `?${newQuery}` : ''}` window.history.replaceState({}, '', newUrl) }, [isLoading, bankConnections, companies, supabase]) function releaseConnectingLock() { connectingRef.current = false setIsConnecting(false) setConnectingBankName(null) } async function fetchConnections() { // Only the first load blanks the panel to a spinner. Later refetches (after // a sync, disconnect, or account save) refresh in the background so the // panel doesn't flash back to a full-height spinner and lose scroll // position on every action. if (!hasLoadedRef.current) setIsLoading(true) setLoadError(false) try { const { data: { user } } = await supabase.auth.getUser() if (!user || !company) { setBankConnections([]) return } const { data: connections, error } = await supabase .from('bank_connections') .select('*') .eq('company_id', company.id) .order('created_at', { ascending: false }) if (error) { // Surface the failure instead of rendering an empty panel: an empty // panel reads as "your bank got disconnected" when it's really a // transient fetch/RLS error. setLoadError(true) return } setBankConnections(connections || []) // Same-bank connections in the user's other companies. Only sessions // that actually hold a consent count: a revoked or errored row is not // competing for the bank's one-session-per-login slot. const { data: allConnections } = await supabase .from('bank_connections') .select('bank_name, company_id, status') .in('status', ['active', 'pending_selection']) setOtherCompanyConnections( ((allConnections || []) as { bank_name: string; company_id: string }[]).filter( (c) => c.company_id !== company.id ) ) // Reuse offers. Best-effort: a failure here costs the shortcut, never the // panel, so the normal connect flow stays available either way. try { const reuseResponse = await fetch('/api/extensions/ext/enable-banking/reusable-sessions') if (reuseResponse.ok) { const { sessions } = await reuseResponse.json() setReusableSessions((sessions || []) as ReusableSessionOffer[]) } else { setReusableSessions([]) } } catch { setReusableSessions([]) } // If a pending connection exists from a recent attempt (e.g. user bounced back from // the bank's auth page), keep the connect button disabled until the server-side lock expires. const freshPending = (connections || []).find((c) => c.status === 'pending') if (freshPending) { const age = Date.now() - new Date(freshPending.created_at).getTime() const remaining = PENDING_LOCK_MS - age if (remaining > 0) { connectingRef.current = true setIsConnecting(true) setConnectingBankName(freshPending.bank_name) if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current) releaseTimerRef.current = setTimeout(releaseConnectingLock, remaining) } } } finally { // Always clear the spinner, even on the early `!user || !company` return, // so an expired session can't leave the panel spinning forever. hasLoadedRef.current = true setIsLoading(false) } } /** * Warn before authorizing a bank where the same user already holds live * connections in other companies. Several ASPSPs bind one active AIS session * per PSU, so the new authorization silently invalidates the existing ones, * and nothing in the product tells the user until a sync fails days later. * Advisory only: legitimate multi-company setups must still be able to * proceed, so the dialog always offers a working "Fortsätt". */ async function confirmSameBankConnections(bankName: string): Promise { const clashes = otherCompanyConnections.filter((c) => c.bank_name === bankName) if (clashes.length === 0) return true const names = clashes .map((c) => companies.find((entry) => entry.company.id === c.company_id)?.company.name) .filter((name): name is string => !!name) const companyList = names.length > 0 ? ` (${names.join(', ')})` : '' const count = clashes.length return confirm({ title: `Du har redan ${count} ${count === 1 ? 'anslutning' : 'anslutningar'} till ${bankName}`, description: `${bankName} är sedan tidigare ansluten i ${count === 1 ? 'ett annat bolag' : 'andra bolag'}${companyList}. ` + 'Vissa banker tillåter bara en aktiv anslutning per inloggning: när du slutför den här kan de andra sluta synka ' + 'och behöva förnyas. Fortsätt om du vet att din bank tillåter flera.', confirmLabel: 'Fortsätt', variant: 'warning', }) } /** * Reuse a session authorized for another of the user's companies. No bank * round-trip: the server creates this company's connection against the same * consent and parks it in 'pending_selection', so the account picker opens * exactly as it does after a real authorization. */ async function handleReuseConnection(offer: ReusableSessionOffer) { if (attachingConnectionId) return setAttachingConnectionId(offer.connection_id) try { const response = await fetch('/api/extensions/ext/enable-banking/attach', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: offer.connection_id }), }) const result = await response.json() if (!response.ok) { toast({ title: 'Kunde inte återanvända anslutningen', description: result?.error || 'Försök igen om en stund.', variant: 'destructive', }) return } await fetchConnections() notifyBankSyncUpdated() // Straight into account selection: the connection exists but syncs // nothing until the user picks which accounts belong to this company. setPickerConnectionId(result.connection_id) } catch (error) { console.error('[enable-banking] Reuse failed', error) toast({ title: 'Kunde inte återanvända anslutningen', description: 'Ett oväntat fel uppstod. Försök igen om en stund.', variant: 'destructive', }) } finally { setAttachingConnectionId(null) } } async function handleConnectBank(bank: Bank, psuTypeOverride?: 'personal' | 'business') { if (connectingRef.current) return // Claim the lock BEFORE the confirm await. The dialog can sit open // indefinitely, and a second click in that window would otherwise sail // past the guard above and start a concurrent connect flow. connectingRef.current = true if (!(await confirmSameBankConnections(bank.name))) { connectingRef.current = false return } setIsConnecting(true) setConnectingBankName(bank.name) try { console.log('[enable-banking] Initiating bank connection', { bankName: bank.name, bankCountry: bank.country, psuTypeOverride, }) const body: Record = { aspsp_name: bank.name, aspsp_country: bank.country } if (psuTypeOverride) body.psu_type = psuTypeOverride const response = await fetch('/api/extensions/ext/enable-banking/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) const data = await response.json() if (!response.ok) { console.error('[enable-banking] Connect request failed', { status: response.status, statusText: response.statusText, error: data.error, bankName: bank.name, }) throw new Error(data.error) } console.log('[enable-banking] Redirecting to bank authorization', { connectionId: data.connection_id, hasAuthUrl: !!data.authorization_url, }) window.location.href = data.authorization_url } catch (error) { console.error('[enable-banking] Connect flow failed', { message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, bankName: bank.name, }) toast({ title: 'Fel', description: error instanceof Error ? error.message : 'Kunde inte ansluta bank', variant: 'destructive', }) connectingRef.current = false setIsConnecting(false) setConnectingBankName(null) setShowCsvFallback(true) } } // Re-authorize an existing connection in place: no disconnect required. // Posts to /connect with the existing connection_id so the server reuses the // same row (revoking the dead session, issuing fresh authorization), then // hands off to the bank's consent screen. The OAuth callback drives the row // back through account selection to active. async function handleReconnect(connection: BankConnection, psuTypeOverride?: 'personal' | 'business') { if (connectingRef.current) return // Lock before the confirm await, same reason as handleConnectBank. connectingRef.current = true if (!(await confirmSameBankConnections(connection.bank_name))) { connectingRef.current = false return } setIsConnecting(true) setConnectingBankName(connection.bank_name) try { const country = (connection.provider as string)?.split('-').pop()?.toUpperCase() || 'SE' const response = await fetch('/api/extensions/ext/enable-banking/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connection.id, aspsp_name: connection.bank_name, aspsp_country: country, // Omitted → server reuses the connection's stored psu_type (falling // back to entity_type). Set → switch account type in place. ...(psuTypeOverride ? { psu_type: psuTypeOverride } : {}), }), }) const data = await response.json() if (!response.ok) { throw new Error(data.error) } window.location.href = data.authorization_url } catch (error) { console.error('[enable-banking] Reconnect flow failed', { message: error instanceof Error ? error.message : String(error), connectionId: connection.id, }) toast({ title: 'Fel', description: error instanceof Error ? error.message : 'Kunde inte förnya anslutningen', variant: 'destructive', }) connectingRef.current = false setIsConnecting(false) setConnectingBankName(null) } } async function handleSyncTransactions(connectionId: string) { setSyncingConnectionId(connectionId) // A slow bank can hold the request open up to the route's 300s budget. // Cap the client wait so the spinner can't hang indefinitely; the sync is // idempotent (imports dedup), so a background completion or manual retry is // safe. const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 180_000) try { console.log('[enable-banking] Starting sync', { connectionId }) const response = await fetch('/api/extensions/ext/enable-banking/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connectionId }), signal: controller.signal, }) const data = await response.json() if (!response.ok) { console.error('[enable-banking] Sync request failed', { status: response.status, statusText: response.statusText, error: data.error, connectionId, }) throw new Error(data.error) } console.log('[enable-banking] Sync completed', { connectionId, imported: data.imported, duplicates: data.duplicates, }) toast({ title: 'Synkronisering klar', description: `${data.imported} nya transaktioner importerade`, }) setShowCsvFallback(false) notifyBankSyncUpdated() fetchConnections() } catch (error) { if (controller.signal.aborted) { toast({ title: 'Synkronisering tar längre tid än vanligt', description: 'Transaktionerna hämtas i bakgrunden. Uppdatera sidan om en stund.', }) fetchConnections() } else { console.error('[enable-banking] Sync flow failed', { message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, connectionId, }) toast({ title: 'Fel', description: error instanceof Error ? error.message : 'Synkronisering misslyckades', variant: 'destructive', }) setShowCsvFallback(true) // Refresh so a now-expired connection (e.g. closed PSD2 session) moves // into "Åtgärd krävs" and surfaces the "Förnya anslutning" button. fetchConnections() } } finally { clearTimeout(timeout) setSyncingConnectionId(null) } } async function handleDisconnectBank(connectionId: string) { const ok = await confirm({ title: 'Koppla bort bank?', description: 'PSD2-samtycket kommer återkallas. Befintliga transaktioner påverkas inte.', confirmLabel: 'Koppla bort', variant: 'warning', }) if (!ok) return try { console.log('[enable-banking] Disconnecting bank', { connectionId }) const response = await fetch('/api/extensions/ext/enable-banking/disconnect', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connectionId }), }) if (!response.ok) { const data = await response.json() console.error('[enable-banking] Disconnect request failed', { status: response.status, statusText: response.statusText, error: data.error, connectionId, }) throw new Error(data.error || 'Disconnect failed') } console.log('[enable-banking] Bank disconnected', { connectionId }) toast({ title: 'Bank bortkopplad', description: 'Bankanslutningen och PSD2-samtycket har återkallats', }) fetchConnections() } catch (error) { console.error('[enable-banking] Disconnect flow failed', { message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, connectionId, }) toast({ title: 'Fel', description: error instanceof Error ? error.message : 'Kunde inte koppla bort bank', variant: 'destructive', }) } } if (isLoading) { // Coming back from the bank's consent flow the connection already exists, // so tell the user that instead of showing an anonymous spinner: this is // the last silent gap between "approved at the bank" and the account // picker opening. if (arrivedFromBankCallback) { return (
Banken är ansluten
Hämtar dina konton…
) } return (
) } // First-load failure: show a recoverable error instead of an empty panel (a // blank panel misreads as "no banks connected"). A background-refetch failure // keeps the already-loaded connections visible instead of wiping them. if (loadError && bankConnections.length === 0) { return (

Kunde inte ladda bankanslutningar

Något gick fel när dina bankanslutningar skulle hämtas. Dina anslutningar och transaktioner är oförändrade.

) } const activeConnections = bankConnections.filter((c) => c.status === 'active') const pendingSelectionConnections = bankConnections.filter((c) => c.status === 'pending_selection') const actionRequiredConnections = bankConnections.filter((c) => ['expired', 'error'].includes(c.status)) const pickerConnection = pickerConnectionId ? bankConnections.find(c => c.id === pickerConnectionId) : null const pickerAccounts = pickerConnection ? ((pickerConnection.accounts_data as StoredAccount[] | null) || []) : [] return (
{pickerConnection && ( { if (!open) setPickerConnectionId(null) }} connectionId={pickerConnection.id} bankName={pickerConnection.bank_name} accounts={pickerAccounts} isInitialSelection={pickerConnection.status === 'pending_selection'} onSaved={() => fetchConnections()} /> )} {/* The callback landed on a connection owned by another of the user's companies: one ochre line naming where it went (convention 6). */} {pickerCompanyMismatch && (

Bankanslutningen slutfördes för {pickerCompanyMismatch}, inte för det bolag som är aktivt nu. Byt till {pickerCompanyMismatch} för att välja vilka konton som ska synka.

)} {/* Persistent CSV fallback after connection/sync failure: a live hint, kept visible as a compact line instead of a boxed strip. */} {showCsvFallback && (

Har du problem med bankanslutningen? Du kan{' '} importera transaktioner manuellt via bankfil .

)} {/* Pending account selection: new connections waiting for the user to pick accounts */} {pendingSelectionConnections.length > 0 && ( {pendingSelectionConnections.map((connection) => { const accountsList = (connection.accounts_data as StoredAccount[] | null) || [] return (
{connection.bank_name} {accountsList.length} konton tillgängliga: inga transaktioner synkas ännu
) })}
)} {/* Action required: expired/error connections */} {actionRequiredConnections.length > 0 && ( {actionRequiredConnections.map((connection) => ( setPickerConnectionId(connection.id)} isSyncing={syncingConnectionId === connection.id} /> ))} )} {/* Connected banks */} {activeConnections.length > 0 && ( {activeConnections.map((connection) => ( setPickerConnectionId(connection.id)} isSyncing={syncingConnectionId === connection.id} /> ))} )} {/* Reuse a session authorized for another of the user's companies. Sits ABOVE the bank list deliberately: at a one-session-per-login bank, choosing the bank below is the very action that kills the other company's feed, so the cheaper and safer path has to be seen first. Renders only when a live session actually has unclaimed accounts. */} {hasBankSync && reusableSessions.length > 0 && (

Du har redan en giltig bankanslutning i ett annat bolag, och den ser konton som inget bolag använder ännu.

Vissa banker tillåter bara en aktiv anslutning per inloggning. Att återanvända anslutningen i stället för att logga in på nytt låter bolagen dela samma samtycke, så bolaget som redan är anslutet fortsätter att synka.

Bolagen delar bara samtycket. Konton, transaktioner och bokföring hålls isär, och du väljer i nästa steg vilka konton som hör till det här bolaget.

} > {reusableSessions.map((offer) => ( Ansluten för{' '} {offer.company_name ?? 'ett annat bolag'} . {offer.available_account_count}{' '} {offer.available_account_count === 1 ? 'ledigt konto' : 'lediga konton'} kan kopplas till{' '} {company?.name ?? 'det här bolaget'} {' '} utan nytt BankID. ))} )} {/* Connect new bank. Non-payers keep seeing the group (conversion surface) but the bank list is replaced by an upgrade note: the server gate would 403 the connect anyway. The former "Om bankintegration (PSD2)" card lives on as group-level help. */}

Välj din bank nedan för att koppla ditt konto via PSD2.

Anslutningen görs för det bolag som är aktivt just nu. Byt bolag först om du vill ansluta banken åt ett annat bolag.

Om bankintegration (PSD2)

Automatisk import av transaktioner via PSD2 open banking. Samtycket gäller i 90 dagar och behöver sedan förnyas.

Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner, aldrig flytta pengar. Du kan också importera transaktioner manuellt via bankfiler på importsidan.

} > {!hasBankSync ? (
Automatisk banksynk kräver ett abonnemang. Du kan fortfarande importera transaktioner manuellt via bankfiler på importsidan.
) : ( <>
{/* Name the company on the surface itself, not only in the help popover: the bank login that follows says nothing about which set of books the accounts will land in. */} {company?.name && (

Anslutningen görs för{' '} {company.name}.

)} handleConnectBank(bank, psuType)} onPsuTypeDetected={setPsuType} isConnecting={isConnecting} connectingBankName={connectingBankName} />
)}
) }