/** * AAMOS Bank CSV Parsers * Stöder: Swedbank, SEB, Handelsbanken, Nordea, auto-detect */ /** * Normalisera svenska siffror: "1 500,00" → 1500.00 */ function parseSwedishNumber(str) { if (str == null || str === '') return null; // Ta bort mellanslagsseparatorer, ersätt komma med punkt const cleaned = String(str).trim().replace(/\s/g, '').replace(',', '.'); const val = parseFloat(cleaned); return isNaN(val) ? null : val; } /** * Splitta en CSV-rad med hänsyn till citattecken */ function splitRow(line, delimiter) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (ch === '"') { inQuotes = !inQuotes; } else if (!inQuotes && ch === delimiter) { result.push(current.trim().replace(/^"|"$/g, '')); current = ''; } else { current += ch; } } result.push(current.trim().replace(/^"|"$/g, '')); return result; } /** * Hitta rader (skippa tomma rader) */ function getLines(csv) { return csv.split('\n').map(l => l.replace(/\r$/, '')).filter(l => l.trim() !== ''); } // ── Swedbank ────────────────────────────────────────────────────────────────── // Format: Datum;Transaktion;Kategori;Belopp;Saldo export function parseSwedbank(csv) { const lines = getLines(csv); const headerLine = lines[0]; const headers = splitRow(headerLine, ';').map(h => h.trim()); const iDatum = headers.findIndex(h => /^datum$/i.test(h)); const iTransaktion = headers.findIndex(h => /^transaktion$/i.test(h)); const iBelopp = headers.findIndex(h => /^belopp$/i.test(h)); const iSaldo = headers.findIndex(h => /^saldo$/i.test(h)); if (iDatum === -1 || iBelopp === -1) { throw new Error('Swedbank: saknar kolumnerna Datum och/eller Belopp'); } const rows = []; for (let i = 1; i < lines.length; i++) { const cols = splitRow(lines[i], ';'); const amount = parseSwedishNumber(cols[iBelopp]); if (amount == null) continue; rows.push({ date: (cols[iDatum] || '').trim(), description: (cols[iTransaktion] || '').trim(), amount, balance: iSaldo >= 0 ? parseSwedishNumber(cols[iSaldo]) : null, raw: lines[i], }); } return rows; } // ── SEB ─────────────────────────────────────────────────────────────────────── // Format: Bokföringsdatum,Valutadatum,Namn/Beskrivning,Belopp,Saldo export function parseSEB(csv) { const lines = getLines(csv); const headerLine = lines[0]; const headers = splitRow(headerLine, ',').map(h => h.trim()); const iBokDatum = headers.findIndex(h => /bokf[öo]ringsdatum/i.test(h)); const iNamn = headers.findIndex(h => /namn|beskrivning/i.test(h)); const iBelopp = headers.findIndex(h => /^belopp$/i.test(h)); const iSaldo = headers.findIndex(h => /^saldo$/i.test(h)); if (iBokDatum === -1 || iBelopp === -1) { throw new Error('SEB: saknar kolumnerna Bokföringsdatum och/eller Belopp'); } const rows = []; for (let i = 1; i < lines.length; i++) { const cols = splitRow(lines[i], ','); const amount = parseSwedishNumber(cols[iBelopp]); if (amount == null) continue; rows.push({ date: (cols[iBokDatum] || '').trim(), description: (iNamn >= 0 ? cols[iNamn] : '').trim(), amount, balance: iSaldo >= 0 ? parseSwedishNumber(cols[iSaldo]) : null, raw: lines[i], }); } return rows; } // ── Handelsbanken ───────────────────────────────────────────────────────────── // Format: Datum\tText\tBelopp\tSaldo (tab-separerat) export function parseHandelsbanken(csv) { const lines = getLines(csv); const headerLine = lines[0]; const headers = splitRow(headerLine, '\t').map(h => h.trim()); const iDatum = headers.findIndex(h => /^datum$/i.test(h)); const iText = headers.findIndex(h => /^text$/i.test(h)); const iBelopp = headers.findIndex(h => /^belopp$/i.test(h)); const iSaldo = headers.findIndex(h => /^saldo$/i.test(h)); if (iDatum === -1 || iBelopp === -1) { throw new Error('Handelsbanken: saknar kolumnerna Datum och/eller Belopp'); } const rows = []; for (let i = 1; i < lines.length; i++) { const cols = splitRow(lines[i], '\t'); const amount = parseSwedishNumber(cols[iBelopp]); if (amount == null) continue; rows.push({ date: (cols[iDatum] || '').trim(), description: (iText >= 0 ? cols[iText] : '').trim(), amount, balance: iSaldo >= 0 ? parseSwedishNumber(cols[iSaldo]) : null, raw: lines[i], }); } return rows; } // ── Nordea ──────────────────────────────────────────────────────────────────── // Format: Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo export function parseNordea(csv) { const lines = getLines(csv); const headerLine = lines[0]; const headers = splitRow(headerLine, ';').map(h => h.trim()); const iBokDag = headers.findIndex(h => /bokf[öo]ringsdag/i.test(h)); const iBelopp = headers.findIndex(h => /^belopp$/i.test(h)); const iNamn = headers.findIndex(h => /^namn$/i.test(h)); const iRubrik = headers.findIndex(h => /^rubrik$/i.test(h)); const iSaldo = headers.findIndex(h => /^saldo$/i.test(h)); if (iBokDag === -1 || iBelopp === -1) { throw new Error('Nordea: saknar kolumnerna Bokföringsdag och/eller Belopp'); } const rows = []; for (let i = 1; i < lines.length; i++) { const cols = splitRow(lines[i], ';'); const amount = parseSwedishNumber(cols[iBelopp]); if (amount == null) continue; // Beskrivning: kombinera Namn + Rubrik const namePart = (iNamn >= 0 ? cols[iNamn] : '').trim(); const rubrikPart = (iRubrik >= 0 ? cols[iRubrik] : '').trim(); const description = [namePart, rubrikPart].filter(Boolean).join(' — '); rows.push({ date: (cols[iBokDag] || '').trim(), description, amount, balance: iSaldo >= 0 ? parseSwedishNumber(cols[iSaldo]) : null, raw: lines[i], }); } return rows; } // ── Auto-detect ─────────────────────────────────────────────────────────────── export function detectBank(csv) { const firstLine = getLines(csv)[0] || ''; const lower = firstLine.toLowerCase(); // Handelsbanken: tab-separerad med Datum, Text if (firstLine.includes('\t')) return 'handelsbanken'; // Nordea: semikolon, innehåller "bokföringsdag" if (lower.includes('bokf') && lower.includes('ringsdag') && firstLine.includes(';')) return 'nordea'; // SEB: komma-separerad, innehåller "bokföringsdatum" eller "valutadatum" if ((lower.includes('bokf') && lower.includes('ringsdatum') || lower.includes('valutadatum')) && firstLine.includes(',')) return 'seb'; // Swedbank: semikolon, innehåller "transaktion" if (lower.includes('transaktion') && firstLine.includes(';')) return 'swedbank'; // Fallback: prova utifrån separator if (firstLine.includes(';')) { if (lower.includes('belopp') && lower.includes('saldo')) return 'nordea'; // educated guess return 'swedbank'; } if (firstLine.includes(',')) return 'seb'; return null; } // ── Unified parse ───────────────────────────────────────────────────────────── export function parseCSV(bank, csv) { const resolvedBank = bank === 'auto' ? detectBank(csv) : bank; if (!resolvedBank) throw new Error('Kunde inte auto-detektera bankformat'); switch (resolvedBank.toLowerCase()) { case 'swedbank': return { bank: 'swedbank', rows: parseSwedbank(csv) }; case 'seb': return { bank: 'seb', rows: parseSEB(csv) }; case 'handelsbanken': return { bank: 'handelsbanken', rows: parseHandelsbanken(csv) }; case 'nordea': return { bank: 'nordea', rows: parseNordea(csv) }; default: throw new Error(`Okänt bankformat: ${resolvedBank}`); } } export const SUPPORTED_FORMATS = { swedbank: { delimiter: ';', columns: ['Datum', 'Transaktion', 'Kategori', 'Belopp', 'Saldo'], date_format: 'YYYY-MM-DD', number_format: 'swedish (komma decimal, mellanslagstusentalsseparator)', }, seb: { delimiter: ',', columns: ['Bokföringsdatum', 'Valutadatum', 'Namn/Beskrivning', 'Belopp', 'Saldo'], date_format: 'YYYY-MM-DD', number_format: 'decimal punkt', }, handelsbanken: { delimiter: 'tab', columns: ['Datum', 'Text', 'Belopp', 'Saldo'], date_format: 'YYYY-MM-DD', number_format: 'swedish (komma decimal, mellanslagstusentalsseparator)', }, nordea: { delimiter: ';', columns: ['Bokföringsdag', 'Belopp', 'Avsändare', 'Mottagare', 'Namn', 'Rubrik', 'Saldo'], date_format: 'YYYY-MM-DD', number_format: 'decimal punkt', }, };