Replace PSD2 bank integration as the default with file-based bank import (CSV/XML), which better suits Swedish sole traders and small companies. Enable Banking is now an opt-in extension. - Phase 1: Extract generic transaction ingestion service (ingest.ts) with dedup, auto-categorization, and OCR-based invoice matching - Phase 2: Bank file parser library supporting Nordea, SEB, Swedbank, Handelsbanken CSV formats and ISO 20022 camt.053 XML - Phase 3: Database migration adding import_source, reference columns and bank_file_imports tracking table - Phase 4: Import wizard UI (5-step flow) and API routes for parse/execute - Phase 5: Move Enable Banking to extensions/enable-banking/ with commented-out loader entry for opt-in activation - Phase 6: 104 new tests (ingestion + all parser formats), fixing Nordea detection overlap and camt.053 XML tag collision bugs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
973 lines
32 KiB
TypeScript
973 lines
32 KiB
TypeScript
/**
|
|
* 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'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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')
|
|
|
|
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')
|
|
|
|
const CAMT053_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
|
<BkToCstmrStmt>
|
|
<Stmt>
|
|
<Acct><Ccy>SEK</Ccy></Acct>
|
|
<Ntry>
|
|
<BookgDt><Dt>2024-01-15</Dt></BookgDt>
|
|
<Amt Ccy="SEK">99.00</Amt>
|
|
<CdtDbtInd>DBIT</CdtDbtInd>
|
|
<NtryRef>REF001</NtryRef>
|
|
<NtryDtls><TxDtls>
|
|
<RmtInf><Ustrd>SPOTIFY AB</Ustrd></RmtInf>
|
|
</TxDtls></NtryDtls>
|
|
</Ntry>
|
|
<Ntry>
|
|
<BookgDt><Dt>2024-01-14</Dt></BookgDt>
|
|
<Amt Ccy="SEK">25000.00</Amt>
|
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
|
<NtryRef>REF002</NtryRef>
|
|
<NtryDtls><TxDtls>
|
|
<RmtInf><Ustrd>LÖNEUTBETALNING</Ustrd></RmtInf>
|
|
</TxDtls></NtryDtls>
|
|
</Ntry>
|
|
</Stmt>
|
|
</BkToCstmrStmt>
|
|
</Document>`
|
|
|
|
const CAMT053_XML_WITH_STRUCTURED_REF = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
|
<BkToCstmrStmt>
|
|
<Stmt>
|
|
<Ntry>
|
|
<BookgDt><Dt>2024-02-01</Dt></BookgDt>
|
|
<Amt Ccy="SEK">1500.00</Amt>
|
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
|
<NtryRef>REF100</NtryRef>
|
|
<NtryDtls><TxDtls>
|
|
<RmtInf>
|
|
<Strd><CdtrRefInf><Ref>OCR123456789</Ref></CdtrRefInf></Strd>
|
|
<Ustrd>Betalning faktura 1001</Ustrd>
|
|
</RmtInf>
|
|
</TxDtls></NtryDtls>
|
|
</Ntry>
|
|
</Stmt>
|
|
</BkToCstmrStmt>
|
|
</Document>`
|
|
|
|
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'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 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 = '<?xml version="1.0"?><Document><BkToCstmrStmt></BkToCstmrStmt></Document>'
|
|
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('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 — 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)
|
|
})
|
|
})
|
|
|
|
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('prefers transaktionsdatum over reskontradatum when both are present', () => {
|
|
// Handelsbanken has both columns; transaktionsdatum 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 transaktionsdatum as the primary date field', () => {
|
|
// Create data where reskontradatum differs from transaktionsdatum
|
|
const diffDates = [
|
|
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
|
|
'2024-01-16;2024-01-15;PURCHASE;-100,00;5000,00',
|
|
].join('\n')
|
|
|
|
const result = parseBankFile(diffDates, 'shb.csv')
|
|
expect(result.transactions[0].date).toBe('2024-01-15')
|
|
})
|
|
})
|
|
|
|
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 = `<?xml version="1.0"?>
|
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
|
<BkToCstmrStmt><Stmt></Stmt></BkToCstmrStmt></Document>`
|
|
|
|
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 <Ntry> 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('Could not auto-detect')
|
|
})
|
|
|
|
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(6)
|
|
|
|
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('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)
|
|
})
|
|
})
|