246 lines
9.3 KiB
JavaScript
246 lines
9.3 KiB
JavaScript
|
|
/**
|
||
|
|
* 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',
|
||
|
|
},
|
||
|
|
};
|