diff --git a/lib/import/bank-file/__tests__/parser.test.ts b/lib/import/bank-file/__tests__/parser.test.ts index b91e02ed..e6da1216 100644 --- a/lib/import/bank-file/__tests__/parser.test.ts +++ b/lib/import/bank-file/__tests__/parser.test.ts @@ -217,6 +217,18 @@ const LUNAR_CSV_2026 = '\uFEFF' + [ '2026-05-12,05:47,Card purchase,"-2 500,00","86 421,43",7f0a4c9e-1111-2222-3333-444455558888', ].join('\n') +// The same 2026 Lunar export downloaded with the app in Swedish: identical +// layout, translated headers. "Transaktions-ID" contains the substring +// "transaktion", which used to make Nordea's substring detector claim the file +// and parse the Tid column as the description (2026-08-18 report, 117 rows +// titled "21:30", "08:38"). +const LUNAR_CSV_2026_SV = '\uFEFF' + [ + 'Datum,Tid,Titel,Belopp,Balans,Transaktions-ID,Utländska belopp', + '2026-08-15,21:30,Bankgironummer avgift,"-39,00","13 034,28",3464e5f2-aaaa-bbbb-cccc-111122223333,', + '2026-08-15,08:38,Lunar Plan Essential,"-119,00","13 073,28",3464e5f2-aaaa-bbbb-cccc-444455556666,', + '2026-08-13,20:12,STRIPE Shopi,"-5 058,13","13 192,28",3464e5f2-aaaa-bbbb-cccc-777788889999,"-456,30 EUR"', +].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. @@ -422,6 +434,25 @@ describe('detectFileFormat', () => { expect(format!.id).toBe('lunar') }) + it('detects a Swedish-header Lunar export as lunar, not nordea', () => { + // Regression: Nordea is checked first and matched "transaktion" as a + // substring of "Transaktions-ID", so it won and mangled the file. + const format = detectFileFormat(LUNAR_CSV_2026_SV, 'transactions.csv') + expect(format).not.toBeNull() + expect(format!.id).toBe('lunar') + }) + + it('parses the Swedish Lunar export with Titel as the description', () => { + const format = detectFileFormat(LUNAR_CSV_2026_SV, 'transactions.csv') + const result = format!.parse(LUNAR_CSV_2026_SV) + expect(result.transactions).toHaveLength(3) + // The bug put the Tid column here ("21:30"). + expect(result.transactions[0].description).toBe('Bankgironummer avgift') + expect(result.transactions[0].amount).toBe(-39) + expect(result.transactions[2].amount).toBe(-5058.13) + expect(result.transactions.some((t) => /^\d{1,2}:\d{2}$/.test(t.description))).toBe(false) + }) + it('detects the 2026 Lunar CSV header (Title column, BOM) as lunar', () => { const format = detectFileFormat(LUNAR_CSV_2026, 'lunar.csv') expect(format).not.toBeNull() diff --git a/lib/import/bank-file/formats/lunar.ts b/lib/import/bank-file/formats/lunar.ts index adf4ec13..afc7e4b0 100644 --- a/lib/import/bank-file/formats/lunar.ts +++ b/lib/import/bank-file/formats/lunar.ts @@ -8,7 +8,10 @@ * Encoding: UTF-8, may start with a BOM * * Notes: - * - English headers distinguish Lunar from Nordea (Swedish headers) + * - Lunar exports in the app's display language, so the same file ships with + * English (Date, Time, Title, Amount, Balance) or Swedish (Datum, Tid, + * Titel, Belopp, Balans) headers. Both sets are accepted. Nordea is still + * distinguishable: it has Transaktion and Saldo, never Titel and Balans. * - Amounts use comma as decimal separator but are quoted since the file * delimiter is also comma * - The delimiter is sniffed from the header line: the documented export is @@ -57,12 +60,26 @@ function parseLunarHeader(headerLine: string, delimiter: string): string[] { * balance" is never claimed. Exact cells are also what parse() resolves on, * so detect() can never accept a header parse() then rejects. */ +const DATE_HEADERS = ['date', 'datum'] +/** Ordered by preference: the 2026 export's "title", then the legacy "text". */ +const DESC_HEADERS = ['title', 'titel', 'text'] +const AMOUNT_HEADERS = ['amount', 'belopp'] +const BALANCE_HEADERS = ['balance', 'balans'] + +function firstIndexOf(headers: string[], candidates: string[]): number { + for (const candidate of candidates) { + const index = headers.indexOf(candidate) + if (index !== -1) return index + } + return -1 +} + function isLunarHeader(cells: string[]): boolean { return ( - cells.includes('date') && - (cells.includes('title') || cells.includes('text')) && - cells.includes('amount') && - cells.includes('balance') + firstIndexOf(cells, DATE_HEADERS) !== -1 && + firstIndexOf(cells, DESC_HEADERS) !== -1 && + firstIndexOf(cells, AMOUNT_HEADERS) !== -1 && + firstIndexOf(cells, BALANCE_HEADERS) !== -1 ) } @@ -80,16 +97,16 @@ function parseLunarAmount(value: string): number { export const lunarFormat: BankFileFormat = { id: 'lunar', name: 'Lunar', - description: 'Lunar CSV (comma-delimited, English headers)', + description: 'Lunar CSV (comma-delimited, English or Swedish headers)', fileExtensions: ['.csv', '.txt'], detect(content: string, _filename: string): boolean { const prepared = prepareContent(content) const firstLine = prepared.split('\n')[0] || '' - // Lunar: English headers "date", "amount", "balance" and a description - // column: "title" (2026 export) or "text" (legacy export). Delimiter is - // sniffed (comma, semicolon or tab); the Swedish-header banks are all - // checked before this format, so the English set is what distinguishes it. + // Lunar: a date, description, amount and balance column, in English or + // Swedish. Delimiter is sniffed (comma, semicolon or tab). Nordea keeps + // priority in the registry and is now matched on whole cells, so a Swedish + // Lunar export no longer lands there (2026-08-18 report). return isLunarHeader(parseLunarHeader(firstLine, sniffLunarDelimiter(firstLine))) }, @@ -106,12 +123,10 @@ export const lunarFormat: BankFileFormat = { const delimiter = sniffLunarDelimiter(headerLine) const headers = parseLunarHeader(headerLine, delimiter) - const dateIdx = headers.findIndex((h) => h === 'date') - // "title" is the 2026 export's description column; "text" is the legacy one - const titleIdx = headers.findIndex((h) => h === 'title') - const descIdx = titleIdx !== -1 ? titleIdx : headers.findIndex((h) => h === 'text') - const amountIdx = headers.findIndex((h) => h === 'amount') - const balanceIdx = headers.findIndex((h) => h === 'balance') + const dateIdx = firstIndexOf(headers, DATE_HEADERS) + const descIdx = firstIndexOf(headers, DESC_HEADERS) + const amountIdx = firstIndexOf(headers, AMOUNT_HEADERS) + const balanceIdx = firstIndexOf(headers, BALANCE_HEADERS) if (dateIdx === -1 || amountIdx === -1) { issues.push({ diff --git a/lib/import/bank-file/formats/nordea.ts b/lib/import/bank-file/formats/nordea.ts index 9ef6e63c..2b5a6f4d 100644 --- a/lib/import/bank-file/formats/nordea.ts +++ b/lib/import/bank-file/formats/nordea.ts @@ -29,15 +29,17 @@ export const nordeaFormat: BankFileFormat = { detect(content: string, _filename: string): boolean { const prepared = prepareContent(content) - const firstLine = prepared.split('\n')[0]?.toLowerCase() || '' - // Nordea header: comma-delimited with "datum", "transaktion", "belopp" + const firstLine = prepared.split('\n')[0] || '' // Must NOT contain semicolons (that would be SEB or Handelsbanken) - return ( - !firstLine.includes(';') && - firstLine.includes('datum') && - firstLine.includes('transaktion') && - firstLine.includes('belopp') + if (firstLine.includes(';')) return false + // Whole cells, not substrings. A substring test claimed a Swedish-language + // Lunar export, whose "Transaktions-ID" column contains "transaktion": + // Nordea is checked first, so it won and parsed Lunar's "Tid" column as the + // description. Reported 2026-08-18 with 117 rows titled "21:30", "08:38". + const cells = parseCSVLine(firstLine, ',').map((cell) => + cell.trim().toLowerCase().replace(/"/g, ''), ) + return cells.includes('datum') && cells.includes('transaktion') && cells.includes('belopp') }, parse(content: string): BankFileParseResult {