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