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
+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>
)
}