Files
boc/aamos-bank-import/parsers.mjs
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

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',
},
};