diff --git a/lib/import/bank-file/__tests__/parser.test.ts b/lib/import/bank-file/__tests__/parser.test.ts index fa9dffb3..191dfe3d 100644 --- a/lib/import/bank-file/__tests__/parser.test.ts +++ b/lib/import/bank-file/__tests__/parser.test.ts @@ -199,6 +199,15 @@ const LUNAR_CSV = [ '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. @@ -404,6 +413,12 @@ describe('detectFileFormat', () => { 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() @@ -1156,6 +1171,58 @@ describe('parseBankFile: Lunar format', () => { 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', () => { diff --git a/lib/import/bank-file/formats/lunar.ts b/lib/import/bank-file/formats/lunar.ts index 25ac16f7..6dec67cd 100644 --- a/lib/import/bank-file/formats/lunar.ts +++ b/lib/import/bank-file/formats/lunar.ts @@ -2,15 +2,17 @@ * Lunar CSV format parser * * Format: Comma-delimited, comma decimal separator (amounts are quoted) - * Columns: Date, Text, Amount, Balance (English headers) + * Columns (2026 export): Date, Time, Title, Amount, Balance, Transaction ID + * Columns (legacy): Date, Text, Amount, Balance * Date format: YYYY-MM-DD - * Encoding: UTF-8 + * Encoding: UTF-8, may start with a BOM * * Notes: * - English headers distinguish Lunar from Nordea (Swedish headers) * - Amounts use comma as decimal separator but are quoted since the file * delimiter is also comma - * - Thousand separator is period (e.g. "1.234,56") + * - Thousand separator is a space in the 2026 export (e.g. "12 345,00"); + * legacy exports used a period (e.g. "1.234,56"). Both are handled. */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' @@ -19,10 +21,14 @@ import { normalizeDate } from '../date-utils' import { parseCSVLine } from './nordea' function parseLunarAmount(value: string): number { - // Lunar format: "1.234,56" or "-1.234,56" - // Remove period (thousand separator), replace comma (decimal separator) with period - const cleaned = value.replace(/\./g, '').replace(',', '.') - return parseFloat(cleaned) + // Lunar format: "12 345,00" (2026, space thousands) or "1.234,56" (legacy, + // period thousands). Strip all whitespace (including NBSP U+00A0 and narrow + // NBSP U+202F) and periods, then convert the comma decimal to a period. + const cleaned = value.replace(/[\s\u00A0\u202F.]/g, '').replace(',', '.') + if (cleaned === '') return NaN + // Number() rejects trailing garbage that parseFloat would silently accept + const parsed = Number(cleaned) + return Number.isFinite(parsed) ? parsed : NaN } export const lunarFormat: BankFileFormat = { @@ -35,11 +41,12 @@ export const lunarFormat: BankFileFormat = { const prepared = prepareContent(content) const firstLine = prepared.split('\n')[0]?.toLowerCase() || '' // Lunar: comma-delimited with English headers - // Must NOT contain semicolons, must have "date", "text", "amount", "balance" + // Must NOT contain semicolons, must have "date", "amount", "balance" and a + // description column: "title" (2026 export) or "text" (legacy export) return ( !firstLine.includes(';') && firstLine.includes('date') && - firstLine.includes('text') && + (firstLine.includes('title') || firstLine.includes('text')) && firstLine.includes('amount') && firstLine.includes('balance') ) @@ -58,7 +65,9 @@ export const lunarFormat: BankFileFormat = { const headers = parseCSVLine(headerLine, ',').map((h) => h.trim().toLowerCase().replace(/"/g, '')) const dateIdx = headers.findIndex((h) => h === 'date') - const descIdx = headers.findIndex((h) => h === 'text') + // "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')