The Lunar parser was written against an assumed format. The actual 2026 export is: Date,Time,Title,Amount,Balance,Transaction ID with quoted amounts using a comma decimal and a SPACE thousands separator, UTF-8 BOM. Defect A (silent data corruption): parseLunarAmount only stripped '.' as a thousands separator, so parseFloat stopped at the space and "12 345,00" parsed as 12. Now strips all whitespace (including NBSP U+00A0 and narrow NBSP U+202F) plus periods, converts the comma decimal, and guards with Number() + Number.isFinite so garbage rows are skipped instead of partially parsed. Legacy period-thousands files still parse correctly. Defect B (auto-detection miss): detect() required the header token "text" but the real header uses "Title", so the file fell through to "Unknown format". detect() and the description column lookup now accept title (2026) with text as the legacy fallback. Regression tests cover 2026 header auto-detection with BOM, space thousands amounts and balances, Title-column descriptions, stats and date range, and legacy format backward compatibility. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c1ea0d9bf2
commit
b21aa84268
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user