/** * Comprehensive tests for the bank file parser library. * * Covers auto-detection, parsing for all Swedish bank formats (Nordea, SEB, * Swedbank, Handelsbanken), ISO 20022 camt.053 XML, external ID generation, * file hashing, stats calculation, date range extraction, and edge cases. */ import { detectFileFormat, parseBankFile, generateExternalId, generateFileHash, getFormat, getAllFormats } from '../parser' import type { ParsedBankTransaction, BankFileFormatId } from '../types' import { parseGenericCSV, normalizeMinusSign } from '../formats/generic-csv' import { parseCSVLine } from '../formats/nordea' // --------------------------------------------------------------------------- // Test data: realistic CSV/XML content for each Swedish bank format // --------------------------------------------------------------------------- const NORDEA_CSV = [ 'Datum,Transaktion,Kategori,Belopp,Saldo', '2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"', '2024-01-14,ICA MAXI LINDHAGEN,,"-432,50","12 444,67"', '2024-01-13,LÖNEUTBETALNING,,"25 000,00","12 877,17"', ].join('\n') const NORDEA_CSV_WITH_RESERVED = [ 'Datum,Transaktion,Kategori,Belopp,Saldo', '2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"', '2024-01-14,Reserverat köp CLAS OHLSON,,"-199,00","12 444,67"', '2024-01-13,LÖNEUTBETALNING,,"25 000,00","12 643,67"', ].join('\n') const NORDEA_CSV_SWEDISH_CHARS = [ 'Datum,Transaktion,Kategori,Belopp,Saldo', '2024-03-01,GÖTEBORGS HAMNCAFÉ,,"-85,00","5 000,00"', '2024-03-02,ÅHLENS CITY,,"-249,00","4 751,00"', '2024-03-03,ÄRLA GÅRD AB,,"1 200,00","5 951,00"', ].join('\n') const SEB_CSV = [ 'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo', '2024-01-15;2024-01-15;12345;SPOTIFY AB;-99,00;12345,67', '2024-01-14;2024-01-14;12346;HEMKÖP FRIDHEMSPLAN;-432,50;12444,67', '2024-01-13;2024-01-13;12347;LÖNEUTBETALNING;25000,00;12877,17', ].join('\n') // SEB privatbanken web export: "Bokföringsdatum" (with ö AND -datum suffix) const SEB_PRIVAT_CSV = [ 'Bokföringsdatum;Valutadatum;Verifikationsnummer;Text;Belopp;Saldo', '2025-12-30;2025-12-31;0;RÄNTA;7,84;1241,16', '2025-09-15;2025-09-14;5490990004;53290171515;-1000,00;1233,32', '2024-10-31;2024-10-31;5841990687;H31520956893;433,16;5147,56', ].join('\n') const SWEDBANK_CSV = [ 'Kontouppgifter', 'Clearingnummer,Kontonummer,Datum,Text,Belopp,Saldo', '8123,12345678,2024-01-15,SPOTIFY AB,-99.00,12345.67', '8123,12345678,2024-01-14,ICA MAXI,-432.50,12444.67', '8123,12345678,2024-01-13,LÖNEUTBETALNING,25000.00,12877.17', ].join('\n') const SWEDBANK_CSV_NO_METADATA = [ 'Clearingnummer,Kontonummer,Datum,Text,Belopp,Saldo', '8123,12345678,2024-02-01,TELIA SVERIGE,-299.00,10000.00', '8123,12345678,2024-02-02,SKATTEVERKET INBETALNING,5000.00,15000.00', ].join('\n') const HANDELSBANKEN_CSV = [ 'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo', '2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12345,67', '2024-01-14;2024-01-14;HEMKÖP;-432,50;12444,67', '2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12877,17', ].join('\n') const HANDELSBANKEN_CSV_WITH_PREL = [ 'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo', '2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12345,67', '2024-01-14;2024-01-14;Prel kortköp CLAS OHLSON;-199,00;12444,67', '2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12643,67', ].join('\n') // Real Handelsbanken web exports can prepend account/period metadata rows // (and a blank line) before the actual column header. const HANDELSBANKEN_CSV_WITH_PREAMBLE = [ 'Kontonummer;6789 123 456 789', 'Kontohavare;Wiklund, Cristel', 'Period;2024-01-01 - 2024-01-31', '', 'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo', '2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12345,67', '2024-01-14;2024-01-14;HEMKÖP;-432,50;12444,67', '2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12877,17', ].join('\n') // Negative amounts exported with a Unicode minus (U+2212) instead of ASCII '-'. const HANDELSBANKEN_CSV_UNICODE_MINUS = [ 'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo', '2024-01-15;2024-01-15;SPOTIFY AB;−139,00;12345,67', '2024-01-14;2024-01-14;HEMKÖP;−1 432,50;12444,67', '2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12877,17', ].join('\n') // A quoted Text field that itself contains the semicolon delimiter. const HANDELSBANKEN_CSV_QUOTED_SEMICOLON = [ 'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo', '2024-01-15;2024-01-15;"BETALNING; FAKTURA 100";-99,00;12345,67', '2024-01-14;2024-01-14;HEMKÖP;-432,50;12444,67', ].join('\n') const CAMT053_XML = ` SEK
2024-01-15
99.00 DBIT REF001 SPOTIFY AB
2024-01-14
25000.00 CRDT REF002 LÖNEUTBETALNING
` const CAMT053_XML_WITH_STRUCTURED_REF = `
2024-02-01
1500.00 CRDT REF100 OCR123456789 Betalning faktura 1001
` const LANSFORSAKRINGAR_CSV = [ '"Datum";"Bokföringsdag";"Typ";"Text";"Belopp";"Saldo"', '"2024-01-15";"2024-01-15";"Kortköp";"SPOTIFY AB";"-99,00";"12 345,67"', '"2024-01-14";"2024-01-14";"Kortköp";"ICA MAXI";"-432,50";"12 444,67"', '"2024-01-13";"2024-01-13";"Insättning";"LÖNEUTBETALNING";"25 000,00";"12 877,17"', ].join('\n') const LANSFORSAKRINGAR_CSV_NO_HEADER = [ '"2024-01-15";"2024-01-15";"Kortköp";"SPOTIFY AB";"-99,00";"12 345,67"', '"2024-01-14";"2024-01-14";"Kortköp";"ICA MAXI";"-432,50";"12 444,67"', ].join('\n') const ICA_BANKEN_CSV = [ 'Kontonamn: Lönekonto', 'Kontonummer: 1234 567 890', 'Saldo: 12 877,17', 'Tillgängligt belopp: 12 877,17', 'Period: 2024-01-01 - 2024-01-31', 'Exporterad: 2024-02-01', 'Datum;Text;Belopp;Saldo', '2024-01-15;SPOTIFY AB;-99,00;12345,67', '2024-01-14;ICA MAXI LINDHAGEN;-432,50;12444,67', '2024-01-13;LÖNEUTBETALNING;25000,00;12877,17', ].join('\n') const SKANDIA_CSV = [ 'Datum;Beskrivning;Belopp;Saldo', '2024-01-15;SPOTIFY AB;-99,00;12345,67', '2024-01-14;HEMKÖP FRIDHEMSPLAN;-432,50;12444,67', '2024-01-13;LÖNEUTBETALNING;25000,00;12877,17', ].join('\n') const SKANDIA_CSV_WITH_BANKKATEGORI = [ 'Datum;Beskrivning;Belopp;Saldo;Bankkategori', '2024-01-15;SPOTIFY AB;-99,00;12345,67;Underhållning', '2024-01-14;ICA MAXI;-432,50;12444,67;Livsmedel', ].join('\n') const LUNAR_CSV = [ 'Date,Text,Amount,Balance', '2024-01-15,SPOTIFY AB,"-99,00","12.345,67"', '2024-01-14,ICA MAXI LINDHAGEN,"-432,50","12.444,67"', '2024-01-13,LÖNEUTBETALNING,"25.000,00","12.877,17"', ].join('\n') // Real Lunar export as of 2026: Time and Transaction ID columns, "Title" // instead of "Text", SPACE thousands separator, UTF-8 BOM (issue #915). const LUNAR_CSV_2026 = '\uFEFF' + [ 'Date,Time,Title,Amount,Balance,Transaction ID', '2026-06-30,12:11,Incoming payment,"12 345,00","98 764,94",7f0a4c9e-1111-2222-3333-444455556666', '2026-06-12,05:47,Fee,"-1,49","86 419,94",7f0a4c9e-1111-2222-3333-444455557777', '2026-05-12,05:47,Card purchase,"-2 500,00","86 421,43",7f0a4c9e-1111-2222-3333-444455558888', ].join('\n') // Northmill exports include a 5-line metadata preamble (Kontonummer, Saldo, // Kontohavare, Org. Nr, Period) plus blank lines before the actual transaction // header. Negative amounts use Unicode minus (U+2212), not ASCII hyphen. const NORTHMILL_CSV = [ 'Kontonummer,9750-8770139', 'Saldo,"251495,41",SEK', 'Kontohavare,Arcim Technology AB', 'Org. Nr,559538-6219', 'Period,2025-10-01,2026-04-07', '', '', '', 'Bokföringsdag,Beskrivning,Belopp,Saldo,Valuta', '2026-04-01,Månadsavgift företagspaket april,"\u2212139,00","251495,41",SEK', '2026-01-22,200176580348155,"\u22121000,00","251912,41",SEK', '2025-10-16,092221155575,"400000,00","422005,00",SEK', '2025-10-01,Inbetalning av aktiekapital,"25000,00","25000,00",SEK', ].join('\n') const UNKNOWN_CSV = [ 'id,name,value,timestamp', '1,Widget A,100,2024-01-15T10:00:00', '2,Widget B,200,2024-01-16T11:00:00', ].join('\n') const EMPTY_FILE = '' const HEADER_ONLY_NORDEA = 'Datum,Transaktion,Kategori,Belopp,Saldo\n' const NORDEA_BUSINESS_CSV = [ 'Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta', '2024-01-15;-99,00;;SPOTIFY AB;SPOTIFY AB;Kortköp;12 345,67;SEK', '2024-01-14;-432,50;;ICA MAXI;ICA MAXI LINDHAGEN;Kortköp;12 444,67;SEK', '2024-01-13;25 000,00;ARBETSGIVAREN AB;;ARBETSGIVAREN AB;Löneutbetalning;12 877,17;SEK', ].join('\n') const NORDEA_BUSINESS_CSV_SWEDISH_CHARS = [ 'Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta', '2024-03-01;-85,00;;GÖTEBORGS HAMNCAFÉ;GÖTEBORGS HAMNCAFÉ;Kortköp;5 000,00;SEK', '2024-03-02;-249,00;;ÅHLENS CITY;ÅHLENS CITY;Kortköp;4 751,00;SEK', '2024-03-03;1 200,00;ÄRLA GÅRD AB;;ÄRLA GÅRD AB;Betalning;5 951,00;SEK', ].join('\n') const HEADER_ONLY_NORDEA_BUSINESS = 'Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta\n' const NORDEA_BUSINESS_CSV_VARIANT_A = [ 'Bokföringsdag;Värdedag;Betalningstyp;Betalare/Mottagare;Meddelande/Referens;Belopp;Saldo', '2024-01-15;2024-01-15;Kortbetalning;SPOTIFY AB;Spotify Premium;-99,00;12 345,67', '2024-01-14;2024-01-14;Kortbetalning;ICA MAXI;Dagligvaror;-432,50;12 444,67', '2024-01-13;2024-01-13;Inbetalning;ARBETSGIVAREN AB;Lön jan;25 000,00;12 877,17', ].join('\n') const NORDEA_BUSINESS_CSV_VARIANT_B = [ 'Bokföringsdatum;Valutadatum;Text;Belopp;Saldo', '2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12 345,67', '2024-01-14;2024-01-14;ICA MAXI LINDHAGEN;-432,50;12 444,67', '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 // --------------------------------------------------------------------------- describe('detectFileFormat', () => { it('detects Nordea CSV from header keywords', () => { const format = detectFileFormat(NORDEA_CSV, 'transaktioner.csv') expect(format).not.toBeNull() expect(format!.id).toBe('nordea') }) it('detects Nordea Business CSV from semicolon-delimited header with bokföringsdag and rubrik', () => { const format = detectFileFormat(NORDEA_BUSINESS_CSV, 'PLUSGIROKONTO FTG 212 68 87-5.csv') expect(format).not.toBeNull() expect(format!.id).toBe('nordea_business') }) it('does not confuse Nordea Business with SEB (SEB has valutadag)', () => { const format = detectFileFormat(NORDEA_BUSINESS_CSV, 'export.csv') expect(format).not.toBeNull() expect(format!.id).toBe('nordea_business') }) it('detects Nordea Business CSV variant with Betalare/Mottagare combined column', () => { const format = detectFileFormat(NORDEA_BUSINESS_CSV_VARIANT_A, 'nordea_ftg.csv') expect(format).not.toBeNull() expect(format!.id).toBe('nordea_business') }) it('detects Nordea Business CSV variant with Bokföringsdatum header', () => { const format = detectFileFormat(NORDEA_BUSINESS_CSV_VARIANT_B, 'nordea_ftg.csv') expect(format).not.toBeNull() 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') expect(format).not.toBeNull() expect(format!.id).toBe('seb') }) it('detects SEB CSV from semicolon-delimited header with bokföringsdag', () => { const format = detectFileFormat(SEB_CSV, 'kontoutdrag.csv') expect(format).not.toBeNull() expect(format!.id).toBe('seb') }) it('detects Swedbank CSV from clearingnummer header', () => { const format = detectFileFormat(SWEDBANK_CSV, 'export.csv') expect(format).not.toBeNull() expect(format!.id).toBe('swedbank') }) it('detects Swedbank CSV when header is on the first line (no metadata)', () => { const format = detectFileFormat(SWEDBANK_CSV_NO_METADATA, 'export.csv') expect(format).not.toBeNull() expect(format!.id).toBe('swedbank') }) it('detects Handelsbanken CSV from reskontradatum/transaktionsdatum header', () => { const format = detectFileFormat(HANDELSBANKEN_CSV, 'handelsbanken.csv') expect(format).not.toBeNull() expect(format!.id).toBe('handelsbanken') }) it('detects camt.053 XML from namespace and .xml extension', () => { const format = detectFileFormat(CAMT053_XML, 'statement.xml') expect(format).not.toBeNull() expect(format!.id).toBe('camt053') }) it('detects camt.053 XML when content includes BkToCstmrStmt tag', () => { const xmlContent = '' const format = detectFileFormat(xmlContent, 'data.xml') expect(format).not.toBeNull() expect(format!.id).toBe('camt053') }) it('does not detect camt.053 without .xml extension', () => { // camt053 detection requires .xml extension const format = detectFileFormat(CAMT053_XML, 'statement.csv') // It should not match camt053 since extension is .csv // But it could match something else if the content resembles a CSV header // The important check is that it does NOT return camt053 if (format) { expect(format.id).not.toBe('camt053') } }) it('detects Länsförsäkringar CSV from header with "typ" keyword', () => { const format = detectFileFormat(LANSFORSAKRINGAR_CSV, 'lansforsakringar.csv') expect(format).not.toBeNull() expect(format!.id).toBe('lansforsakringar') }) it('detects Länsförsäkringar CSV from two adjacent date fields (no header)', () => { const format = detectFileFormat(LANSFORSAKRINGAR_CSV_NO_HEADER, 'export.csv') expect(format).not.toBeNull() expect(format!.id).toBe('lansforsakringar') }) it('detects ICA Banken CSV from metadata rows before header', () => { const format = detectFileFormat(ICA_BANKEN_CSV, 'ica.csv') expect(format).not.toBeNull() expect(format!.id).toBe('ica_banken') }) it('detects Skandia CSV from "beskrivning" header keyword', () => { const format = detectFileFormat(SKANDIA_CSV, 'skandia.csv') expect(format).not.toBeNull() expect(format!.id).toBe('skandia') }) it('detects Skandia CSV from "bankkategori" header keyword', () => { const format = detectFileFormat(SKANDIA_CSV_WITH_BANKKATEGORI, 'skandia.csv') expect(format).not.toBeNull() expect(format!.id).toBe('skandia') }) it('detects Lunar CSV from English headers (date, text, amount, balance)', () => { const format = detectFileFormat(LUNAR_CSV, 'lunar.csv') expect(format).not.toBeNull() expect(format!.id).toBe('lunar') }) it('detects the 2026 Lunar CSV header (Title column, BOM) as lunar', () => { const format = detectFileFormat(LUNAR_CSV_2026, 'lunar.csv') expect(format).not.toBeNull() expect(format!.id).toBe('lunar') }) it('detects Northmill CSV from Kontonummer preamble + transaction header', () => { const format = detectFileFormat(NORTHMILL_CSV, 'Northmill-Account-Statement.csv') expect(format).not.toBeNull() expect(format!.id).toBe('northmill') }) it('does not detect Northmill on a file that just happens to mention Kontonummer in transactions', () => { const fake = [ 'Datum,Transaktion,Kategori,Belopp,Saldo', '2024-01-15,Överföring kontonummer 1234,Inkomst,"100,00","1000,00"', ].join('\n') const format = detectFileFormat(fake, 'test.csv') // Should detect as Nordea, not Northmill: Northmill needs Kontonummer at start of first line expect(format!.id).toBe('nordea') }) it('returns null for unrecognized CSV content', () => { const format = detectFileFormat(UNKNOWN_CSV, 'data.csv') expect(format).toBeNull() }) it('returns null for empty content', () => { const format = detectFileFormat(EMPTY_FILE, 'empty.csv') expect(format).toBeNull() }) it('generic_csv format never auto-detects', () => { // Even with simple CSV content, generic should not be picked const simpleCSV = 'date,description,amount\n2024-01-15,Test,-100' const format = detectFileFormat(simpleCSV, 'test.csv') if (format) { expect(format.id).not.toBe('generic_csv') } }) it('is case-insensitive on header detection', () => { const upperNordea = 'DATUM,TRANSAKTION,KATEGORI,BELOPP,SALDO\n2024-01-15,Test,,"-100,00","5000,00"' const format = detectFileFormat(upperNordea, 'test.csv') expect(format).not.toBeNull() expect(format!.id).toBe('nordea') }) }) describe('parseBankFile: Nordea format', () => { it('parses comma-delimited CSV with comma decimal separator', () => { const result = parseBankFile(NORDEA_CSV, 'nordea.csv') expect(result.format).toBe('nordea') expect(result.format_name).toBe('Nordea') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) }) it('correctly parses negative amounts with comma decimal and space thousands', () => { const result = parseBankFile(NORDEA_CSV, 'nordea.csv') const spotify = result.transactions[0] expect(spotify.amount).toBe(-99) expect(spotify.description).toBe('SPOTIFY AB') expect(spotify.date).toBe('2024-01-15') expect(spotify.currency).toBe('SEK') const ica = result.transactions[1] expect(ica.amount).toBe(-432.5) }) it('correctly parses positive amounts with space thousands separator', () => { const result = parseBankFile(NORDEA_CSV, 'nordea.csv') const salary = result.transactions[2] expect(salary.amount).toBe(25000) expect(salary.description).toBe('LÖNEUTBETALNING') }) it('parses balance field with space thousands separator', () => { const result = parseBankFile(NORDEA_CSV, 'nordea.csv') const spotify = result.transactions[0] expect(spotify.balance).toBe(12345.67) }) it('filters out "Reserverat" (pending) transactions', () => { const result = parseBankFile(NORDEA_CSV_WITH_RESERVED, 'nordea.csv') expect(result.transactions).toHaveLength(2) expect(result.stats.skipped_rows).toBe(1) const descriptions = result.transactions.map((t) => t.description) expect(descriptions).not.toContain(expect.stringContaining('Reserverat')) }) it('handles Swedish characters (a-ring, a-diaeresis, o-diaeresis)', () => { const result = parseBankFile(NORDEA_CSV_SWEDISH_CHARS, 'nordea.csv') expect(result.transactions).toHaveLength(3) expect(result.transactions[0].description).toBe('GÖTEBORGS HAMNCAFÉ') expect(result.transactions[1].description).toBe('ÅHLENS CITY') expect(result.transactions[2].description).toBe('ÄRLA GÅRD AB') }) it('stores raw_line for each transaction', () => { const result = parseBankFile(NORDEA_CSV, 'nordea.csv') result.transactions.forEach((tx) => { expect(tx.raw_line).toBeDefined() expect(tx.raw_line!.length).toBeGreaterThan(0) }) }) it('handles header-only file with no data rows', () => { const result = parseBankFile(HEADER_ONLY_NORDEA, 'nordea.csv') expect(result.format).toBe('nordea') expect(result.transactions).toHaveLength(0) expect(result.date_from).toBeNull() expect(result.date_to).toBeNull() expect(result.stats.parsed_rows).toBe(0) }) }) describe('parseBankFile: Nordea Business format', () => { it('parses semicolon-delimited CSV with correct columns', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv') expect(result.format).toBe('nordea_business') expect(result.format_name).toBe('Nordea Företag') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) }) it('correctly parses negative amounts', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv') const spotify = result.transactions[0] expect(spotify.amount).toBe(-99) expect(spotify.date).toBe('2024-01-15') expect(spotify.currency).toBe('SEK') }) it('correctly parses positive amounts with space thousands separator', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv') const salary = result.transactions[2] expect(salary.amount).toBe(25000) expect(salary.date).toBe('2024-01-13') }) it('builds description from Namn and Rubrik columns', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv') const spotify = result.transactions[0] expect(spotify.description).toBe('SPOTIFY AB - Kortköp') const salary = result.transactions[2] expect(salary.description).toBe('ARBETSGIVAREN AB - Löneutbetalning') }) it('extracts counterparty from Mottagare (expense) or Avsändare (income)', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv') // Expense: counterparty from Mottagare expect(result.transactions[0].counterparty).toBe('SPOTIFY AB') // Income: counterparty from Avsändare expect(result.transactions[2].counterparty).toBe('ARBETSGIVAREN AB') }) it('parses balance field', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv') expect(result.transactions[0].balance).toBe(12345.67) expect(result.transactions[2].balance).toBe(12877.17) }) it('handles Swedish characters', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV_SWEDISH_CHARS, 'nordea_ftg.csv') expect(result.transactions).toHaveLength(3) expect(result.transactions[0].description).toContain('GÖTEBORGS HAMNCAFÉ') expect(result.transactions[1].description).toContain('ÅHLENS CITY') expect(result.transactions[2].description).toContain('ÄRLA GÅRD AB') }) it('stores raw_line for each transaction', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv') result.transactions.forEach((tx) => { expect(tx.raw_line).toBeDefined() expect(tx.raw_line!.length).toBeGreaterThan(0) }) }) it('handles header-only file with no data rows', () => { const result = parseBankFile(HEADER_ONLY_NORDEA_BUSINESS, 'nordea_ftg.csv') expect(result.format).toBe('nordea_business') expect(result.transactions).toHaveLength(0) expect(result.date_from).toBeNull() expect(result.date_to).toBeNull() expect(result.stats.parsed_rows).toBe(0) }) it('calculates correct date range', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv') expect(result.date_from).toBe('2024-01-13') expect(result.date_to).toBe('2024-01-15') }) it('calculates correct income and expense stats', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv') expect(result.stats.total_income).toBe(25000) expect(result.stats.total_expenses).toBe(-531.5) expect(result.stats.parsed_rows).toBe(3) expect(result.stats.skipped_rows).toBe(0) }) }) describe('parseBankFile: Nordea Business variant A (Betalare/Mottagare)', () => { it('parses the alternate Nordea Business format with combined party column', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_A, 'nordea_ftg.csv') expect(result.format).toBe('nordea_business') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) }) it('builds description from Betalningstyp and Meddelande/Referens', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_A, 'nordea_ftg.csv') expect(result.transactions[0].description).toBe('Kortbetalning - Spotify Premium') expect(result.transactions[2].description).toBe('Inbetalning - Lön jan') }) it('extracts counterparty from combined Betalare/Mottagare column', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_A, 'nordea_ftg.csv') expect(result.transactions[0].counterparty).toBe('SPOTIFY AB') expect(result.transactions[2].counterparty).toBe('ARBETSGIVAREN AB') }) it('parses amounts and dates correctly', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_A, 'nordea_ftg.csv') expect(result.transactions[0].amount).toBe(-99) expect(result.transactions[0].date).toBe('2024-01-15') expect(result.transactions[2].amount).toBe(25000) }) }) describe('parseBankFile: Nordea Business variant B (Bokföringsdatum)', () => { it('parses the simple Nordea Business format with Bokföringsdatum', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_B, 'nordea_ftg.csv') expect(result.format).toBe('nordea_business') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) }) it('builds description from Text column', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_B, 'nordea_ftg.csv') expect(result.transactions[0].description).toBe('SPOTIFY AB') expect(result.transactions[2].description).toBe('LÖNEUTBETALNING') }) it('parses amounts correctly', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_B, 'nordea_ftg.csv') expect(result.transactions[0].amount).toBe(-99) expect(result.transactions[1].amount).toBe(-432.5) expect(result.transactions[2].amount).toBe(25000) }) it('calculates correct stats', () => { const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_B, 'nordea_ftg.csv') expect(result.stats.total_income).toBe(25000) expect(result.stats.total_expenses).toBe(-531.5) expect(result.stats.parsed_rows).toBe(3) }) }) 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') expect(result.format).toBe('seb') expect(result.format_name).toBe('SEB') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) }) it('correctly extracts columns using dynamic header mapping', () => { const result = parseBankFile(SEB_CSV, 'seb.csv') const spotify = result.transactions[0] expect(spotify.date).toBe('2024-01-15') expect(spotify.description).toBe('SPOTIFY AB') expect(spotify.amount).toBe(-99) expect(spotify.balance).toBe(12345.67) }) it('parses positive income amounts correctly', () => { const result = parseBankFile(SEB_CSV, 'seb.csv') const salary = result.transactions[2] expect(salary.amount).toBe(25000) expect(salary.description).toBe('LÖNEUTBETALNING') }) it('handles alternative SEB header names', () => { const altSEB = [ 'Bokforingsdatum;Valutadag;Verifikationsnummer;Text;Belopp;Saldo', '2024-01-15;2024-01-15;12345;TEST;-50,00;1000,00', ].join('\n') const result = parseBankFile(altSEB, 'seb_alt.csv') expect(result.format).toBe('seb') expect(result.transactions).toHaveLength(1) expect(result.transactions[0].amount).toBe(-50) }) it('auto-detects SEB privatbanken variant with Bokföringsdatum + Valutadatum headers', () => { const format = detectFileFormat(SEB_PRIVAT_CSV, 'kontoutdrag.csv') expect(format).not.toBeNull() expect(format!.id).toBe('seb') }) it('parses SEB privatbanken CSV (Bokföringsdatum / Valutadatum / RÄNTA / negative amounts)', () => { const result = parseBankFile(SEB_PRIVAT_CSV, 'kontoutdrag.csv') expect(result.format).toBe('seb') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) const ranta = result.transactions[0] expect(ranta.date).toBe('2025-12-30') expect(ranta.description).toBe('RÄNTA') expect(ranta.amount).toBe(7.84) expect(ranta.balance).toBe(1241.16) const withdrawal = result.transactions[1] expect(withdrawal.date).toBe('2025-09-15') expect(withdrawal.amount).toBe(-1000) const deposit = result.transactions[2] expect(deposit.amount).toBe(433.16) }) }) describe('parseBankFile: Swedbank format', () => { it('parses comma-delimited CSV with PERIOD decimal separator', () => { const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv') expect(result.format).toBe('swedbank') expect(result.format_name).toBe('Swedbank') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) }) it('correctly handles period decimal separator (the Swedish exception)', () => { const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv') const spotify = result.transactions[0] expect(spotify.amount).toBe(-99) expect(spotify.balance).toBe(12345.67) const ica = result.transactions[1] expect(ica.amount).toBe(-432.5) }) it('skips metadata line when present (first line is account info)', () => { const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv') // With metadata line, there are 3 data rows after headerLineIdx=1 expect(result.transactions).toHaveLength(3) // No transaction should have "Kontouppgifter" as description const descriptions = result.transactions.map((t) => t.description) expect(descriptions).not.toContain('Kontouppgifter') }) it('works when header is on the first line (no metadata)', () => { const result = parseBankFile(SWEDBANK_CSV_NO_METADATA, 'swedbank.csv') expect(result.format).toBe('swedbank') expect(result.transactions).toHaveLength(2) expect(result.transactions[0].amount).toBe(-299) expect(result.transactions[1].amount).toBe(5000) }) it('extracts dates correctly', () => { const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv') expect(result.transactions[0].date).toBe('2024-01-15') expect(result.transactions[2].date).toBe('2024-01-13') }) }) describe('parseBankFile: Handelsbanken format', () => { it('parses semicolon-delimited CSV with comma decimal separator', () => { const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv') expect(result.format).toBe('handelsbanken') expect(result.format_name).toBe('Handelsbanken') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) }) it('correctly parses amounts and balances', () => { const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv') expect(result.transactions[0].amount).toBe(-99) expect(result.transactions[0].balance).toBe(12345.67) expect(result.transactions[2].amount).toBe(25000) }) it('filters out "Prel" (preliminary) transactions', () => { const result = parseBankFile(HANDELSBANKEN_CSV_WITH_PREL, 'handelsbanken.csv') expect(result.transactions).toHaveLength(2) expect(result.stats.skipped_rows).toBe(1) const descriptions = result.transactions.map((t) => t.description) expect(descriptions).not.toContain(expect.stringContaining('Prel')) }) it('uses reskontradatum when both date columns are present', () => { // Handelsbanken has both columns; reskontradatum (booking date) should be used const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv') // In our test data both dates are the same, but verify it selects dates properly expect(result.transactions[0].date).toBe('2024-01-15') }) it('uses reskontradatum (booking date), not transaktionsdatum, as the primary date field', () => { // A card purchase booked two days after the swipe: the PSD2 / Enable Banking // feed delivers this row on its booking_date (the 16th). Emitting the // transaktionsdatum here would put the same affärshändelse in a different // exact-date content-dedup bucket and insert it twice. const diffDates = [ 'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo', '2026-03-16;2026-03-14;KORTKÖP CLAS OHLSON;-100,00;5000,00', ].join('\n') const result = parseBankFile(diffDates, 'shb.csv') expect(result.transactions[0].date).toBe('2026-03-16') }) it('falls back to transaktionsdatum when reskontradatum is absent', () => { const txOnly = [ 'Transaktionsdatum;Text;Belopp;Saldo', '2026-03-14;KORTKÖP CLAS OHLSON;-100,00;5000,00', ].join('\n') const result = parseBankFile(txOnly, 'shb.csv') expect(result.format).toBe('handelsbanken') expect(result.transactions).toHaveLength(1) expect(result.issues).toHaveLength(0) expect(result.transactions[0].date).toBe('2026-03-14') }) it('detects Handelsbanken CSV when a metadata preamble precedes the header', () => { const format = detectFileFormat(HANDELSBANKEN_CSV_WITH_PREAMBLE, 'kontoutdrag.csv') expect(format).not.toBeNull() expect(format!.id).toBe('handelsbanken') }) it('skips the metadata preamble rows and parses the transactions', () => { const result = parseBankFile(HANDELSBANKEN_CSV_WITH_PREAMBLE, 'kontoutdrag.csv') expect(result.format).toBe('handelsbanken') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) expect(result.stats.skipped_rows).toBe(0) const descriptions = result.transactions.map((t) => t.description) expect(descriptions).not.toContain('Kontonummer') expect(result.transactions[0].description).toBe('SPOTIFY AB') expect(result.transactions[2].amount).toBe(25000) }) it('parses negative amounts that use a Unicode minus (U+2212) instead of dropping them', () => { const result = parseBankFile(HANDELSBANKEN_CSV_UNICODE_MINUS, 'shb.csv') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) expect(result.stats.skipped_rows).toBe(0) expect(result.transactions[0].amount).toBe(-139) expect(result.transactions[1].amount).toBe(-1432.5) expect(result.transactions[2].amount).toBe(25000) }) it('handles a quoted Text field that contains the semicolon delimiter', () => { const result = parseBankFile(HANDELSBANKEN_CSV_QUOTED_SEMICOLON, 'shb.csv') expect(result.transactions).toHaveLength(2) expect(result.issues).toHaveLength(0) expect(result.transactions[0].description).toBe('BETALNING; FAKTURA 100') expect(result.transactions[0].amount).toBe(-99) expect(result.transactions[1].amount).toBe(-432.5) }) }) describe('parseBankFile: Länsförsäkringar format', () => { it('parses semicolon-delimited CSV with quoted fields and comma decimal separator', () => { const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv') expect(result.format).toBe('lansforsakringar') expect(result.format_name).toBe('Länsförsäkringar') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) }) it('correctly parses amounts with comma decimal and space thousands', () => { const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv') expect(result.transactions[0].amount).toBe(-99) expect(result.transactions[0].description).toBe('SPOTIFY AB') expect(result.transactions[0].date).toBe('2024-01-15') expect(result.transactions[1].amount).toBe(-432.5) expect(result.transactions[2].amount).toBe(25000) }) it('parses balance field', () => { const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv') expect(result.transactions[0].balance).toBe(12345.67) }) it('handles files without a header row (data-only)', () => { const result = parseBankFile(LANSFORSAKRINGAR_CSV_NO_HEADER, 'lf.csv') expect(result.format).toBe('lansforsakringar') expect(result.transactions).toHaveLength(2) expect(result.transactions[0].amount).toBe(-99) expect(result.transactions[1].amount).toBe(-432.5) }) it('calculates stats correctly', () => { const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv') expect(result.stats.total_income).toBe(25000) expect(result.stats.total_expenses).toBe(-531.5) expect(result.stats.parsed_rows).toBe(3) }) it('extracts correct date range', () => { const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv') expect(result.date_from).toBe('2024-01-13') expect(result.date_to).toBe('2024-01-15') }) it('uses Bokföringsdag (booking date), not Datum, when the two differ', () => { // Same rationale as Handelsbanken: the PSD2 feed keys the row on its // booking_date, so the CSV must emit the same date or the affärshändelse // lands in two different exact-date dedup buckets and inserts twice. const diffDates = [ '"Datum";"Bokföringsdag";"Typ";"Text";"Belopp";"Saldo"', '"2026-03-14";"2026-03-16";"Kortköp";"CLAS OHLSON";"-100,00";"5 000,00"', ].join('\n') const result = parseBankFile(diffDates, 'lf.csv') expect(result.format).toBe('lansforsakringar') expect(result.transactions).toHaveLength(1) expect(result.transactions[0].date).toBe('2026-03-16') }) it('uses the Bokföringsdag column position on header-less files', () => { // The header-less layout is pinned by isLFRow (two adjacent dates, comma // number in field 4), so field 1 is Bokföringsdag. Header-ful and // header-less exports of the same account must agree on the date, or the // two upload paths duplicate each other. const noHeaderDiffDates = [ '"2026-03-14";"2026-03-16";"Kortköp";"CLAS OHLSON";"-100,00";"5 000,00"', '"2026-03-11";"2026-03-12";"Kortköp";"ICA MAXI";"-432,50";"5 100,00"', ].join('\n') const result = parseBankFile(noHeaderDiffDates, 'lf.csv') expect(result.format).toBe('lansforsakringar') expect(result.transactions).toHaveLength(2) expect(result.transactions[0].date).toBe('2026-03-16') expect(result.transactions[1].date).toBe('2026-03-12') }) }) describe('parseBankFile: ICA Banken format', () => { it('parses semicolon-delimited CSV with metadata rows before header', () => { const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv') expect(result.format).toBe('ica_banken') expect(result.format_name).toBe('ICA Banken') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) }) it('skips metadata rows and finds the correct header', () => { const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv') // No transaction should contain metadata text const descriptions = result.transactions.map((t) => t.description) expect(descriptions).not.toContain(expect.stringContaining('Kontonamn')) expect(descriptions).not.toContain(expect.stringContaining('Exporterad')) }) it('correctly parses amounts with comma decimal separator', () => { const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv') expect(result.transactions[0].amount).toBe(-99) expect(result.transactions[0].description).toBe('SPOTIFY AB') expect(result.transactions[0].date).toBe('2024-01-15') expect(result.transactions[1].amount).toBe(-432.5) expect(result.transactions[2].amount).toBe(25000) }) it('parses balance field', () => { const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv') expect(result.transactions[0].balance).toBe(12345.67) }) it('calculates stats correctly', () => { const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv') expect(result.stats.total_income).toBe(25000) expect(result.stats.total_expenses).toBe(-531.5) expect(result.stats.parsed_rows).toBe(3) }) it('extracts correct date range', () => { const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv') expect(result.date_from).toBe('2024-01-13') expect(result.date_to).toBe('2024-01-15') }) }) describe('parseBankFile: Skandia format', () => { it('parses semicolon-delimited CSV with comma decimal separator', () => { const result = parseBankFile(SKANDIA_CSV, 'skandia.csv') expect(result.format).toBe('skandia') expect(result.format_name).toBe('Skandia') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) }) it('correctly parses amounts and descriptions', () => { const result = parseBankFile(SKANDIA_CSV, 'skandia.csv') expect(result.transactions[0].amount).toBe(-99) expect(result.transactions[0].description).toBe('SPOTIFY AB') expect(result.transactions[0].date).toBe('2024-01-15') expect(result.transactions[1].amount).toBe(-432.5) expect(result.transactions[1].description).toBe('HEMKÖP FRIDHEMSPLAN') expect(result.transactions[2].amount).toBe(25000) }) it('parses balance field', () => { const result = parseBankFile(SKANDIA_CSV, 'skandia.csv') expect(result.transactions[0].balance).toBe(12345.67) }) it('handles files with bankkategori column', () => { const result = parseBankFile(SKANDIA_CSV_WITH_BANKKATEGORI, 'skandia.csv') expect(result.format).toBe('skandia') expect(result.transactions).toHaveLength(2) expect(result.transactions[0].amount).toBe(-99) expect(result.transactions[1].amount).toBe(-432.5) }) it('calculates stats correctly', () => { const result = parseBankFile(SKANDIA_CSV, 'skandia.csv') expect(result.stats.total_income).toBe(25000) expect(result.stats.total_expenses).toBe(-531.5) expect(result.stats.parsed_rows).toBe(3) }) it('extracts correct date range', () => { const result = parseBankFile(SKANDIA_CSV, 'skandia.csv') expect(result.date_from).toBe('2024-01-13') expect(result.date_to).toBe('2024-01-15') }) }) describe('parseBankFile: Lunar format', () => { it('parses comma-delimited CSV with English headers', () => { const result = parseBankFile(LUNAR_CSV, 'lunar.csv') expect(result.format).toBe('lunar') expect(result.format_name).toBe('Lunar') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) }) it('correctly parses amounts with comma decimal and period thousand separator', () => { const result = parseBankFile(LUNAR_CSV, 'lunar.csv') expect(result.transactions[0].amount).toBe(-99) expect(result.transactions[0].description).toBe('SPOTIFY AB') expect(result.transactions[0].date).toBe('2024-01-15') expect(result.transactions[1].amount).toBe(-432.5) expect(result.transactions[2].amount).toBe(25000) }) it('parses balance field with period thousand separator', () => { const result = parseBankFile(LUNAR_CSV, 'lunar.csv') expect(result.transactions[0].balance).toBe(12345.67) expect(result.transactions[2].balance).toBe(12877.17) }) it('calculates stats correctly', () => { const result = parseBankFile(LUNAR_CSV, 'lunar.csv') expect(result.stats.total_income).toBe(25000) expect(result.stats.total_expenses).toBe(-531.5) expect(result.stats.parsed_rows).toBe(3) }) it('extracts correct date range', () => { const result = parseBankFile(LUNAR_CSV, 'lunar.csv') expect(result.date_from).toBe('2024-01-13') expect(result.date_to).toBe('2024-01-15') }) it('does not confuse Lunar (English) with Nordea (Swedish) headers', () => { // Nordea has Swedish headers, Lunar has English const nordeaResult = detectFileFormat(NORDEA_CSV, 'test.csv') const lunarResult = detectFileFormat(LUNAR_CSV, 'test.csv') expect(nordeaResult!.id).toBe('nordea') expect(lunarResult!.id).toBe('lunar') }) // Regression tests for issue #915: the real 2026 Lunar export uses a SPACE // thousands separator ("12 345,00") and a "Title" column instead of "Text". it('parses 2026 Lunar amounts with space thousands separator without truncation', () => { const result = parseBankFile(LUNAR_CSV_2026, 'lunar.csv') expect(result.format).toBe('lunar') expect(result.transactions).toHaveLength(3) expect(result.issues).toHaveLength(0) expect(result.transactions[0].amount).toBe(12345) expect(result.transactions[1].amount).toBe(-1.49) expect(result.transactions[2].amount).toBe(-2500) }) it('parses 2026 Lunar balance with space thousands separator', () => { const result = parseBankFile(LUNAR_CSV_2026, 'lunar.csv') expect(result.transactions[0].balance).toBe(98764.94) expect(result.transactions[1].balance).toBe(86419.94) expect(result.transactions[2].balance).toBe(86421.43) }) it('takes the description from the Title column in the 2026 format', () => { const result = parseBankFile(LUNAR_CSV_2026, 'lunar.csv') expect(result.transactions[0].description).toBe('Incoming payment') expect(result.transactions[1].description).toBe('Fee') expect(result.transactions[2].description).toBe('Card purchase') }) it('calculates 2026 format stats and date range correctly', () => { const result = parseBankFile(LUNAR_CSV_2026, 'lunar.csv') expect(result.stats.total_income).toBe(12345) expect(result.stats.total_expenses).toBe(-2501.49) expect(result.stats.parsed_rows).toBe(3) expect(result.date_from).toBe('2026-05-12') expect(result.date_to).toBe('2026-06-30') }) it('still parses the legacy Lunar period thousands separator ("1.234,56")', () => { const legacy = [ 'Date,Text,Amount,Balance', '2024-01-15,PAYMENT,"1.234,56","10.000,00"', ].join('\n') const result = parseBankFile(legacy, 'lunar.csv') expect(result.format).toBe('lunar') expect(result.transactions[0].amount).toBe(1234.56) expect(result.transactions[0].balance).toBe(10000) }) }) describe('parseBankFile: Northmill format', () => { it('skips the 5-line metadata preamble and blank lines, parses transaction rows', () => { const result = parseBankFile(NORTHMILL_CSV, 'Northmill.csv') expect(result.format).toBe('northmill') expect(result.format_name).toBe('Northmill') expect(result.transactions).toHaveLength(4) expect(result.issues).toHaveLength(0) }) it('correctly parses negative amounts that use Unicode minus (U+2212)', () => { const result = parseBankFile(NORTHMILL_CSV, 'Northmill.csv') // First transaction is "−139,00" with U+2212: must become -139, not NaN expect(result.transactions[0].amount).toBe(-139) expect(result.transactions[0].description).toBe('Månadsavgift företagspaket april') expect(result.transactions[0].date).toBe('2026-04-01') expect(result.transactions[1].amount).toBe(-1000) expect(result.transactions[2].amount).toBe(400000) expect(result.transactions[3].amount).toBe(25000) }) it('extracts the saldo (running balance) column', () => { const result = parseBankFile(NORTHMILL_CSV, 'Northmill.csv') expect(result.transactions[0].balance).toBe(251495.41) expect(result.transactions[3].balance).toBe(25000) }) it('calculates income vs expenses correctly with Unicode minus amounts', () => { const result = parseBankFile(NORTHMILL_CSV, 'Northmill.csv') expect(result.stats.total_income).toBe(425000) expect(result.stats.total_expenses).toBe(-1139) expect(result.stats.parsed_rows).toBe(4) expect(result.stats.skipped_rows).toBe(0) }) it('extracts the correct date range from the transactions, not the Period metadata row', () => { const result = parseBankFile(NORTHMILL_CSV, 'Northmill.csv') expect(result.date_from).toBe('2025-10-01') expect(result.date_to).toBe('2026-04-01') }) }) describe('parseBankFile: camt.053 XML format', () => { it('parses XML with credit and debit entries', () => { const result = parseBankFile(CAMT053_XML, 'statement.xml') expect(result.format).toBe('camt053') expect(result.format_name).toBe('ISO 20022 camt.053') expect(result.transactions).toHaveLength(2) }) it('applies DBIT indicator as negative amount', () => { const result = parseBankFile(CAMT053_XML, 'statement.xml') const debit = result.transactions.find((t) => t.description === 'SPOTIFY AB') expect(debit).toBeDefined() expect(debit!.amount).toBe(-99) }) it('applies CRDT indicator as positive amount', () => { const result = parseBankFile(CAMT053_XML, 'statement.xml') const credit = result.transactions.find((t) => t.description === 'LÖNEUTBETALNING') expect(credit).toBeDefined() expect(credit!.amount).toBe(25000) }) it('extracts entry reference into raw_line for external ID generation', () => { const result = parseBankFile(CAMT053_XML, 'statement.xml') const debit = result.transactions[0] expect(debit.raw_line).toBe('REF001') }) it('extracts OCR reference from structured remittance info', () => { const result = parseBankFile(CAMT053_XML_WITH_STRUCTURED_REF, 'statement.xml') expect(result.transactions).toHaveLength(1) expect(result.transactions[0].reference).toBe('OCR123456789') }) it('uses unstructured remittance info as description', () => { const result = parseBankFile(CAMT053_XML, 'statement.xml') expect(result.transactions[0].description).toBe('SPOTIFY AB') }) it('extracts currency from Amount element', () => { const result = parseBankFile(CAMT053_XML, 'statement.xml') result.transactions.forEach((tx) => { expect(tx.currency).toBe('SEK') }) }) it('handles XML with no Ntry elements', () => { const emptyXml = ` ` const result = parseBankFile(emptyXml, 'empty.xml') expect(result.format).toBe('camt053') expect(result.transactions).toHaveLength(0) expect(result.issues.length).toBeGreaterThan(0) expect(result.issues[0].message).toContain('No elements') }) }) describe('parseBankFile: explicit format override', () => { it('uses the specified format instead of auto-detection', () => { // Force parsing Nordea content as SEB (will produce issues but should use SEB format) const result = parseBankFile( 'Datum,Transaktion,Kategori,Belopp,Saldo\n2024-01-15,Test,,"-100,00","5000,00"', 'nordea.csv', 'seb' ) expect(result.format).toBe('seb') }) it('returns error for unknown formatId', () => { const result = parseBankFile(NORDEA_CSV, 'test.csv', 'unknown_format' as BankFileFormatId) expect(result.format).toBe('unknown_format') expect(result.format_name).toBe('Unknown') expect(result.transactions).toHaveLength(0) expect(result.issues).toHaveLength(1) expect(result.issues[0].severity).toBe('error') expect(result.issues[0].message).toContain('Unknown format') }) it('returns format detection error when no format matches and no override given', () => { const result = parseBankFile(UNKNOWN_CSV, 'unknown.csv') expect(result.format).toBe('generic_csv') expect(result.format_name).toBe('Unknown') expect(result.transactions).toHaveLength(0) expect(result.issues).toHaveLength(1) expect(result.issues[0].message).toContain('Kunde inte identifiera bankformat') }) it('can force generic_csv format by explicit ID', () => { const csvContent = '2024-01-15,Some purchase,-50.00\n2024-01-16,Income,1000.00' const result = parseBankFile(csvContent, 'test.csv', 'generic_csv') // generic_csv uses a default mapping (date=0, description=1, amount=2) // But the first line is treated as header (skip_rows=1), so only second row is data expect(result.format).toBe('generic_csv') }) }) describe('generateExternalId', () => { const baseTx: ParsedBankTransaction = { date: '2024-01-15', description: 'SPOTIFY AB', amount: -99, currency: 'SEK', balance: 12345.67, reference: null, counterparty: null, raw_line: '2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"', } it('generates SHA-256 composite key for CSV formats', () => { const id = generateExternalId(baseTx, 'nordea', 0) expect(id).toMatch(/^nordea_[0-9a-f]{16}$/) }) it('generates different IDs for different row indices (same transaction data)', () => { const id1 = generateExternalId(baseTx, 'nordea', 0) const id2 = generateExternalId(baseTx, 'nordea', 1) expect(id1).not.toBe(id2) }) it('generates different IDs for different formats (same data, same row index)', () => { const nordeaId = generateExternalId(baseTx, 'nordea', 0) const sebId = generateExternalId(baseTx, 'seb', 0) expect(nordeaId).not.toBe(sebId) }) it('generates deterministic IDs for the same inputs', () => { const id1 = generateExternalId(baseTx, 'nordea', 0) const id2 = generateExternalId(baseTx, 'nordea', 0) expect(id1).toBe(id2) }) it('uses entry reference for camt.053 transactions with NtryRef', () => { const camtTx: ParsedBankTransaction = { date: '2024-01-15', description: 'SPOTIFY AB', amount: -99, currency: 'SEK', raw_line: 'REF001', // NtryRef stored in raw_line } const id = generateExternalId(camtTx, 'camt053', 0) expect(id).toBe('camt053_REF001') }) it('falls back to hash for camt.053 when raw_line starts with camt053_entry_', () => { const camtTx: ParsedBankTransaction = { date: '2024-01-15', description: 'SPOTIFY AB', amount: -99, currency: 'SEK', raw_line: 'camt053_entry_0', // Auto-generated fallback reference } const id = generateExternalId(camtTx, 'camt053', 0) // Should fall through to hash-based ID since raw_line starts with 'camt053_entry_' expect(id).toMatch(/^camt053_[0-9a-f]{16}$/) }) it('falls back to hash for camt.053 when raw_line is undefined', () => { const camtTx: ParsedBankTransaction = { date: '2024-01-15', description: 'SPOTIFY AB', amount: -99, currency: 'SEK', } const id = generateExternalId(camtTx, 'camt053', 0) expect(id).toMatch(/^camt053_[0-9a-f]{16}$/) }) it('includes amount in hash so different amounts produce different IDs', () => { const tx1 = { ...baseTx, amount: -99 } const tx2 = { ...baseTx, amount: -100 } const id1 = generateExternalId(tx1, 'nordea', 0) const id2 = generateExternalId(tx2, 'nordea', 0) expect(id1).not.toBe(id2) }) it('includes description in hash so different descriptions produce different IDs', () => { const tx1 = { ...baseTx, description: 'SPOTIFY AB' } const tx2 = { ...baseTx, description: 'NETFLIX' } const id1 = generateExternalId(tx1, 'nordea', 0) const id2 = generateExternalId(tx2, 'nordea', 0) expect(id1).not.toBe(id2) }) }) describe('generateFileHash', () => { it('returns a SHA-256 hex string', () => { const hash = generateFileHash(NORDEA_CSV) expect(hash).toMatch(/^[0-9a-f]{64}$/) }) it('produces deterministic output for the same input', () => { const hash1 = generateFileHash(NORDEA_CSV) const hash2 = generateFileHash(NORDEA_CSV) expect(hash1).toBe(hash2) }) it('produces different hashes for different content', () => { const hash1 = generateFileHash(NORDEA_CSV) const hash2 = generateFileHash(SEB_CSV) expect(hash1).not.toBe(hash2) }) it('produces different hash even for tiny content differences', () => { const hash1 = generateFileHash('abc') const hash2 = generateFileHash('abd') expect(hash1).not.toBe(hash2) }) it('handles empty string', () => { const hash = generateFileHash('') expect(hash).toMatch(/^[0-9a-f]{64}$/) }) }) describe('stats calculation', () => { it('calculates total_income as sum of positive amounts (Nordea)', () => { const result = parseBankFile(NORDEA_CSV, 'nordea.csv') expect(result.stats.total_income).toBe(25000) }) it('calculates total_expenses as sum of negative amounts (Nordea)', () => { const result = parseBankFile(NORDEA_CSV, 'nordea.csv') // -99 + -432.5 = -531.5 expect(result.stats.total_expenses).toBe(-531.5) }) it('calculates parsed_rows correctly', () => { const result = parseBankFile(NORDEA_CSV, 'nordea.csv') expect(result.stats.parsed_rows).toBe(3) }) it('calculates total_rows (excluding header)', () => { const result = parseBankFile(NORDEA_CSV, 'nordea.csv') // 4 lines total - 1 header = 3 data rows expect(result.stats.total_rows).toBe(3) }) it('tracks skipped_rows for reserved/preliminary transactions', () => { const result = parseBankFile(NORDEA_CSV_WITH_RESERVED, 'nordea.csv') expect(result.stats.skipped_rows).toBe(1) expect(result.stats.parsed_rows).toBe(2) }) it('calculates stats correctly for SEB format', () => { const result = parseBankFile(SEB_CSV, 'seb.csv') expect(result.stats.total_income).toBe(25000) expect(result.stats.total_expenses).toBe(-531.5) expect(result.stats.parsed_rows).toBe(3) expect(result.stats.skipped_rows).toBe(0) }) it('calculates stats correctly for Swedbank format', () => { const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv') expect(result.stats.total_income).toBe(25000) expect(result.stats.total_expenses).toBe(-531.5) expect(result.stats.parsed_rows).toBe(3) }) it('calculates stats correctly for camt.053 XML', () => { const result = parseBankFile(CAMT053_XML, 'statement.xml') expect(result.stats.total_income).toBe(25000) expect(result.stats.total_expenses).toBe(-99) expect(result.stats.parsed_rows).toBe(2) expect(result.stats.total_rows).toBe(2) }) it('uses Math.round(x * 100) / 100 for monetary precision', () => { // Create a file that would produce floating point imprecision const precisionCSV = [ 'Datum,Transaktion,Kategori,Belopp,Saldo', '2024-01-01,TX1,,"-0,10","100,00"', '2024-01-02,TX2,,"-0,20","99,90"', '2024-01-03,TX3,,"-0,30","99,70"', ].join('\n') const result = parseBankFile(precisionCSV, 'precision.csv') // 0.1 + 0.2 + 0.3 = 0.6000000000000001 without rounding // With Math.round(x * 100) / 100, it should be -0.6 expect(result.stats.total_expenses).toBe(-0.6) }) }) describe('date range extraction', () => { it('sets date_from to the earliest date', () => { const result = parseBankFile(NORDEA_CSV, 'nordea.csv') expect(result.date_from).toBe('2024-01-13') }) it('sets date_to to the latest date', () => { const result = parseBankFile(NORDEA_CSV, 'nordea.csv') expect(result.date_to).toBe('2024-01-15') }) it('returns null dates for empty transaction set', () => { const result = parseBankFile(HEADER_ONLY_NORDEA, 'nordea.csv') expect(result.date_from).toBeNull() expect(result.date_to).toBeNull() }) it('handles single-transaction file (date_from equals date_to)', () => { const singleRow = [ 'Datum,Transaktion,Kategori,Belopp,Saldo', '2024-06-15,ENSKILD BETALNING,,"-500,00","10 000,00"', ].join('\n') const result = parseBankFile(singleRow, 'single.csv') expect(result.date_from).toBe('2024-06-15') expect(result.date_to).toBe('2024-06-15') }) it('calculates correct date range for camt.053', () => { const result = parseBankFile(CAMT053_XML, 'statement.xml') expect(result.date_from).toBe('2024-01-14') expect(result.date_to).toBe('2024-01-15') }) it('sorts dates lexicographically (YYYY-MM-DD is naturally sortable)', () => { const multiMonth = [ 'Datum,Transaktion,Kategori,Belopp,Saldo', '2024-12-31,DEC TX,,"-10,00","1000,00"', '2024-01-01,JAN TX,,"-20,00","990,00"', '2024-06-15,JUN TX,,"-30,00","960,00"', ].join('\n') const result = parseBankFile(multiMonth, 'multimonth.csv') expect(result.date_from).toBe('2024-01-01') expect(result.date_to).toBe('2024-12-31') }) }) describe('empty file handling', () => { it('returns error result for completely empty file (no auto-detect match)', () => { const result = parseBankFile(EMPTY_FILE, 'empty.csv') expect(result.transactions).toHaveLength(0) expect(result.issues.length).toBeGreaterThan(0) expect(result.date_from).toBeNull() expect(result.date_to).toBeNull() expect(result.stats.parsed_rows).toBe(0) }) it('returns zero transactions for file with only whitespace', () => { const whitespace = ' \n \n ' const result = parseBankFile(whitespace, 'blank.csv') expect(result.transactions).toHaveLength(0) }) it('returns zero transactions for Nordea header-only file', () => { const result = parseBankFile(HEADER_ONLY_NORDEA, 'nordea.csv') expect(result.format).toBe('nordea') expect(result.transactions).toHaveLength(0) expect(result.stats.total_rows).toBe(0) }) }) describe('getFormat and getAllFormats', () => { it('getFormat returns the correct format by ID', () => { const nordea = getFormat('nordea') expect(nordea).toBeDefined() expect(nordea!.id).toBe('nordea') expect(nordea!.name).toBe('Nordea') const seb = getFormat('seb') expect(seb).toBeDefined() expect(seb!.id).toBe('seb') }) it('getFormat returns undefined for unknown ID', () => { const unknown = getFormat('nonexistent' as BankFileFormatId) expect(unknown).toBeUndefined() }) it('getAllFormats returns all registered formats', () => { const formats = getAllFormats() expect(formats.length).toBeGreaterThanOrEqual(10) const ids = formats.map((f) => f.id) expect(ids).toContain('nordea') expect(ids).toContain('seb') expect(ids).toContain('swedbank') expect(ids).toContain('handelsbanken') expect(ids).toContain('lansforsakringar') expect(ids).toContain('ica_banken') expect(ids).toContain('skandia') expect(ids).toContain('lunar') expect(ids).toContain('camt053') expect(ids).toContain('generic_csv') }) it('camt053 is listed before bank-specific CSV formats (detection priority)', () => { const formats = getAllFormats() const camtIdx = formats.findIndex((f) => f.id === 'camt053') const nordeaIdx = formats.findIndex((f) => f.id === 'nordea') expect(camtIdx).toBeLessThan(nordeaIdx) }) it('generic_csv is listed last (manual fallback only)', () => { const formats = getAllFormats() const genericIdx = formats.findIndex((f) => f.id === 'generic_csv') expect(genericIdx).toBe(formats.length - 1) }) }) describe('edge cases and robustness', () => { it('handles Windows-style line endings (CRLF)', () => { const crlfContent = 'Datum,Transaktion,Kategori,Belopp,Saldo\r\n2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"\r\n' const result = parseBankFile(crlfContent, 'nordea.csv') expect(result.format).toBe('nordea') expect(result.transactions).toHaveLength(1) expect(result.transactions[0].amount).toBe(-99) }) it('handles BOM (Byte Order Mark) prefix', () => { const bomContent = '\uFEFF' + NORDEA_CSV const result = parseBankFile(bomContent, 'nordea.csv') expect(result.format).toBe('nordea') expect(result.transactions).toHaveLength(3) }) it('handles rows with invalid dates gracefully', () => { const invalidDate = [ 'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo', 'not-a-date;2024-01-15;12345;SPOTIFY AB;-99,00;12345,67', '2024-01-14;2024-01-14;12346;VALID TX;-50,00;12395,67', ].join('\n') const result = parseBankFile(invalidDate, 'seb.csv') expect(result.transactions).toHaveLength(1) expect(result.transactions[0].description).toBe('VALID TX') expect(result.issues.length).toBeGreaterThan(0) expect(result.stats.skipped_rows).toBe(1) }) it('handles rows with invalid amounts gracefully', () => { const invalidAmount = [ 'Datum,Transaktion,Kategori,Belopp,Saldo', '2024-01-15,SPOTIFY AB,,"abc","12 345,67"', '2024-01-14,VALID TX,,"-50,00","12 395,67"', ].join('\n') const result = parseBankFile(invalidAmount, 'nordea.csv') expect(result.transactions).toHaveLength(1) expect(result.transactions[0].description).toBe('VALID TX') expect(result.stats.skipped_rows).toBe(1) }) it('handles trailing blank lines', () => { const trailing = NORDEA_CSV + '\n\n\n' const result = parseBankFile(trailing, 'nordea.csv') expect(result.transactions).toHaveLength(3) }) it('handles Handelsbanken CSV with only reskontradatum (no transaktionsdatum)', () => { const onlyReskontra = [ 'Reskontradatum;Text;Belopp;Saldo', '2024-01-15;SPOTIFY AB;-99,00;12345,67', ].join('\n') const format = detectFileFormat(onlyReskontra, 'shb.csv') expect(format).not.toBeNull() expect(format!.id).toBe('handelsbanken') }) it('handles large amounts without overflow', () => { const largeAmounts = [ 'Datum,Transaktion,Kategori,Belopp,Saldo', '2024-01-15,BIG TRANSFER,,"1 500 000,00","2 000 000,00"', ].join('\n') const result = parseBankFile(largeAmounts, 'nordea.csv') expect(result.transactions).toHaveLength(1) expect(result.transactions[0].amount).toBe(1500000) expect(result.transactions[0].balance).toBe(2000000) }) it('handles zero amounts', () => { const zeroAmount = [ 'Datum,Transaktion,Kategori,Belopp,Saldo', '2024-01-15,FEE REVERSAL,,"0,00","5 000,00"', ].join('\n') const result = parseBankFile(zeroAmount, 'nordea.csv') expect(result.transactions).toHaveLength(1) expect(result.transactions[0].amount).toBe(0) }) }) // --- Fix 5: SEB duplicate condition removal --- describe('SEB detection: no duplicate conditions', () => { it('detects SEB with bokföringsdag header', () => { const content = 'Bokföringsdag;Valutadag;Text;Belopp;Saldo\n2024-01-15;2024-01-15;Test;-100,00;5000,00' const format = detectFileFormat(content, 'seb.csv') expect(format).not.toBeNull() expect(format!.id).toBe('seb') }) it('detects SEB with bokforingsdatum header (no diacritics)', () => { const content = 'Bokforingsdatum;Valutadag;Text;Belopp;Saldo\n2024-01-15;2024-01-15;Test;-100,00;5000,00' const format = detectFileFormat(content, 'seb.csv') expect(format).not.toBeNull() expect(format!.id).toBe('seb') }) }) // --- Fix 6: Länsförsäkringar false positive prevention --- describe('Länsförsäkringar detection: false positive prevention', () => { it('detects valid LF data rows with comma-decimal amounts', () => { const format = detectFileFormat(LANSFORSAKRINGAR_CSV, 'lf.csv') expect(format).not.toBeNull() expect(format!.id).toBe('lansforsakringar') }) it('detects LF header-less data with two dates and comma-decimal amount', () => { const format = detectFileFormat(LANSFORSAKRINGAR_CSV_NO_HEADER, 'lf.csv') expect(format).not.toBeNull() expect(format!.id).toBe('lansforsakringar') }) it('does not false-positive on generic semicolon CSV with two date columns but non-numeric 5th field', () => { // This CSV has two date columns + 5 fields but the 5th field is not a number const falsePositive = [ '"2024-01-15";"2024-01-15";"Typ";"Text";"not-a-number"', ].join('\n') const format = detectFileFormat(falsePositive, 'generic.csv') // Should NOT detect as Länsförsäkringar expect(format?.id).not.toBe('lansforsakringar') }) }) // --- Fix 7: Generic CSV column bounds checking --- describe('parseGenericCSV: column bounds checking', () => { it('skips rows with too few columns and adds warning', () => { const content = [ 'Date,Description,Amount', '2024-01-15,Test,-100.00', '2024-01-16,Short', // Only 2 columns, mapping needs column 2 (amount) ].join('\n') const result = parseGenericCSV(content, { date: 0, description: 1, amount: 2, delimiter: ',', decimal_separator: '.', skip_rows: 1, date_format: 'YYYY-MM-DD', }) expect(result.transactions).toHaveLength(1) expect(result.transactions[0].description).toBe('Test') expect(result.stats.skipped_rows).toBe(1) expect(result.issues.some((i) => i.message.includes('columns but mapping requires'))).toBe(true) }) it('skips rows when mapping index exceeds field count', () => { const content = [ 'A,B', 'val1,val2', ].join('\n') const result = parseGenericCSV(content, { date: 0, description: 1, amount: 5, // Column 5 does not exist (only 2 columns) delimiter: ',', decimal_separator: '.', skip_rows: 1, date_format: 'YYYY-MM-DD', }) expect(result.transactions).toHaveLength(0) expect(result.stats.skipped_rows).toBe(1) expect(result.issues).toHaveLength(1) }) it('parses normally when all columns are within bounds', () => { const content = [ 'Date,Description,Amount', '2024-01-15,SPOTIFY,-99.00', '2024-01-16,SALARY,25000.00', ].join('\n') const result = parseGenericCSV(content, { date: 0, description: 1, amount: 2, delimiter: ',', decimal_separator: '.', skip_rows: 1, date_format: 'YYYY-MM-DD', }) expect(result.transactions).toHaveLength(2) expect(result.stats.skipped_rows).toBe(0) expect(result.issues).toHaveLength(0) }) it('parses amounts that use Unicode minus (U+2212): was NaN before normalization', () => { const content = [ 'Date,Description,Amount', '2024-01-15,SPOTIFY,"\u221299,00"', '2024-01-16,REFUND,"\u201350,00"', '2024-01-17,SALARY,"25000,00"', ].join('\n') const result = parseGenericCSV(content, { date: 0, description: 1, amount: 2, delimiter: ',', decimal_separator: ',', skip_rows: 1, date_format: 'YYYY-MM-DD', }) expect(result.transactions).toHaveLength(3) expect(result.transactions[0].amount).toBe(-99) expect(result.transactions[1].amount).toBe(-50) expect(result.transactions[2].amount).toBe(25000) }) it('skips metadata rows when skip_rows is set higher than 1 (multi-row preamble)', () => { // Mimics a file with 5 metadata rows + header + 2 transactions. const content = [ 'Account,12345', 'Owner,Acme AB', 'Period,2024-01,2024-12', 'Currency,SEK', 'Type,Statement', 'Date,Description,Amount', '2024-01-15,SPOTIFY,-99.00', '2024-01-16,SALARY,25000.00', ].join('\n') const result = parseGenericCSV(content, { date: 0, description: 1, amount: 2, delimiter: ',', decimal_separator: '.', skip_rows: 6, date_format: 'YYYY-MM-DD', }) expect(result.transactions).toHaveLength(2) expect(result.transactions[0].amount).toBe(-99) expect(result.transactions[1].amount).toBe(25000) expect(result.issues).toHaveLength(0) }) }) describe('normalizeMinusSign', () => { it('replaces U+2212 (minus sign) with ASCII hyphen', () => { expect(normalizeMinusSign('\u2212139,00')).toBe('-139,00') }) it('replaces U+2013 (en dash) and U+2014 (em dash) with ASCII hyphen', () => { expect(normalizeMinusSign('\u2013100')).toBe('-100') expect(normalizeMinusSign('\u2014250')).toBe('-250') }) it('leaves ASCII hyphen and digits untouched', () => { expect(normalizeMinusSign('-139.00')).toBe('-139.00') expect(normalizeMinusSign('139.00')).toBe('139.00') }) it('makes parseFloat work on Unicode-minus strings (regression for Northmill)', () => { expect(parseFloat('\u2212139.00')).toBeNaN() expect(parseFloat(normalizeMinusSign('\u2212139.00'))).toBe(-139) }) }) // --- Fix 8: parseCSVLine unclosed quote handling --- describe('parseCSVLine: unclosed quote handling', () => { it('handles normal quoted fields correctly', () => { const fields = parseCSVLine('"hello","world"', ',') expect(fields).toEqual(['hello', 'world']) }) it('handles escaped quotes (doubled) inside fields', () => { const fields = parseCSVLine('"he said ""hi""",other', ',') expect(fields).toEqual(['he said "hi"', 'other']) }) it('produces a result even with unclosed quotes instead of hanging', () => { // Unclosed quote: the parser should not hang or crash const fields = parseCSVLine('"unclosed,field2,field3', ',') // With unclosed quote, everything after the opening quote is one field // The important thing is it doesn't crash and returns something expect(fields.length).toBeGreaterThan(0) }) it('preserves data with unclosed quote rather than losing it', () => { const fields = parseCSVLine('normal,"unclosed value', ',') // Should have at least the first field and whatever was accumulated expect(fields.length).toBe(2) expect(fields[0]).toBe('normal') // The unclosed quoted field should still contain the text expect(fields[1]).toContain('unclosed value') }) it('handles semicolon delimiter with quotes', () => { const fields = parseCSVLine('"2024-01-15";"SPOTIFY AB";"-99,00"', ';') expect(fields).toEqual(['2024-01-15', 'SPOTIFY AB', '-99,00']) }) }) // --------------------------------------------------------------------------- // Wise (TransferWise) multi-currency transaction history // --------------------------------------------------------------------------- const WISE_HEADER = 'ID,Status,Direction,"Created on","Finished on","Source fee amount","Source fee currency","Target fee amount","Target fee currency","Source name","Source amount (after fees)","Source currency","Target name","Target amount (after fees)","Target currency","Exchange rate",Reference,Batch,"Created by",Category,Note' const WISE_CSV = [ WISE_HEADER, 'TRANSFER-2247230173,COMPLETED,IN,"2026-07-13 13:39:01","2026-07-13 13:39:08",,,,,"Bluedot Impact Ltd",2500.0,USD,"Aligned Intelligence AB",2500.0,USD,1,Facilitation,,,"Money added",', 'TRANSFER-2214309703,COMPLETED,IN,"2026-06-26 20:53:33","2026-06-26 20:54:14",2.20,SEK,,,"Aligned Intelligence AB",200.0,SEK,"Aligned Intelligence AB",200.0,SEK,1.0,,,"Peter Alexander Reinthal","Money added",', 'PLAN_ORDER-28688820,COMPLETED,OUT,"2026-06-24 06:13:45","2026-06-24 06:41:51",,,,,,520.00,SEK,TransferWise,520.00,SEK,1.00000000,28688820,,"Peter Alexander Reinthal",General,', 'TRANSFER-2208605608,COMPLETED,IN,"2026-06-24 06:13:45","2026-06-24 06:41:50",0.00,SEK,,,"Aligned Intelligence AB",520.0,SEK,"Aligned Intelligence AB",520.0,SEK,1.0,invoice-28688820,,"Peter Alexander Reinthal","Money added",', ].join('\n') describe('Wise format', () => { it('auto-detects the Wise header', () => { const format = detectFileFormat(WISE_CSV, 'transactionhistory.csv') expect(format?.id).toBe('wise') }) const byId = (txs: ParsedBankTransaction[], id: string) => txs.find((t) => t.raw_line === id) it('signs IN as income and OUT as expense, on the moved-side currency', () => { const result = parseBankFile(WISE_CSV, 'wise.csv') expect(result.format).toBe('wise') const usdIn = byId(result.transactions, 'TRANSFER-2247230173') expect(usdIn).toMatchObject({ amount: 2500, currency: 'USD', date: '2026-07-13' }) const sekOut = byId(result.transactions, 'PLAN_ORDER-28688820') expect(sekOut).toMatchObject({ amount: -520, currency: 'SEK', date: '2026-06-24' }) expect(sekOut?.counterparty).toBe('TransferWise') }) it('emits a non-zero fee as its own negative "Wise avgift" row', () => { const result = parseBankFile(WISE_CSV, 'wise.csv') const fee = byId(result.transactions, 'TRANSFER-2214309703-fee') expect(fee).toBeDefined() expect(fee?.amount).toBe(-2.2) expect(fee?.currency).toBe('SEK') expect(fee?.description).toMatch(/^Wise avgift/) // A 0.00 fee produces no extra row. expect(byId(result.transactions, 'TRANSFER-2208605608-fee')).toBeUndefined() // 4 movements + 1 fee row. expect(result.transactions).toHaveLength(5) }) it('keys external_id on the stable Wise ID, including fee rows', () => { const result = parseBankFile(WISE_CSV, 'wise.csv') const main = byId(result.transactions, 'TRANSFER-2247230173')! const fee = byId(result.transactions, 'TRANSFER-2214309703-fee')! expect(generateExternalId(main, 'wise', 0)).toBe('wise_TRANSFER-2247230173') expect(generateExternalId(fee, 'wise', 1)).toBe('wise_TRANSFER-2214309703-fee') }) it('skips rows that are not COMPLETED', () => { const withCancelled = [ WISE_HEADER, 'TRANSFER-9,CANCELLED,IN,"2026-06-01 10:00:00","2026-06-01 10:00:00",,,,,"X",100.0,SEK,"Y",100.0,SEK,1,,,,General,', 'TRANSFER-2247230173,COMPLETED,IN,"2026-07-13 13:39:01","2026-07-13 13:39:08",,,,,"Bluedot Impact Ltd",2500.0,USD,"Aligned Intelligence AB",2500.0,USD,1,Facilitation,,,"Money added",', ].join('\n') const result = parseBankFile(withCancelled, 'wise.csv') expect(result.transactions).toHaveLength(1) expect(result.transactions[0].raw_line).toBe('TRANSFER-2247230173') expect(result.stats.skipped_rows).toBe(1) expect(result.issues).toContainEqual( expect.objectContaining({ severity: 'warning', message: expect.stringMatching(/unsupported status "CANCELLED"/), }), ) }) }) describe('Wise format hardening', () => { const row = (over: Partial> = {}) => { const f: Record = { id: 'TRANSFER-1', status: 'COMPLETED', direction: 'IN', created: '2026-06-01 10:00:00', finished: '2026-06-01 10:00:00', sfeeA: '', sfeeC: '', tfeeA: '', tfeeC: '', sname: 'X', samt: '100.0', scur: 'SEK', tname: 'Y', tamt: '100.0', tcur: 'SEK', rate: '1', ref: '', batch: '', by: '', cat: 'General', note: '', ...over, } return [ f.id, f.status, f.direction, `"${f.created}"`, `"${f.finished}"`, f.sfeeA, f.sfeeC, f.tfeeA, f.tfeeC, `"${f.sname}"`, f.samt, f.scur, `"${f.tname}"`, f.tamt, f.tcur, f.rate, f.ref, f.batch, `"${f.by}"`, f.cat, f.note, ].join(',') } it('skips and surfaces an unsupported Direction without aborting valid rows', () => { const csv = [ WISE_HEADER, row({ id: 'PLAN_ORDER-9', direction: 'NEUTRAL', scur: 'USD', tcur: 'SEK' }), row({ id: 'TRANSFER-2' }), ].join('\n') const result = parseBankFile(csv, 'wise.csv') expect(result.transactions).toHaveLength(1) expect(result.transactions[0].raw_line).toBe('TRANSFER-2') expect(result.stats.skipped_rows).toBe(1) expect(result.issues).toContainEqual( expect.objectContaining({ severity: 'error', message: expect.stringMatching(/unsupported Direction "NEUTRAL"/), }), ) }) it('skips and surfaces a cross-currency row instead of importing one side', () => { const csv = [ WISE_HEADER, row({ id: 'TRANSFER-FX', direction: 'OUT', samt: '100', scur: 'USD', tamt: '900', tcur: 'SEK' }), ].join('\n') const result = parseBankFile(csv, 'wise.csv') expect(result.transactions).toHaveLength(0) expect(result.stats.skipped_rows).toBe(1) expect(result.issues).toContainEqual( expect.objectContaining({ severity: 'error', message: expect.stringMatching(/Cross-currency Wise row TRANSFER-FX \(USD to SEK\)/), }), ) }) it('surfaces a REFUNDED row instead of silently dropping it', () => { const csv = [WISE_HEADER, row({ id: 'TRANSFER-REFUND', status: 'REFUNDED' })].join('\n') const result = parseBankFile(csv, 'wise.csv') expect(result.transactions).toHaveLength(0) expect(result.stats.skipped_rows).toBe(1) expect(result.issues).toContainEqual( expect.objectContaining({ severity: 'error', message: expect.stringMatching(/unsupported status "REFUNDED"/), }), ) }) it('does not import a row with a blank status', () => { const csv = [WISE_HEADER, row({ status: '' })].join('\n') const result = parseBankFile(csv, 'wise.csv') expect(result.transactions).toHaveLength(0) expect(result.stats.skipped_rows).toBe(1) expect(result.issues[0].severity).toBe('error') }) it('rejects a partially numeric amount instead of coercing it', () => { const csv = [WISE_HEADER, row({ samt: '12abc', tamt: '12abc' })].join('\n') const result = parseBankFile(csv, 'wise.csv') expect(result.transactions).toHaveLength(0) expect(result.issues.some((iss) => /Invalid amount/.test(iss.message))).toBe(true) }) it('skips a row with no movement currency rather than defaulting to SEK', () => { const csv = [WISE_HEADER, row({ direction: 'IN', tcur: '' })].join('\n') const result = parseBankFile(csv, 'wise.csv') expect(result.transactions).toHaveLength(0) expect(result.issues.some((iss) => /Missing\/invalid currency/.test(iss.message))).toBe(true) }) it('does not inherit the movement currency for a fee with no currency', () => { const csv = [WISE_HEADER, row({ sfeeA: '2.20', sfeeC: '' })].join('\n') const result = parseBankFile(csv, 'wise.csv') // Main row still imports; the fee is dropped with a warning, not booked in SEK. expect(result.transactions).toHaveLength(1) expect(result.transactions[0].raw_line).toBe('TRANSFER-1') expect(result.issues.some((iss) => /no currency/.test(iss.message))).toBe(true) }) }) // --------------------------------------------------------------------------- // Wise per-currency balance statement // --------------------------------------------------------------------------- const WISE_STATEMENT_HEADER = '"TransferWise ID",Date,Amount,Currency,Description,"Payment Reference","Running Balance","Exchange From","Exchange To","Exchange Rate","Payer Name","Payee Name","Payee Account Number",Merchant,"Card Last Four Digits","Card Holder Full Name",Attachment,Note,"Total fees"' function wiseStatementRow(over: Partial> = {}): string { const fields: Record = { id: 'TRANSFER-100', date: '01/08/2026', amount: '1250.50', currency: 'SEK', description: 'Received money from Example AB', reference: 'INV-100', balance: '5000.50', exchangeFrom: '', exchangeTo: '', exchangeRate: '', payerName: 'Example AB', payeeName: '', payeeAccount: '', merchant: '', cardLastFour: '', cardHolder: '', attachment: '', note: '', totalFees: '0', ...over, } return [ fields.id, fields.date, fields.amount, fields.currency, fields.description, fields.reference, fields.balance, fields.exchangeFrom, fields.exchangeTo, fields.exchangeRate, fields.payerName, fields.payeeName, fields.payeeAccount, fields.merchant, fields.cardLastFour, fields.cardHolder, fields.attachment, fields.note, fields.totalFees, ].join(',') } const WISE_STATEMENT_CSV = [ WISE_STATEMENT_HEADER, wiseStatementRow(), wiseStatementRow({ id: 'CARD-200', date: '02-08-2026', amount: '-49.90', description: '', reference: '', balance: '4950.60', payerName: '', merchant: 'Corner Shop', note: 'Lunch', }), wiseStatementRow({ id: 'FEE-TRANSFER-300', date: '03.08.2026', amount: '-2.20', description: 'Wise Charges for: TRANSFER-300', reference: '', balance: '4948.40', payerName: '', payeeName: 'Wise', }), wiseStatementRow({ id: 'TRANSFER-300', date: '2026-08-04', amount: '-100', description: 'Sent money to Supplier AB', reference: 'BILL-300', balance: '4848.40', payerName: '', payeeName: 'Supplier AB', totalFees: '0.35', }), ].join('\n') describe('Wise balance statement format', () => { const byId = (transactions: ParsedBankTransaction[], id: string) => transactions.find((transaction) => transaction.raw_line === id) it('auto-detects the distinct balance statement header', () => { const format = detectFileFormat(WISE_STATEMENT_CSV, 'statement_123_SEK_2026.csv') expect(format?.id).toBe('wise_statement') }) it('parses signed movements, balances, counterparties, notes, and date variants', () => { const result = parseBankFile(WISE_STATEMENT_CSV, 'statement_123_SEK_2026.csv') expect(result.format).toBe('wise_statement') expect(result.transactions).toHaveLength(4) expect(byId(result.transactions, 'TRANSFER-100')).toMatchObject({ date: '2026-08-01', amount: 1250.5, currency: 'SEK', balance: 5000.5, reference: 'INV-100', counterparty: 'Example AB', }) expect(byId(result.transactions, 'CARD-200')).toMatchObject({ date: '2026-08-02', amount: -49.9, description: 'Corner Shop - Lunch', counterparty: 'Corner Shop', }) expect(byId(result.transactions, 'FEE-TRANSFER-300')).toMatchObject({ date: '2026-08-03', amount: -2.2, counterparty: 'Wise', }) expect(byId(result.transactions, 'TRANSFER-300')?.description).toContain( 'Wise avgift: 0.35 SEK', ) expect(result.date_from).toBe('2026-08-01') expect(result.date_to).toBe('2026-08-04') expect(result.stats).toMatchObject({ total_rows: 4, parsed_rows: 4, skipped_rows: 0, total_income: 1250.5, total_expenses: -152.1, }) }) it('imports explicit fee rows exactly once and does not synthesize extra movements', () => { const result = parseBankFile(WISE_STATEMENT_CSV, 'statement.csv') expect(result.transactions.filter((transaction) => transaction.amount === -2.2)).toHaveLength(1) expect(result.transactions).toHaveLength(4) }) it('shares ordinary movement IDs with transaction history across formats', () => { const statement = parseBankFile( [ WISE_STATEMENT_HEADER, wiseStatementRow({ id: 'TRANSFER-2247230173', currency: 'USD', amount: '2500', balance: '5000', }), ].join('\n'), 'statement_USD.csv', ).transactions[0] const history = parseBankFile(WISE_CSV, 'wise.csv').transactions.find( (transaction) => transaction.raw_line === 'TRANSFER-2247230173', )! expect(generateExternalId(statement, 'wise_statement', 0)).toBe( generateExternalId(history, 'wise', 0), ) }) it('qualifies conversion legs by statement currency', () => { const conversion = (currency: string) => parseBankFile( [ WISE_STATEMENT_HEADER, wiseStatementRow({ id: 'PLAN_ORDER-9', currency, exchangeFrom: '100 USD', exchangeTo: '900 SEK', exchangeRate: '9', }), ].join('\n'), `statement_${currency}.csv`, ).transactions[0] expect(generateExternalId(conversion('SEK'), 'wise_statement', 0)).toBe( 'wise_PLAN_ORDER-9:SEK', ) expect(generateExternalId(conversion('USD'), 'wise_statement', 0)).toBe( 'wise_PLAN_ORDER-9:USD', ) }) it('blocks a duplicate scoped Wise movement ID within one statement', () => { const result = parseBankFile( [WISE_STATEMENT_HEADER, wiseStatementRow(), wiseStatementRow()].join('\n'), 'statement.csv', ) expect(result.transactions).toHaveLength(1) expect(result.stats.skipped_rows).toBe(1) expect(result.issues).toContainEqual( expect.objectContaining({ severity: 'error', message: 'Duplicate Wise movement ID TRANSFER-100; skipped', }), ) }) it('accepts netted-fee statements in either ordering without continuity warnings', () => { const oldestFirst = parseBankFile(WISE_STATEMENT_CSV, 'statement.csv') const rows = WISE_STATEMENT_CSV.split('\n') const newestFirst = parseBankFile( [rows[0], ...rows.slice(1).reverse()].join('\n'), 'statement.csv', ) for (const result of [oldestFirst, newestFirst]) { expect(result.transactions).toHaveLength(4) expect(result.issues.filter((issue) => /Running balance break/.test(issue.message))).toEqual([]) } }) it('warns when the balance moves by more than Amount (fees not netted)', () => { const csv = [ WISE_STATEMENT_HEADER, wiseStatementRow({ id: 'IN-1', amount: '100', balance: '1100' }), wiseStatementRow({ id: 'OUT-2', date: '02/08/2026', amount: '-50', balance: '1049.65', totalFees: '0.35', }), ].join('\n') const result = parseBankFile(csv, 'statement.csv') expect(result.transactions).toHaveLength(2) expect( result.issues.some( (issue) => issue.severity === 'warning' && /Running balance break at OUT-2/.test(issue.message), ), ).toBe(true) }) it('skips malformed movements while retaining non-fatal metadata warnings', () => { const csv = [ WISE_STATEMENT_HEADER, wiseStatementRow({ id: 'BAD-AMOUNT', amount: '12abc' }), wiseStatementRow({ id: 'GOOD', balance: 'not-a-balance', totalFees: 'fee?' }), ].join('\n') const result = parseBankFile(csv, 'statement.csv') expect(result.transactions).toHaveLength(1) expect(result.stats.skipped_rows).toBe(1) expect(result.issues.some((issue) => /Invalid amount on BAD-AMOUNT/.test(issue.message))).toBe(true) expect(result.issues.some((issue) => /Invalid running balance on GOOD/.test(issue.message))).toBe(true) expect(result.issues.some((issue) => /Invalid total fees on GOOD/.test(issue.message))).toBe(true) }) })