'use client' import { useState, useEffect, useRef } from 'react' import Link from 'next/link' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { AlertTriangle, Loader2, Upload } from 'lucide-react' import { cn } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' import { BankSelector, type Bank } from './BankSelector' import { BankConnectionStatus } from './BankConnectionStatus' import { AccountPickerDialog } from './AccountPickerDialog' import type { BankConnection } from '@/types' import type { StoredAccount } from '../types' /** * Self-contained banking settings panel for the enable-banking extension. * Loaded dynamically by the settings panel registry. */ export default function BankingSettingsPanel() { const { toast } = useToast() const supabase = createClient() const { dialogProps, confirm } = useDestructiveConfirm() const { company } = useCompany() 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 [showCsvFallback, setShowCsvFallback] = useState(false) const [psuType, setPsuType] = useState<'personal' | 'business'>('business') const [pickerConnectionId, setPickerConnectionId] = 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) } }, []) // 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) } params.delete('select_accounts') const newQuery = params.toString() const newUrl = `${window.location.pathname}${newQuery ? `?${newQuery}` : ''}` window.history.replaceState({}, '', newUrl) }, [isLoading, bankConnections]) function releaseConnectingLock() { connectingRef.current = false setIsConnecting(false) setConnectingBankName(null) } async function fetchConnections() { setIsLoading(true) const { data: { user } } = await supabase.auth.getUser() if (!user) return if (!company) return const { data: connections } = await supabase .from('bank_connections') .select('*') .eq('company_id', company.id) .order('created_at', { ascending: false }) setBankConnections(connections || []) // 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) } } setIsLoading(false) } async function handleConnectBank(bank: Bank, psuTypeOverride?: 'personal' | 'business') { if (connectingRef.current) return connectingRef.current = true 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 connectingRef.current = true 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) 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 }), }) 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) fetchConnections() } catch (error) { 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() } 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) { return (
) } 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()} /> )} {/* Persistent CSV fallback after connection/sync failure */} {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 && ( Välj konton att synka Banken har gett åtkomst till flera konton. Välj vilka du vill synka innan några transaktioner hämtas. {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 && ( Åtgärd krävs Dessa anslutningar behöver uppmärksamhet. {actionRequiredConnections.map((connection) => ( setPickerConnectionId(connection.id)} isSyncing={syncingConnectionId === connection.id} /> ))} )} {/* Connected banks */} {activeConnections.length > 0 && ( Anslutna banker {activeConnections.map((connection) => ( setPickerConnectionId(connection.id)} isSyncing={syncingConnectionId === connection.id} /> ))} )} {/* Connect new bank */} Anslut ny bank Välj din bank nedan för att koppla ditt konto via PSD2. {/* Account type selector */}
Kontotyp:
{psuType === 'personal' && (

Välj Privatkonto om du använder ditt personliga bankkonto för din verksamhet (vanligt för enskild firma).

)} handleConnectBank(bank, psuType)} onPsuTypeDetected={setPsuType} isConnecting={isConnecting} connectingBankName={connectingBankName} />
{/* Info about PSD2 */} 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.

) }