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:
co-authored by
Claude Opus 4.6
parent
226c78a64a
commit
20818e3283
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user