feat: add bank file import as core, move Enable Banking to extension

Replace PSD2 bank integration as the default with file-based bank
import (CSV/XML), which better suits Swedish sole traders and small
companies. Enable Banking is now an opt-in extension.

- Phase 1: Extract generic transaction ingestion service (ingest.ts)
  with dedup, auto-categorization, and OCR-based invoice matching
- Phase 2: Bank file parser library supporting Nordea, SEB, Swedbank,
  Handelsbanken CSV formats and ISO 20022 camt.053 XML
- Phase 3: Database migration adding import_source, reference columns
  and bank_file_imports tracking table
- Phase 4: Import wizard UI (5-step flow) and API routes for parse/execute
- Phase 5: Move Enable Banking to extensions/enable-banking/ with
  commented-out loader entry for opt-in activation
- Phase 6: 104 new tests (ingestion + all parser formats), fixing
  Nordea detection overlap and camt.053 XML tag collision bugs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-20 14:21:28 +01:00
co-authored by Claude Opus 4.6
parent 838dc6b8b5
commit 885f362a29
60 changed files with 8232 additions and 853 deletions
+343 -139
View File
@@ -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<ImportWizardStep, string> = {
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<BankFileStep, string> = {
upload: 'Ladda upp',
preview: 'Förhandsgranskning',
column_mapping: 'Kolumnmappning',
confirm: 'Bekräfta',
result: 'Resultat',
}
function BankFileImportWizard() {
const { toast } = useToast()
const [bankStep, setBankStep] = useState<BankFileStep>('upload')
const [bankIsLoading, setBankIsLoading] = useState(false)
const [bankError, setBankError] = useState<string | null>(null)
// Parse results
const [parseResult, setParseResult] = useState<BankFileParseResult | null>(null)
const [detectedFormat, setDetectedFormat] = useState<string | null>(null)
const [detectedFormatName, setDetectedFormatName] = useState<string | null>(null)
const [fileHash, setFileHash] = useState<string>('')
const [filename, setFilename] = useState<string>('')
const [existingTxCount, setExistingTxCount] = useState(0)
const [rawFileContent, setRawFileContent] = useState<string>('')
// Column mapping for generic CSV
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
const [csvPreview, setCsvPreview] = useState<string[][]>([])
// Import result
const [ingestResult, setIngestResult] = useState<IngestResult | null>(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 (
<div className="space-y-6">
{/* Progress */}
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
{steps.map((s, i) => (
<span
key={s}
className={
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'
}
>
{BANK_STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progress} className="h-2" />
</div>
</CardContent>
</Card>
{/* Step content */}
{bankStep === 'upload' && (
<BankFileUploadStep
onFileSelect={handleFileSelect}
isLoading={bankIsLoading}
error={bankError}
detectedFormat={detectedFormat}
detectedFormatName={detectedFormatName}
/>
)}
{bankStep === 'preview' && parseResult && (
<BankFilePreviewStep
parseResult={parseResult}
existingTransactionCount={existingTxCount}
onContinue={() => {
if (parseResult.format === 'generic_csv') {
setBankStep('column_mapping')
} else {
setBankStep('confirm')
}
}}
onBack={() => setBankStep('upload')}
/>
)}
{bankStep === 'column_mapping' && (
<BankFileColumnMappingStep
headers={csvHeaders}
previewRows={csvPreview}
onConfirm={handleColumnMappingConfirm}
onBack={() => setBankStep('preview')}
/>
)}
{bankStep === 'confirm' && parseResult && (
<BankFileConfirmStep
parseResult={parseResult}
onExecute={handleExecuteImport}
onBack={() => {
if (parseResult.format === 'generic_csv') {
setBankStep('column_mapping')
} else {
setBankStep('preview')
}
}}
isLoading={bankIsLoading}
/>
)}
{bankStep === 'result' && ingestResult && (
<BankFileResultStep
result={ingestResult}
onNewImport={handleNewImport}
/>
)}
</div>
)
}
// ============================================================
// SIE Import Wizard (unchanged, extracted into component)
// ============================================================
const SIE_STEPS: ImportWizardStep[] = ['upload', 'preview', 'mapping', 'review', 'result']
const SIE_STEP_LABELS: Record<ImportWizardStep, string> = {
upload: 'Ladda upp',
preview: 'Förhandsgranskning',
mapping: 'Kontomappning',
@@ -30,15 +303,13 @@ const STEP_LABELS: Record<ImportWizardStep, string> = {
result: 'Resultat',
}
export default function ImportPage() {
function SIEImportWizard() {
const { toast } = useToast()
// Wizard state
const [step, setStep] = useState<ImportWizardStep>('upload')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Data state
const [file, setFile] = useState<File | null>(null)
const [_parsed, setParsed] = useState<ParsedSIEFile | null>(null)
const [mappings, setMappings] = useState<AccountMapping[]>([])
@@ -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 (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">Importera bokföring</h1>
<p className="text-muted-foreground">
Migrera din bokföring från Fortnox, Visma eller annat bokföringssystem via SIE-fil
</p>
</div>
{/* Progress */}
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
{STEPS.map((s, i) => (
<span
key={s}
className={`${
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'
}`}
>
{STEP_LABELS[s]}
{SIE_STEPS.map((s, i) => (
<span key={s} className={i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'}>
{SIE_STEP_LABELS[s]}
</span>
))}
</div>
@@ -319,53 +515,61 @@ export default function ImportPage() {
</CardContent>
</Card>
{/* Step content */}
{step === 'upload' && (
<SIEUploadStep
onFileSelect={handleFileSelect}
isLoading={isLoading}
error={error}
/>
)}
{step === 'upload' && <SIEUploadStep onFileSelect={handleFileSelect} isLoading={isLoading} error={error} />}
{step === 'preview' && preview && (
<SIEPreviewStep
preview={preview}
issues={issues}
missingAccounts={missingAccounts}
onCreateAccounts={handleCreateAccounts}
isCreatingAccounts={isCreatingAccounts}
onContinue={() => goToStep('mapping')}
onBack={goBack}
/>
<SIEPreviewStep preview={preview} issues={issues} missingAccounts={missingAccounts}
onCreateAccounts={handleCreateAccounts} isCreatingAccounts={isCreatingAccounts}
onContinue={() => goToStep('mapping')} onBack={goBack} />
)}
{step === 'mapping' && (
<AccountMappingStep
mappings={mappings}
basAccounts={basAccounts}
onMappingChange={handleMappingChange}
onContinue={() => goToStep('review')}
onBack={goBack}
/>
<AccountMappingStep mappings={mappings} basAccounts={basAccounts}
onMappingChange={handleMappingChange} onContinue={() => goToStep('review')} onBack={goBack} />
)}
{step === 'review' && preview && (
<ImportReviewStep
preview={preview}
mappings={mappings}
onExecute={handleExecuteImport}
onBack={goBack}
isLoading={isLoading}
/>
)}
{step === 'result' && importResult && (
<ImportResultStep
result={importResult}
onNewImport={handleNewImport}
/>
<ImportReviewStep preview={preview} mappings={mappings}
onExecute={handleExecuteImport} onBack={goBack} isLoading={isLoading} />
)}
{step === 'result' && importResult && <ImportResultStep result={importResult} onNewImport={handleNewImport} />}
</div>
)
}
// ============================================================
// Import Page with Tabs
// ============================================================
export default function ImportPage() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">Importera</h1>
<p className="text-muted-foreground">
Importera banktransaktioner eller bokföringsdata till ditt företag
</p>
</div>
{/* Tabbed layout */}
<Tabs defaultValue="bank" className="space-y-6">
<TabsList>
<TabsTrigger value="bank">
<ArrowLeftRight className="mr-2 h-4 w-4" />
Banktransaktioner
</TabsTrigger>
<TabsTrigger value="sie">
<FileText className="mr-2 h-4 w-4" />
Bokföringsdata (SIE)
</TabsTrigger>
</TabsList>
<TabsContent value="bank">
<BankFileImportWizard />
</TabsContent>
<TabsContent value="sie">
<SIEImportWizard />
</TabsContent>
</Tabs>
</div>
)
}
+3 -4
View File
@@ -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
+125 -92
View File
@@ -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<BankConnection[]>([])
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 (
<div className="space-y-6">
<div>
@@ -262,10 +295,12 @@ export default function SettingsPage() {
<Building className="mr-2 h-4 w-4" />
Företag
</TabsTrigger>
<TabsTrigger value="banking">
<CreditCard className="mr-2 h-4 w-4" />
Bank
</TabsTrigger>
{hasBankingExtension && (
<TabsTrigger value="banking">
<CreditCard className="mr-2 h-4 w-4" />
Bank (PSD2)
</TabsTrigger>
)}
<TabsTrigger value="notifications">
<Bell className="mr-2 h-4 w-4" />
Aviseringar
@@ -469,93 +504,91 @@ export default function SettingsPage() {
</form>
</TabsContent>
{/* Banking settings */}
<TabsContent value="banking" className="space-y-6">
{/* Connected banks */}
{activeConnections.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Anslutna banker</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{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 && (
<TabsContent value="banking" className="space-y-6">
{/* Connected banks */}
{activeConnections.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Anslutna banker</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{activeConnections.map((connection) => {
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
return (
<div
key={connection.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<CreditCard className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">{connection.bank_name}</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
Senast synkad: {formatDate(connection.last_synced_at || connection.created_at)}
</span>
{isExpiring && (
<Badge variant="warning" className="flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{daysUntilExpiry} dagar kvar
</Badge>
)}
return (
<div
key={connection.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<CreditCard className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">{connection.bank_name}</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
Senast synkad: {formatDate(connection.last_synced_at || connection.created_at)}
</span>
{isExpiring && (
<Badge variant="warning" className="flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{daysUntilExpiry} dagar kvar
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleSyncTransactions(connection.id)}
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDisconnectBank(connection.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleSyncTransactions(connection.id)}
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDisconnectBank(connection.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
)
})}
)
})}
</CardContent>
</Card>
)}
{/* Info about PSD2 */}
<Card>
<CardHeader>
<CardTitle>Bankintegration (PSD2)</CardTitle>
<CardDescription>
Automatisk import av transaktioner via PSD2 open banking.
Samtycket gäller i 90 dagar och behöver sedan förnyas.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
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.
</p>
</CardContent>
</Card>
)}
{/* Connect new bank */}
<Card>
<CardHeader>
<CardTitle>Anslut bank</CardTitle>
<CardDescription>
Koppla din bank för att automatiskt importera transaktioner via PSD2
</CardDescription>
</CardHeader>
<CardContent>
<BankSelector
onSelect={handleConnectBank}
isLoading={isConnecting}
country="SE"
sandbox={true}
/>
<p className="text-sm text-muted-foreground mt-4">
Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner,
aldrig flytta pengar.
</p>
</CardContent>
</Card>
</TabsContent>
</TabsContent>
)}
{/* Notification settings */}
<TabsContent value="notifications">
+21 -6
View File
@@ -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() {
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
Importera
</Link>
</Button>
{uncategorizedTransactions.length > 0 && (
<Button variant="outline" onClick={openSwipeView} disabled={isLoadingSuggestions}>
<Sparkles className="mr-2 h-4 w-4" />
@@ -492,13 +499,21 @@ export default function TransactionsPage() {
<p className="text-muted-foreground text-center mt-1">
{searchTerm
? 'Inga transaktioner matchar din sökning'
: 'Lägg till din första transaktion eller anslut din bank'}
: 'Importera transaktioner från din bank eller lägg till manuellt'}
</p>
{!searchTerm && (
<Button className="mt-4" onClick={() => setIsDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Lägg till transaktion
</Button>
<div className="flex gap-2 mt-4">
<Button asChild>
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
Importera transaktioner
</Link>
</Button>
<Button variant="outline" onClick={() => setIsDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Lägg till manuellt
</Button>
</div>
)}
</CardContent>
</Card>
-92
View File
@@ -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 }
)
}
}
-111
View File
@@ -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 }
)
}
}
@@ -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')
@@ -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
}
}
+130
View File
@@ -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 }
)
}
}
+98
View File
@@ -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 }
)
}
}
+5 -3
View File
@@ -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
}
+4 -3
View File
@@ -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
+2 -1
View File
@@ -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
+12 -2
View File
@@ -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
@@ -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',
-2
View File
@@ -1,2 +0,0 @@
export { BankSelector, type Bank } from './BankSelector'
export { BankConnectionStatus } from './BankConnectionStatus'
@@ -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<number>(-1)
const [descCol, setDescCol] = useState<number>(-1)
const [amountCol, setAmountCol] = useState<number>(-1)
const [referenceCol, setReferenceCol] = useState<number>(-1)
const [counterpartyCol, setCounterpartyCol] = useState<number>(-1)
const [balanceCol, setBalanceCol] = useState<number>(-1)
const [delimiter, setDelimiter] = useState<string>(',')
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 (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Columns3 className="h-5 w-5" />
Kolumnmappning
</CardTitle>
<CardDescription>
Vi kunde inte identifiera bankformatet automatiskt. Mappa kolumnerna manuellt.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Delimiter and decimal settings */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Avgränsare</Label>
<Select value={delimiter} onValueChange={setDelimiter}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value=",">Komma (,)</SelectItem>
<SelectItem value=";">Semikolon (;)</SelectItem>
<SelectItem value="\t">Tab</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Decimalavgränsare</Label>
<Select value={decimalSep} onValueChange={(v) => setDecimalSep(v as ',' | '.')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value=",">Komma (1 234,56)</SelectItem>
<SelectItem value=".">Punkt (1234.56)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Required column mappings */}
<div>
<h3 className="text-sm font-medium mb-3">Obligatoriska kolumner</h3>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Datum *</Label>
<Select
value={dateCol >= 0 ? dateCol.toString() : ''}
onValueChange={(v) => setDateCol(parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Beskrivning *</Label>
<Select
value={descCol >= 0 ? descCol.toString() : ''}
onValueChange={(v) => setDescCol(parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Belopp *</Label>
<Select
value={amountCol >= 0 ? amountCol.toString() : ''}
onValueChange={(v) => setAmountCol(parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Optional column mappings */}
<div>
<h3 className="text-sm font-medium mb-3">Valfria kolumner</h3>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Referens/OCR</Label>
<Select
value={referenceCol >= 0 ? referenceCol.toString() : 'none'}
onValueChange={(v) => setReferenceCol(v === 'none' ? -1 : parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Ingen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen</SelectItem>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Motpart</Label>
<Select
value={counterpartyCol >= 0 ? counterpartyCol.toString() : 'none'}
onValueChange={(v) => setCounterpartyCol(v === 'none' ? -1 : parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Ingen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen</SelectItem>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Saldo</Label>
<Select
value={balanceCol >= 0 ? balanceCol.toString() : 'none'}
onValueChange={(v) => setBalanceCol(v === 'none' ? -1 : parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Ingen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen</SelectItem>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Live preview */}
{isValid && previewRows.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Förhandsgranskning</CardTitle>
<CardDescription>
Så tolkas dina data med den valda mappningen
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border max-h-64 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={i}>
<TableCell className="font-mono text-sm">{row[dateCol] || '–'}</TableCell>
<TableCell className="text-sm">{row[descCol] || '–'}</TableCell>
<TableCell
className={`text-right font-mono text-sm ${
!isNaN(amount) && amount >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{!isNaN(amount) ? formatCurrency(amount) : amountStr}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Navigation */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button onClick={handleConfirm} disabled={!isValid}>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)
}
+178
View File
@@ -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 (
<div className="space-y-6">
{/* Summary */}
<Card>
<CardHeader>
<CardTitle>Bekräfta import</CardTitle>
<CardDescription>
Granska sammanfattningen och importera transaktionerna.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-xs">Transaktioner</span>
</div>
<p className="text-xl font-bold">{stats.parsed_rows}</p>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Calendar className="h-4 w-4" />
<span className="text-xs">Period</span>
</div>
<p className="text-sm font-medium">
{date_from} – {date_to}
</p>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-green-600 mb-1">
<span className="text-xs">Inkomster</span>
</div>
<p className="text-xl font-bold text-green-600">
{formatCurrency(stats.total_income)}
</p>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-red-600 mb-1">
<span className="text-xs">Utgifter</span>
</div>
<p className="text-xl font-bold text-red-600">
{formatCurrency(stats.total_expenses)}
</p>
</div>
</div>
{/* Additional info */}
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">Format: {format_name}</Badge>
{refsCount > 0 && (
<Badge variant="outline" className="text-blue-600 border-blue-300">
<Link2 className="mr-1 h-3 w-3" />
{refsCount} med OCR/referens
</Badge>
)}
</div>
{/* Options */}
<div className="border rounded-lg p-4 space-y-4">
<h3 className="text-sm font-medium">Importinställningar</h3>
<div className="flex items-center space-x-3">
<Checkbox
id="skip-duplicates"
checked={skipDuplicates}
onCheckedChange={(checked) => setSkipDuplicates(checked === true)}
/>
<div>
<Label htmlFor="skip-duplicates" className="text-sm font-medium cursor-pointer">
Hoppa över dubletter
</Label>
<p className="text-xs text-muted-foreground">
Transaktioner som redan finns i systemet importeras inte igen
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Checkbox
id="auto-categorize"
checked={autoCategorize}
onCheckedChange={(checked) => setAutoCategorize(checked === true)}
/>
<div>
<Label htmlFor="auto-categorize" className="text-sm font-medium cursor-pointer">
Auto-kategorisera kända transaktioner
</Label>
<p className="text-xs text-muted-foreground">
Skapar automatiskt bokföringsposter för transaktioner med hög konfidens
</p>
</div>
</div>
</div>
{/* Warning note */}
<div className="flex gap-3 p-3 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<AlertTriangle className="h-4 w-4 text-yellow-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground">
Importerade transaktioner som inte automatiskt kategoriseras visas som
&quot;okategoriserade&quot; på transaktionssidan. Du kan kategorisera dem manuellt
efteråt.
</p>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack} disabled={isLoading}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button
onClick={() => onExecute({
skip_duplicates: skipDuplicates,
auto_categorize: autoCategorize,
})}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Importerar...
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
Importera {stats.parsed_rows} transaktioner
</>
)}
</Button>
</div>
</div>
)
}
+216
View File
@@ -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 (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Transaktioner</span>
</div>
<p className="text-2xl font-bold">{stats.parsed_rows}</p>
{stats.skipped_rows > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{stats.skipped_rows} rader hoppades över
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Calendar className="h-4 w-4" />
<span className="text-sm">Period</span>
</div>
<p className="text-sm font-medium">
{date_from || '–'} till {date_to || '–'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-green-600 mb-1">
<TrendingUp className="h-4 w-4" />
<span className="text-sm">Inkomster</span>
</div>
<p className="text-lg font-bold text-green-600">
{formatCurrency(stats.total_income)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-red-600 mb-1">
<TrendingDown className="h-4 w-4" />
<span className="text-sm">Utgifter</span>
</div>
<p className="text-lg font-bold text-red-600">
{formatCurrency(stats.total_expenses)}
</p>
</CardContent>
</Card>
</div>
{/* Format and duplicate info */}
<div className="flex items-center gap-4">
<Badge variant="secondary">
Format: {format_name}
</Badge>
{existingTransactionCount > 0 && (
<Badge variant="outline" className="text-yellow-600 border-yellow-300">
<AlertTriangle className="mr-1 h-3 w-3" />
{existingTransactionCount} befintliga transaktioner i samma period
</Badge>
)}
</div>
{/* Warnings */}
{warnings.length > 0 && (
<Card className="border-yellow-300">
<CardHeader className="py-3">
<CardTitle className="text-sm flex items-center gap-2 text-yellow-600">
<AlertTriangle className="h-4 w-4" />
{warnings.length} varning{warnings.length !== 1 ? 'ar' : ''}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-1 max-h-32 overflow-y-auto">
{warnings.slice(0, 10).map((issue, i) => (
<p key={i} className="text-xs text-muted-foreground">
Rad {issue.row}: {issue.message}
</p>
))}
{warnings.length > 10 && (
<p className="text-xs text-muted-foreground font-medium">
...och {warnings.length - 10} till
</p>
)}
</div>
</CardContent>
</Card>
)}
{/* Transaction preview table */}
<Card>
<CardHeader>
<CardTitle className="text-base">Transaktioner</CardTitle>
<CardDescription>
Förhandsgranskning av de {Math.min(transactions.length, 50)} första transaktionerna
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border max-h-96 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-28">Datum</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right w-32">Belopp</TableHead>
{transactions.some((t) => t.balance != null) && (
<TableHead className="text-right w-32">Saldo</TableHead>
)}
{transactions.some((t) => t.reference) && (
<TableHead className="w-32">Referens</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{transactions.slice(0, 50).map((tx, i) => (
<TableRow key={i}>
<TableCell className="font-mono text-sm">{tx.date}</TableCell>
<TableCell className="text-sm">{tx.description}</TableCell>
<TableCell
className={`text-right font-mono text-sm ${
tx.amount >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{formatCurrency(tx.amount)}
</TableCell>
{transactions.some((t) => t.balance != null) && (
<TableCell className="text-right font-mono text-sm text-muted-foreground">
{tx.balance != null ? formatCurrency(tx.balance) : '–'}
</TableCell>
)}
{transactions.some((t) => t.reference) && (
<TableCell className="text-sm">
{tx.reference ? (
<Badge variant="outline" className="font-mono text-xs">
{tx.reference}
</Badge>
) : (
'–'
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
{transactions.length > 50 && (
<p className="text-sm text-muted-foreground mt-2 text-center">
Visar 50 av {transactions.length} transaktioner
</p>
)}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button onClick={onContinue} disabled={hasIssues || transactions.length === 0}>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)
}
+166
View File
@@ -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 (
<div className="space-y-6">
{/* Status header */}
<Card className={isSuccess ? 'border-green-300' : 'border-destructive/50'}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{isSuccess ? (
<>
<CheckCircle className="h-6 w-6 text-green-600" />
Import genomförd
</>
) : (
<>
<XCircle className="h-6 w-6 text-destructive" />
Import misslyckades
</>
)}
</CardTitle>
<CardDescription>
{isSuccess
? `${result.imported} transaktioner importerades framgångsrikt.`
: `${result.errors} fel uppstod under importen.`}
</CardDescription>
</CardHeader>
</Card>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Importerade</span>
</div>
<p className="text-2xl font-bold text-green-600">{result.imported}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Copy className="h-4 w-4" />
<span className="text-sm">Dubletter</span>
</div>
<p className="text-2xl font-bold text-muted-foreground">{result.duplicates}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Sparkles className="h-4 w-4" />
<span className="text-sm">Auto-kategoriserade</span>
</div>
<p className="text-2xl font-bold">{result.auto_categorized}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Link2 className="h-4 w-4" />
<span className="text-sm">Fakturamatchade</span>
</div>
<p className="text-2xl font-bold">{result.auto_matched_invoices}</p>
</CardContent>
</Card>
</div>
{/* Next steps */}
{isSuccess && (
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base">Nästa steg</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium flex-shrink-0">
1
</div>
<div>
<p className="font-medium">Granska okategoriserade transaktioner</p>
<p className="text-sm text-muted-foreground">
{result.imported - result.auto_categorized > 0
? `${result.imported - result.auto_categorized} transaktioner behöver kategoriseras manuellt.`
: 'Alla transaktioner kategoriserades automatiskt.'}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium flex-shrink-0">
2
</div>
<div>
<p className="font-medium">Bekräfta fakturamatchningar</p>
<p className="text-sm text-muted-foreground">
{result.auto_matched_invoices > 0
? `${result.auto_matched_invoices} transaktioner matchades mot fakturor. Bekräfta dessa på transaktionssidan.`
: 'Inga automatiska fakturamatchningar hittades.'}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium flex-shrink-0">
3
</div>
<div>
<p className="font-medium">Importera fler kontoutdrag</p>
<p className="text-sm text-muted-foreground">
Importera löpande kontoutdrag för att hålla bokföringen uppdaterad.
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={onNewImport}>
<RotateCcw className="mr-2 h-4 w-4" />
Ny import
</Button>
<div className="flex gap-2">
{isSuccess && (
<Button asChild>
<Link href="/transactions">
Visa transaktioner
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
)}
</div>
</div>
</div>
)
}
+236
View File
@@ -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<string, string> = {
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<File | null>(null)
const [formatOverride, setFormatOverride] = useState<BankFileFormatId | undefined>(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<HTMLInputElement>) => {
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 (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Ladda upp kontoutdrag
</CardTitle>
<CardDescription>
Exportera transaktioner som CSV eller XML från din internetbank och ladda upp filen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Format override */}
<div className="flex items-center gap-3">
<label className="text-sm font-medium whitespace-nowrap">Bank/format:</label>
<Select
value={formatOverride || 'auto'}
onValueChange={handleFormatChange}
>
<SelectTrigger className="w-64">
<SelectValue placeholder="Automatisk identifiering" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Automatisk identifiering</SelectItem>
<SelectItem value="nordea">Nordea</SelectItem>
<SelectItem value="seb">SEB</SelectItem>
<SelectItem value="swedbank">Swedbank</SelectItem>
<SelectItem value="handelsbanken">Handelsbanken</SelectItem>
<SelectItem value="camt053">ISO 20022 camt.053 (XML)</SelectItem>
<SelectItem value="generic_csv">Annan CSV (manuell mappning)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Drop zone */}
<div
className={`
relative border-2 border-dashed rounded-lg p-8 text-center transition-colors
${isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
${error ? 'border-destructive bg-destructive/5' : ''}
${isLoading ? 'pointer-events-none opacity-50' : 'cursor-pointer hover:border-primary/50'}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => document.getElementById('bank-file-input')?.click()}
>
<input
id="bank-file-input"
type="file"
accept={acceptedExtensions}
className="hidden"
onChange={handleFileInput}
disabled={isLoading}
/>
{isLoading ? (
<div className="space-y-4">
<FileText className="mx-auto h-12 w-12 text-muted-foreground animate-pulse" />
<p className="text-muted-foreground">Analyserar fil...</p>
<Progress value={33} className="w-48 mx-auto" />
</div>
) : selectedFile && detectedFormat ? (
<div className="space-y-4">
<CheckCircle className="mx-auto h-12 w-12 text-green-600" />
<div>
<p className="font-medium">{selectedFile.name}</p>
<p className="text-sm text-muted-foreground">
{(selectedFile.size / 1024).toFixed(1)} KB
</p>
<Badge variant="secondary" className="mt-2">
<Building2 className="mr-1 h-3 w-3" />
{detectedFormatName || FORMAT_NAMES[detectedFormat] || detectedFormat}
</Badge>
</div>
</div>
) : (
<div className="space-y-4">
<Upload className="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<p className="font-medium">Dra och släpp bankfil här</p>
<p className="text-sm text-muted-foreground">
CSV, TXT eller XML (max 10 MB)
</p>
</div>
</div>
)}
</div>
{/* Error display */}
{error && (
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg flex gap-3">
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-destructive">Kunde inte läsa filen</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Bank export instructions */}
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<HelpCircle className="h-4 w-4" />
Så exporterar du från din bank
</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-3">
<div>
<p className="font-medium">Nordea</p>
<p className="text-muted-foreground">
Logga in → Konton → Välj konto → Transaktioner → Exportera (CSV)
</p>
</div>
<div>
<p className="font-medium">SEB</p>
<p className="text-muted-foreground">
Logga in → Konton → Kontoutdrag → Hämta som fil (CSV)
</p>
</div>
<div>
<p className="font-medium">Swedbank</p>
<p className="text-muted-foreground">
Logga in → Konton → Transaktioner → Exportera kontoutdrag (CSV)
</p>
</div>
<div>
<p className="font-medium">Handelsbanken</p>
<p className="text-muted-foreground">
Logga in → Konton → Transaktioner → Ladda ner (CSV)
</p>
</div>
</CardContent>
</Card>
</div>
)
}
+2 -2
View File
@@ -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,
+103 -169
View File
@@ -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<string | null>(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 (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Anslut din bank</h1>
<h1 className="text-3xl font-bold tracking-tight">Bankuppgifter</h1>
<p className="text-muted-foreground mt-2">
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.
</p>
</div>
@@ -99,159 +68,125 @@ export default function Step6ConnectBank({
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Landmark className="h-5 w-5" />
Välj din bank
Bankuppgifter för fakturor
</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<BankSelector
onSelect={handleBankSelect}
isLoading={isConnecting}
/>
{error && (
<div className="p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
{error}
<CardContent>
<form onSubmit={handleSubmit(onManualSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bank_name">Bank</Label>
<Input
id="bank_name"
placeholder="t.ex. Nordea, SEB, Swedbank"
{...register('bank_name')}
/>
</div>
)}
<div className="bg-muted/50 rounded-lg p-4">
<h4 className="font-medium mb-2">Säker anslutning</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• Krypterad anslutning via PSD2</li>
<li>• Vi kan endast läsa transaktioner</li>
<li>• Du kan koppla bort när som helst</li>
<li>• Samtycke gäller i 90 dagar</li>
</ul>
</div>
{/* Collapsible manual bank details */}
<div className="border-t pt-4">
<button
type="button"
onClick={() => setShowManual(!showManual)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
>
{showManual ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<span>Ange bankuppgifter manuellt</span>
</button>
{showManual && (
<form onSubmit={handleSubmit(onManualSubmit)} className="space-y-4 mt-4 animate-fade-in">
<p className="text-sm text-muted-foreground">
Dessa uppgifter visas på dina fakturor så att kunder kan betala dig.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är clearingnummer?</p>
<p>De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Nordea: 3300</li>
<li>SEB: 5000</li>
<li>Swedbank: 8XXX</li>
<li>Handelsbanken: 6XXX</li>
<li>Avanza: 9550/9551</li>
</ul>
</div>
}
side="top"
>
<Label htmlFor="clearing_number">Clearingnummer</Label>
</InfoTooltip>
<Input
id="clearing_number"
placeholder="XXXX"
{...register('clearing_number')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Input
id="account_number"
placeholder="XXX XXX XXX"
{...register('account_number')}
/>
</div>
</div>
<div className="pt-4 border-t">
<h4 className="font-medium mb-4">Internationella betalningar (valfritt)</h4>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bank_name">Bank</Label>
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är IBAN?</p>
<p>Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.</p>
</div>
}
side="right"
>
<Label htmlFor="iban">IBAN</Label>
</InfoTooltip>
<Input
id="bank_name"
placeholder="t.ex. Nordea, SEB, Swedbank"
{...register('bank_name')}
id="iban"
placeholder="SE00 0000 0000 0000 0000 0000"
{...register('iban')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är clearingnummer?</p>
<p>De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Nordea: 3300</li>
<li>SEB: 5000</li>
<li>Swedbank: 8XXX</li>
<li>Handelsbanken: 6XXX</li>
<li>Avanza: 9550/9551</li>
</ul>
</div>
}
side="top"
>
<Label htmlFor="clearing_number">Clearingnummer</Label>
</InfoTooltip>
<Input
id="clearing_number"
placeholder="XXXX"
{...register('clearing_number')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Input
id="account_number"
placeholder="XXX XXX XXX"
{...register('account_number')}
/>
</div>
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är BIC/SWIFT?</p>
<p>Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.</p>
</div>
}
side="right"
>
<Label htmlFor="bic">BIC/SWIFT</Label>
</InfoTooltip>
<Input
id="bic"
placeholder="XXXXSESS"
{...register('bic')}
/>
</div>
</div>
</div>
<div className="pt-4 border-t">
<h4 className="font-medium mb-4">Internationella betalningar (valfritt)</h4>
<div className="space-y-4">
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är IBAN?</p>
<p>Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.</p>
</div>
}
side="right"
>
<Label htmlFor="iban">IBAN</Label>
</InfoTooltip>
<Input
id="iban"
placeholder="SE00 0000 0000 0000 0000 0000"
{...register('iban')}
/>
</div>
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är BIC/SWIFT?</p>
<p>Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.</p>
</div>
}
side="right"
>
<Label htmlFor="bic">BIC/SWIFT</Label>
</InfoTooltip>
<Input
id="bic"
placeholder="XXXXSESS"
{...register('bic')}
/>
</div>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-sm text-muted-foreground">
Du kan importera kontoutdrag (CSV, XML) från din bank under <strong>Import</strong>-sidan
efter att du slutfört registreringen.
</p>
</div>
<Button type="submit" className="w-full" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara bankuppgifter och slutför'
)}
</Button>
</form>
)}
</div>
<Button type="submit" className="w-full" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara och slutför'
)}
</Button>
</form>
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isConnecting}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
@@ -260,7 +195,6 @@ export default function Step6ConnectBank({
type="button"
variant="outline"
onClick={onSkip}
disabled={isConnecting}
>
<SkipForward className="mr-2 h-4 w-4" />
Hoppa över
+5 -5
View File
@@ -117,8 +117,8 @@ export function EmptyTransactions() {
<EmptyState
icon={ArrowLeftRight}
title="Inga transaktioner"
description="Koppla din bank för att automatiskt importera transaktioner, eller lägg till dem manuellt."
actionLabel="Koppla bank"
description="Importera kontoutdrag från din bank eller lägg till transaktioner manuellt."
actionLabel="Importera transaktioner"
actionHref="/import"
secondaryActionLabel="Lägg till manuellt"
secondaryActionHref="/transactions/new"
@@ -152,9 +152,9 @@ export function NoBankConnected() {
return (
<EmptyState
icon={Building2}
title="Ingen bank kopplad"
description="Koppla din bank för att automatiskt importera transaktioner och få bättre koll på ekonomin."
actionLabel="Koppla bank"
title="Inga transaktioner importerade"
description="Importera kontoutdrag från din bank för att automatiskt bokföra och få bättre koll på ekonomin."
actionLabel="Importera transaktioner"
actionHref="/import"
/>
)
File diff suppressed because it is too large Load Diff
+467
View File
@@ -0,0 +1,467 @@
1/9
BAS Förenklat årsbokslut (K1) – Kontoplan 2018
Inga ändring eller tillägg har gjorts
jämfört med 2017.
BAS-konton Rad Underkonton Rad
1000 Immateriella
anläggningstillgångar
B1 1009 Årets avskrivningar på
immateriella
anläggningstillgångar
B1
BAS-konton Rad Underkonton Rad
1110 Byggnader B2 1119 Ackumulerade avskrivningar på
byggnader
B2
1130 Mark B3
1150 Markanläggningar B2 1159 Ackumulerade avskrivningar på
markanläggningar
B2
1180 Pågående nyanläggningar och
förskott för byggnader och
mark
B3
Rad Rad
1220 Maskiner och inventarier B4 1221 Årets nyanskaffning av
maskiner och inventarier
B4
1222 Årets ersättning för maskiner
och inventarier
B4
1229 Årets avskrivningar på maskiner
och inventarier
B4
1230 Byggnads- och markinventarier B4 1231 Årets nyanskaffning av
byggnads- och markinventarier
B4
1232 Årets ersättning för byggnadsoch markinventarier
B4
1239 Årets avskrivningar på
byggnads- och markinventarier
B4
1240 Bilar och andra transportmedel B4 1241 Årets nyanskaffning av bilar och
andra transportmedel
B4
1242 Årets ersättning för bilar och
andra transportmedel
B4
1249 Årets avskrivningar på bilar och
andra transportmedel
B4
Rad Rad
1300 Andelar B5
BAS-konton Rad Rad
1 Tillgångar
10 Immateriella anläggningstillgångar
11 Byggnader och mark
12 Maskiner och inventarier
14 Lager
BAS-konton Underkonton
Underkonton
13 Övriga anläggningstillgångar
BAS-konton Underkonton
Kontoplan_K1_2018_ver1
2/9
1400 Lager B6
Rad Rad
1500 Kundfordringar B7
Rad Rad
1600 Övriga fordringar B8
1650 Momsfordran B8
Rad
1700 Förskott till leverantörer B8
Rad
1910 Kassa B9
1920 PlusGiro B9
1930 Företagskonto/checkkonto/affär
skonto
B9
1940 Övriga bankkonton B9
1970 Särskilda bankkonton B9
Rad Rad
2010 Eget kapital, delägare 1 B10 2011 Egna varuuttag B10
2012 Avräkning för skatter och
avgifter (skattekonto)
B10
2013 Övriga egna uttag B10
2014 Uttag förmåner B10
2017 Egna insättningar B10
2019 Årets resultat, delägare 1 B10
2020 Eget kapital, delägare 2 B10 Se delägre 1
2030 Eget kapital, delägare 3 B10 Se delägre 1
2040 Eget kapital, delägare 4 B10 Se delägre 1
2050 Avsättning till expansionsfond U2
2060 Ersättningsfond U3
2070 Insatsemissioner,
avbetalningsplan på skog,
skogskonto,
upphovsmannakonto
U4
2080 Periodiseringsfonder U1 2083 Periodiseringsfond vid 2012 års
taxering
U1
BAS-konton
16 Övriga fordringar
BAS-konton Underkonton
BAS-konton Underkonton
Underkonton
2 EGET KAPITAL OCH SKULDER
19 Kassa och bank
17 Förskott till leverantörer
15 Kundfordringar
BAS-konton
BAS-konton Underkonton
20 Eget kapital
Underkonton
Kontoplan_K1_2018_ver1
3/9
2084 Periodiseringsfond vid 2013 års
taxering
U1
2085 Periodiseringsfond 2013 U1
2086 Periodiseringsfond 2014 U1
2087 Periodiseringsfond 2015 U1
2088 Periodiseringsfond 2016 U1
2089 Periodiseringsfond 2017 U1
2090 Utjämningskonto upplysningar
1-4
Rad Rad
2330 Checkräkningskredit B13
2350 Skulder till kreditinstitut B13
2390 Övriga låneskulder B13
Rad Rad
2440 Leverantörsskulder B15
Rad Rad
2610 Utgående moms, 25 % B14 2611 Utgående moms på försäljning
inom Sverige, 25 %
B14
2612 Utgående moms på egna uttag,
25 %
B14
2613 Utgående moms för uthyrning,
25 %
B14
2614 Utgående moms omvänd
skattskyldighet, 25 %
B14
2615 Utgående moms import av
varor, 25 %
B14
2618 Vilande utgående moms, 25 % B14
2620 Utgående moms, 12 % B14 2621 Utgående moms på försäljning
inom Sverige, 12 %
B14
2622 Utgående moms på egna uttag,
12 %
B14
2623 Utgående moms för uthyrning,
12 %
B14
2624 Utgående moms omvänd
skattskyldighet, 12 %
B14
2625 Utgående moms import av
varor, 12 %
B14
2628 Vilande utgående moms, 12 % B14
2630 Utgående moms, 6 % B14 2631 Utgående moms på försäljning
inom Sverige, 6 %
B14
2632 Utgående moms på egna uttag,
6 %
B14
BAS-konton Underkonton
23 Låneskulder
Underkonton
BAS-konton Underkonton
24 Skulder till leverantörer
26 Moms och särskilda punktskatter
BAS-konton
Kontoplan_K1_2018_ver1
4/9
2633 Utgående moms för uthyrning,
6 %
B14
2634 Utgående moms omvänd
skattskyldighet, 6 %
B14
2635 Utgående moms import av
varor, 6 %
B14
2638 Vilande utgående moms, 6 % B14
2640 Ingående moms B14 2641 Debiterad ingående moms B14
2642 Debiterad ingående moms i
anslutning till frivillig
skattskyldighet
B14
2645 Beräknad ingående moms på
förvärv från utlandet
B14
2646 Ingående moms på uthyrning B14
2648 Vilande ingående moms B14
2649 Ingående moms, blandad
verksamhet
B14
2650 Redovisningskonto för moms B14 B14
2660 Särskilda punktskatter B14 B14
Rad Rad
2710 Personalskatt B14
2730 Lagstadgade/avtalade sociala
avgifter och särskild löneskatt
B14
Rad Rad
2900 Övriga skulder B16
Rad Rad
3000 Försäljning och utfört arbete
samt övriga momspliktiga
intäkter
R1
3100 Momsfria intäkter R2
3200 Bil- och bostadsförmån m.m. R3
Rad Rad
3500 Fakturerade kostnader R1
Rad Rad
3700 Lämnade rabatter, bonus etc. R1/R2
Underkonton
35 Fakturerade kostnader
37 Intäktskorrigeringar
Underkonton
BAS-konton
Underkonton
3 RÖRELSENS INKOMSTER/INTÄKTER
30-34 Huvudintäkter
BAS-konton Underkonton
BAS-konton
29 Övriga skulder
27 Personalens skatter, avgifter och löneavdrag
BAS-konton Underkonton
BAS-konton
Kontoplan_K1_2018_ver1
5/9
Rad Rad
3900 Övriga rörelseintäkter R1/R2
3970 Vinst vid avyttring av
immateriella och materiella
anläggningstillgångar
R2
3980 Erhållna bidrag R2
Rad Rad
4000 Varor R5
Rad Rad
4600 Legoarbeten och
underentreprenader
R5
Rad Rad
4700 Erhållna rabatter, bonus etc. R6
Rad Rad
4900 Förändring av lager R5
Rad Rad
5000 Lokalkostnader R6
Rad Rad
5100 Fastighetskostnader R6
Rad Rad
5200 Hyra av anläggningstillgångar R6
Underkonton
Underkonton
BAS-konton Underkonton
39 Övriga rörelseintäkter
Underkonton
BAS-konton
4 UTGIFTER/KOSTNADER FÖR VAROR, MATERIAL OCH VISSA
KÖPTA TJÄNSTER
Underkonton
47 Reduktion av inköpspriser
49 Förändring av lager
BAS-konton Underkonton
Underkonton
BAS-konton Underkonton
46 Legoarbeten, underentreprenader
BAS-konton
BAS-konton
BAS-konton
40-45 Inköp av varor och material
BAS-konton
5-6 ÖVRIGA EXTERNA RÖRELSEUTGIFTER/KOSTNADER
50 Lokalkostnader
51 Fastighetskostnader
52 Hyra av anläggningstillgångar
54 Förbrukningsinventarier och förbrukningsmaterial
Kontoplan_K1_2018_ver1
6/9
Rad Rad
5400 Förbrukningsinventarier och
förbrukningsmaterial
R6
Rad Rad
5500 Reparation och underhåll R6
Rad Rad
5600 Kostnader för transportmedel R6
5610 Personbilskostnader R6 5611 Drivmedel för personbilar R6
5612 Försäkring och skatt för
personbilar
R6
5613 Reparation och underhåll av
personbilar
R6
5615 Leasing av personbilar R6
5618 Schablonmässig milkostnad
privat personbil
R6
5619 Övriga personbilskostnader R6
5620 Lastbilskostnader R6
Rad Rad
5700 Frakter och transporter R6
Rad Rad
5800 Resekostnader R6
Rad Rad
5900 Reklam och PR R6
Rad Rad
6000 Övriga försäljningskostnader
R6
R6 R6
6070 Representation R6 R6 6071 Representation, avdragsgill
6072 Representation, ej avdragsgill R6
+
NE
sid.
2
BAS-konton
BAS-konton
Underkonton
55 Reparation och underhåll
56 Kostnader för transportmedel
BAS-konton Underkonton
Underkonton
Underkonton
BAS-konton Underkonton
57 Frakter och transporter
58 Resekostnader
59 Reklam och PR
60 Övriga försäljningskostnader
BAS-konton
Underkonton
BAS-konton
Underkonton
BAS-konton
Kontoplan_K1_2018_ver1
7/9
Rad Rad
6100 Kontorsmateriel och trycksaker R6
Rad Rad
6200 Tele och post R6
Rad Rad
6300 Företagsförsäkringar och
övriga riskkostnader
R6
6310 Företagsförsäkringar R6
Rad Rad
6500 Övriga externa tjänster R6
Rad Rad
6800 Inhyrd personal R6
Rad Rad
6900 Övriga kostnader R6
6980 Föreningsavgifter R6
Rad Rad
7000 Löner till anställda R7
Rad Rad
7300 Kostnadsersättningar och
förmåner
R7
Rad Rad
7400 Pensionskostnader R7
74 Pensionskostnader
61 Kontorsmateriel och trycksaker
62 Tele och post
BAS-konton Underkonton
BAS-konton Underkonton
63 Företagsförsäkringar och övriga riskkostnader
Underkonton
70 Löner till anställda
7 UTGIFTER/KOSTNADER FÖR PERSONAL, AVSKRIVNINGAR
BAS-konton
68 Inhyrd personal
69 Övriga kostnader
BAS-konton Underkonton
BAS-konton Underkonton
BAS-konton Underkonton
65 Övriga externa tjänster
BAS-konton Underkonton
BAS-konton Underkonton
73 Kostnadsersättningar och förmåner
BAS-konton Underkonton
Kontoplan_K1_2018_ver1
8/9
Rad Rad
7500 Sociala och andra avgifter
enligt lag och avtal
R7
Rad Rad
7600 Övriga personalkostnader R7 7631 Personalrepresentation,
avdragsgill
R7
7632 Personalrepresentation, ej
avdragsgill
R7
+
NE
sid.
2
Rad Rad
7700 Nedskrivningar R9
alt.
R10
Rad Rad
7810 Avskrivningar på immateriella
anläggningstillgångar
R10
7820 Avskrivningar på byggnader
och markanläggningar
R9
7830 Avskrivningar på maskiner och
inventarier
R10
Rad Rad
7970 Förlust vid avyttring av
immateriella och materiella
anläggningstillgångar
R6
7980 Ersättningsfonder R9/
10
Rad Rad
8 FINANSIELLA OCH ANDRA INKOMSTER/INTÄKTER OCH
UTGIFTER/ KOSTNADER
Underkonton
BAS-konton Underkonton
79 Övriga rörelsekostnader
77 Nedskrivningar och återföring av nedskrivningar
76 Övriga personalkostnader
78 Avskrivningar
83 Övriga ränteintäkter och liknande resultatposter
BAS-konton Underkonton
BAS-konton Underkonton
75 Sociala och andra avgifter enligt lag och avtal
BAS-konton
BAS-konton Underkonton
BAS-konton Underkonton
Kontoplan_K1_2018_ver1
9/9
8310 Ränteintäkter och utdelningar R4 8314 Skattefria ränteintäkter R4
+
NE
sid.
2
8330 Valutakursdifferenser på
fordringar och placeringar
R4
Rad Rad
8410 Räntekostnader för skulder R8
8430 Valutakursdifferenser på
skulder R8
Rad Rad
8990 Resultat R11 R11 8999 Årets resultat R11
84 Räntekostnader och liknande
BAS-konton Underkonton
BAS-konton Underkonton
88 Bokslutsdispositioner
89 Årets resultat
Kontoplan_K1_2018_ver1
+51
View File
@@ -0,0 +1,51 @@
ERP-Base: Gap-analys mot svensk bokföringsmarknad
1. SAKNAS HELT — Kritiska luckor
1.1 Leverantörsreskontra
Alla etablerade system har fullständig leverantörsreskontra: registrering av inkommande fakturor, förfallodatum, betalningsstatus, automatisk bokföring vid betalning. Ditt system saknar tabeller och flöden för leverantörsfakturor. Detta är ett absolut krav för att kunna kallas bokföringssystem.
Behövs: suppliers-tabell, supplier_invoices-tabell, flöde för registrering/betalning/bokföring, leverantörsreskontra-rapport, stöd för både kontant- och fakturametoden.
1.2 Kundreskontra (formellt)
Du har invoices och customers, men det saknas en explicit kundreskontra-vy som visar utestående fordringar, förfallna fakturor, och avstämning mot konto 1510. Alla konkurrenter har detta som standardfunktion.
1.3 Lönehantering
salary_payments finns men alla konkurrenter (Fortnox, Bokio, Visma) erbjuder komplett lönehantering: lönespecifikationer, arbetsgivaravgifter, skattetabeller (FOS-förfrågan mot Skatteverket), AGI-rapportering, semesterhantering. Detta är en separat modul som de flesta SME-kunder förväntar sig.
Behövs: Skattetabellhantering, lönespec-generering (PDF), arbetsgivaravgiftsberäkning, AGI-rapportering, semesterskuld, förmånsberäkning (bil, etc).
1.4 Årsredovisning (K2/K3)
Aktiebolag måste lämna årsredovisning till Bolagsverket. Fortnox och Björn Lundén genererar detta. Din plattform har årsbokslut men saknar årsredovisningsgenerering med förvaltningsberättelse, noter, och formell K2/K3-struktur.
Behövs: Generering av förvaltningsberättelse, resultaträkning (K2-format), balansräkning (K2-format), noter, digital inlämning till Bolagsverket (XBRL).
1.5 Kontantmetod-stöd
Många enskilda firmor bokför med kontantmetoden (bokslutsmetoden). Ditt system verkar byggt kring faktureringsmetoden. Båda måste stödjas, med automatisk övergång till fakturametod vid bokslut för kontantmetoden.
1.6 Anläggningsregister
Inventarier, maskiner, fastigheter — med avskrivningsplaner (linjär/degressiv), restvärden, och automatisk avskrivningsbokföring. Saknas helt. Krävs för AB med tillgångar.
1.7 Offert/Order-flöde
Fortnox och Visma har offert → order → faktura-kedja. Inte nödvändigt för MVP men förväntat i ett komplett system.
2. FINNS MEN OTILLRÄCKLIGT — Behöver utökas
2.1 Bokföringsmallar / Konteringshjälp
Bokio's stora USP är smart konteringshjälp: användaren väljer "IT-tjänst 25% moms" och systemet konterar automatiskt. Du har AI-kategorisering, men saknar troligen ett bibliotek av färdiga bokföringsmallar för vanliga affärshändelser som en nybörjare kan välja mellan.
Behövs: 50-100 vanliga transaktionsmallar (kontorsmateriell, IT-tjänst, bensin, representation, etc) med korrekt moms och kontering.
2.2 Bankavstämning
Du har PSD2-transaktionssynk, men behöver explicit bankavstämning: matcha banktransaktioner mot bokförda poster, markera avstämda, visa differenser. Alla konkurrenter har detta.
2.3 Momsdeklaration
Du nämner "10 rutor" men verifierar att den genererar korrekt SKV 4820-underlag? Behöver också stödja: EU-handel (omvänd skattskyldighet), import/export-moms, olika momssatser (25/12/6/0%), tröskelbelopp (120 000 SEK från 2025).
2.4 SIE-export
Du har SIE4-export. Verifiera att SIE-import också fungerar korrekt (ingående balanser, verifikationer, kontoplan) — detta är kritiskt för att kunder ska kunna byta till ditt system från Fortnox/Bokio.
2.5 Rapporter
Du har saldobalans, resultat, balans, moms. Saknar troligen:
Huvudbok (alla transaktioner per konto)
Grundbok (verifikationslista i datumordning)
Kundreskontra-rapport
Leverantörsreskontra-rapport
Periodrapporter (jämförelse mellan perioder)
Kassaflödesanalys
3. HYGIEN-FUNKTIONER — Förväntas av alla
3.1 Autentisering
BankID-inloggning förväntas av svenska användare. Inte nödvändigt dag 1, men e-post + lösenord + 2FA via TOTP är minimum.
3.2 Mobilapp / Responsivt
Alla konkurrenter har mobilapp eller fullt responsivt gränssnitt. Kvittofotografering från mobil är en hygienfaktor.
3.3 Periodlåsning
Bokföringslagen kräver att bokföring är "varaktig" — du behöver kunna låsa perioder så att poster inte kan ändras i efterhand utan att det syns. Du har WORM-arkiv, verifiera att periodlåsning är implementerad.
3.4 Fleranvändarstöd
Roller: ägare, redovisningskonsult (extern), anställd. Behörigheter per modul. Alla konkurrenter har detta. Redovisningskonsult-access är affärskritiskt — byråer är den viktigaste distributionskanalen.
3.5 Verifikationskedja
Varje verifikation behöver: löpnummer utan luckor, datum, belopp, motkonto, beskrivning, bifogat underlag. Du har detta delvis via WORM + voucher numbering, men verifiera fullständigt BFL-compliance.
+27 -21
View File
@@ -52,22 +52,25 @@ export interface CategorizationProvider {
// BAS Account + Category Mapping (used in prompt)
// ============================================================
const CATEGORY_ACCOUNT_MAP: Record<string, { account: string; label: string }> = {
income_services: { account: '3001', label: 'Tjänsteförsäljning' },
income_products: { account: '3001', label: 'Varuförsäljning' },
income_other: { account: '3900', label: 'Övriga intäkter' },
expense_equipment: { account: '5410', label: 'Förbrukningsinventarier' },
expense_software: { account: '5420', label: 'Programvara' },
expense_travel: { account: '5800', label: 'Resekostnader' },
expense_office: { account: '5010', label: 'Lokalhyra/kontorskostnad' },
expense_marketing: { account: '5910', label: 'Annonsering/marknadsföring' },
expense_professional_services: { account: '6530', label: 'Redovisning/konsulttjänster' },
expense_education: { account: '6991', label: 'Utbildning' },
expense_bank_fees: { account: '6570', label: 'Bankavgifter' },
expense_card_fees: { account: '6570', label: 'Kortavgifter' },
expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' },
expense_other: { account: '6991', label: 'Övriga kostnader' },
private: { account: '2013', label: 'Privat uttag (EF) / Skuld till ägare (AB)' },
function getCategoryAccountMap(entityType: EntityType): Record<string, { account: string; label: string }> {
const educationAccount = entityType === 'aktiebolag' ? '7610' : '6991'
return {
income_services: { account: '3001', label: 'Tjänsteförsäljning' },
income_products: { account: '3001', label: 'Varuförsäljning' },
income_other: { account: '3900', label: 'Övriga intäkter' },
expense_equipment: { account: '5410', label: 'Förbrukningsinventarier' },
expense_software: { account: '5420', label: 'Programvara' },
expense_travel: { account: '5800', label: 'Resekostnader' },
expense_office: { account: '5010', label: 'Lokalhyra/kontorskostnad' },
expense_marketing: { account: '5910', label: 'Annonsering/marknadsföring' },
expense_professional_services: { account: '6530', label: 'Redovisning/konsulttjänster' },
expense_education: { account: educationAccount, label: 'Utbildning' },
expense_bank_fees: { account: '6570', label: 'Bankavgifter' },
expense_card_fees: { account: '6570', label: 'Kortavgifter' },
expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' },
expense_other: { account: '6991', label: 'Övriga kostnader' },
private: { account: '2013', label: 'Privat uttag (EF) / Skuld till ägare (AB)' },
}
}
const NON_DEDUCTIBLE_RULES = `
@@ -107,12 +110,13 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
if (batch.length === 0) return []
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
const categoryAccountMap = getCategoryAccountMap(context.entityType)
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
Din uppgift är att kategorisera varje transaktion till rätt kategori och BAS-konto.
KATEGORIER OCH BAS-KONTON:
${Object.entries(CATEGORY_ACCOUNT_MAP)
${Object.entries(categoryAccountMap)
.map(([cat, info]) => `- ${cat}: ${info.account} (${info.label})`)
.join('\n')}
@@ -209,7 +213,7 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
jsonText = jsonText.trim()
const parsed = JSON.parse(jsonText)
return this.validateSuggestions(parsed.suggestions || [], batch)
return this.validateSuggestions(parsed.suggestions || [], batch, context.entityType)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
@@ -231,12 +235,14 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
private validateSuggestions(
raw: unknown[],
transactions: TransactionForCategorization[]
transactions: TransactionForCategorization[],
entityType: EntityType
): CategorizationSuggestion[] {
if (!Array.isArray(raw)) return []
const categoryAccountMap = getCategoryAccountMap(entityType)
const validTransactionIds = new Set(transactions.map((t) => t.id))
const validCategories = new Set(Object.keys(CATEGORY_ACCOUNT_MAP).concat(['uncategorized']))
const validCategories = new Set(Object.keys(categoryAccountMap).concat(['uncategorized']))
return raw
.filter(
@@ -249,7 +255,7 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
? (s.category as TransactionCategory)
: 'expense_other'
const accountInfo = CATEGORY_ACCOUNT_MAP[category]
const accountInfo = categoryAccountMap[category]
return {
transactionId: s.transactionId as string,
@@ -2,7 +2,7 @@
import { Button } from '@/components/ui/button'
import { formatDate } from '@/lib/utils'
import { getDaysUntilExpiry, isConsentExpiringSoon } from '@/lib/banking/enable-banking'
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
import {
CreditCard,
AlertTriangle,
+225
View File
@@ -0,0 +1,225 @@
import type { Extension } from '@/lib/extensions/types'
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import {
startAuthorization,
getASPSPs,
createSession,
getAccountBalance,
isConsentExpiringSoon,
getDaysUntilExpiry,
type ASPSP,
type AccountInfo,
} from './lib/api-client'
import { syncAccountTransactions } from './lib/sync'
import type { StoredAccount } from './types'
import type { Transaction } from '@/types'
/**
* Enable Banking (PSD2) extension
*
* Provides automatic bank transaction sync via PSD2 open banking.
* This is an opt-in extension — uncomment the import in loader.ts to activate.
*
* Required environment variables:
* - ENABLE_BANKING_APP_ID
* - ENABLE_BANKING_PRIVATE_KEY (base64-encoded PEM)
* - ENABLE_BANKING_SANDBOX (optional, for sandbox mode)
*/
export const enableBankingExtension: Extension = {
id: 'enable-banking',
name: 'Enable Banking (PSD2)',
version: '1.0.0',
settingsPanel: {
label: 'Bankintegration (PSD2)',
path: '/settings?tab=banking',
},
apiRoutes: [
{
method: 'GET',
path: '/banks',
handler: async () => {
try {
const aspsps = await getASPSPs('SE')
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 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' },
]
})
}
},
},
{
method: 'POST',
path: '/connect',
handler: async (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/extensions/enable-banking/callback`
const { url, authorization_id } = await startAuthorization(
aspsp_name,
aspsp_country,
redirectUrl,
user.id,
'personal'
)
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 }
)
}
},
},
{
method: 'POST',
path: '/sync',
handler: async (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()
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
}
const syncedAt = new Date().toISOString()
await supabase
.from('bank_connections')
.update({
accounts_data: accounts,
last_synced_at: syncedAt,
})
.eq('id', connection.id)
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 }
)
}
},
},
],
eventHandlers: [],
}
+62
View File
@@ -0,0 +1,62 @@
import { SupabaseClient } from '@supabase/supabase-js'
import { getTransactions, getAccountBalance } from './api-client'
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
import type { StoredAccount } from '../types'
export interface SyncResult {
imported: number
duplicates: number
errors: number
}
/**
* Sync transactions for a single bank account via Enable Banking PSD2.
*
* Fetches transactions from the Enable Banking API, converts to RawTransaction
* format, and delegates to the shared ingestion pipeline.
*/
export async function syncAccountTransactions(
supabase: SupabaseClient,
userId: string,
connectionId: string,
account: StoredAccount,
fromDate: string,
toDate: string
): Promise<SyncResult> {
const bankTransactions = await getTransactions(
account.uid,
fromDate,
toDate,
account.currency
)
// Convert Enable Banking format to generic RawTransaction
const rawTransactions: RawTransaction[] = bankTransactions.map((tx) => ({
date: tx.booking_date || tx.date,
description: tx.description || tx.counterparty_name || 'Unknown',
amount: tx.amount,
currency: tx.currency || account.currency,
external_id: `${connectionId}_${tx.id}`,
mcc_code: tx.merchant_category_code ? parseInt(tx.merchant_category_code, 10) : null,
merchant_name: tx.counterparty_name || null,
reference: tx.reference || null,
bank_connection_id: connectionId,
import_source: 'enable_banking',
}))
const ingestResult = await ingestTransactions(supabase, userId, rawTransactions)
// Update account balance
try {
const balance = await getAccountBalance(account.uid)
account.balance = balance.amount
} catch {
// Ignore balance fetch errors
}
return {
imported: ingestResult.imported,
duplicates: ingestResult.duplicates,
errors: ingestResult.errors,
}
}
+24
View File
@@ -0,0 +1,24 @@
// Enable Banking extension types
export interface StoredAccount {
uid: string
iban?: string
name?: string
currency: string
balance?: number
}
// Re-export API types from the client
export type {
ASPSP,
AuthMethod,
AuthResponse,
SessionResponse,
AccountInfo,
Balance,
BalanceResponse,
Transaction as EnableBankingTransaction,
TransactionsResponse,
Bank,
BankTransaction,
} from './lib/api-client'
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest'
import { NE_ACCOUNT_MAPPINGS } from '../ne-engine'
import type { NEAccountMapping } from '@/types'
/**
* Helper to check if an account falls into a specific ruta
*/
function findRutaForAccount(accountNumber: string): string | null {
for (const mapping of NE_ACCOUNT_MAPPINGS) {
for (const range of mapping.accountRanges) {
if (accountNumber >= range.start && accountNumber <= range.end) {
if (range.exclude && range.exclude.includes(accountNumber)) {
continue
}
return mapping.ruta
}
}
}
return null
}
describe('NE Account Mappings', () => {
describe('R1 - Försäljning med moms', () => {
it('includes standard revenue accounts 3001-3003', () => {
expect(findRutaForAccount('3001')).toBe('R1')
expect(findRutaForAccount('3002')).toBe('R1')
expect(findRutaForAccount('3003')).toBe('R1')
})
it('excludes 3100 (momsfria intäkter)', () => {
expect(findRutaForAccount('3100')).not.toBe('R1')
})
it('includes 3500 (Fakturerade kostnader)', () => {
expect(findRutaForAccount('3500')).toBe('R1')
})
it('includes 3500-3599 range', () => {
expect(findRutaForAccount('3510')).toBe('R1')
expect(findRutaForAccount('3599')).toBe('R1')
})
it('includes 3700-3799 (Lämnade rabatter)', () => {
expect(findRutaForAccount('3700')).toBe('R1')
expect(findRutaForAccount('3731')).toBe('R1')
expect(findRutaForAccount('3799')).toBe('R1')
})
})
describe('R2 - Momsfria intäkter', () => {
it('includes 3100', () => {
expect(findRutaForAccount('3100')).toBe('R2')
})
it('includes 3900 (Övriga rörelseintäkter)', () => {
expect(findRutaForAccount('3900')).toBe('R2')
})
it('includes 3910 (Hyresintäkter)', () => {
expect(findRutaForAccount('3910')).toBe('R2')
})
it('includes 3920 (Provisionsintäkter)', () => {
expect(findRutaForAccount('3920')).toBe('R2')
})
it('includes 3950 (Återvunna kundfordringar)', () => {
expect(findRutaForAccount('3950')).toBe('R2')
})
it('includes 3960 (Valutakursvinster)', () => {
expect(findRutaForAccount('3960')).toBe('R2')
})
it('includes 3970-3980 range', () => {
expect(findRutaForAccount('3970')).toBe('R2')
expect(findRutaForAccount('3980')).toBe('R2')
})
it('includes 3981-3999 range', () => {
expect(findRutaForAccount('3981')).toBe('R2')
expect(findRutaForAccount('3990')).toBe('R2')
expect(findRutaForAccount('3999')).toBe('R2')
})
})
describe('accounts are not silently dropped', () => {
it('3500 is mapped (not dropped)', () => {
expect(findRutaForAccount('3500')).not.toBeNull()
})
it('3700 is mapped (not dropped)', () => {
expect(findRutaForAccount('3700')).not.toBeNull()
})
it('3910 is mapped (not dropped)', () => {
expect(findRutaForAccount('3910')).not.toBeNull()
})
it('3960 is mapped (not dropped)', () => {
expect(findRutaForAccount('3960')).not.toBeNull()
})
it('3990 is mapped (not dropped)', () => {
expect(findRutaForAccount('3990')).not.toBeNull()
})
})
describe('no overlap between R1 and R2', () => {
it('3100 is in R2 not R1', () => {
expect(findRutaForAccount('3100')).toBe('R2')
})
it('3001 is in R1 not R2', () => {
expect(findRutaForAccount('3001')).toBe('R1')
})
it('3900 is in R2 not R1', () => {
expect(findRutaForAccount('3900')).toBe('R2')
})
})
})
+6 -4
View File
@@ -15,8 +15,8 @@ import type {
* tax reporting to Skatteverket.
*
* Account mappings:
* R1: Försäljning med moms (3000-3499 excl 3100)
* R2: Momsfria intäkter (3100, 3900, 3970, 3980) - inkl gåvor utan motprestation
* R1: Försäljning med moms (3000-3599 excl 3100, 3700-3799)
* R2: Momsfria intäkter (3100, 3900-3969, 3970-3980, 3981-3999) - inkl gåvor utan motprestation
* R3: Bil/bostadsförmån (3200)
* R4: Ränteintäkter (8310-8330)
* R5: Varuinköp (4000-4990)
@@ -41,7 +41,8 @@ export const NE_ACCOUNT_MAPPINGS: NEAccountMapping[] = [
ruta: 'R1',
description: 'Försäljning med moms (25%)',
accountRanges: [
{ start: '3000', end: '3499', exclude: ['3100'] },
{ start: '3000', end: '3599', exclude: ['3100'] },
{ start: '3700', end: '3799' },
],
isExpense: false,
},
@@ -50,8 +51,9 @@ export const NE_ACCOUNT_MAPPINGS: NEAccountMapping[] = [
description: 'Momsfria intäkter',
accountRanges: [
{ start: '3100', end: '3100' },
{ start: '3900', end: '3900' }, // Övriga rörelseintäkter (inkl gåvor utan motprestation)
{ start: '3900', end: '3969' }, // Övriga rörelseintäkter (inkl gåvor utan motprestation)
{ start: '3970', end: '3980' },
{ start: '3981', end: '3999' },
],
isExpense: false,
},
-143
View File
@@ -1,143 +0,0 @@
import { SupabaseClient } from '@supabase/supabase-js'
import { getTransactions, getAccountBalance } from '@/lib/banking/enable-banking'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { getBestInvoiceMatch } from '@/lib/invoice/invoice-matching'
import type { Transaction } from '@/types'
interface StoredAccount {
uid: string
iban?: string
name?: string
currency: string
balance?: number
}
export interface SyncResult {
imported: number
duplicates: number
errors: number
}
/**
* Sync transactions for a single bank account
* Shared logic used by both manual sync and cron job
*/
export async function syncAccountTransactions(
supabase: SupabaseClient,
userId: string,
connectionId: string,
account: StoredAccount,
fromDate: string,
toDate: string
): Promise<SyncResult> {
const result: SyncResult = { imported: 0, duplicates: 0, errors: 0 }
const bankTransactions = await getTransactions(
account.uid,
fromDate,
toDate,
account.currency
)
for (const tx of bankTransactions) {
const externalId = `${connectionId}_${tx.id}`
// Check for duplicates
const { data: existing } = await supabase
.from('transactions')
.select('id')
.eq('user_id', userId)
.eq('external_id', externalId)
.single()
if (existing) {
result.duplicates++
continue
}
// Insert new transaction
const { data: newTransaction, error: insertError } = await supabase
.from('transactions')
.insert({
user_id: userId,
bank_connection_id: connectionId,
external_id: externalId,
date: tx.booking_date || tx.date,
description: tx.description || tx.counterparty_name || 'Unknown',
amount: tx.amount,
currency: tx.currency || account.currency,
category: 'uncategorized',
is_business: null,
mcc_code: tx.merchant_category_code || null,
merchant_name: tx.counterparty_name || null,
})
.select()
.single()
if (insertError || !newTransaction) {
result.errors++
continue
}
result.imported++
// For income transactions, try to find matching invoices
if (newTransaction.amount > 0) {
try {
const bestMatch = await getBestInvoiceMatch(
userId,
newTransaction as Transaction,
0.50
)
if (bestMatch) {
await supabase
.from('transactions')
.update({ potential_invoice_id: bestMatch.invoice.id })
.eq('id', newTransaction.id)
}
} catch {
// Non-critical
}
}
// Evaluate mapping rules for auto-categorization
try {
const mappingResult = await evaluateMappingRules(
userId,
newTransaction as Transaction
)
if (mappingResult.confidence >= 0.8 && !mappingResult.requires_review) {
const journalEntry = await createTransactionJournalEntry(
userId,
newTransaction as Transaction,
mappingResult
)
if (journalEntry) {
await supabase
.from('transactions')
.update({
journal_entry_id: journalEntry.id,
is_business: !mappingResult.default_private,
})
.eq('id', newTransaction.id)
}
}
} catch {
// Non-critical
}
}
// Update account balance
try {
const balance = await getAccountBalance(account.uid)
account.balance = balance.amount
} catch {
// Ignore balance fetch errors
}
return result
}
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest'
import { getCategoryAccountMapping, getExpenseAccountForCategory } from '../category-mapping'
describe('getCategoryAccountMapping', () => {
describe('income_products uses correct account', () => {
it('maps income_products to 3001 (25% moms)', () => {
const result = getCategoryAccountMapping('income_products', 1000, true)
expect(result.creditAccount).toBe('3001')
})
it('income_products matches income_services account', () => {
const products = getCategoryAccountMapping('income_products', 1000, true)
const services = getCategoryAccountMapping('income_services', 1000, true)
expect(products.creditAccount).toBe(services.creditAccount)
})
})
describe('expense_education entity-type-aware', () => {
it('defaults to 6991 for enskild_firma', () => {
const result = getCategoryAccountMapping('expense_education', -500, true, 'enskild_firma')
expect(result.debitAccount).toBe('6991')
})
it('uses 7610 for aktiebolag', () => {
const result = getCategoryAccountMapping('expense_education', -500, true, 'aktiebolag')
expect(result.debitAccount).toBe('7610')
})
it('defaults to 6991 when no entityType provided', () => {
const result = getCategoryAccountMapping('expense_education', -500, true)
expect(result.debitAccount).toBe('6991')
})
})
})
describe('getExpenseAccountForCategory', () => {
it('returns null for non-expense categories', () => {
expect(getExpenseAccountForCategory('income_services')).toBeNull()
})
it('returns correct accounts for expense categories', () => {
expect(getExpenseAccountForCategory('expense_equipment')).toBe('5410')
expect(getExpenseAccountForCategory('expense_bank_fees')).toBe('6570')
})
})
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest'
import { getRevenueAccount } from '../invoice-entries'
describe('getRevenueAccount', () => {
it('standard_25 returns 3001', () => {
expect(getRevenueAccount('standard_25')).toBe('3001')
})
it('reduced_12 returns 3002', () => {
expect(getRevenueAccount('reduced_12')).toBe('3002')
})
it('reduced_6 returns 3003', () => {
expect(getRevenueAccount('reduced_6')).toBe('3003')
})
it('reverse_charge returns 3308', () => {
expect(getRevenueAccount('reverse_charge')).toBe('3308')
})
it('export returns 3305', () => {
expect(getRevenueAccount('export')).toBe('3305')
})
it('exempt defaults to 3100 for enskild_firma', () => {
expect(getRevenueAccount('exempt')).toBe('3100')
expect(getRevenueAccount('exempt', 'enskild_firma')).toBe('3100')
})
it('exempt returns 3004 for aktiebolag', () => {
expect(getRevenueAccount('exempt', 'aktiebolag')).toBe('3004')
})
it('entityType does not affect non-exempt treatments', () => {
expect(getRevenueAccount('standard_25', 'aktiebolag')).toBe('3001')
expect(getRevenueAccount('reduced_12', 'aktiebolag')).toBe('3002')
expect(getRevenueAccount('export', 'aktiebolag')).toBe('3305')
})
})
+3 -2
View File
@@ -62,6 +62,7 @@ export function getCategoryAccountMapping(
}
// Business expense categories
const educationAccount = entityType === 'aktiebolag' ? '7610' : '6991' // Utbildning (AB) / Övriga avdragsgilla kostnader (EF)
const expenseMapping: Record<string, string> = {
expense_equipment: '5410', // Förbrukningsinventarier
expense_software: '5420', // Programvaror
@@ -69,7 +70,7 @@ export function getCategoryAccountMapping(
expense_office: '5010', // Lokalhyra
expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster
expense_education: '6991', // Övriga avdragsgilla kostnader
expense_education: educationAccount,
expense_bank_fees: '6570', // Bankavgifter
expense_card_fees: '6570', // Kortavgifter
expense_currency_exchange: '7960', // Valutakursförluster
@@ -79,7 +80,7 @@ export function getCategoryAccountMapping(
// Business income categories
const incomeMapping: Record<string, string> = {
income_services: '3001', // Försäljning tjänster 25%
income_products: '3002', // Försäljning varor 25%
income_products: '3001', // Försäljning varor 25% moms
income_other: '3900', // Övriga rörelseintäkter
}
+15 -8
View File
@@ -3,6 +3,7 @@ import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
EntityType,
Invoice,
JournalEntry,
VatTreatment,
@@ -26,7 +27,8 @@ import type {
*/
export async function createInvoiceJournalEntry(
userId: string,
invoice: Invoice
invoice: Invoice,
entityType: EntityType = 'enskild_firma'
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, invoice.invoice_date)
if (!fiscalPeriodId) {
@@ -37,7 +39,7 @@ export async function createInvoiceJournalEntry(
const lines: CreateJournalEntryLineInput[] = []
// Determine revenue account based on VAT treatment
const revenueAccount = getRevenueAccount(invoice.vat_treatment)
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
// Debit: Kundfordringar (total including VAT)
lines.push({
@@ -133,7 +135,8 @@ export async function createInvoicePaymentJournalEntry(
*/
export async function createCreditNoteJournalEntry(
userId: string,
creditNote: Invoice
creditNote: Invoice,
entityType: EntityType = 'enskild_firma'
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, creditNote.invoice_date)
if (!fiscalPeriodId) {
@@ -141,7 +144,7 @@ export async function createCreditNoteJournalEntry(
return null
}
const revenueAccount = getRevenueAccount(creditNote.vat_treatment)
const revenueAccount = getRevenueAccount(creditNote.vat_treatment, entityType)
const absSubtotal = Math.abs(creditNote.subtotal)
const absVat = Math.abs(creditNote.vat_amount)
const absTotal = Math.abs(creditNote.total)
@@ -198,7 +201,8 @@ export async function createCreditNoteJournalEntry(
export async function createInvoiceCashEntry(
userId: string,
invoice: Invoice,
paymentDate: string
paymentDate: string,
entityType: EntityType = 'enskild_firma'
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
if (!fiscalPeriodId) {
@@ -206,7 +210,7 @@ export async function createInvoiceCashEntry(
return null
}
const revenueAccount = getRevenueAccount(invoice.vat_treatment)
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
const lines: CreateJournalEntryLineInput[] = []
// Debit: Företagskonto (total received)
@@ -250,8 +254,11 @@ export async function createInvoiceCashEntry(
/**
* Get the appropriate revenue account based on VAT treatment
*
* For 'exempt': AB uses 3004 (Försäljning inom Sverige, momsfri),
* EF uses 3100 (Momsfria intäkter, mapped to R2 in NE engine).
*/
export function getRevenueAccount(vatTreatment: VatTreatment): string {
export function getRevenueAccount(vatTreatment: VatTreatment, entityType: EntityType = 'enskild_firma'): string {
switch (vatTreatment) {
case 'standard_25':
return '3001' // Försäljning 25%
@@ -264,7 +271,7 @@ export function getRevenueAccount(vatTreatment: VatTreatment): string {
case 'export':
return '3305' // Försäljning tjänst Export
case 'exempt':
return '3100' // Momsfria intäkter
return entityType === 'aktiebolag' ? '3004' : '3100'
default:
return '3001'
}
+7
View File
@@ -7,6 +7,12 @@ import { neBilagaExtension } from '@/extensions/ne-bilaga'
import { aiChatExtension } from '@/extensions/ai-chat'
import type { Extension } from './types'
// ── Enable Banking (PSD2) — opt-in extension ───────────────────────────
// Uncomment the following line to enable automatic PSD2 bank transaction sync.
// Requires ENABLE_BANKING_APP_ID and ENABLE_BANKING_PRIVATE_KEY env vars.
//
// import { enableBankingExtension } from '@/extensions/enable-banking'
/**
* Explicit list of first-party extensions.
*
@@ -20,6 +26,7 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
sruExportExtension,
neBilagaExtension,
aiChatExtension,
// enableBankingExtension, // Uncomment to activate PSD2 bank sync
]
let loaded = false
@@ -0,0 +1,972 @@
/**
* Comprehensive tests for the bank file parser library.
*
* Covers auto-detection, parsing for all Swedish bank formats (Nordea, SEB,
* Swedbank, Handelsbanken), ISO 20022 camt.053 XML, external ID generation,
* file hashing, stats calculation, date range extraction, and edge cases.
*/
import { detectFileFormat, parseBankFile, generateExternalId, generateFileHash, getFormat, getAllFormats } from '../parser'
import type { ParsedBankTransaction, BankFileFormatId } from '../types'
// ---------------------------------------------------------------------------
// Test data — realistic CSV/XML content for each Swedish bank format
// ---------------------------------------------------------------------------
const NORDEA_CSV = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"',
'2024-01-14,ICA MAXI LINDHAGEN,,"-432,50","12 444,67"',
'2024-01-13,LÖNEUTBETALNING,,"25 000,00","12 877,17"',
].join('\n')
const NORDEA_CSV_WITH_RESERVED = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"',
'2024-01-14,Reserverat köp CLAS OHLSON,,"-199,00","12 444,67"',
'2024-01-13,LÖNEUTBETALNING,,"25 000,00","12 643,67"',
].join('\n')
const NORDEA_CSV_SWEDISH_CHARS = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-03-01,GÖTEBORGS HAMNCAFÉ,,"-85,00","5 000,00"',
'2024-03-02,ÅHLENS CITY,,"-249,00","4 751,00"',
'2024-03-03,ÄRLA GÅRD AB,,"1 200,00","5 951,00"',
].join('\n')
const SEB_CSV = [
'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;12345;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;2024-01-14;12346;HEMKÖP FRIDHEMSPLAN;-432,50;12444,67',
'2024-01-13;2024-01-13;12347;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
const SWEDBANK_CSV = [
'Kontouppgifter',
'Clearingnummer,Kontonummer,Datum,Text,Belopp,Saldo',
'8123,12345678,2024-01-15,SPOTIFY AB,-99.00,12345.67',
'8123,12345678,2024-01-14,ICA MAXI,-432.50,12444.67',
'8123,12345678,2024-01-13,LÖNEUTBETALNING,25000.00,12877.17',
].join('\n')
const SWEDBANK_CSV_NO_METADATA = [
'Clearingnummer,Kontonummer,Datum,Text,Belopp,Saldo',
'8123,12345678,2024-02-01,TELIA SVERIGE,-299.00,10000.00',
'8123,12345678,2024-02-02,SKATTEVERKET INBETALNING,5000.00,15000.00',
].join('\n')
const HANDELSBANKEN_CSV = [
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;2024-01-14;HEMKÖP;-432,50;12444,67',
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
const HANDELSBANKEN_CSV_WITH_PREL = [
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;2024-01-14;Prel kortköp CLAS OHLSON;-199,00;12444,67',
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12643,67',
].join('\n')
const CAMT053_XML = `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct><Ccy>SEK</Ccy></Acct>
<Ntry>
<BookgDt><Dt>2024-01-15</Dt></BookgDt>
<Amt Ccy="SEK">99.00</Amt>
<CdtDbtInd>DBIT</CdtDbtInd>
<NtryRef>REF001</NtryRef>
<NtryDtls><TxDtls>
<RmtInf><Ustrd>SPOTIFY AB</Ustrd></RmtInf>
</TxDtls></NtryDtls>
</Ntry>
<Ntry>
<BookgDt><Dt>2024-01-14</Dt></BookgDt>
<Amt Ccy="SEK">25000.00</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<NtryRef>REF002</NtryRef>
<NtryDtls><TxDtls>
<RmtInf><Ustrd>LÖNEUTBETALNING</Ustrd></RmtInf>
</TxDtls></NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`
const CAMT053_XML_WITH_STRUCTURED_REF = `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Ntry>
<BookgDt><Dt>2024-02-01</Dt></BookgDt>
<Amt Ccy="SEK">1500.00</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<NtryRef>REF100</NtryRef>
<NtryDtls><TxDtls>
<RmtInf>
<Strd><CdtrRefInf><Ref>OCR123456789</Ref></CdtrRefInf></Strd>
<Ustrd>Betalning faktura 1001</Ustrd>
</RmtInf>
</TxDtls></NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`
const UNKNOWN_CSV = [
'id,name,value,timestamp',
'1,Widget A,100,2024-01-15T10:00:00',
'2,Widget B,200,2024-01-16T11:00:00',
].join('\n')
const EMPTY_FILE = ''
const HEADER_ONLY_NORDEA = 'Datum,Transaktion,Kategori,Belopp,Saldo\n'
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('detectFileFormat', () => {
it('detects Nordea CSV from header keywords', () => {
const format = detectFileFormat(NORDEA_CSV, 'transaktioner.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('nordea')
})
it('detects SEB CSV from semicolon-delimited header with bokföringsdag', () => {
const format = detectFileFormat(SEB_CSV, 'kontoutdrag.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('seb')
})
it('detects Swedbank CSV from clearingnummer header', () => {
const format = detectFileFormat(SWEDBANK_CSV, 'export.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('swedbank')
})
it('detects Swedbank CSV when header is on the first line (no metadata)', () => {
const format = detectFileFormat(SWEDBANK_CSV_NO_METADATA, 'export.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('swedbank')
})
it('detects Handelsbanken CSV from reskontradatum/transaktionsdatum header', () => {
const format = detectFileFormat(HANDELSBANKEN_CSV, 'handelsbanken.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('handelsbanken')
})
it('detects camt.053 XML from namespace and .xml extension', () => {
const format = detectFileFormat(CAMT053_XML, 'statement.xml')
expect(format).not.toBeNull()
expect(format!.id).toBe('camt053')
})
it('detects camt.053 XML when content includes BkToCstmrStmt tag', () => {
const xmlContent = '<?xml version="1.0"?><Document><BkToCstmrStmt></BkToCstmrStmt></Document>'
const format = detectFileFormat(xmlContent, 'data.xml')
expect(format).not.toBeNull()
expect(format!.id).toBe('camt053')
})
it('does not detect camt.053 without .xml extension', () => {
// camt053 detection requires .xml extension
const format = detectFileFormat(CAMT053_XML, 'statement.csv')
// It should not match camt053 since extension is .csv
// But it could match something else if the content resembles a CSV header
// The important check is that it does NOT return camt053
if (format) {
expect(format.id).not.toBe('camt053')
}
})
it('returns null for unrecognized CSV content', () => {
const format = detectFileFormat(UNKNOWN_CSV, 'data.csv')
expect(format).toBeNull()
})
it('returns null for empty content', () => {
const format = detectFileFormat(EMPTY_FILE, 'empty.csv')
expect(format).toBeNull()
})
it('generic_csv format never auto-detects', () => {
// Even with simple CSV content, generic should not be picked
const simpleCSV = 'date,description,amount\n2024-01-15,Test,-100'
const format = detectFileFormat(simpleCSV, 'test.csv')
if (format) {
expect(format.id).not.toBe('generic_csv')
}
})
it('is case-insensitive on header detection', () => {
const upperNordea = 'DATUM,TRANSAKTION,KATEGORI,BELOPP,SALDO\n2024-01-15,Test,,"-100,00","5000,00"'
const format = detectFileFormat(upperNordea, 'test.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('nordea')
})
})
describe('parseBankFile — Nordea format', () => {
it('parses comma-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
expect(result.format).toBe('nordea')
expect(result.format_name).toBe('Nordea')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses negative amounts with comma decimal and space thousands', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
const spotify = result.transactions[0]
expect(spotify.amount).toBe(-99)
expect(spotify.description).toBe('SPOTIFY AB')
expect(spotify.date).toBe('2024-01-15')
expect(spotify.currency).toBe('SEK')
const ica = result.transactions[1]
expect(ica.amount).toBe(-432.5)
})
it('correctly parses positive amounts with space thousands separator', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
const salary = result.transactions[2]
expect(salary.amount).toBe(25000)
expect(salary.description).toBe('LÖNEUTBETALNING')
})
it('parses balance field with space thousands separator', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
const spotify = result.transactions[0]
expect(spotify.balance).toBe(12345.67)
})
it('filters out "Reserverat" (pending) transactions', () => {
const result = parseBankFile(NORDEA_CSV_WITH_RESERVED, 'nordea.csv')
expect(result.transactions).toHaveLength(2)
expect(result.stats.skipped_rows).toBe(1)
const descriptions = result.transactions.map((t) => t.description)
expect(descriptions).not.toContain(expect.stringContaining('Reserverat'))
})
it('handles Swedish characters (a-ring, a-diaeresis, o-diaeresis)', () => {
const result = parseBankFile(NORDEA_CSV_SWEDISH_CHARS, 'nordea.csv')
expect(result.transactions).toHaveLength(3)
expect(result.transactions[0].description).toBe('GÖTEBORGS HAMNCAFÉ')
expect(result.transactions[1].description).toBe('ÅHLENS CITY')
expect(result.transactions[2].description).toBe('ÄRLA GÅRD AB')
})
it('stores raw_line for each transaction', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
result.transactions.forEach((tx) => {
expect(tx.raw_line).toBeDefined()
expect(tx.raw_line!.length).toBeGreaterThan(0)
})
})
it('handles header-only file with no data rows', () => {
const result = parseBankFile(HEADER_ONLY_NORDEA, 'nordea.csv')
expect(result.format).toBe('nordea')
expect(result.transactions).toHaveLength(0)
expect(result.date_from).toBeNull()
expect(result.date_to).toBeNull()
expect(result.stats.parsed_rows).toBe(0)
})
})
describe('parseBankFile — SEB format', () => {
it('parses semicolon-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(SEB_CSV, 'seb.csv')
expect(result.format).toBe('seb')
expect(result.format_name).toBe('SEB')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly extracts columns using dynamic header mapping', () => {
const result = parseBankFile(SEB_CSV, 'seb.csv')
const spotify = result.transactions[0]
expect(spotify.date).toBe('2024-01-15')
expect(spotify.description).toBe('SPOTIFY AB')
expect(spotify.amount).toBe(-99)
expect(spotify.balance).toBe(12345.67)
})
it('parses positive income amounts correctly', () => {
const result = parseBankFile(SEB_CSV, 'seb.csv')
const salary = result.transactions[2]
expect(salary.amount).toBe(25000)
expect(salary.description).toBe('LÖNEUTBETALNING')
})
it('handles alternative SEB header names', () => {
const altSEB = [
'Bokforingsdatum;Valutadag;Verifikationsnummer;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;12345;TEST;-50,00;1000,00',
].join('\n')
const result = parseBankFile(altSEB, 'seb_alt.csv')
expect(result.format).toBe('seb')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].amount).toBe(-50)
})
})
describe('parseBankFile — Swedbank format', () => {
it('parses comma-delimited CSV with PERIOD decimal separator', () => {
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
expect(result.format).toBe('swedbank')
expect(result.format_name).toBe('Swedbank')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly handles period decimal separator (the Swedish exception)', () => {
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
const spotify = result.transactions[0]
expect(spotify.amount).toBe(-99)
expect(spotify.balance).toBe(12345.67)
const ica = result.transactions[1]
expect(ica.amount).toBe(-432.5)
})
it('skips metadata line when present (first line is account info)', () => {
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
// With metadata line, there are 3 data rows after headerLineIdx=1
expect(result.transactions).toHaveLength(3)
// No transaction should have "Kontouppgifter" as description
const descriptions = result.transactions.map((t) => t.description)
expect(descriptions).not.toContain('Kontouppgifter')
})
it('works when header is on the first line (no metadata)', () => {
const result = parseBankFile(SWEDBANK_CSV_NO_METADATA, 'swedbank.csv')
expect(result.format).toBe('swedbank')
expect(result.transactions).toHaveLength(2)
expect(result.transactions[0].amount).toBe(-299)
expect(result.transactions[1].amount).toBe(5000)
})
it('extracts dates correctly', () => {
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[2].date).toBe('2024-01-13')
})
})
describe('parseBankFile — Handelsbanken format', () => {
it('parses semicolon-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv')
expect(result.format).toBe('handelsbanken')
expect(result.format_name).toBe('Handelsbanken')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses amounts and balances', () => {
const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].balance).toBe(12345.67)
expect(result.transactions[2].amount).toBe(25000)
})
it('filters out "Prel" (preliminary) transactions', () => {
const result = parseBankFile(HANDELSBANKEN_CSV_WITH_PREL, 'handelsbanken.csv')
expect(result.transactions).toHaveLength(2)
expect(result.stats.skipped_rows).toBe(1)
const descriptions = result.transactions.map((t) => t.description)
expect(descriptions).not.toContain(expect.stringContaining('Prel'))
})
it('prefers transaktionsdatum over reskontradatum when both are present', () => {
// Handelsbanken has both columns; transaktionsdatum should be used
const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv')
// In our test data both dates are the same, but verify it selects dates properly
expect(result.transactions[0].date).toBe('2024-01-15')
})
it('uses transaktionsdatum as the primary date field', () => {
// Create data where reskontradatum differs from transaktionsdatum
const diffDates = [
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
'2024-01-16;2024-01-15;PURCHASE;-100,00;5000,00',
].join('\n')
const result = parseBankFile(diffDates, 'shb.csv')
expect(result.transactions[0].date).toBe('2024-01-15')
})
})
describe('parseBankFile — camt.053 XML format', () => {
it('parses XML with credit and debit entries', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
expect(result.format).toBe('camt053')
expect(result.format_name).toBe('ISO 20022 camt.053')
expect(result.transactions).toHaveLength(2)
})
it('applies DBIT indicator as negative amount', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
const debit = result.transactions.find((t) => t.description === 'SPOTIFY AB')
expect(debit).toBeDefined()
expect(debit!.amount).toBe(-99)
})
it('applies CRDT indicator as positive amount', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
const credit = result.transactions.find((t) => t.description === 'LÖNEUTBETALNING')
expect(credit).toBeDefined()
expect(credit!.amount).toBe(25000)
})
it('extracts entry reference into raw_line for external ID generation', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
const debit = result.transactions[0]
expect(debit.raw_line).toBe('REF001')
})
it('extracts OCR reference from structured remittance info', () => {
const result = parseBankFile(CAMT053_XML_WITH_STRUCTURED_REF, 'statement.xml')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].reference).toBe('OCR123456789')
})
it('uses unstructured remittance info as description', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
expect(result.transactions[0].description).toBe('SPOTIFY AB')
})
it('extracts currency from Amount element', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
result.transactions.forEach((tx) => {
expect(tx.currency).toBe('SEK')
})
})
it('handles XML with no Ntry elements', () => {
const emptyXml = `<?xml version="1.0"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt><Stmt></Stmt></BkToCstmrStmt></Document>`
const result = parseBankFile(emptyXml, 'empty.xml')
expect(result.format).toBe('camt053')
expect(result.transactions).toHaveLength(0)
expect(result.issues.length).toBeGreaterThan(0)
expect(result.issues[0].message).toContain('No <Ntry> elements')
})
})
describe('parseBankFile — explicit format override', () => {
it('uses the specified format instead of auto-detection', () => {
// Force parsing Nordea content as SEB (will produce issues but should use SEB format)
const result = parseBankFile(
'Datum,Transaktion,Kategori,Belopp,Saldo\n2024-01-15,Test,,"-100,00","5000,00"',
'nordea.csv',
'seb'
)
expect(result.format).toBe('seb')
})
it('returns error for unknown formatId', () => {
const result = parseBankFile(NORDEA_CSV, 'test.csv', 'unknown_format' as BankFileFormatId)
expect(result.format).toBe('unknown_format')
expect(result.format_name).toBe('Unknown')
expect(result.transactions).toHaveLength(0)
expect(result.issues).toHaveLength(1)
expect(result.issues[0].severity).toBe('error')
expect(result.issues[0].message).toContain('Unknown format')
})
it('returns format detection error when no format matches and no override given', () => {
const result = parseBankFile(UNKNOWN_CSV, 'unknown.csv')
expect(result.format).toBe('generic_csv')
expect(result.format_name).toBe('Unknown')
expect(result.transactions).toHaveLength(0)
expect(result.issues).toHaveLength(1)
expect(result.issues[0].message).toContain('Could not auto-detect')
})
it('can force generic_csv format by explicit ID', () => {
const csvContent = '2024-01-15,Some purchase,-50.00\n2024-01-16,Income,1000.00'
const result = parseBankFile(csvContent, 'test.csv', 'generic_csv')
// generic_csv uses a default mapping (date=0, description=1, amount=2)
// But the first line is treated as header (skip_rows=1), so only second row is data
expect(result.format).toBe('generic_csv')
})
})
describe('generateExternalId', () => {
const baseTx: ParsedBankTransaction = {
date: '2024-01-15',
description: 'SPOTIFY AB',
amount: -99,
currency: 'SEK',
balance: 12345.67,
reference: null,
counterparty: null,
raw_line: '2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"',
}
it('generates SHA-256 composite key for CSV formats', () => {
const id = generateExternalId(baseTx, 'nordea', 0)
expect(id).toMatch(/^nordea_[0-9a-f]{16}$/)
})
it('generates different IDs for different row indices (same transaction data)', () => {
const id1 = generateExternalId(baseTx, 'nordea', 0)
const id2 = generateExternalId(baseTx, 'nordea', 1)
expect(id1).not.toBe(id2)
})
it('generates different IDs for different formats (same data, same row index)', () => {
const nordeaId = generateExternalId(baseTx, 'nordea', 0)
const sebId = generateExternalId(baseTx, 'seb', 0)
expect(nordeaId).not.toBe(sebId)
})
it('generates deterministic IDs for the same inputs', () => {
const id1 = generateExternalId(baseTx, 'nordea', 0)
const id2 = generateExternalId(baseTx, 'nordea', 0)
expect(id1).toBe(id2)
})
it('uses entry reference for camt.053 transactions with NtryRef', () => {
const camtTx: ParsedBankTransaction = {
date: '2024-01-15',
description: 'SPOTIFY AB',
amount: -99,
currency: 'SEK',
raw_line: 'REF001', // NtryRef stored in raw_line
}
const id = generateExternalId(camtTx, 'camt053', 0)
expect(id).toBe('camt053_REF001')
})
it('falls back to hash for camt.053 when raw_line starts with camt053_entry_', () => {
const camtTx: ParsedBankTransaction = {
date: '2024-01-15',
description: 'SPOTIFY AB',
amount: -99,
currency: 'SEK',
raw_line: 'camt053_entry_0', // Auto-generated fallback reference
}
const id = generateExternalId(camtTx, 'camt053', 0)
// Should fall through to hash-based ID since raw_line starts with 'camt053_entry_'
expect(id).toMatch(/^camt053_[0-9a-f]{16}$/)
})
it('falls back to hash for camt.053 when raw_line is undefined', () => {
const camtTx: ParsedBankTransaction = {
date: '2024-01-15',
description: 'SPOTIFY AB',
amount: -99,
currency: 'SEK',
}
const id = generateExternalId(camtTx, 'camt053', 0)
expect(id).toMatch(/^camt053_[0-9a-f]{16}$/)
})
it('includes amount in hash so different amounts produce different IDs', () => {
const tx1 = { ...baseTx, amount: -99 }
const tx2 = { ...baseTx, amount: -100 }
const id1 = generateExternalId(tx1, 'nordea', 0)
const id2 = generateExternalId(tx2, 'nordea', 0)
expect(id1).not.toBe(id2)
})
it('includes description in hash so different descriptions produce different IDs', () => {
const tx1 = { ...baseTx, description: 'SPOTIFY AB' }
const tx2 = { ...baseTx, description: 'NETFLIX' }
const id1 = generateExternalId(tx1, 'nordea', 0)
const id2 = generateExternalId(tx2, 'nordea', 0)
expect(id1).not.toBe(id2)
})
})
describe('generateFileHash', () => {
it('returns a SHA-256 hex string', () => {
const hash = generateFileHash(NORDEA_CSV)
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('produces deterministic output for the same input', () => {
const hash1 = generateFileHash(NORDEA_CSV)
const hash2 = generateFileHash(NORDEA_CSV)
expect(hash1).toBe(hash2)
})
it('produces different hashes for different content', () => {
const hash1 = generateFileHash(NORDEA_CSV)
const hash2 = generateFileHash(SEB_CSV)
expect(hash1).not.toBe(hash2)
})
it('produces different hash even for tiny content differences', () => {
const hash1 = generateFileHash('abc')
const hash2 = generateFileHash('abd')
expect(hash1).not.toBe(hash2)
})
it('handles empty string', () => {
const hash = generateFileHash('')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
})
describe('stats calculation', () => {
it('calculates total_income as sum of positive amounts (Nordea)', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
expect(result.stats.total_income).toBe(25000)
})
it('calculates total_expenses as sum of negative amounts (Nordea)', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
// -99 + -432.5 = -531.5
expect(result.stats.total_expenses).toBe(-531.5)
})
it('calculates parsed_rows correctly', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
expect(result.stats.parsed_rows).toBe(3)
})
it('calculates total_rows (excluding header)', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
// 4 lines total - 1 header = 3 data rows
expect(result.stats.total_rows).toBe(3)
})
it('tracks skipped_rows for reserved/preliminary transactions', () => {
const result = parseBankFile(NORDEA_CSV_WITH_RESERVED, 'nordea.csv')
expect(result.stats.skipped_rows).toBe(1)
expect(result.stats.parsed_rows).toBe(2)
})
it('calculates stats correctly for SEB format', () => {
const result = parseBankFile(SEB_CSV, 'seb.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
expect(result.stats.skipped_rows).toBe(0)
})
it('calculates stats correctly for Swedbank format', () => {
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('calculates stats correctly for camt.053 XML', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-99)
expect(result.stats.parsed_rows).toBe(2)
expect(result.stats.total_rows).toBe(2)
})
it('uses Math.round(x * 100) / 100 for monetary precision', () => {
// Create a file that would produce floating point imprecision
const precisionCSV = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-01,TX1,,"-0,10","100,00"',
'2024-01-02,TX2,,"-0,20","99,90"',
'2024-01-03,TX3,,"-0,30","99,70"',
].join('\n')
const result = parseBankFile(precisionCSV, 'precision.csv')
// 0.1 + 0.2 + 0.3 = 0.6000000000000001 without rounding
// With Math.round(x * 100) / 100, it should be -0.6
expect(result.stats.total_expenses).toBe(-0.6)
})
})
describe('date range extraction', () => {
it('sets date_from to the earliest date', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
expect(result.date_from).toBe('2024-01-13')
})
it('sets date_to to the latest date', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
expect(result.date_to).toBe('2024-01-15')
})
it('returns null dates for empty transaction set', () => {
const result = parseBankFile(HEADER_ONLY_NORDEA, 'nordea.csv')
expect(result.date_from).toBeNull()
expect(result.date_to).toBeNull()
})
it('handles single-transaction file (date_from equals date_to)', () => {
const singleRow = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-06-15,ENSKILD BETALNING,,"-500,00","10 000,00"',
].join('\n')
const result = parseBankFile(singleRow, 'single.csv')
expect(result.date_from).toBe('2024-06-15')
expect(result.date_to).toBe('2024-06-15')
})
it('calculates correct date range for camt.053', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
expect(result.date_from).toBe('2024-01-14')
expect(result.date_to).toBe('2024-01-15')
})
it('sorts dates lexicographically (YYYY-MM-DD is naturally sortable)', () => {
const multiMonth = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-12-31,DEC TX,,"-10,00","1000,00"',
'2024-01-01,JAN TX,,"-20,00","990,00"',
'2024-06-15,JUN TX,,"-30,00","960,00"',
].join('\n')
const result = parseBankFile(multiMonth, 'multimonth.csv')
expect(result.date_from).toBe('2024-01-01')
expect(result.date_to).toBe('2024-12-31')
})
})
describe('empty file handling', () => {
it('returns error result for completely empty file (no auto-detect match)', () => {
const result = parseBankFile(EMPTY_FILE, 'empty.csv')
expect(result.transactions).toHaveLength(0)
expect(result.issues.length).toBeGreaterThan(0)
expect(result.date_from).toBeNull()
expect(result.date_to).toBeNull()
expect(result.stats.parsed_rows).toBe(0)
})
it('returns zero transactions for file with only whitespace', () => {
const whitespace = ' \n \n '
const result = parseBankFile(whitespace, 'blank.csv')
expect(result.transactions).toHaveLength(0)
})
it('returns zero transactions for Nordea header-only file', () => {
const result = parseBankFile(HEADER_ONLY_NORDEA, 'nordea.csv')
expect(result.format).toBe('nordea')
expect(result.transactions).toHaveLength(0)
expect(result.stats.total_rows).toBe(0)
})
})
describe('getFormat and getAllFormats', () => {
it('getFormat returns the correct format by ID', () => {
const nordea = getFormat('nordea')
expect(nordea).toBeDefined()
expect(nordea!.id).toBe('nordea')
expect(nordea!.name).toBe('Nordea')
const seb = getFormat('seb')
expect(seb).toBeDefined()
expect(seb!.id).toBe('seb')
})
it('getFormat returns undefined for unknown ID', () => {
const unknown = getFormat('nonexistent' as BankFileFormatId)
expect(unknown).toBeUndefined()
})
it('getAllFormats returns all registered formats', () => {
const formats = getAllFormats()
expect(formats.length).toBeGreaterThanOrEqual(6)
const ids = formats.map((f) => f.id)
expect(ids).toContain('nordea')
expect(ids).toContain('seb')
expect(ids).toContain('swedbank')
expect(ids).toContain('handelsbanken')
expect(ids).toContain('camt053')
expect(ids).toContain('generic_csv')
})
it('camt053 is listed before bank-specific CSV formats (detection priority)', () => {
const formats = getAllFormats()
const camtIdx = formats.findIndex((f) => f.id === 'camt053')
const nordeaIdx = formats.findIndex((f) => f.id === 'nordea')
expect(camtIdx).toBeLessThan(nordeaIdx)
})
it('generic_csv is listed last (manual fallback only)', () => {
const formats = getAllFormats()
const genericIdx = formats.findIndex((f) => f.id === 'generic_csv')
expect(genericIdx).toBe(formats.length - 1)
})
})
describe('edge cases and robustness', () => {
it('handles Windows-style line endings (CRLF)', () => {
const crlfContent = 'Datum,Transaktion,Kategori,Belopp,Saldo\r\n2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"\r\n'
const result = parseBankFile(crlfContent, 'nordea.csv')
expect(result.format).toBe('nordea')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].amount).toBe(-99)
})
it('handles BOM (Byte Order Mark) prefix', () => {
const bomContent = '\uFEFF' + NORDEA_CSV
const result = parseBankFile(bomContent, 'nordea.csv')
expect(result.format).toBe('nordea')
expect(result.transactions).toHaveLength(3)
})
it('handles rows with invalid dates gracefully', () => {
const invalidDate = [
'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo',
'not-a-date;2024-01-15;12345;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;2024-01-14;12346;VALID TX;-50,00;12395,67',
].join('\n')
const result = parseBankFile(invalidDate, 'seb.csv')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].description).toBe('VALID TX')
expect(result.issues.length).toBeGreaterThan(0)
expect(result.stats.skipped_rows).toBe(1)
})
it('handles rows with invalid amounts gracefully', () => {
const invalidAmount = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-15,SPOTIFY AB,,"abc","12 345,67"',
'2024-01-14,VALID TX,,"-50,00","12 395,67"',
].join('\n')
const result = parseBankFile(invalidAmount, 'nordea.csv')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].description).toBe('VALID TX')
expect(result.stats.skipped_rows).toBe(1)
})
it('handles trailing blank lines', () => {
const trailing = NORDEA_CSV + '\n\n\n'
const result = parseBankFile(trailing, 'nordea.csv')
expect(result.transactions).toHaveLength(3)
})
it('handles Handelsbanken CSV with only reskontradatum (no transaktionsdatum)', () => {
const onlyReskontra = [
'Reskontradatum;Text;Belopp;Saldo',
'2024-01-15;SPOTIFY AB;-99,00;12345,67',
].join('\n')
const format = detectFileFormat(onlyReskontra, 'shb.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('handelsbanken')
})
it('handles large amounts without overflow', () => {
const largeAmounts = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-15,BIG TRANSFER,,"1 500 000,00","2 000 000,00"',
].join('\n')
const result = parseBankFile(largeAmounts, 'nordea.csv')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].amount).toBe(1500000)
expect(result.transactions[0].balance).toBe(2000000)
})
it('handles zero amounts', () => {
const zeroAmount = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-15,FEE REVERSAL,,"0,00","5 000,00"',
].join('\n')
const result = parseBankFile(zeroAmount, 'nordea.csv')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].amount).toBe(0)
})
})
+87
View File
@@ -0,0 +1,87 @@
/**
* Encoding detection and conversion for Swedish bank files.
*
* Swedish bank exports use either UTF-8 or Windows-1252 (ISO-8859-1).
* We detect encoding by checking for valid Swedish characters.
*/
/**
* Decode file content, handling both UTF-8 and Windows-1252 encodings.
*
* Strategy: Try UTF-8 first. If the result contains replacement characters
* (U+FFFD) or garbled Swedish chars, fall back to Windows-1252.
*/
export function decodeFileContent(buffer: ArrayBuffer): string {
// Try UTF-8 first
const utf8Decoder = new TextDecoder('utf-8', { fatal: false })
const utf8Result = utf8Decoder.decode(buffer)
// Check if UTF-8 decode produced valid Swedish text
if (!hasEncodingIssues(utf8Result)) {
return utf8Result
}
// Fall back to Windows-1252 (superset of ISO-8859-1)
const latin1Decoder = new TextDecoder('windows-1252', { fatal: false })
return latin1Decoder.decode(buffer)
}
/**
* Decode a string that may have been incorrectly decoded as UTF-8
* when the source was actually Windows-1252.
*/
export function decodeStringContent(content: string): string {
// If the string already contains valid Swedish chars, return as-is
if (!hasEncodingIssues(content)) {
return content
}
// Try re-encoding as Latin-1 and decoding as Windows-1252
try {
const bytes = new Uint8Array(content.length)
for (let i = 0; i < content.length; i++) {
bytes[i] = content.charCodeAt(i) & 0xff
}
const decoder = new TextDecoder('windows-1252', { fatal: false })
return decoder.decode(bytes)
} catch {
return content
}
}
/**
* Check if a string has encoding issues (garbled Swedish characters).
*/
function hasEncodingIssues(text: string): boolean {
// U+FFFD = replacement character (means invalid UTF-8 byte sequences)
if (text.includes('\uFFFD')) return true
// Common garbled patterns when Windows-1252 is read as UTF-8:
// Ã¥ = å, ä = ä, ö = ö, Ã… = Å, Ä = Ä, Ö = Ö
const garbledPatterns = ['Ã¥', 'ä', 'ö', 'Ã\u0085', 'Ã\u0084', 'Ã\u0096']
return garbledPatterns.some((pattern) => text.includes(pattern))
}
/**
* Normalize line endings to \n
*/
export function normalizeLineEndings(content: string): string {
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
/**
* Strip BOM (Byte Order Mark) from start of content
*/
export function stripBOM(content: string): string {
if (content.charCodeAt(0) === 0xfeff) {
return content.slice(1)
}
return content
}
/**
* Prepare file content for parsing: strip BOM, normalize line endings, handle encoding
*/
export function prepareContent(content: string): string {
return normalizeLineEndings(stripBOM(decodeStringContent(content)))
}
+208
View File
@@ -0,0 +1,208 @@
/**
* ISO 20022 camt.053 (BankToCustomerStatement) XML parser
*
* This is the EU standard for bank statements, increasingly used by Swedish banks.
* Namespace: urn:iso:std:iso:20022:tech:xsd:camt.053.001.XX (various versions)
*
* Key elements:
* - <BkToCstmrStmt> — root container
* - <Stmt> — one statement per account
* - <Ntry> — individual transaction entries
* - <NtryRef> / <AcctSvcrRef> — unique entry reference (external_id)
* - <CdtDbtInd> — CRDT/DBIT indicator
* - <RmtInf><Strd><CdtrRefInf> — structured remittance (OCR/Bankgiro reference)
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
export const camt053Format: BankFileFormat = {
id: 'camt053',
name: 'ISO 20022 camt.053',
description: 'ISO 20022 BankToCustomerStatement (XML)',
fileExtensions: ['.xml'],
detect(content: string, filename: string): boolean {
// Check for XML with camt.053 namespace
if (filename.toLowerCase().endsWith('.xml')) {
const lower = content.toLowerCase()
return (
lower.includes('camt.053') ||
lower.includes('bktocstmrstmt') ||
lower.includes('banktoCustomerstatement'.toLowerCase())
)
}
return false
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
// Simple XML parsing without external dependencies
// Extract <Ntry> elements
const entries = extractElements(prepared, 'Ntry')
if (entries.length === 0) {
issues.push({
row: 0,
message: 'No <Ntry> elements found in camt.053 file',
severity: 'error',
})
}
// Try to extract currency from statement level
const stmtCcy = extractTextContent(prepared, 'Ccy') || 'SEK'
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
try {
// Date: <BookgDt><Dt> or <ValDt><Dt>
const bookingDate = extractTextContent(entry, 'BookgDt>.*?<Dt') ||
extractNestedText(entry, 'BookgDt', 'Dt')
const valueDate = extractTextContent(entry, 'ValDt>.*?<Dt') ||
extractNestedText(entry, 'ValDt', 'Dt')
const date = bookingDate || valueDate
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid or missing date in entry ${i}`, severity: 'warning' })
continue
}
// Amount: <Amt Ccy="SEK">1234.56</Amt>
const amountMatch = entry.match(/<Amt[^>]*>([^<]+)<\/Amt>/i)
const amountStr = amountMatch?.[1]
const currencyMatch = entry.match(/<Amt[^>]*Ccy="([^"]+)"[^>]*>/i)
const currency = currencyMatch?.[1] || stmtCcy
if (!amountStr) {
issues.push({ row: i + 1, message: `Missing amount in entry ${i}`, severity: 'warning' })
continue
}
let amount = parseFloat(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
continue
}
// Credit/Debit indicator: <CdtDbtInd>CRDT</CdtDbtInd> or DBIT
const cdtDbtInd = extractTextContent(entry, 'CdtDbtInd')
if (cdtDbtInd === 'DBIT') {
amount = -Math.abs(amount)
} else {
amount = Math.abs(amount)
}
// Description: <AddtlNtryInf> or <RmtInf><Ustrd>
const additionalInfo = extractTextContent(entry, 'AddtlNtryInf')
const unstructuredRemittance = extractTextContent(entry, 'Ustrd')
const description = additionalInfo || unstructuredRemittance || 'Unknown'
// Reference: Try structured remittance info first, then entry reference
const structuredRef = extractTextContent(entry, 'Ref') // Inside <CdtrRefInf><Ref>
const entryRef = extractTextContent(entry, 'NtryRef')
const acctSvcrRef = extractTextContent(entry, 'AcctSvcrRef')
const reference = structuredRef || null
// Counterparty
const creditorName = extractTextContent(entry, 'CdtrNm') ||
extractNestedText(entry, 'Cdtr', 'Nm')
const debtorName = extractTextContent(entry, 'DbtrNm') ||
extractNestedText(entry, 'Dbtr', 'Nm')
const counterparty = cdtDbtInd === 'DBIT' ? creditorName : debtorName
// Balance after entry
const balanceStr = extractTextContent(entry, 'ClsgAvlblAmt') ||
extractTextContent(entry, 'ClsgBookdAmt')
const balance = balanceStr ? parseFloat(balanceStr) : null
transactions.push({
date,
description: description.trim(),
amount: Math.round(amount * 100) / 100,
currency,
balance: balance !== null && !isNaN(balance) ? balance : null,
reference,
counterparty: counterparty?.trim() || null,
raw_line: entryRef || acctSvcrRef || `camt053_entry_${i}`,
})
} catch (err) {
issues.push({
row: i + 1,
message: `Error parsing entry ${i}: ${err instanceof Error ? err.message : 'Unknown'}`,
severity: 'warning',
})
}
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'camt053',
format_name: 'ISO 20022 camt.053',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: entries.length,
parsed_rows: transactions.length,
skipped_rows: entries.length - transactions.length,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
/**
* Extract all occurrences of a named XML element (simple parser, no dependencies)
*/
function extractElements(xml: string, tagName: string): string[] {
const elements: string[] = []
// Match exact tag name (followed by > or whitespace, not a longer tag name)
const regex = new RegExp(`<${tagName}(?=[\\s>/])`, 'gi')
let match
while ((match = regex.exec(xml)) !== null) {
const startIdx = match.index
// Find matching closing tag
const closeTag = `</${tagName}>`
const closeIdx = xml.indexOf(closeTag, startIdx + match[0].length)
if (closeIdx === -1) continue
elements.push(xml.substring(startIdx, closeIdx + closeTag.length))
// Advance regex past this element to avoid re-matching inside it
regex.lastIndex = closeIdx + closeTag.length
}
return elements
}
/**
* Extract text content of the first occurrence of a tag
*/
function extractTextContent(xml: string, tagName: string): string | null {
const regex = new RegExp(`<${tagName}[^>]*>([^<]+)<`, 'i')
const match = xml.match(regex)
return match?.[1]?.trim() || null
}
/**
* Extract text content of a nested tag within a parent tag
*/
function extractNestedText(xml: string, parentTag: string, childTag: string): string | null {
const parentRegex = new RegExp(`<${parentTag}[^>]*>([\\s\\S]*?)<\\/${parentTag}>`, 'i')
const parentMatch = xml.match(parentRegex)
if (!parentMatch) return null
const childRegex = new RegExp(`<${childTag}[^>]*>([^<]+)<`, 'i')
const childMatch = parentMatch[1].match(childRegex)
return childMatch?.[1]?.trim() || null
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Generic CSV format parser
*
* Fallback parser that requires the user to map columns manually.
* Supports configurable delimiter, decimal separator, and column mapping.
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue, GenericCSVColumnMapping } from '../types'
import { prepareContent } from '../encoding'
import { parseCSVLine } from './nordea'
/**
* Parse a generic CSV with user-provided column mapping
*/
export function parseGenericCSV(
content: string,
mapping: GenericCSVColumnMapping
): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Skip configured number of header/metadata rows
const startRow = mapping.skip_rows
for (let i = startRow; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, mapping.delimiter).map((f) =>
f.trim().replace(/^"|"$/g, '')
)
const dateStr = fields[mapping.date]
const description = fields[mapping.description] || 'Unknown'
const amountStr = fields[mapping.amount]
const referenceStr = mapping.reference !== undefined ? fields[mapping.reference] : undefined
const counterpartyStr = mapping.counterparty !== undefined ? fields[mapping.counterparty] : undefined
const balanceStr = mapping.balance !== undefined ? fields[mapping.balance] : undefined
if (!dateStr || !amountStr) {
skippedRows++
continue
}
// Parse amount based on configured decimal separator
let amount: number
if (mapping.decimal_separator === ',') {
amount = parseFloat(amountStr.replace(/\s/g, '').replace(',', '.'))
} else {
amount = parseFloat(amountStr.replace(/\s/g, ''))
}
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
// Parse date - expect YYYY-MM-DD
const date = dateStr.trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date format: ${date} (expected YYYY-MM-DD)`, severity: 'warning' })
skippedRows++
continue
}
let balance: number | null = null
if (balanceStr) {
if (mapping.decimal_separator === ',') {
balance = parseFloat(balanceStr.replace(/\s/g, '').replace(',', '.'))
} else {
balance = parseFloat(balanceStr.replace(/\s/g, ''))
}
if (isNaN(balance)) balance = null
}
transactions.push({
date,
description: description.trim(),
amount,
currency: 'SEK',
balance,
reference: referenceStr?.trim() || null,
counterparty: counterpartyStr?.trim() || null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'generic_csv',
format_name: 'CSV (manuell mappning)',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - startRow,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
}
/**
* Get column headers from a CSV file for the mapping UI
*/
export function getCSVHeaders(content: string, delimiter: string = ','): string[] {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0] || ''
return parseCSVLine(firstLine, delimiter).map((h) => h.trim().replace(/^"|"$/g, ''))
}
/**
* Get a preview of the first few rows of a CSV file
*/
export function getCSVPreview(content: string, delimiter: string = ',', rows: number = 5): string[][] {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
return lines.slice(0, rows).map((line) =>
parseCSVLine(line, delimiter).map((f) => f.trim().replace(/^"|"$/g, ''))
)
}
/**
* Generic CSV format definition (used for format detection)
* Always returns false for detect() since it's a fallback requiring user mapping
*/
export const genericCSVFormat: BankFileFormat = {
id: 'generic_csv',
name: 'CSV (manuell mappning)',
description: 'Generisk CSV-fil med manuell kolumnmappning',
fileExtensions: ['.csv', '.txt'],
detect(_content: string, _filename: string): boolean {
// Generic CSV never auto-detects — it's the manual fallback
return false
},
parse(content: string): BankFileParseResult {
// Default mapping for a basic CSV: date, description, amount
const defaultMapping: GenericCSVColumnMapping = {
date: 0,
description: 1,
amount: 2,
delimiter: ',',
decimal_separator: ',',
skip_rows: 1,
date_format: 'YYYY-MM-DD',
}
return parseGenericCSV(content, defaultMapping)
},
}
@@ -0,0 +1,143 @@
/**
* Handelsbanken CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Columns: Reskontradatum, Transaktionsdatum, Text, Belopp, Saldo
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - Filter rows with "Prel" prefix (preliminary/pending transactions)
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const handelsbankenFormat: BankFileFormat = {
id: 'handelsbanken',
name: 'Handelsbanken',
description: 'Handelsbanken CSV (semicolon-delimited)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
return (
firstLine.includes(';') &&
(firstLine.includes('reskontradatum') || firstLine.includes('transaktionsdatum'))
)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex(
(h) => h.includes('reskontradatum') || h.includes('transaktionsdatum')
)
const txDateIdx = headers.findIndex((h) => h.includes('transaktionsdatum'))
const descIdx = headers.findIndex((h) => h === 'text' || h.includes('beskrivning'))
const amountIdx = headers.findIndex((h) => h.includes('belopp'))
const balanceIdx = headers.findIndex((h) => h.includes('saldo'))
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns',
severity: 'error',
})
return {
format: 'handelsbanken',
format_name: 'Handelsbanken',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
// Prefer transaktionsdatum over reskontradatum if available
const primaryDateIdx = txDateIdx >= 0 ? txDateIdx : dateIdx
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[primaryDateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
// Skip preliminary transactions
if (description?.toLowerCase().startsWith('prel')) {
skippedRows++
continue
}
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
description: (description || 'Unknown').trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'handelsbanken',
format_name: 'Handelsbanken',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Nordea CSV format parser
*
* Format: Comma-delimited, comma decimal separator
* Columns: Datum, Transaktion, Kategori, Belopp, Saldo
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - Skip rows with "Reserverat" in Transaktion (pending transactions)
* - Skip trailing blank lines
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
// Swedish format: "1 234,56" or "-1 234,56"
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const nordeaFormat: BankFileFormat = {
id: 'nordea',
name: 'Nordea',
description: 'Nordea CSV (Datum, Transaktion, Kategori, Belopp, Saldo)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// Nordea header: comma-delimited with "datum", "transaktion", "belopp"
// Must NOT contain semicolons (that would be SEB or Handelsbanken)
return (
!firstLine.includes(';') &&
firstLine.includes('datum') &&
firstLine.includes('transaktion') &&
firstLine.includes('belopp')
)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Skip header row
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
// Parse CSV with comma delimiter
// Handle quoted fields that may contain commas
const fields = parseCSVLine(line, ',')
if (fields.length < 4) {
issues.push({ row: i + 1, message: 'Too few columns', severity: 'warning' })
skippedRows++
continue
}
const [date, description, _category, amountStr, balanceStr] = fields
// Skip reserved/pending transactions
if (description?.toLowerCase().includes('reserverat')) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
// Validate date format
if (!/^\d{4}-\d{2}-\d{2}$/.test(date?.trim())) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date: date.trim(),
description: description?.trim() || 'Unknown',
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'nordea',
format_name: 'Nordea',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
/**
* Parse a CSV line respecting quoted fields
*/
function parseCSVLine(line: string, delimiter: string): string[] {
const fields: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"'
i++ // Skip escaped quote
} else {
inQuotes = !inQuotes
}
} else if (char === delimiter && !inQuotes) {
fields.push(current)
current = ''
} else {
current += char
}
}
fields.push(current)
return fields
}
export { parseCSVLine }
+138
View File
@@ -0,0 +1,138 @@
/**
* SEB CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Columns vary but typically: Bokföringsdag, Valutadag, Verifikationsnummer,
* Text/mottagare, Belopp, Saldo
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const sebFormat: BankFileFormat = {
id: 'seb',
name: 'SEB',
description: 'SEB CSV (semicolon-delimited)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// SEB headers contain "bokföringsdag" or "bokforingsdatum" and use semicolons
return (
firstLine.includes(';') &&
(firstLine.includes('bokföringsdag') ||
firstLine.includes('bokforingsdatum') ||
firstLine.includes('bokföringsdag'))
)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header to find column indices
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
// Find column indices dynamically
const dateIdx = headers.findIndex(
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdatum') || h.includes('bokföringsdag')
)
const descIdx = headers.findIndex(
(h) => h.includes('text') || h.includes('mottagare') || h.includes('beskrivning')
)
const amountIdx = headers.findIndex((h) => h.includes('belopp'))
const balanceIdx = headers.findIndex((h) => h.includes('saldo'))
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (date, amount)',
severity: 'error',
})
return {
format: 'seb',
format_name: 'SEB',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = fields[descIdx >= 0 ? descIdx : dateIdx + 1] || 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
issues.push({ row: i + 1, message: 'Missing required fields', severity: 'warning' })
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
description: description.trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'seb',
format_name: 'SEB',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Swedbank CSV format parser
*
* Format: Comma-delimited, PERIOD decimal separator (exception among Swedish banks!)
* Columns: Clearingnummer, Kontonummer, Datum, Text, Belopp, Saldo, and more (12 columns)
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - First line is metadata (account info), SKIP it
* - Second line is the actual header
* - Uses period as decimal separator (unlike Nordea/SEB/Handelsbanken)
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { parseCSVLine } from './nordea'
export const swedbankFormat: BankFileFormat = {
id: 'swedbank',
name: 'Swedbank',
description: 'Swedbank CSV (comma-delimited, period decimal)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const lines = prepared.split('\n')
// Check first two lines — Swedbank has metadata line, then header
const line1 = lines[0]?.toLowerCase() || ''
const line2 = lines[1]?.toLowerCase() || ''
return (
(line1.includes('clearingnummer') || line2.includes('clearingnummer') ||
line1.includes('radnummer') || line2.includes('radnummer'))
)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Determine where the header is
// Line 0 might be metadata, line 1 might be header
let headerLineIdx = 0
const line0Lower = lines[0]?.toLowerCase() || ''
const line1Lower = lines[1]?.toLowerCase() || ''
if (line1Lower.includes('clearingnummer') || line1Lower.includes('radnummer')) {
headerLineIdx = 1
} else if (line0Lower.includes('clearingnummer') || line0Lower.includes('radnummer')) {
headerLineIdx = 0
}
const headerLine = lines[headerLineIdx] || ''
const headers = parseCSVLine(headerLine, ',').map((h) =>
h.trim().toLowerCase().replace(/"/g, '')
)
// Find column indices
const dateIdx = headers.findIndex((h) => h === 'datum' || h.includes('bokföringsdatum'))
const descIdx = headers.findIndex((h) => h === 'text' || h.includes('beskrivning'))
const amountIdx = headers.findIndex((h) => h === 'belopp')
const balanceIdx = headers.findIndex((h) => h === 'saldo')
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (datum, belopp)',
severity: 'error',
})
return {
format: 'swedbank',
format_name: 'Swedbank',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
// Data starts after header
for (let i = headerLineIdx + 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, ',').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
// Swedbank uses PERIOD decimal separator
const amount = parseFloat(amountStr.replace(/\s/g, ''))
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseFloat(balanceStr.replace(/\s/g, '')) : null
transactions.push({
date,
description: (description || 'Unknown').trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'swedbank',
format_name: 'Swedbank',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - headerLineIdx - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Bank file parser — main entry point
*
* Auto-detects Swedish bank file formats and parses to normalized transactions.
* Supports Nordea, SEB, Swedbank, Handelsbanken CSV and ISO 20022 camt.053 XML.
*/
import * as crypto from 'crypto'
import type { BankFileFormat, BankFileFormatId, BankFileParseResult, ParsedBankTransaction } from './types'
import { nordeaFormat } from './formats/nordea'
import { sebFormat } from './formats/seb'
import { swedbankFormat } from './formats/swedbank'
import { handelsbankenFormat } from './formats/handelsbanken'
import { camt053Format } from './formats/camt053'
import { genericCSVFormat } from './formats/generic-csv'
/**
* Ordered list of format detectors.
* camt.053 first (XML detection is unambiguous), then bank-specific CSV formats.
* Generic CSV is last — it never auto-detects (manual fallback only).
*/
const FORMATS: BankFileFormat[] = [
camt053Format,
nordeaFormat,
sebFormat,
swedbankFormat,
handelsbankenFormat,
genericCSVFormat,
]
/**
* Get a format by its ID
*/
export function getFormat(id: BankFileFormatId): BankFileFormat | undefined {
return FORMATS.find((f) => f.id === id)
}
/**
* Get all available formats
*/
export function getAllFormats(): BankFileFormat[] {
return FORMATS
}
/**
* Auto-detect the bank file format from content and filename
*
* Returns the first matching format, or null if no format matches.
* Uses filename extension as a hint (e.g. .xml for camt.053).
*/
export function detectFileFormat(content: string, filename: string): BankFileFormat | null {
for (const format of FORMATS) {
if (format.detect(content, filename)) {
return format
}
}
return null
}
/**
* Parse a bank file with auto-detection or explicit format
*
* @param content - File content as string (already decoded)
* @param filename - Original filename (used for format detection hints)
* @param formatId - Optional explicit format to use (skips auto-detection)
*/
export function parseBankFile(
content: string,
filename: string,
formatId?: BankFileFormatId
): BankFileParseResult {
let format: BankFileFormat | undefined
if (formatId) {
format = getFormat(formatId)
if (!format) {
return {
format: formatId,
format_name: 'Unknown',
transactions: [],
date_from: null,
date_to: null,
issues: [{ row: 0, message: `Unknown format: ${formatId}`, severity: 'error' }],
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
} else {
format = detectFileFormat(content, filename) || undefined
if (!format) {
return {
format: 'generic_csv',
format_name: 'Unknown',
transactions: [],
date_from: null,
date_to: null,
issues: [{
row: 0,
message: 'Could not auto-detect file format. Please select your bank manually.',
severity: 'error',
}],
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
}
return format.parse(content)
}
/**
* Generate a stable external_id for a parsed bank transaction.
*
* For CSV files: SHA-256 of (format + date + description + amount + row_index)
* For camt.053: Uses the entry reference from the XML if available
*
* Two identical transactions on the same day will get different IDs due to row_index.
*/
export function generateExternalId(
tx: ParsedBankTransaction,
formatId: BankFileFormatId,
rowIndex: number
): string {
// For camt.053, prefer the raw_line which contains the entry reference
if (formatId === 'camt053' && tx.raw_line && !tx.raw_line.startsWith('camt053_entry_')) {
return `camt053_${tx.raw_line}`
}
// For CSV formats, create a composite hash
const composite = `${formatId}|${tx.date}|${tx.description}|${tx.amount}|${rowIndex}`
const hash = crypto.createHash('sha256').update(composite).digest('hex').substring(0, 16)
return `${formatId}_${hash}`
}
/**
* Generate a file hash for dedup of the same file being uploaded twice
*/
export function generateFileHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex')
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Bank file import types
*
* Supports Swedish bank CSV formats (Nordea, SEB, Swedbank, Handelsbanken)
* and ISO 20022 camt.053 XML.
*/
/** Parsed and normalized bank transaction from any file format */
export interface ParsedBankTransaction {
date: string // YYYY-MM-DD
description: string
amount: number // Positive = income, negative = expense
currency: string
balance?: number | null
reference?: string | null // OCR number, Bankgiro reference
counterparty?: string | null
raw_line?: string // Original CSV line for debugging
}
/** Result from parsing a bank file */
export interface BankFileParseResult {
format: BankFileFormatId
format_name: string
transactions: ParsedBankTransaction[]
date_from: string | null
date_to: string | null
issues: BankFileParseIssue[]
stats: {
total_rows: number
parsed_rows: number
skipped_rows: number
total_income: number
total_expenses: number
}
}
/** Issue encountered during parsing */
export interface BankFileParseIssue {
row: number
message: string
severity: 'warning' | 'error'
}
/** Supported bank file format identifiers */
export type BankFileFormatId =
| 'nordea'
| 'seb'
| 'swedbank'
| 'handelsbanken'
| 'generic_csv'
| 'camt053'
/** Format definition with detection and parsing capability */
export interface BankFileFormat {
id: BankFileFormatId
name: string
description: string
fileExtensions: string[]
detect: (content: string, filename: string) => boolean
parse: (content: string) => BankFileParseResult
}
/** Import tracking record stored in DB */
export interface BankFileImport {
id: string
user_id: string
filename: string
file_hash: string
file_format: string
transaction_count: number
imported_count: number
duplicate_count: number
matched_count: number
date_from: string | null
date_to: string | null
status: 'pending' | 'processing' | 'completed' | 'failed'
error_message: string | null
created_at: string
updated_at: string
}
/** Column mapping for generic CSV format */
export interface GenericCSVColumnMapping {
date: number
description: number
amount: number
reference?: number
counterparty?: number
balance?: number
delimiter: string
decimal_separator: ',' | '.'
skip_rows: number
date_format: string // e.g. 'YYYY-MM-DD'
}
+24
View File
@@ -11,6 +11,7 @@ export interface InvoiceMatch {
* Confidence thresholds for invoice matching
*/
const CONFIDENCE = {
OCR_REFERENCE_MATCH: 0.99,
EXACT_AMOUNT_CUSTOMER: 0.95,
EXACT_AMOUNT_ONLY: 0.80,
FUZZY_AMOUNT_CUSTOMER: 0.70,
@@ -144,6 +145,29 @@ export async function findMatchingInvoices(
const matches: InvoiceMatch[] = []
// OCR/Bankgiro reference matching — highest confidence
// Swedish standard: match transaction reference to invoice OCR number
const txReference = (transaction as Transaction & { reference?: string | null }).reference
if (txReference) {
const normalizedRef = txReference.replace(/\s+/g, '')
for (const invoice of invoices) {
// Match against invoice_number (used as OCR reference in Swedish payments)
const invoiceRef = invoice.invoice_number?.replace(/\s+/g, '')
if (invoiceRef && normalizedRef === invoiceRef) {
matches.push({
invoice: invoice as Invoice & { customer?: Customer },
confidence: CONFIDENCE.OCR_REFERENCE_MATCH,
matchReason: `OCR-referens matchar fakturanummer ${invoice.invoice_number}`,
})
}
}
// If we found an OCR match, return immediately (highest possible confidence)
if (matches.length > 0) {
return matches
}
}
for (const invoice of invoices) {
// Currency filter - must match or be SEK equivalent
const currencyMatch =
+477
View File
@@ -0,0 +1,477 @@
/**
* Tests for the generic transaction ingestion pipeline.
*
* Covers deduplication, insert, invoice matching, auto-categorization,
* and result aggregation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ingestTransactions, type RawTransaction } from '../ingest'
import { makeJournalEntry, makeTransaction } from '@/tests/helpers'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock('@/lib/supabase/server')
const mockEvaluateMappingRules = vi.fn()
vi.mock('@/lib/bookkeeping/mapping-engine', () => ({
evaluateMappingRules: (...args: unknown[]) => mockEvaluateMappingRules(...args),
}))
const mockCreateTransactionJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: (...args: unknown[]) =>
mockCreateTransactionJournalEntry(...args),
}))
const mockGetBestInvoiceMatch = vi.fn()
vi.mock('@/lib/invoice/invoice-matching', () => ({
getBestInvoiceMatch: (...args: unknown[]) => mockGetBestInvoiceMatch(...args),
}))
// ---------------------------------------------------------------------------
// Queue-based Supabase mock
// ---------------------------------------------------------------------------
function createQueueMockSupabase() {
const resultQueue: { data: unknown; error: unknown }[] = []
/**
* Push one or more results onto the queue.
* Each awaited Supabase chain pops the next result in FIFO order.
*/
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
for (const r of results) {
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
}
}
const buildChain = (): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const next = resultQueue.shift() ?? { data: null, error: null }
return (resolve: (v: unknown) => void) => resolve(next)
}
return (..._args: unknown[]) => buildChain()
},
}
return new Proxy({}, handler)
}
const supabase = {
from: vi.fn().mockImplementation(() => buildChain()),
rpc: vi.fn().mockImplementation(() => buildChain()),
}
return { supabase, enqueue }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const USER_ID = 'user-1'
function makeRaw(overrides: Partial<RawTransaction> = {}): RawTransaction {
return {
date: '2024-06-15',
description: 'Test transaction',
amount: -250.0,
currency: 'SEK',
external_id: `ext-${Math.random().toString(36).slice(2, 8)}`,
mcc_code: null,
merchant_name: null,
reference: null,
bank_connection_id: null,
import_source: 'test',
...overrides,
}
}
function makeMappingResult(overrides: Record<string, unknown> = {}) {
return {
rule: null,
debit_account: '5410',
credit_account: '1930',
risk_level: 'low',
confidence: 0.9,
requires_review: false,
default_private: false,
vat_lines: [],
description: 'Office supplies',
...overrides,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ingestTransactions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// -----------------------------------------------------------------------
// 1. Successfully imports new transactions
// -----------------------------------------------------------------------
it('imports new transactions when no duplicate exists', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -100 })
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
// Dedup check returns null (no existing row)
enqueue({ data: null, error: null })
// Insert returns the new transaction
enqueue({ data: inserted, error: null })
// evaluateMappingRules will be called but we want low confidence
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
expect(result.errors).toBe(0)
expect(result.transaction_ids).toEqual(['tx-1'])
})
// -----------------------------------------------------------------------
// 2. Detects duplicates
// -----------------------------------------------------------------------
it('detects duplicates via external_id', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw()
// Dedup check returns an existing record
enqueue({ data: { id: 'existing-tx-1' }, error: null })
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
expect(result.transaction_ids).toEqual([])
})
// -----------------------------------------------------------------------
// 3. Counts errors when insert fails
// -----------------------------------------------------------------------
it('counts errors when insert fails', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw()
// Dedup check: no duplicate
enqueue({ data: null, error: null })
// Insert fails
enqueue({ data: null, error: { message: 'DB constraint violation' } })
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.errors).toBe(1)
expect(result.imported).toBe(0)
expect(result.transaction_ids).toEqual([])
})
// -----------------------------------------------------------------------
// 4. Auto-matches invoices for income transactions (amount > 0)
// -----------------------------------------------------------------------
it('auto-matches invoices for income transactions', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: 5000, description: 'Payment received' })
const inserted = makeTransaction({
id: 'tx-income',
amount: 5000,
external_id: raw.external_id,
})
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert returns the new transaction
enqueue({ data: inserted, error: null })
// Invoice match update (supabase.from('transactions').update(...))
enqueue({ data: null, error: null })
// Mapping rules auto-categorization update (if triggered)
enqueue({ data: null, error: null })
mockGetBestInvoiceMatch.mockResolvedValue({
invoice: { id: 'inv-1' },
confidence: 0.95,
matchReason: 'OCR reference match',
})
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.auto_matched_invoices).toBe(1)
expect(mockGetBestInvoiceMatch).toHaveBeenCalledWith(
USER_ID,
expect.objectContaining({ id: 'tx-income' }),
0.50
)
})
// -----------------------------------------------------------------------
// 5. Does not attempt invoice matching for expenses (amount < 0)
// -----------------------------------------------------------------------
it('does not attempt invoice matching for expenses', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -350 })
const inserted = makeTransaction({
id: 'tx-expense',
amount: -350,
external_id: raw.external_id,
})
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.auto_matched_invoices).toBe(0)
expect(mockGetBestInvoiceMatch).not.toHaveBeenCalled()
})
// -----------------------------------------------------------------------
// 6. Auto-categorizes when mapping confidence >= 0.8
// -----------------------------------------------------------------------
it('auto-categorizes when mapping confidence is at least 0.8', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -500, mcc_code: 5411, merchant_name: 'ICA' })
const inserted = makeTransaction({
id: 'tx-cat',
amount: -500,
external_id: raw.external_id,
})
const journalEntry = makeJournalEntry({ id: 'je-1' })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
// Update after journal entry creation
enqueue({ data: null, error: null })
mockEvaluateMappingRules.mockResolvedValue(
makeMappingResult({ confidence: 0.85, requires_review: false })
)
mockCreateTransactionJournalEntry.mockResolvedValue(journalEntry)
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.auto_categorized).toBe(1)
expect(mockCreateTransactionJournalEntry).toHaveBeenCalledWith(
USER_ID,
expect.objectContaining({ id: 'tx-cat' }),
expect.objectContaining({ confidence: 0.85 })
)
})
// -----------------------------------------------------------------------
// 7. Skips auto-categorization when confidence < 0.8
// -----------------------------------------------------------------------
it('skips auto-categorization when confidence is below 0.8', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -200 })
const inserted = makeTransaction({
id: 'tx-lowconf',
amount: -200,
external_id: raw.external_id,
})
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(
makeMappingResult({ confidence: 0.6 })
)
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.auto_categorized).toBe(0)
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
})
// -----------------------------------------------------------------------
// 7b. Skips auto-categorization when requires_review is true
// -----------------------------------------------------------------------
it('skips auto-categorization when requires_review is true even if confidence is high', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -800 })
const inserted = makeTransaction({
id: 'tx-review',
amount: -800,
external_id: raw.external_id,
})
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(
makeMappingResult({ confidence: 0.95, requires_review: true })
)
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.auto_categorized).toBe(0)
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
})
// -----------------------------------------------------------------------
// 8. Returns correct IngestResult totals
// -----------------------------------------------------------------------
it('returns correct IngestResult totals', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw1 = makeRaw({ external_id: 'ext-a', amount: -100 })
const raw2 = makeRaw({ external_id: 'ext-b', amount: -200 })
const inserted1 = makeTransaction({ id: 'tx-a', amount: -100 })
const inserted2 = makeTransaction({ id: 'tx-b', amount: -200 })
// Transaction 1: dedup (no match), insert OK
enqueue({ data: null, error: null })
enqueue({ data: inserted1, error: null })
// Transaction 2: dedup (no match), insert OK
enqueue({ data: null, error: null })
enqueue({ data: inserted2, error: null })
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw1, raw2])
expect(result.imported).toBe(2)
expect(result.duplicates).toBe(0)
expect(result.errors).toBe(0)
expect(result.auto_categorized).toBe(0)
expect(result.auto_matched_invoices).toBe(0)
expect(result.transaction_ids).toEqual(['tx-a', 'tx-b'])
})
// -----------------------------------------------------------------------
// 9. Handles mixed batch (new, duplicates, errors)
// -----------------------------------------------------------------------
it('handles a mixed batch of new transactions, duplicates, and errors', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const rawNew = makeRaw({ external_id: 'ext-new', amount: 3000 })
const rawDup = makeRaw({ external_id: 'ext-dup', amount: -150 })
const rawErr = makeRaw({ external_id: 'ext-err', amount: -75 })
const insertedNew = makeTransaction({
id: 'tx-new',
amount: 3000,
external_id: 'ext-new',
})
// Transaction rawNew: dedup (no match), insert OK
enqueue({ data: null, error: null })
enqueue({ data: insertedNew, error: null })
// Invoice match update for income transaction
enqueue({ data: null, error: null })
// Auto-categorization update
enqueue({ data: null, error: null })
// Transaction rawDup: dedup returns existing record
enqueue({ data: { id: 'existing-dup' }, error: null })
// Transaction rawErr: dedup (no match), insert fails
enqueue({ data: null, error: null })
enqueue({ data: null, error: { message: 'Insert failed' } })
// Income transaction gets an invoice match
mockGetBestInvoiceMatch.mockResolvedValue({
invoice: { id: 'inv-match' },
confidence: 0.95,
matchReason: 'Exact amount match',
})
// Auto-categorization with high confidence
mockEvaluateMappingRules.mockResolvedValue(
makeMappingResult({ confidence: 0.85 })
)
const journalEntry = makeJournalEntry({ id: 'je-mixed' })
mockCreateTransactionJournalEntry.mockResolvedValue(journalEntry)
const result = await ingestTransactions(
supabase as never,
USER_ID,
[rawNew, rawDup, rawErr]
)
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(1)
expect(result.errors).toBe(1)
expect(result.auto_matched_invoices).toBe(1)
expect(result.auto_categorized).toBe(1)
expect(result.transaction_ids).toEqual(['tx-new'])
})
// -----------------------------------------------------------------------
// Edge: empty input array
// -----------------------------------------------------------------------
it('returns zero totals for an empty input array', async () => {
const { supabase } = createQueueMockSupabase()
const result = await ingestTransactions(supabase as never, USER_ID, [])
expect(result).toEqual({
imported: 0,
duplicates: 0,
auto_categorized: 0,
auto_matched_invoices: 0,
errors: 0,
transaction_ids: [],
})
})
// -----------------------------------------------------------------------
// Edge: invoice matching error is non-critical
// -----------------------------------------------------------------------
it('continues processing when invoice matching throws', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: 1000 })
const inserted = makeTransaction({ id: 'tx-inv-err', amount: 1000 })
enqueue({ data: null, error: null })
enqueue({ data: inserted, error: null })
mockGetBestInvoiceMatch.mockRejectedValue(new Error('Network error'))
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
// Should still count as imported even though invoice matching failed
expect(result.imported).toBe(1)
expect(result.auto_matched_invoices).toBe(0)
expect(result.errors).toBe(0)
})
// -----------------------------------------------------------------------
// Edge: auto-categorization error is non-critical
// -----------------------------------------------------------------------
it('continues processing when auto-categorization throws', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -400 })
const inserted = makeTransaction({ id: 'tx-cat-err', amount: -400 })
enqueue({ data: null, error: null })
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockRejectedValue(new Error('Mapping error'))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.auto_categorized).toBe(0)
expect(result.errors).toBe(0)
})
})
+159
View File
@@ -0,0 +1,159 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { getBestInvoiceMatch } from '@/lib/invoice/invoice-matching'
import type { Transaction } from '@/types'
/**
* Normalized transaction input for the generic ingestion pipeline.
* Both file import and PSD2 sync convert to this format before ingesting.
*/
export interface RawTransaction {
date: string
description: string
amount: number
currency: string
external_id: string // dedup key
mcc_code?: number | null
merchant_name?: string | null
reference?: string | null // OCR number, Bankgiro ref, etc.
bank_connection_id?: string | null
import_source?: string // 'csv_nordea', 'camt053', 'enable_banking', etc.
}
export interface IngestResult {
imported: number
duplicates: number
auto_categorized: number
auto_matched_invoices: number
errors: number
transaction_ids: string[]
}
/**
* Generic transaction ingestion pipeline.
*
* Handles:
* 1. Deduplication via external_id
* 2. Insert into transactions table
* 3. OCR/reference-based invoice matching (highest confidence)
* 4. Amount+customer fallback invoice matching
* 5. Mapping rule evaluation for auto-categorization
* 6. Auto-journal-entry creation for high-confidence matches
*
* Used by both bank file import and Enable Banking PSD2 sync.
*/
export async function ingestTransactions(
supabase: SupabaseClient,
userId: string,
rawTransactions: RawTransaction[]
): Promise<IngestResult> {
const result: IngestResult = {
imported: 0,
duplicates: 0,
auto_categorized: 0,
auto_matched_invoices: 0,
errors: 0,
transaction_ids: [],
}
for (const raw of rawTransactions) {
// 1. Check for duplicates via external_id
const { data: existing } = await supabase
.from('transactions')
.select('id')
.eq('user_id', userId)
.eq('external_id', raw.external_id)
.single()
if (existing) {
result.duplicates++
continue
}
// 2. Insert new transaction
const { data: newTransaction, error: insertError } = await supabase
.from('transactions')
.insert({
user_id: userId,
bank_connection_id: raw.bank_connection_id || null,
external_id: raw.external_id,
date: raw.date,
description: raw.description,
amount: raw.amount,
currency: raw.currency,
category: 'uncategorized',
is_business: null,
mcc_code: raw.mcc_code || null,
merchant_name: raw.merchant_name || null,
reference: raw.reference || null,
import_source: raw.import_source || null,
})
.select()
.single()
if (insertError || !newTransaction) {
result.errors++
continue
}
result.imported++
result.transaction_ids.push(newTransaction.id)
// 3. For income transactions, try invoice matching
if (newTransaction.amount > 0) {
try {
// OCR/reference matching is handled inside getBestInvoiceMatch
// (which calls findMatchingInvoices, which now checks references)
const bestMatch = await getBestInvoiceMatch(
userId,
newTransaction as Transaction,
0.50
)
if (bestMatch) {
await supabase
.from('transactions')
.update({ potential_invoice_id: bestMatch.invoice.id })
.eq('id', newTransaction.id)
result.auto_matched_invoices++
}
} catch {
// Non-critical — continue processing
}
}
// 4. Evaluate mapping rules for auto-categorization
try {
const mappingResult = await evaluateMappingRules(
userId,
newTransaction as Transaction
)
if (mappingResult.confidence >= 0.8 && !mappingResult.requires_review) {
const journalEntry = await createTransactionJournalEntry(
userId,
newTransaction as Transaction,
mappingResult
)
if (journalEntry) {
await supabase
.from('transactions')
.update({
journal_entry_id: journalEntry.id,
is_business: !mappingResult.default_private,
})
.eq('id', newTransaction.id)
result.auto_categorized++
}
}
} catch {
// Non-critical — continue processing
}
}
return result
}
@@ -0,0 +1,44 @@
-- Bank file import support
-- Adds import_source and reference to transactions,
-- and a bank_file_imports tracking table
-- Track import origin on transactions
ALTER TABLE public.transactions ADD COLUMN IF NOT EXISTS import_source text;
CREATE INDEX IF NOT EXISTS idx_transactions_import_source ON public.transactions(import_source);
-- Store OCR/Bankgiro reference for Swedish payment matching
ALTER TABLE public.transactions ADD COLUMN IF NOT EXISTS reference text;
CREATE INDEX IF NOT EXISTS idx_transactions_reference ON public.transactions(reference);
-- Bank file import tracking (prevents duplicate file uploads, provides history)
CREATE TABLE public.bank_file_imports (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid REFERENCES auth.users ON DELETE CASCADE NOT NULL,
filename text NOT NULL,
file_hash text NOT NULL,
file_format text NOT NULL,
transaction_count integer NOT NULL DEFAULT 0,
imported_count integer NOT NULL DEFAULT 0,
duplicate_count integer NOT NULL DEFAULT 0,
matched_count integer NOT NULL DEFAULT 0,
date_from date,
date_to date,
status text NOT NULL DEFAULT 'pending',
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, file_hash)
);
ALTER TABLE public.bank_file_imports ENABLE ROW LEVEL SECURITY;
CREATE POLICY "bank_file_imports_select" ON public.bank_file_imports
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "bank_file_imports_insert" ON public.bank_file_imports
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "bank_file_imports_update" ON public.bank_file_imports
FOR UPDATE USING (auth.uid() = user_id);
CREATE TRIGGER bank_file_imports_updated_at
BEFORE UPDATE ON public.bank_file_imports
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+2
View File
@@ -142,6 +142,8 @@ export function makeTransaction(overrides: Partial<Transaction> = {}): Transacti
mcc_code: null,
merchant_name: 'ICA Maxi',
receipt_id: null,
import_source: null,
reference: null,
notes: null,
created_at: '2024-06-15T14:30:00Z',
updated_at: '2024-06-15T14:30:00Z',
+36
View File
@@ -157,6 +157,17 @@ export interface BankAccount {
balance: number | null
}
// Import source identifiers
export type ImportSource =
| 'enable_banking'
| 'csv_nordea'
| 'csv_seb'
| 'csv_swedbank'
| 'csv_handelsbanken'
| 'csv_generic'
| 'camt053'
| 'manual'
// Transaction
export interface Transaction {
id: string
@@ -198,6 +209,10 @@ export interface Transaction {
// Receipt link
receipt_id: string | null
// Import tracking
import_source: string | null
reference: string | null // OCR number, Bankgiro reference
// Notes
notes: string | null
@@ -205,6 +220,27 @@ export interface Transaction {
updated_at: string
}
// Bank File Import (tracking table for file-based imports)
export type BankFileImportStatus = 'pending' | 'processing' | 'completed' | 'failed'
export interface BankFileImport {
id: string
user_id: string
filename: string
file_hash: string
file_format: string
transaction_count: number
imported_count: number
duplicate_count: number
matched_count: number
date_from: string | null
date_to: string | null
status: BankFileImportStatus
error_message: string | null
created_at: string
updated_at: string
}
// Customer
export interface Customer {
id: string
+1 -1
View File
@@ -17,7 +17,7 @@
"schedule": "0 0 2 1 *"
},
{
"path": "/api/banking/sync/cron",
"path": "/api/extensions/enable-banking/sync/cron",
"schedule": "0 5 * * *"
},
{