fix(import): stop a Swedish Lunar export being parsed as Nordea (#1734)
Nordea's detector tested includes('transaktion') against the raw header
line, so Lunar's Transaktions-ID column matched, and Nordea is
registered first. The file was parsed with Nordea's column layout and
the Tid column landed in the description: a live import on 2026-08-18
produced 117 transactions titled 21:30 and 08:38.
Nordea now matches whole header cells, and Lunar's detector and column
resolution accept the Swedish header set next to the English one.
Verified against the real file: 117 rows, 0 issues, descriptions are
the Titel column.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
4e20c9dec4
commit
68ca152127
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user