From 20818e3283098e4b19bfe410d1f6c17b13c89e61 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:02:18 +0200 Subject: [PATCH] fix: Nordea Datum CSV variant + bank sync loading screen (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/(dashboard)/settings/banking/page.tsx | 105 +++++++++++++----- lib/import/bank-file/__tests__/parser.test.ts | 76 +++++++++++++ .../bank-file/formats/nordea-business.ts | 33 ++++-- 3 files changed, 180 insertions(+), 34 deletions(-) diff --git a/app/(dashboard)/settings/banking/page.tsx b/app/(dashboard)/settings/banking/page.tsx index 995a6764..776941c6 100644 --- a/app/(dashboard)/settings/banking/page.tsx +++ b/app/(dashboard)/settings/banking/page.tsx @@ -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(null) + const [isSyncing, setIsSyncing] = useState(false) + const [syncResult, setSyncResult] = useState<{ imported: number } | null>(null) + const successTimerRef = useRef>(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 ( +
+ + + {syncResult ? ( + <> +
+ +
+

Bank ansluten!

+

+ {syncResult.imported} transaktioner importerade +

+ + ) : ( + <> + +

Hämtar transaktioner från din bank...

+

+ Detta kan ta upp till en minut +

+ + )} +
+
+
+ ) + } return (
diff --git a/lib/import/bank-file/__tests__/parser.test.ts b/lib/import/bank-file/__tests__/parser.test.ts index 52fa17ed..04c599c7 100644 --- a/lib/import/bank-file/__tests__/parser.test.ts +++ b/lib/import/bank-file/__tests__/parser.test.ts @@ -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') diff --git a/lib/import/bank-file/formats/nordea-business.ts b/lib/import/bank-file/formats/nordea-business.ts index 94bb5818..cbf313d4 100644 --- a/lib/import/bank-file/formats/nordea-business.ts +++ b/lib/import/bank-file/formats/nordea-business.ts @@ -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',