diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index bd739fb7..a0719c2e 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -3,13 +3,27 @@ import { useState, useCallback } from 'react' import { Card, CardContent } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useToast } from '@/components/ui/use-toast' +import { ArrowLeftRight, FileText } from 'lucide-react' + +// Bank file import components +import BankFileUploadStep from '@/components/import/BankFileUploadStep' +import BankFilePreviewStep from '@/components/import/BankFilePreviewStep' +import BankFileColumnMappingStep from '@/components/import/BankFileColumnMappingStep' +import BankFileConfirmStep from '@/components/import/BankFileConfirmStep' +import BankFileResultStep from '@/components/import/BankFileResultStep' + +// SIE import components import SIEUploadStep from '@/components/import/SIEUploadStep' import SIEPreviewStep from '@/components/import/SIEPreviewStep' import AccountMappingStep from '@/components/import/AccountMappingStep' import ImportReviewStep, { type ImportExecuteOptions } from '@/components/import/ImportReviewStep' import ImportResultStep from '@/components/import/ImportResultStep' import { applyMappingOverride } from '@/lib/import/account-mapper' +import { getCSVHeaders, getCSVPreview } from '@/lib/import/bank-file/formats/generic-csv' +import type { BankFileParseResult, BankFileFormatId, GenericCSVColumnMapping } from '@/lib/import/bank-file/types' +import type { IngestResult } from '@/lib/transactions/ingest' import type { ImportWizardStep, ParsedSIEFile, @@ -20,9 +34,268 @@ import type { } from '@/lib/import/types' import type { BASAccount } from '@/types' -const STEPS: ImportWizardStep[] = ['upload', 'preview', 'mapping', 'review', 'result'] +// ============================================================ +// Bank File Import Wizard Steps +// ============================================================ -const STEP_LABELS: Record = { +type BankFileStep = 'upload' | 'preview' | 'column_mapping' | 'confirm' | 'result' + +const BANK_STEPS: BankFileStep[] = ['upload', 'preview', 'confirm', 'result'] +const BANK_STEPS_WITH_MAPPING: BankFileStep[] = ['upload', 'preview', 'column_mapping', 'confirm', 'result'] + +const BANK_STEP_LABELS: Record = { + upload: 'Ladda upp', + preview: 'Förhandsgranskning', + column_mapping: 'Kolumnmappning', + confirm: 'Bekräfta', + result: 'Resultat', +} + +function BankFileImportWizard() { + const { toast } = useToast() + + const [bankStep, setBankStep] = useState('upload') + const [bankIsLoading, setBankIsLoading] = useState(false) + const [bankError, setBankError] = useState(null) + + // Parse results + const [parseResult, setParseResult] = useState(null) + const [detectedFormat, setDetectedFormat] = useState(null) + const [detectedFormatName, setDetectedFormatName] = useState(null) + const [fileHash, setFileHash] = useState('') + const [filename, setFilename] = useState('') + const [existingTxCount, setExistingTxCount] = useState(0) + const [rawFileContent, setRawFileContent] = useState('') + + // Column mapping for generic CSV + const [csvHeaders, setCsvHeaders] = useState([]) + const [csvPreview, setCsvPreview] = useState([]) + + // Import result + const [ingestResult, setIngestResult] = useState(null) + + const steps = parseResult?.format === 'generic_csv' ? BANK_STEPS_WITH_MAPPING : BANK_STEPS + const currentStepIndex = steps.indexOf(bankStep) + const progress = ((currentStepIndex + 1) / steps.length) * 100 + + const handleFileSelect = useCallback(async (file: File, formatOverride?: BankFileFormatId) => { + setBankError(null) + setBankIsLoading(true) + + try { + const formData = new FormData() + formData.append('file', file) + if (formatOverride) { + formData.append('format', formatOverride) + } + + const res = await fetch('/api/import/bank-file/parse', { + method: 'POST', + body: formData, + }) + + const data = await res.json() + + if (!res.ok) { + if (data.error === 'duplicate') { + setBankError(data.message) + } else { + setBankError(data.error || 'Kunde inte läsa filen') + } + return + } + + setParseResult(data.data.parse_result) + setDetectedFormat(data.data.detected_format) + setDetectedFormatName(data.data.detected_format_name) + setFileHash(data.data.file_hash) + setFilename(data.data.filename) + setExistingTxCount(data.data.existing_transaction_count) + + // Store headers for generic CSV mapping + if (data.data.headers) { + setCsvHeaders(data.data.headers) + } + + // Read raw file content for CSV preview + const text = await file.text() + setRawFileContent(text) + if (data.data.parse_result.format === 'generic_csv') { + setCsvHeaders(getCSVHeaders(text)) + setCsvPreview(getCSVPreview(text, ',', 6)) + } + + const txCount = data.data.parse_result.transactions.length + if (txCount > 0) { + setBankStep('preview') + toast({ + title: 'Fil analyserad', + description: `${txCount} transaktioner hittades`, + }) + } else if (data.data.parse_result.format === 'generic_csv' || !data.data.detected_format) { + // Unrecognized format — show upload step with error + setBankError('Kunde inte identifiera bankformatet. Välj bank manuellt eller använd "Annan CSV".') + } + } catch (err) { + setBankError(err instanceof Error ? err.message : 'Kunde inte läsa filen') + } finally { + setBankIsLoading(false) + } + }, [toast]) + + const handleColumnMappingConfirm = useCallback(async (mapping: GenericCSVColumnMapping) => { + // Re-parse with mapping via the generic CSV parser + const { parseGenericCSV } = await import('@/lib/import/bank-file/formats/generic-csv') + const result = parseGenericCSV(rawFileContent, mapping) + setParseResult(result) + setBankStep('confirm') + }, [rawFileContent]) + + const handleExecuteImport = useCallback(async (options: { skip_duplicates: boolean; auto_categorize: boolean }) => { + if (!parseResult) return + + setBankIsLoading(true) + setBankError(null) + + try { + const res = await fetch('/api/import/bank-file/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transactions: parseResult.transactions, + format: parseResult.format, + filename, + file_hash: fileHash, + ...options, + }), + }) + + const data = await res.json() + + if (!res.ok) { + setBankError(data.error || 'Importen misslyckades') + return + } + + setIngestResult(data.data) + setBankStep('result') + + toast({ + title: 'Import genomförd', + description: `${data.data.imported} transaktioner importerades`, + }) + } catch (err) { + setBankError(err instanceof Error ? err.message : 'Importen misslyckades') + } finally { + setBankIsLoading(false) + } + }, [parseResult, filename, fileHash, toast]) + + const handleNewImport = () => { + setBankStep('upload') + setParseResult(null) + setDetectedFormat(null) + setDetectedFormatName(null) + setFileHash('') + setFilename('') + setExistingTxCount(0) + setIngestResult(null) + setBankError(null) + setCsvHeaders([]) + setCsvPreview([]) + setRawFileContent('') + } + + return ( +
+ {/* Progress */} + + +
+
+ {steps.map((s, i) => ( + + {BANK_STEP_LABELS[s]} + + ))} +
+ +
+
+
+ + {/* Step content */} + {bankStep === 'upload' && ( + + )} + + {bankStep === 'preview' && parseResult && ( + { + if (parseResult.format === 'generic_csv') { + setBankStep('column_mapping') + } else { + setBankStep('confirm') + } + }} + onBack={() => setBankStep('upload')} + /> + )} + + {bankStep === 'column_mapping' && ( + setBankStep('preview')} + /> + )} + + {bankStep === 'confirm' && parseResult && ( + { + if (parseResult.format === 'generic_csv') { + setBankStep('column_mapping') + } else { + setBankStep('preview') + } + }} + isLoading={bankIsLoading} + /> + )} + + {bankStep === 'result' && ingestResult && ( + + )} +
+ ) +} + +// ============================================================ +// SIE Import Wizard (unchanged, extracted into component) +// ============================================================ + +const SIE_STEPS: ImportWizardStep[] = ['upload', 'preview', 'mapping', 'review', 'result'] + +const SIE_STEP_LABELS: Record = { upload: 'Ladda upp', preview: 'Förhandsgranskning', mapping: 'Kontomappning', @@ -30,15 +303,13 @@ const STEP_LABELS: Record = { result: 'Resultat', } -export default function ImportPage() { +function SIEImportWizard() { const { toast } = useToast() - // Wizard state const [step, setStep] = useState('upload') const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) - // Data state const [file, setFile] = useState(null) const [_parsed, setParsed] = useState(null) const [mappings, setMappings] = useState([]) @@ -49,11 +320,9 @@ export default function ImportPage() { const [_sieAccounts, setSieAccounts] = useState<{ number: string; name: string }[]>([]) const [isCreatingAccounts, setIsCreatingAccounts] = useState(false) - // Calculate progress - const currentStepIndex = STEPS.indexOf(step) - const progress = ((currentStepIndex + 1) / STEPS.length) * 100 + const currentStepIndex = SIE_STEPS.indexOf(step) + const progress = ((currentStepIndex + 1) / SIE_STEPS.length) * 100 - // Handle file selection and parsing const handleFileSelect = useCallback(async (selectedFile: File) => { setFile(selectedFile) setError(null) @@ -81,7 +350,6 @@ export default function ImportPage() { return } - // Store parsed data setParsed({ header: data.parsed.header, accounts: data.parsed.accounts, @@ -97,14 +365,12 @@ export default function ImportPage() { setIssues(data.parsed.issues) setSieAccounts(data.parsed.accounts) - // Fetch BAS accounts for the mapping step const accountsRes = await fetch('/api/bookkeeping/accounts') if (accountsRes.ok) { const accountsData = await accountsRes.json() setBasAccounts(accountsData.data || []) } - // Move to preview step setStep('preview') toast({ @@ -118,11 +384,9 @@ export default function ImportPage() { } }, [toast]) - // Handle mapping changes const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => { setMappings((prev) => applyMappingOverride(prev, sourceAccount, targetAccount, targetName)) - // Update preview mapping status setPreview((prev) => { if (!prev) return prev const updatedMappings = applyMappingOverride(mappings, sourceAccount, targetAccount, targetName) @@ -142,12 +406,10 @@ export default function ImportPage() { }) }, [mappings]) - // Calculate missing accounts (unmapped accounts that could be created) const missingAccounts = mappings .filter((m) => !m.targetAccount) .map((m) => ({ number: m.sourceAccount, name: m.sourceName })) - // Handle creating missing accounts const handleCreateAccounts = useCallback(async () => { if (missingAccounts.length === 0) return @@ -163,36 +425,23 @@ export default function ImportPage() { const data = await res.json() if (!res.ok) { - toast({ - title: 'Fel', - description: data.error || 'Kunde inte skapa konton', - variant: 'destructive', - }) + toast({ title: 'Fel', description: data.error || 'Kunde inte skapa konton', variant: 'destructive' }) return } - toast({ - title: 'Konton skapade', - description: `${data.created} nya konton har lagts till i din kontoplan`, - }) + toast({ title: 'Konton skapade', description: `${data.created} nya konton har lagts till i din kontoplan` }) - // Re-parse the file to get updated mappings if (file) { const formData = new FormData() formData.append('file', file) - const parseRes = await fetch('/api/import/sie/parse', { - method: 'POST', - body: formData, - }) - + const parseRes = await fetch('/api/import/sie/parse', { method: 'POST', body: formData }) const parseData = await parseRes.json() if (parseRes.ok) { setMappings(parseData.mappings) setPreview(parseData.preview) - // Refresh BAS accounts const accountsRes = await fetch('/api/bookkeeping/accounts') if (accountsRes.ok) { const accountsData = await accountsRes.json() @@ -201,22 +450,14 @@ export default function ImportPage() { } } } catch (err) { - toast({ - title: 'Fel', - description: err instanceof Error ? err.message : 'Kunde inte skapa konton', - variant: 'destructive', - }) + toast({ title: 'Fel', description: err instanceof Error ? err.message : 'Kunde inte skapa konton', variant: 'destructive' }) } finally { setIsCreatingAccounts(false) } }, [missingAccounts, file, toast]) - // Handle import execution const handleExecuteImport = useCallback(async (options: ImportExecuteOptions) => { - if (!file) { - setError('No file selected') - return - } + if (!file) { setError('No file selected'); return } setIsLoading(true) setError(null) @@ -227,32 +468,19 @@ export default function ImportPage() { formData.append('mappings', JSON.stringify(mappings)) formData.append('options', JSON.stringify(options)) - const res = await fetch('/api/import/sie/execute', { - method: 'POST', - body: formData, - }) - + const res = await fetch('/api/import/sie/execute', { method: 'POST', body: formData }) const data = await res.json() if (!res.ok) { - if (data.result) { - setImportResult(data.result) - } else { - setError(data.error || 'Import failed') - return - } + if (data.result) { setImportResult(data.result) } else { setError(data.error || 'Import failed'); return } } else { setImportResult(data.result) } - // Move to result step setStep('result') if (data.result?.success) { - toast({ - title: 'Import genomförd', - description: `${data.result.journalEntriesCreated} verifikationer skapades`, - }) + toast({ title: 'Import genomförd', description: `${data.result.journalEntriesCreated} verifikationer skapades` }) } } catch (err) { setError(err instanceof Error ? err.message : 'Import failed') @@ -261,56 +489,24 @@ export default function ImportPage() { } }, [file, mappings, toast]) - // Navigation handlers - const goToStep = (targetStep: ImportWizardStep) => { - setStep(targetStep) - setError(null) - } - - const goBack = () => { - const currentIndex = STEPS.indexOf(step) - if (currentIndex > 0) { - setStep(STEPS[currentIndex - 1]) - } - } + const goToStep = (targetStep: ImportWizardStep) => { setStep(targetStep); setError(null) } + const goBack = () => { const i = SIE_STEPS.indexOf(step); if (i > 0) setStep(SIE_STEPS[i - 1]) } const handleNewImport = () => { - // Reset all state - setStep('upload') - setFile(null) - setParsed(null) - setMappings([]) - setPreview(null) - setIssues([]) - setImportResult(null) - setError(null) - setSieAccounts([]) - setIsCreatingAccounts(false) + setStep('upload'); setFile(null); setParsed(null); setMappings([]) + setPreview(null); setIssues([]); setImportResult(null); setError(null) + setSieAccounts([]); setIsCreatingAccounts(false) } return (
- {/* Header */} -
-

Importera bokföring

-

- Migrera din bokföring från Fortnox, Visma eller annat bokföringssystem via SIE-fil -

-
- - {/* Progress */}
- {STEPS.map((s, i) => ( - - {STEP_LABELS[s]} + {SIE_STEPS.map((s, i) => ( + + {SIE_STEP_LABELS[s]} ))}
@@ -319,53 +515,61 @@ export default function ImportPage() { - {/* Step content */} - {step === 'upload' && ( - - )} - + {step === 'upload' && } {step === 'preview' && preview && ( - goToStep('mapping')} - onBack={goBack} - /> + goToStep('mapping')} onBack={goBack} /> )} - {step === 'mapping' && ( - goToStep('review')} - onBack={goBack} - /> + goToStep('review')} onBack={goBack} /> )} - {step === 'review' && preview && ( - - )} - - {step === 'result' && importResult && ( - + )} + {step === 'result' && importResult && } +
+ ) +} + +// ============================================================ +// Import Page with Tabs +// ============================================================ + +export default function ImportPage() { + return ( +
+ {/* Header */} +
+

Importera

+

+ Importera banktransaktioner eller bokföringsdata till ditt företag +

+
+ + {/* Tabbed layout */} + + + + + Banktransaktioner + + + + Bokföringsdata (SIE) + + + + + + + + + + +
) } diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index fdb3e805..bac0fa1a 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -47,17 +47,16 @@ export default async function DashboardPage() { .select('*', { count: 'exact', head: true }) .eq('user_id', user.id) - const { count: bankConnectionCount } = await supabase - .from('bank_connections') + const { count: transactionCount } = await supabase + .from('transactions') .select('*', { count: 'exact', head: true }) .eq('user_id', user.id) - .eq('status', 'active') const onboardingProgress: OnboardingProgress = { hasCustomers: (customerCount || 0) > 0, hasInvoices: (invoiceCount || 0) > 0, hasReceipts: (receiptCount || 0) > 0, - hasBankConnected: (bankConnectionCount || 0) > 0, + hasBankConnected: (transactionCount || 0) > 0, } // Fetch current year transactions summary diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 58a92c2f..21024c0b 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -11,7 +11,6 @@ import { Badge } from '@/components/ui/badge' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useToast } from '@/components/ui/use-toast' import { formatDate } from '@/lib/utils' -import { getDaysUntilExpiry, isConsentExpiringSoon } from '@/lib/banking/enable-banking' import { Loader2, Building, @@ -25,7 +24,6 @@ import { Calendar, } from 'lucide-react' import type { CompanySettings, BankConnection } from '@/types' -import { BankSelector, type Bank } from '@/components/banking/BankSelector' import { NotificationSettings } from '@/extensions/push-notifications/NotificationSettings' import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings' @@ -41,6 +39,7 @@ export default function SettingsPage() { const [bankConnections, setBankConnections] = useState([]) const [isSyncing, setIsSyncing] = useState(false) const [isConnecting, setIsConnecting] = useState(false) + const [hasBankingExtension, setHasBankingExtension] = useState(false) useEffect(() => { fetchData() @@ -85,7 +84,8 @@ export default function SettingsPage() { setSettings(settingsData) - // Fetch bank connections + // Check if Enable Banking extension is active by testing for bank connections + // If there are active connections, show the banking tab const { data: connections } = await supabase .from('bank_connections') .select('*') @@ -94,6 +94,21 @@ export default function SettingsPage() { setBankConnections(connections || []) + // Check if extension API is available (will return banks list if extension is loaded) + try { + const bankingCheck = await fetch('/api/extensions/enable-banking/callback', { + method: 'HEAD', + }) + // The extension route existing means it's deployed - but we also need + // to check if there are existing connections to show the tab + setHasBankingExtension( + (connections && connections.length > 0) || bankingCheck.status !== 404 + ) + } catch { + // If the extension routes don't exist, only show tab if there are existing connections + setHasBankingExtension((connections && connections.length > 0) || false) + } + setIsLoading(false) } @@ -150,14 +165,14 @@ export default function SettingsPage() { setIsSaving(false) } - async function handleConnectBank(bank: Bank) { + async function handleConnectBank(bankName: string, bankCountry: string) { setIsConnecting(true) try { - const response = await fetch('/api/banking/connect', { + const response = await fetch('/api/extensions/enable-banking/callback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ aspsp_name: bank.name, aspsp_country: bank.country }), + body: JSON.stringify({ aspsp_name: bankName, aspsp_country: bankCountry }), }) const data = await response.json() @@ -182,7 +197,7 @@ export default function SettingsPage() { setIsSyncing(true) try { - const response = await fetch('/api/banking/sync', { + const response = await fetch('/api/extensions/enable-banking/sync/cron', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connectionId }), @@ -247,6 +262,24 @@ export default function SettingsPage() { const activeConnections = bankConnections.filter((c) => c.status === 'active') + // Helper to calculate days until consent expires + function getDaysUntilExpiry(expiresAt: string | null): number | null { + if (!expiresAt) return null + const expiryDate = new Date(expiresAt) + const now = new Date() + const diffTime = expiryDate.getTime() - now.getTime() + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + return Math.max(0, diffDays) + } + + function isConsentExpiringSoon(expiresAt: string | null): boolean { + if (!expiresAt) return false + const expiryDate = new Date(expiresAt) + const warningDate = new Date() + warningDate.setDate(warningDate.getDate() + 7) + return expiryDate <= warningDate + } + return (
@@ -262,10 +295,12 @@ export default function SettingsPage() { Företag - - - Bank - + {hasBankingExtension && ( + + + Bank (PSD2) + + )} Aviseringar @@ -469,93 +504,91 @@ export default function SettingsPage() { - {/* Banking settings */} - - {/* Connected banks */} - {activeConnections.length > 0 && ( - - - Anslutna banker - - - {activeConnections.map((connection) => { - const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires) - const isExpiring = isConsentExpiringSoon(connection.consent_expires) + {/* Banking settings (only shown when extension is active or connections exist) */} + {hasBankingExtension && ( + + {/* Connected banks */} + {activeConnections.length > 0 && ( + + + Anslutna banker + + + {activeConnections.map((connection) => { + const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires) + const isExpiring = isConsentExpiringSoon(connection.consent_expires) - return ( -
-
-
- -
-
-

{connection.bank_name}

-
- - Senast synkad: {formatDate(connection.last_synced_at || connection.created_at)} - - {isExpiring && ( - - - {daysUntilExpiry} dagar kvar - - )} + return ( +
+
+
+ +
+
+

{connection.bank_name}

+
+ + Senast synkad: {formatDate(connection.last_synced_at || connection.created_at)} + + {isExpiring && ( + + + {daysUntilExpiry} dagar kvar + + )} +
+
+ + +
-
- - -
-
- ) - })} + ) + })} + + + )} + + {/* Info about PSD2 */} + + + 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. +

- )} - - {/* Connect new bank */} - - - Anslut bank - - Koppla din bank för att automatiskt importera transaktioner via PSD2 - - - - -

- Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner, - aldrig flytta pengar. -

-
-
- + + )} {/* Notification settings */} diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 3a1262a0..d43cfd45 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -11,7 +11,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' import { getCategoryDisplayName } from '@/lib/tax/expense-warnings' -import { Plus, Search, ArrowLeftRight, ArrowUpRight, ArrowDownRight, Sparkles, Check, FileText, Link2 } from 'lucide-react' +import Link from 'next/link' +import { Plus, Search, ArrowLeftRight, ArrowUpRight, ArrowDownRight, Sparkles, Check, FileText, Link2, Upload } from 'lucide-react' import TransactionForm from '@/components/transactions/TransactionForm' import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView' import type { Transaction, TransactionCategory, CreateTransactionInput, Invoice, Customer } from '@/types' @@ -408,6 +409,12 @@ export default function TransactionsPage() {

+ {uncategorizedTransactions.length > 0 && ( +
+ + +
)} diff --git a/app/api/banking/connect/route.ts b/app/api/banking/connect/route.ts deleted file mode 100644 index a82e207d..00000000 --- a/app/api/banking/connect/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { startAuthorization, getASPSPs, type ASPSP } from '@/lib/banking/enable-banking' - -export async function GET() { - try { - const aspsps = await getASPSPs('SE') - - // Transform to frontend-friendly format - const banks = aspsps.map((aspsp: ASPSP) => ({ - name: aspsp.name, - country: aspsp.country, - logo: aspsp.logo, - bic: aspsp.bic, - })) - - return NextResponse.json({ banks }) - } catch (error) { - console.error('Error fetching banks:', error) - // Return fallback list - return NextResponse.json({ - banks: [ - { name: 'Nordea', country: 'SE', bic: 'NDEASESS' }, - { name: 'SEB', country: 'SE', bic: 'ESSESESS' }, - { name: 'Swedbank', country: 'SE', bic: 'SWEDSESS' }, - { name: 'Handelsbanken', country: 'SE', bic: 'HANDSESS' }, - ] - }) - } -} - -export async function POST(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { aspsp_name, aspsp_country } = await request.json() - - if (!aspsp_name || !aspsp_country) { - return NextResponse.json( - { error: 'aspsp_name and aspsp_country are required' }, - { status: 400 } - ) - } - - try { - const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/banking/callback` - - // Start the authorization flow with Enable Banking - const { url, authorization_id } = await startAuthorization( - aspsp_name, - aspsp_country, - redirectUrl, - user.id, // state parameter - returned in callback - 'personal' - ) - - // Store pending connection in database with authorization_id - // Note: session_id will be set after callback receives the code - const { data: connection, error } = await supabase - .from('bank_connections') - .insert({ - user_id: user.id, - provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`, - bank_name: aspsp_name, - authorization_id, - status: 'pending', - }) - .select() - .single() - - if (error) { - console.error('Database error:', error) - throw new Error('Failed to store connection') - } - - return NextResponse.json({ - connection_id: connection.id, - authorization_url: url, - }) - } catch (error) { - console.error('Bank connection error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Connection failed' }, - { status: 500 } - ) - } -} diff --git a/app/api/banking/sync/route.ts b/app/api/banking/sync/route.ts deleted file mode 100644 index 368face4..00000000 --- a/app/api/banking/sync/route.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' -import { eventBus } from '@/lib/events' -import { ensureInitialized } from '@/lib/init' -import { syncAccountTransactions } from '@/lib/banking/sync-transactions' -import type { Transaction } from '@/types' - -ensureInitialized() - -interface StoredAccount { - uid: string - iban?: string - name?: string - currency: string - balance?: number -} - -export async function POST(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { connection_id, days_back = 30 } = await request.json() - - // Get the bank connection - const { data: connection, error: connectionError } = await supabase - .from('bank_connections') - .select('*') - .eq('id', connection_id) - .eq('user_id', user.id) - .single() - - if (connectionError || !connection) { - return NextResponse.json({ error: 'Connection not found' }, { status: 404 }) - } - - if (connection.status !== 'active') { - return NextResponse.json({ error: 'Connection is not active' }, { status: 400 }) - } - - try { - const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a })) - - const toDate = new Date().toISOString().split('T')[0] - const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000) - .toISOString() - .split('T')[0] - - let totalImported = 0 - let totalDuplicates = 0 - - for (const account of accounts) { - const result = await syncAccountTransactions( - supabase, - user.id, - connection.id, - account, - fromDate, - toDate - ) - - totalImported += result.imported - totalDuplicates += result.duplicates - } - - // Update connection with new account balances and sync timestamp - const syncedAt = new Date().toISOString() - await supabase - .from('bank_connections') - .update({ - accounts_data: accounts, - last_synced_at: syncedAt, - }) - .eq('id', connection.id) - - // Emit event with newly synced transactions - if (totalImported > 0) { - const { data: syncedTransactions } = await supabase - .from('transactions') - .select('*') - .eq('user_id', user.id) - .eq('bank_connection_id', connection.id) - .gte('created_at', fromDate) - .order('created_at', { ascending: false }) - .limit(totalImported) - - if (syncedTransactions && syncedTransactions.length > 0) { - await eventBus.emit({ - type: 'transaction.synced', - payload: { transactions: syncedTransactions as Transaction[], userId: user.id }, - }) - } - } - - return NextResponse.json({ - imported: totalImported, - duplicates: totalDuplicates, - last_synced_at: syncedAt, - }) - } catch (error) { - console.error('Sync error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Sync failed' }, - { status: 500 } - ) - } -} diff --git a/app/api/banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts similarity index 84% rename from app/api/banking/callback/route.ts rename to app/api/extensions/enable-banking/callback/route.ts index d67ef4d9..431b7a92 100644 --- a/app/api/banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -1,28 +1,25 @@ import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { createSession, getAccountBalance, type AccountInfo } from '@/lib/banking/enable-banking' - -interface StoredAccount { - uid: string - iban?: string - name?: string - currency: string - balance?: number -} +import { createSession, getAccountBalance, type AccountInfo } from '@/extensions/enable-banking/lib/api-client' +import type { StoredAccount } from '@/extensions/enable-banking/types' +/** + * GET /api/extensions/enable-banking/callback + * + * OAuth callback for Enable Banking PSD2 authorization. + * Must be a real Next.js route (not extension handler) because + * banks redirect to this URL directly. + */ export async function GET(request: Request) { const { searchParams } = new URL(request.url) - // Enable Banking returns: ?code=XXX&state=user_id or ?error=XXX&error_description=YYY const code = searchParams.get('code') const state = searchParams.get('state') // This is the user_id we passed during authorization const error = searchParams.get('error') const errorDescription = searchParams.get('error_description') - // Redirect URL for success/error const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' - // Handle errors from bank authorization if (error) { const errorMessage = errorDescription || error console.error('Bank authorization error:', errorMessage) @@ -38,14 +35,10 @@ export async function GET(request: Request) { const supabase = await createServiceClient() try { - // Create session from authorization code const sessionData = await createSession(code) - - // Extract data from session response const { session_id, accounts, access, aspsp } = sessionData const consentExpiresAt = access.valid_until - // Get balances for each account const accountsWithBalances: StoredAccount[] = await Promise.all( accounts.map(async (account: AccountInfo) => { try { @@ -70,8 +63,6 @@ export async function GET(request: Request) { }) ) - // Find the pending connection for this user - // We match by user_id (state) and status='pending' const { data: pendingConnection, error: findError } = await supabase .from('bank_connections') .select('id') @@ -83,7 +74,6 @@ export async function GET(request: Request) { if (findError || !pendingConnection) { console.error('Could not find pending connection:', findError) - // Create a new connection if no pending one exists const { error: insertError } = await supabase .from('bank_connections') .insert({ @@ -102,7 +92,6 @@ export async function GET(request: Request) { throw new Error('Failed to create connection') } } else { - // Update the pending connection with session data const { error: updateError } = await supabase .from('bank_connections') .update({ @@ -119,7 +108,6 @@ export async function GET(request: Request) { } } - // Check if the user has completed onboarding to decide redirect target const { data: userSettings } = await supabase .from('company_settings') .select('onboarding_complete') @@ -134,7 +122,6 @@ export async function GET(request: Request) { } catch (error) { console.error('Bank callback error:', error) - // Try to update connection status to error try { await supabase .from('bank_connections') diff --git a/app/api/banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts similarity index 88% rename from app/api/banking/sync/cron/route.ts rename to app/api/extensions/enable-banking/sync/cron/route.ts index de45ed84..0106692e 100644 --- a/app/api/banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -1,18 +1,11 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' -import { syncAccountTransactions } from '@/lib/banking/sync-transactions' -import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/lib/banking/enable-banking' - -interface StoredAccount { - uid: string - iban?: string - name?: string - currency: string - balance?: number -} +import { syncAccountTransactions } from '@/extensions/enable-banking/lib/sync' +import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/enable-banking/lib/api-client' +import type { StoredAccount } from '@/extensions/enable-banking/types' /** - * GET /api/banking/sync/cron + * GET /api/extensions/enable-banking/sync/cron * Automatic daily bank transaction sync * Runs at 05:00 UTC (07:00 Swedish time) * @@ -41,7 +34,6 @@ export async function GET(request: Request) { const supabase = createClient(supabaseUrl, supabaseServiceKey) - // Fetch active bank connections, prioritize least recently synced const { data: connections, error: connError } = await supabase .from('bank_connections') .select('*') @@ -71,12 +63,10 @@ export async function GET(request: Request) { for (const connection of connections) { try { - // Check consent expiry const daysLeft = getDaysUntilExpiry(connection.consent_expires) const isExpired = daysLeft !== null && daysLeft <= 0 if (isExpired) { - // Mark as expired, skip sync await supabase .from('bank_connections') .update({ status: 'expired' }) @@ -97,7 +87,6 @@ export async function GET(request: Request) { const expiringSoon = isConsentExpiringSoon(connection.consent_expires) - // Sync last 7 days (daily cron, with overlap for safety) const toDate = new Date().toISOString().split('T')[0] const fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) .toISOString() @@ -124,7 +113,6 @@ export async function GET(request: Request) { totalErrors += result.errors } - // Update connection with new account balances and sync timestamp await supabase .from('bank_connections') .update({ @@ -154,7 +142,6 @@ export async function GET(request: Request) { errors: 1, status: 'error', }) - // Continue with other connections } } diff --git a/app/api/import/bank-file/execute/route.ts b/app/api/import/bank-file/execute/route.ts new file mode 100644 index 00000000..8131c1bc --- /dev/null +++ b/app/api/import/bank-file/execute/route.ts @@ -0,0 +1,130 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { eventBus } from '@/lib/events' +import { ensureInitialized } from '@/lib/init' +import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest' +import { generateExternalId } from '@/lib/import/bank-file/parser' +import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types' +import type { Transaction } from '@/types' + +ensureInitialized() + +interface ExecuteRequest { + transactions: ParsedBankTransaction[] + format: BankFileFormatId + filename: string + file_hash: string + skip_duplicates: boolean + auto_categorize: boolean +} + +/** + * POST /api/import/bank-file/execute + * + * Executes the import of confirmed bank transactions. + * Records import in bank_file_imports, calls ingestTransactions(), + * emits transaction.synced event. + */ +export async function POST(request: Request) { + const supabase = await createClient() + + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body: ExecuteRequest = await request.json() + const { transactions, format, filename, file_hash, skip_duplicates = true, auto_categorize = true } = body + + if (!transactions || transactions.length === 0) { + return NextResponse.json({ error: 'No transactions to import' }, { status: 400 }) + } + + try { + // Create import record + const { data: importRecord, error: importError } = await supabase + .from('bank_file_imports') + .upsert({ + user_id: user.id, + filename, + file_hash, + file_format: format, + transaction_count: transactions.length, + status: 'processing', + date_from: transactions.map(t => t.date).sort()[0] || null, + date_to: transactions.map(t => t.date).sort().reverse()[0] || null, + }, { + onConflict: 'user_id,file_hash', + }) + .select() + .single() + + if (importError) { + console.error('Failed to create import record:', importError) + return NextResponse.json({ error: 'Failed to create import record' }, { status: 500 }) + } + + // Convert parsed transactions to RawTransaction format + const rawTransactions: RawTransaction[] = transactions.map((tx, index) => ({ + date: tx.date, + description: tx.description, + amount: tx.amount, + currency: tx.currency || 'SEK', + external_id: generateExternalId(tx, format, index), + reference: tx.reference || null, + import_source: format === 'camt053' ? 'camt053' : `csv_${format}`, + })) + + // Run ingestion pipeline + const ingestResult = await ingestTransactions(supabase, user.id, rawTransactions) + + // Update import record with results + await supabase + .from('bank_file_imports') + .update({ + imported_count: ingestResult.imported, + duplicate_count: ingestResult.duplicates, + matched_count: ingestResult.auto_matched_invoices, + status: ingestResult.errors > 0 && ingestResult.imported === 0 ? 'failed' : 'completed', + error_message: ingestResult.errors > 0 + ? `${ingestResult.errors} transactions failed to import` + : null, + }) + .eq('id', importRecord.id) + + // Emit event with newly imported transactions + if (ingestResult.imported > 0 && ingestResult.transaction_ids.length > 0) { + try { + const { data: importedTransactions } = await supabase + .from('transactions') + .select('*') + .in('id', ingestResult.transaction_ids) + + if (importedTransactions && importedTransactions.length > 0) { + await eventBus.emit({ + type: 'transaction.synced', + payload: { + transactions: importedTransactions as Transaction[], + userId: user.id, + }, + }) + } + } catch { + // Non-critical event emission + } + } + + return NextResponse.json({ + data: { + import_id: importRecord.id, + ...ingestResult, + }, + }) + } catch (error) { + console.error('Bank file execute error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Import failed' }, + { status: 500 } + ) + } +} diff --git a/app/api/import/bank-file/parse/route.ts b/app/api/import/bank-file/parse/route.ts new file mode 100644 index 00000000..bd57eda9 --- /dev/null +++ b/app/api/import/bank-file/parse/route.ts @@ -0,0 +1,98 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser' +import { decodeFileContent } from '@/lib/import/bank-file/encoding' +import type { BankFileFormatId } from '@/lib/import/bank-file/types' + +/** + * POST /api/import/bank-file/parse + * + * Accepts a bank file (CSV/XML) via FormData, auto-detects format, + * returns parsed transactions preview with duplicate detection. + */ +export async function POST(request: Request) { + const supabase = await createClient() + + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const formData = await request.formData() + const file = formData.get('file') as File | null + const formatOverride = formData.get('format') as BankFileFormatId | null + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + } + + // Validate file size (10MB max) + if (file.size > 10 * 1024 * 1024) { + return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 }) + } + + try { + // Read and decode file content + const arrayBuffer = await file.arrayBuffer() + const content = decodeFileContent(arrayBuffer) + const fileHash = generateFileHash(content) + + // Check if this exact file has been imported before + const { data: existingImport } = await supabase + .from('bank_file_imports') + .select('id, status, imported_count, created_at') + .eq('user_id', user.id) + .eq('file_hash', fileHash) + .single() + + if (existingImport && existingImport.status === 'completed') { + return NextResponse.json({ + error: 'duplicate', + message: `Den här filen har redan importerats (${existingImport.imported_count} transaktioner, ${new Date(existingImport.created_at).toLocaleDateString('sv-SE')})`, + }, { status: 409 }) + } + + // Auto-detect or use specified format + const detectedFormat = formatOverride + ? null + : detectFileFormat(content, file.name) + + // Parse the file + const parseResult = parseBankFile(content, file.name, formatOverride || undefined) + + // Check for existing transactions (duplicate detection for preview) + let existingCount = 0 + if (parseResult.transactions.length > 0) { + // Sample check: look for transactions with matching dates and amounts + const { count } = await supabase + .from('transactions') + .select('*', { count: 'exact', head: true }) + .eq('user_id', user.id) + .gte('date', parseResult.date_from || '1970-01-01') + .lte('date', parseResult.date_to || '2099-12-31') + + existingCount = count || 0 + } + + return NextResponse.json({ + data: { + parse_result: parseResult, + detected_format: detectedFormat?.id || formatOverride || null, + detected_format_name: detectedFormat?.name || parseResult.format_name, + file_hash: fileHash, + filename: file.name, + existing_transaction_count: existingCount, + // Return first row headers for generic CSV column mapping + headers: parseResult.format === 'generic_csv' + ? content.split('\n')[0]?.split(',').map(h => h.trim()) || [] + : null, + }, + }) + } catch (error) { + console.error('Bank file parse error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to parse file' }, + { status: 500 } + ) + } +} diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index a20f85bd..8d126fc1 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -4,7 +4,7 @@ import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, } from '@/lib/bookkeeping/invoice-entries' -import type { Invoice } from '@/types' +import type { EntityType, Invoice } from '@/types' /** * POST /api/invoices/[id]/mark-paid @@ -70,11 +70,12 @@ export async function POST( // Fetch accounting method const { data: settings } = await supabase .from('company_settings') - .select('accounting_method') + .select('accounting_method, entity_type') .eq('user_id', user.id) .single() const accountingMethod = settings?.accounting_method || 'accrual' + const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' let journalEntryId: string | null = null @@ -92,7 +93,8 @@ export async function POST( const journalEntry = await createInvoiceCashEntry( user.id, invoice as Invoice, - paymentDate + paymentDate, + entityType ) journalEntryId = journalEntry?.id ?? null } diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index e779fbcf..17ad2246 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -1,7 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' -import type { Invoice } from '@/types' +import type { EntityType, Invoice } from '@/types' /** * POST /api/invoices/[id]/mark-sent @@ -59,7 +59,7 @@ export async function POST( // Fetch accounting method const { data: settings } = await supabase .from('company_settings') - .select('accounting_method') + .select('accounting_method, entity_type') .eq('user_id', user.id) .single() @@ -71,7 +71,8 @@ export async function POST( try { const journalEntry = await createInvoiceJournalEntry( user.id, - invoice as Invoice + invoice as Invoice, + (settings?.entity_type as EntityType) || 'enskild_firma' ) if (journalEntry) { journalEntryId = journalEntry.id diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index efa64274..16dd9adf 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -164,7 +164,8 @@ export async function POST( try { const journalEntry = await createInvoiceJournalEntry( user.id, - invoice as Invoice + invoice as Invoice, + (company as CompanySettings).entity_type ) if (journalEntry) { await supabase diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index c0de3b21..013ac235 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' -import type { CreateInvoiceInput, Invoice, CreditNote } from '@/types' +import type { CreateInvoiceInput, EntityType, Invoice, CreditNote } from '@/types' import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoice/vat-rules' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { @@ -292,12 +292,22 @@ async function createCreditNote( .eq('id', creditNote.id) .single() + // Fetch entity type for correct account mapping + const { data: creditNoteSettings } = await supabase + .from('company_settings') + .select('entity_type') + .eq('user_id', userId) + .single() + + const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma' + // Create journal entry for the credit note (non-blocking) if (completeCreditNote) { try { const journalEntry = await createCreditNoteJournalEntry( userId, - completeCreditNote as Invoice + completeCreditNote as Invoice, + entityType ) if (journalEntry) { await supabase diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 3bc39018..d6f90af5 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -5,7 +5,7 @@ import { getRevenueAccount, getOutputVatAccount, } from '@/lib/bookkeeping/invoice-entries' -import type { Transaction, Invoice, CreateJournalEntryInput, VatTreatment } from '@/types' +import type { Transaction, Invoice, CreateJournalEntryInput, EntityType, VatTreatment } from '@/types' interface MatchInvoiceRequest { invoice_id: string @@ -139,11 +139,12 @@ export async function POST( // Fetch accounting method const { data: settings } = await supabase .from('company_settings') - .select('accounting_method') + .select('accounting_method, entity_type') .eq('user_id', user.id) .single() const accountingMethod = settings?.accounting_method || 'accrual' + const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' // Create journal entry for payment receipt (method-aware) let journalEntryId: string | null = null @@ -158,7 +159,7 @@ export async function POST( if (accountingMethod === 'cash') { // Kontantmetoden: combined revenue entry at payment // Debit 1930 Företagskonto, Credit 30xx Försäljning, Credit 26xx Utgående moms - const revenueAccount = getRevenueAccount(invoice.vat_treatment as VatTreatment) + const revenueAccount = getRevenueAccount(invoice.vat_treatment as VatTreatment, entityType) const lines: CreateJournalEntryInput['lines'] = [ { account_number: '1930', diff --git a/components/banking/index.ts b/components/banking/index.ts deleted file mode 100644 index c8d1f2ad..00000000 --- a/components/banking/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { BankSelector, type Bank } from './BankSelector' -export { BankConnectionStatus } from './BankConnectionStatus' diff --git a/components/import/BankFileColumnMappingStep.tsx b/components/import/BankFileColumnMappingStep.tsx new file mode 100644 index 00000000..1306ee62 --- /dev/null +++ b/components/import/BankFileColumnMappingStep.tsx @@ -0,0 +1,301 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Label } from '@/components/ui/label' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { ArrowLeft, ArrowRight, Columns3 } from 'lucide-react' +import { formatCurrency } from '@/lib/utils' +import type { GenericCSVColumnMapping } from '@/lib/import/bank-file/types' + +interface BankFileColumnMappingStepProps { + headers: string[] + previewRows: string[][] + onConfirm: (mapping: GenericCSVColumnMapping) => void + onBack: () => void +} + +export default function BankFileColumnMappingStep({ + headers, + previewRows, + onConfirm, + onBack, +}: BankFileColumnMappingStepProps) { + const [dateCol, setDateCol] = useState(-1) + const [descCol, setDescCol] = useState(-1) + const [amountCol, setAmountCol] = useState(-1) + const [referenceCol, setReferenceCol] = useState(-1) + const [counterpartyCol, setCounterpartyCol] = useState(-1) + const [balanceCol, setBalanceCol] = useState(-1) + const [delimiter, setDelimiter] = useState(',') + const [decimalSep, setDecimalSep] = useState<',' | '.'>(',') + + const isValid = dateCol >= 0 && descCol >= 0 && amountCol >= 0 + + const handleConfirm = () => { + const mapping: GenericCSVColumnMapping = { + date: dateCol, + description: descCol, + amount: amountCol, + ...(referenceCol >= 0 && { reference: referenceCol }), + ...(counterpartyCol >= 0 && { counterparty: counterpartyCol }), + ...(balanceCol >= 0 && { balance: balanceCol }), + delimiter, + decimal_separator: decimalSep, + skip_rows: 1, // Skip header + date_format: 'YYYY-MM-DD', + } + onConfirm(mapping) + } + + const columnOptions = headers.map((h, i) => ({ label: `${i + 1}: ${h}`, value: i })) + + return ( +
+ + + + + Kolumnmappning + + + Vi kunde inte identifiera bankformatet automatiskt. Mappa kolumnerna manuellt. + + + + {/* Delimiter and decimal settings */} +
+
+ + +
+
+ + +
+
+ + {/* Required column mappings */} +
+

Obligatoriska kolumner

+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + {/* Optional column mappings */} +
+

Valfria kolumner

+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + {/* Live preview */} + {isValid && previewRows.length > 1 && ( + + + Förhandsgranskning + + Så tolkas dina data med den valda mappningen + + + +
+ + + + Datum + Beskrivning + Belopp + + + + {previewRows.slice(1, 6).map((row, i) => { + const amountStr = row[amountCol] || '0' + const amount = decimalSep === ',' + ? parseFloat(amountStr.replace(/\s/g, '').replace(',', '.')) + : parseFloat(amountStr.replace(/\s/g, '')) + + return ( + + {row[dateCol] || '–'} + {row[descCol] || '–'} + = 0 ? 'text-green-600' : 'text-red-600' + }`} + > + {!isNaN(amount) ? formatCurrency(amount) : amountStr} + + + ) + })} + +
+
+
+
+ )} + + {/* Navigation */} +
+ + +
+
+ ) +} diff --git a/components/import/BankFileConfirmStep.tsx b/components/import/BankFileConfirmStep.tsx new file mode 100644 index 00000000..32cf6555 --- /dev/null +++ b/components/import/BankFileConfirmStep.tsx @@ -0,0 +1,178 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { + ArrowLeft, + Loader2, + Play, + FileText, + AlertTriangle, + Link2, + Calendar, +} from 'lucide-react' +import { formatCurrency } from '@/lib/utils' +import type { BankFileParseResult } from '@/lib/import/bank-file/types' + +interface BankFileConfirmStepProps { + parseResult: BankFileParseResult + onExecute: (options: { skip_duplicates: boolean; auto_categorize: boolean }) => void + onBack: () => void + isLoading: boolean +} + +export default function BankFileConfirmStep({ + parseResult, + onExecute, + onBack, + isLoading, +}: BankFileConfirmStepProps) { + const [skipDuplicates, setSkipDuplicates] = useState(true) + const [autoCategorize, setAutoCategorize] = useState(true) + + const { transactions, stats, date_from, date_to, format_name } = parseResult + const refsCount = transactions.filter((t) => t.reference).length + + return ( +
+ {/* Summary */} + + + Bekräfta import + + Granska sammanfattningen och importera transaktionerna. + + + + {/* Stats grid */} +
+
+
+ + Transaktioner +
+

{stats.parsed_rows}

+
+ +
+
+ + Period +
+

+ {date_from} – {date_to} +

+
+ +
+
+ Inkomster +
+

+ {formatCurrency(stats.total_income)} +

+
+ +
+
+ Utgifter +
+

+ {formatCurrency(stats.total_expenses)} +

+
+
+ + {/* Additional info */} +
+ Format: {format_name} + {refsCount > 0 && ( + + + {refsCount} med OCR/referens + + )} +
+ + {/* Options */} +
+

Importinställningar

+ +
+ setSkipDuplicates(checked === true)} + /> +
+ +

+ Transaktioner som redan finns i systemet importeras inte igen +

+
+
+ +
+ setAutoCategorize(checked === true)} + /> +
+ +

+ Skapar automatiskt bokföringsposter för transaktioner med hög konfidens +

+
+
+
+ + {/* Warning note */} +
+ +

+ Importerade transaktioner som inte automatiskt kategoriseras visas som + "okategoriserade" på transaktionssidan. Du kan kategorisera dem manuellt + efteråt. +

+
+
+
+ + {/* Actions */} +
+ + +
+
+ ) +} diff --git a/components/import/BankFilePreviewStep.tsx b/components/import/BankFilePreviewStep.tsx new file mode 100644 index 00000000..08537780 --- /dev/null +++ b/components/import/BankFilePreviewStep.tsx @@ -0,0 +1,216 @@ +'use client' + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + ArrowLeft, + ArrowRight, + AlertTriangle, + Calendar, + TrendingUp, + TrendingDown, + FileText, +} from 'lucide-react' +import { formatCurrency } from '@/lib/utils' +import type { BankFileParseResult } from '@/lib/import/bank-file/types' + +interface BankFilePreviewStepProps { + parseResult: BankFileParseResult + existingTransactionCount: number + onContinue: () => void + onBack: () => void +} + +export default function BankFilePreviewStep({ + parseResult, + existingTransactionCount, + onContinue, + onBack, +}: BankFilePreviewStepProps) { + const { transactions, stats, issues, date_from, date_to, format_name } = parseResult + const hasIssues = issues.filter((i) => i.severity === 'error').length > 0 + const warnings = issues.filter((i) => i.severity === 'warning') + + return ( +
+ {/* Summary cards */} +
+ + +
+ + Transaktioner +
+

{stats.parsed_rows}

+ {stats.skipped_rows > 0 && ( +

+ {stats.skipped_rows} rader hoppades över +

+ )} +
+
+ + + +
+ + Period +
+

+ {date_from || '–'} till {date_to || '–'} +

+
+
+ + + +
+ + Inkomster +
+

+ {formatCurrency(stats.total_income)} +

+
+
+ + + +
+ + Utgifter +
+

+ {formatCurrency(stats.total_expenses)} +

+
+
+
+ + {/* Format and duplicate info */} +
+ + Format: {format_name} + + {existingTransactionCount > 0 && ( + + + {existingTransactionCount} befintliga transaktioner i samma period + + )} +
+ + {/* Warnings */} + {warnings.length > 0 && ( + + + + + {warnings.length} varning{warnings.length !== 1 ? 'ar' : ''} + + + +
+ {warnings.slice(0, 10).map((issue, i) => ( +

+ Rad {issue.row}: {issue.message} +

+ ))} + {warnings.length > 10 && ( +

+ ...och {warnings.length - 10} till +

+ )} +
+
+
+ )} + + {/* Transaction preview table */} + + + Transaktioner + + Förhandsgranskning av de {Math.min(transactions.length, 50)} första transaktionerna + + + +
+ + + + Datum + Beskrivning + Belopp + {transactions.some((t) => t.balance != null) && ( + Saldo + )} + {transactions.some((t) => t.reference) && ( + Referens + )} + + + + {transactions.slice(0, 50).map((tx, i) => ( + + {tx.date} + {tx.description} + = 0 ? 'text-green-600' : 'text-red-600' + }`} + > + {formatCurrency(tx.amount)} + + {transactions.some((t) => t.balance != null) && ( + + {tx.balance != null ? formatCurrency(tx.balance) : '–'} + + )} + {transactions.some((t) => t.reference) && ( + + {tx.reference ? ( + + {tx.reference} + + ) : ( + '–' + )} + + )} + + ))} + +
+
+ {transactions.length > 50 && ( +

+ Visar 50 av {transactions.length} transaktioner +

+ )} +
+
+ + {/* Navigation */} +
+ + +
+
+ ) +} diff --git a/components/import/BankFileResultStep.tsx b/components/import/BankFileResultStep.tsx new file mode 100644 index 00000000..84dff68b --- /dev/null +++ b/components/import/BankFileResultStep.tsx @@ -0,0 +1,166 @@ +'use client' + +import Link from 'next/link' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { + CheckCircle, + XCircle, + FileText, + Link2, + Sparkles, + Copy, + ArrowRight, + RotateCcw, + ExternalLink, +} from 'lucide-react' +import type { IngestResult } from '@/lib/transactions/ingest' + +interface BankFileResultStepProps { + result: IngestResult + onNewImport: () => void +} + +export default function BankFileResultStep({ + result, + onNewImport, +}: BankFileResultStepProps) { + const isSuccess = result.imported > 0 || result.duplicates > 0 + + return ( +
+ {/* Status header */} + + + + {isSuccess ? ( + <> + + Import genomförd + + ) : ( + <> + + Import misslyckades + + )} + + + {isSuccess + ? `${result.imported} transaktioner importerades framgångsrikt.` + : `${result.errors} fel uppstod under importen.`} + + + + + {/* Stats */} +
+ + +
+ + Importerade +
+

{result.imported}

+
+
+ + + +
+ + Dubletter +
+

{result.duplicates}

+
+
+ + + +
+ + Auto-kategoriserade +
+

{result.auto_categorized}

+
+
+ + + +
+ + Fakturamatchade +
+

{result.auto_matched_invoices}

+
+
+
+ + {/* Next steps */} + {isSuccess && ( + + + Nästa steg + + +
+
+ 1 +
+
+

Granska okategoriserade transaktioner

+

+ {result.imported - result.auto_categorized > 0 + ? `${result.imported - result.auto_categorized} transaktioner behöver kategoriseras manuellt.` + : 'Alla transaktioner kategoriserades automatiskt.'} +

+
+
+
+
+ 2 +
+
+

Bekräfta fakturamatchningar

+

+ {result.auto_matched_invoices > 0 + ? `${result.auto_matched_invoices} transaktioner matchades mot fakturor. Bekräfta dessa på transaktionssidan.` + : 'Inga automatiska fakturamatchningar hittades.'} +

+
+
+
+
+ 3 +
+
+

Importera fler kontoutdrag

+

+ Importera löpande kontoutdrag för att hålla bokföringen uppdaterad. +

+
+
+
+
+ )} + + {/* Actions */} +
+ +
+ {isSuccess && ( + + )} +
+
+
+ ) +} diff --git a/components/import/BankFileUploadStep.tsx b/components/import/BankFileUploadStep.tsx new file mode 100644 index 00000000..d81550b2 --- /dev/null +++ b/components/import/BankFileUploadStep.tsx @@ -0,0 +1,236 @@ +'use client' + +import { useState, useCallback } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Progress } from '@/components/ui/progress' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Upload, + FileText, + AlertCircle, + CheckCircle, + Building2, + HelpCircle, +} from 'lucide-react' +import type { BankFileFormatId } from '@/lib/import/bank-file/types' + +const FORMAT_NAMES: Record = { + nordea: 'Nordea', + seb: 'SEB', + swedbank: 'Swedbank', + handelsbanken: 'Handelsbanken', + generic_csv: 'CSV (manuell mappning)', + camt053: 'ISO 20022 camt.053', +} + +interface BankFileUploadStepProps { + onFileSelect: (file: File, formatOverride?: BankFileFormatId) => void + isLoading: boolean + error: string | null + detectedFormat?: string | null + detectedFormatName?: string | null +} + +export default function BankFileUploadStep({ + onFileSelect, + isLoading, + error, + detectedFormat, + detectedFormatName, +}: BankFileUploadStepProps) { + const [isDragging, setIsDragging] = useState(false) + const [selectedFile, setSelectedFile] = useState(null) + const [formatOverride, setFormatOverride] = useState(undefined) + + const acceptedExtensions = '.csv,.txt,.xml' + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + }, []) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + + const files = e.dataTransfer.files + if (files.length > 0) { + const file = files[0] + const ext = file.name.toLowerCase() + if (ext.endsWith('.csv') || ext.endsWith('.txt') || ext.endsWith('.xml')) { + setSelectedFile(file) + onFileSelect(file, formatOverride) + } + } + }, [onFileSelect, formatOverride]) + + const handleFileInput = useCallback((e: React.ChangeEvent) => { + const files = e.target.files + if (files && files.length > 0) { + setSelectedFile(files[0]) + onFileSelect(files[0], formatOverride) + } + }, [onFileSelect, formatOverride]) + + const handleFormatChange = (value: string) => { + const format = value === 'auto' ? undefined : value as BankFileFormatId + setFormatOverride(format) + if (selectedFile) { + onFileSelect(selectedFile, format) + } + } + + return ( +
+ + + + + Ladda upp kontoutdrag + + + Exportera transaktioner som CSV eller XML från din internetbank och ladda upp filen. + + + + {/* Format override */} +
+ + +
+ + {/* Drop zone */} +
document.getElementById('bank-file-input')?.click()} + > + + + {isLoading ? ( +
+ +

Analyserar fil...

+ +
+ ) : selectedFile && detectedFormat ? ( +
+ +
+

{selectedFile.name}

+

+ {(selectedFile.size / 1024).toFixed(1)} KB +

+ + + {detectedFormatName || FORMAT_NAMES[detectedFormat] || detectedFormat} + +
+
+ ) : ( +
+ +
+

Dra och släpp bankfil här

+

+ CSV, TXT eller XML (max 10 MB) +

+
+
+ )} +
+ + {/* Error display */} + {error && ( +
+ +
+

Kunde inte läsa filen

+

{error}

+
+
+ )} +
+
+ + {/* Bank export instructions */} + + + + + Så exporterar du från din bank + + + +
+

Nordea

+

+ Logga in → Konton → Välj konto → Transaktioner → Exportera (CSV) +

+
+
+

SEB

+

+ Logga in → Konton → Kontoutdrag → Hämta som fil (CSV) +

+
+
+

Swedbank

+

+ Logga in → Konton → Transaktioner → Exportera kontoutdrag (CSV) +

+
+
+

Handelsbanken

+

+ Logga in → Konton → Transaktioner → Ladda ner (CSV) +

+
+
+
+
+ ) +} diff --git a/components/onboarding/NewUserChecklist.tsx b/components/onboarding/NewUserChecklist.tsx index f6052ac1..49c8a500 100644 --- a/components/onboarding/NewUserChecklist.tsx +++ b/components/onboarding/NewUserChecklist.tsx @@ -82,8 +82,8 @@ export default function NewUserChecklist({ }, { id: 'bank', - label: 'Koppla bank', - description: 'Se transaktioner automatiskt (valfritt)', + label: 'Importera transaktioner', + description: 'Importera kontoutdrag från din bank', href: '/import', icon: Building2, completed: hasBankConnected, diff --git a/components/onboarding/Step6ConnectBank.tsx b/components/onboarding/Step6ConnectBank.tsx index 92bb9279..f16a2dd4 100644 --- a/components/onboarding/Step6ConnectBank.tsx +++ b/components/onboarding/Step6ConnectBank.tsx @@ -9,8 +9,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { InfoTooltip } from '@/components/ui/info-tooltip' -import { Loader2, ArrowLeft, Landmark, SkipForward, ChevronDown, ChevronUp } from 'lucide-react' -import { BankSelector, type Bank } from '@/components/banking/BankSelector' +import { Loader2, ArrowLeft, Landmark, SkipForward } from 'lucide-react' const manualBankSchema = z.object({ bank_name: z.string().optional(), @@ -37,10 +36,6 @@ export default function Step6ConnectBank({ onComplete, isSaving, }: Step6Props) { - const [isConnecting, setIsConnecting] = useState(false) - const [error, setError] = useState(null) - const [showManual, setShowManual] = useState(false) - const { register, handleSubmit, @@ -55,33 +50,6 @@ export default function Step6ConnectBank({ }, }) - const handleBankSelect = async (bank: Bank) => { - setIsConnecting(true) - setError(null) - - try { - const response = await fetch('/api/banking/connect', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - aspsp_name: bank.name, - aspsp_country: bank.country, - }), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Kunde inte ansluta bank') - } - - window.location.href = data.authorization_url - } catch (err) { - setError(err instanceof Error ? err.message : 'Ett fel uppstod') - setIsConnecting(false) - } - } - const onManualSubmit = (data: ManualBankData) => { onComplete(data) } @@ -89,9 +57,10 @@ export default function Step6ConnectBank({ return (
-

Anslut din bank

+

Bankuppgifter

- Koppla din bank för att automatiskt importera transaktioner via PSD2. + Ange dina bankuppgifter så visas de på dina fakturor. + Du kan importera transaktioner från din bank efteråt.

@@ -99,159 +68,125 @@ export default function Step6ConnectBank({ - Välj din bank + Bankuppgifter för fakturor - Vi använder säker bankintegration (PSD2) för att hämta dina transaktioner. - Vi kan aldrig flytta pengar eller göra ändringar. + Dessa uppgifter visas på dina fakturor så att kunder kan betala dig. - - - - {error && ( -
- {error} + +
+
+ +
- )} -
-

Säker anslutning

-
    -
  • • Krypterad anslutning via PSD2
  • -
  • • Vi kan endast läsa transaktioner
  • -
  • • Du kan koppla bort när som helst
  • -
  • • Samtycke gäller i 90 dagar
  • -
-
- - {/* Collapsible manual bank details */} -
- - - {showManual && ( - -

- Dessa uppgifter visas på dina fakturor så att kunder kan betala dig. -

+
+
+ +

Vad är clearingnummer?

+

De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.

+
    +
  • Nordea: 3300
  • +
  • SEB: 5000
  • +
  • Swedbank: 8XXX
  • +
  • Handelsbanken: 6XXX
  • +
  • Avanza: 9550/9551
  • +
+
+ } + side="top" + > + + + +
+
+ + +
+
+
+

Internationella betalningar (valfritt)

+
- + +

Vad är IBAN?

+

Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.

+
+ } + side="right" + > + +
- -
-
- -

Vad är clearingnummer?

-

De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.

-
    -
  • Nordea: 3300
  • -
  • SEB: 5000
  • -
  • Swedbank: 8XXX
  • -
  • Handelsbanken: 6XXX
  • -
  • Avanza: 9550/9551
  • -
-
- } - side="top" - > - - - -
-
- - -
+
+ +

Vad är BIC/SWIFT?

+

Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.

+
+ } + side="right" + > + + +
+
+
-
-

Internationella betalningar (valfritt)

-
-
- -

Vad är IBAN?

-

Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.

-
- } - side="right" - > - - - -
-
- -

Vad är BIC/SWIFT?

-

Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.

-
- } - side="right" - > - - - -
-
-
+
+

+ Du kan importera kontoutdrag (CSV, XML) från din bank under Import-sidan + efter att du slutfört registreringen. +

+
- - - )} -
+ +