fix: Nordea Datum CSV variant + bank sync loading screen (#195)

* fix: support Nordea Datum CSV variant and add bank sync loading screen

Add 4th Nordea Business CSV format variant that uses standalone "Datum"
column header and YYYY/MM/DD date format. Also replace fire-and-forget
bank sync with an awaited flow showing a loading screen after first
bank connection, preventing users from navigating away before sync
completes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback — cleanup timeout, deps, naming

- Clear success setTimeout on unmount via useRef to prevent stale updates
- Add isSyncing to useEffect dependency array for Strict Mode safety
- Rename headers_detect to headersDetect (camelCase consistency)
- Remove redundant toLowerCase() since firstLine is already lowercased

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-08 15:02:18 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 226c78a64a
commit 20818e3283
3 changed files with 180 additions and 34 deletions
+78 -27
View File
@@ -1,12 +1,12 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
import { AlertTriangle, CheckCircle2, CreditCard, ExternalLink, Loader2 } from 'lucide-react'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
@@ -17,38 +17,59 @@ export default function BankingSettingsPage() {
const router = useRouter()
const { toast } = useToast()
const [bankConnectionError, setBankConnectionError] = useState<string | null>(null)
const [isSyncing, setIsSyncing] = useState(false)
const [syncResult, setSyncResult] = useState<{ imported: number } | null>(null)
const successTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
useEffect(() => {
return () => {
if (successTimerRef.current) clearTimeout(successTimerRef.current)
}
}, [])
useEffect(() => {
const bankConnected = searchParams.get('bank_connected')
const bankError = searchParams.get('bank_error')
if (bankConnected === 'true') {
toast({
title: 'Bank ansluten!',
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
})
if (bankConnected === 'true' && !isSyncing) {
const connectionId = searchParams.get('connection_id')
if (connectionId) {
fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId, days_back: 120 }),
})
.then(res => res.json())
.then(data => {
if (data.imported > 0) {
toast({
title: 'Transaktioner hämtade',
description: `${data.imported} transaktioner importerade`,
})
}
})
.catch(() => {})
}
router.replace('/settings/banking')
if (connectionId) {
setIsSyncing(true)
;(async () => {
try {
const res = await fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId, days_back: 120 }),
})
const data = await res.json()
if (res.ok) {
setSyncResult({ imported: data.imported ?? 0 })
successTimerRef.current = setTimeout(() => {
setIsSyncing(false)
setSyncResult(null)
}, 3000)
} else {
throw new Error(data.error || 'Sync failed')
}
} catch (err) {
toast({
title: 'Synkronisering misslyckades',
description: err instanceof Error ? err.message : 'Kunde inte hämta transaktioner',
variant: 'destructive',
})
setIsSyncing(false)
}
})()
} else {
toast({
title: 'Bank ansluten!',
description: 'Din bank är nu kopplad.',
})
}
}
if (bankError) {
@@ -61,7 +82,37 @@ export default function BankingSettingsPage() {
setBankConnectionError(errorMsg)
router.replace('/settings/banking')
}
}, [searchParams, router, toast])
}, [searchParams, router, toast, isSyncing])
if (isSyncing) {
return (
<div className="space-y-6">
<Card className="mx-auto max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
{syncResult ? (
<>
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<CheckCircle2 className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<p className="font-medium text-lg">Bank ansluten!</p>
<p className="mt-1 text-sm text-muted-foreground">
{syncResult.imported} transaktioner importerade
</p>
</>
) : (
<>
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground mb-4" />
<p className="font-medium">Hämtar transaktioner från din bank...</p>
<p className="mt-1 text-sm text-muted-foreground">
Detta kan ta upp till en minut
</p>
</>
)}
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
@@ -203,6 +203,14 @@ const NORDEA_BUSINESS_CSV_VARIANT_B = [
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25 000,00;12 877,17',
].join('\n')
const NORDEA_BUSINESS_CSV_VARIANT_C = [
'Datum;Belopp;Avsändare;Mottagare;Namn;Ytterligare detaljer;Meddelande;Egna anteckningar;Saldo;Valuta;',
'2026/03/02;-18,84;;;;Kortköp 260301 Google Workspace_elv;Google Workspac 7028;;10686,66;SEK;',
'2026/02/04;-1,85;;;;AVGIFTER NORDEA;;;10705,50;SEK;',
'2026/02/02;-87,14;;;;Kortköp 260201 Google Workspace_elv;Google Workspac 7028;;10707,35;SEK;',
'2026/01/15;15000,00;KUNDFÖRETAG AB;;;;Inbetalning;;25707,35;SEK;',
].join('\n')
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -238,6 +246,18 @@ describe('detectFileFormat', () => {
expect(format!.id).toBe('nordea_business')
})
it('detects Nordea Business CSV variant with standalone Datum header and YYYY/MM/DD dates', () => {
const format = detectFileFormat(NORDEA_BUSINESS_CSV_VARIANT_C, 'nordea_ftg.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('nordea_business')
})
it('does not confuse Nordea Datum variant with Länsförsäkringar (which has Datum + Typ)', () => {
// Nordea Format D has Datum without Typ — must not be mistaken for LF
const format = detectFileFormat(NORDEA_BUSINESS_CSV_VARIANT_C, 'export.csv')
expect(format!.id).toBe('nordea_business')
})
it('does not misidentify SEB as Nordea Business when valutadag is present', () => {
const sebLike = 'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo\n2024-01-15;2024-01-15;123;SPOTIFY;-99,00;12345,67'
const format = detectFileFormat(sebLike, 'export.csv')
@@ -596,6 +616,62 @@ describe('parseBankFile — Nordea Business variant B (Bokföringsdatum)', () =>
})
})
describe('parseBankFile — Nordea Business variant C (Datum + YYYY/MM/DD)', () => {
it('parses the Nordea format with Datum header and slash dates', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_C, 'nordea_ftg.csv')
expect(result.format).toBe('nordea_business')
expect(result.transactions).toHaveLength(4)
expect(result.issues).toHaveLength(0)
expect(result.stats.skipped_rows).toBe(0)
})
it('normalizes YYYY/MM/DD dates to YYYY-MM-DD', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_C, 'nordea_ftg.csv')
expect(result.transactions[0].date).toBe('2026-03-02')
expect(result.transactions[1].date).toBe('2026-02-04')
expect(result.transactions[3].date).toBe('2026-01-15')
})
it('builds description from Ytterligare detaljer column', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_C, 'nordea_ftg.csv')
expect(result.transactions[0].description).toBe('Kortköp 260301 Google Workspace_elv')
expect(result.transactions[1].description).toBe('AVGIFTER NORDEA')
})
it('extracts counterparty from Avsändare for income', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_C, 'nordea_ftg.csv')
expect(result.transactions[3].counterparty).toBe('KUNDFÖRETAG AB')
expect(result.transactions[3].amount).toBe(15000)
})
it('parses amounts and balance correctly with comma decimals', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_C, 'nordea_ftg.csv')
expect(result.transactions[0].amount).toBe(-18.84)
expect(result.transactions[0].balance).toBe(10686.66)
expect(result.transactions[0].currency).toBe('SEK')
})
it('handles trailing semicolons in header and data rows', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_C, 'nordea_ftg.csv')
expect(result.stats.parsed_rows).toBe(4)
expect(result.stats.total_income).toBe(15000)
expect(result.stats.total_expenses).toBe(-107.83)
})
it('calculates correct date range', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_C, 'nordea_ftg.csv')
expect(result.date_from).toBe('2026-01-15')
expect(result.date_to).toBe('2026-03-02')
})
})
describe('parseBankFile — SEB format', () => {
it('parses semicolon-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(SEB_CSV, 'seb.csv')
@@ -12,7 +12,11 @@
* Format C (simple): Semicolon-delimited
* Columns: Bokföringsdatum, Valutadatum, Text, Belopp, Saldo
*
* Date format: YYYY-MM-DD
* Format D (Datum variant): Semicolon-delimited, slash dates
* Columns: Datum, Belopp, Avsändare, Mottagare, Namn, Ytterligare detaljer, Meddelande, Egna anteckningar, Saldo, Valuta
* Date format: YYYY/MM/DD (normalized to YYYY-MM-DD)
*
* Date format: YYYY-MM-DD (YYYY/MM/DD also accepted and normalized)
* Encoding: UTF-8 or Windows-1252
*/
@@ -43,7 +47,13 @@ export const nordeaBusinessFormat: BankFileFormat = {
firstLine.includes('bokföringsdatum') ||
firstLine.includes('bokforingsdatum')
if (!hasNordeaDateCol) return false
// Format D: standalone "Datum" column (not reskontradatum/transaktionsdatum)
// Check parsed headers for exact match to avoid false positives with Handelsbanken
// Note: firstLine is already lowercased, so no need for additional toLowerCase()
const headersDetect = firstLine.split(';').map(h => h.replace(/"/g, '').trim())
const hasStandaloneDatum = headersDetect.some(h => h === 'datum')
if (!hasNordeaDateCol && !hasStandaloneDatum) return false
// Exclude SEB (which also has bokföringsdag/bokföringsdatum but adds valutadag/verifikationsnummer)
if (firstLine.includes('valutadag') || firstLine.includes('verifikationsnummer')) return false
@@ -82,10 +92,11 @@ export const nordeaBusinessFormat: BankFileFormat = {
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
// Date column: accept multiple Nordea naming patterns
// Date column: accept multiple Nordea naming patterns (including standalone "datum" for Format D)
const dateIdx = headers.findIndex(
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdag') ||
h.includes('bokföringsdatum') || h.includes('bokforingsdatum')
h.includes('bokföringsdatum') || h.includes('bokforingsdatum') ||
h === 'datum'
)
const amountIdx = headers.findIndex((h) => h === 'belopp' || h.includes('belopp'))
const senderIdx = headers.findIndex((h) => h.includes('avsändare') || h.includes('avsandare'))
@@ -103,6 +114,7 @@ export const nordeaBusinessFormat: BankFileFormat = {
const textIdx = headers.findIndex(
(h) => h === 'text' || h.includes('meddelande') || h.includes('beskrivning')
)
const ytterligareDetaljerIdx = headers.findIndex((h) => h === 'ytterligare detaljer')
const paymentTypeIdx = headers.findIndex((h) => h.includes('betalningstyp'))
const balanceIdx = headers.findIndex((h) => h === 'saldo' || h.includes('saldo'))
const currencyIdx = headers.findIndex((h) => h === 'valuta' || h.includes('valuta'))
@@ -110,7 +122,7 @@ export const nordeaBusinessFormat: BankFileFormat = {
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (Bokföringsdag/Bokföringsdatum, Belopp)',
message: 'Could not identify required columns (Bokföringsdag/Bokföringsdatum/Datum, Belopp)',
severity: 'error',
})
return {
@@ -146,8 +158,11 @@ export const nordeaBusinessFormat: BankFileFormat = {
continue
}
// Normalize YYYY/MM/DD → YYYY-MM-DD (Format D variant)
const normalizedDate = date.includes('/') ? date.replace(/\//g, '-') : date
// Validate date format (YYYY-MM-DD)
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalizedDate)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
@@ -158,11 +173,15 @@ export const nordeaBusinessFormat: BankFileFormat = {
const subject = subjectIdx >= 0 ? fields[subjectIdx]?.trim() : ''
const text = textIdx >= 0 ? fields[textIdx]?.trim() : ''
const paymentType = paymentTypeIdx >= 0 ? fields[paymentTypeIdx]?.trim() : ''
const ytterligareDetaljer = ytterligareDetaljerIdx >= 0 ? fields[ytterligareDetaljerIdx]?.trim() : ''
let description: string
if (name || subject) {
// Classic format: Namn — Rubrik
description = [name, subject].filter(Boolean).join(' — ') || 'Unknown'
} else if (ytterligareDetaljer) {
// Format D: "Ytterligare detaljer" has the full description
description = ytterligareDetaljer
} else if (text) {
// Alternate format: use Text/Meddelande column
description = [paymentType, text].filter(Boolean).join(' — ') || text
@@ -184,7 +203,7 @@ export const nordeaBusinessFormat: BankFileFormat = {
const currency = currencyIdx >= 0 && fields[currencyIdx] ? fields[currencyIdx].trim() : 'SEK'
transactions.push({
date,
date: normalizedDate,
description,
amount,
currency: currency || 'SEK',