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:
Jakob Wennberg
2026-08-19 22:18:58 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 4e20c9dec4
commit 68ca152127
3 changed files with 71 additions and 23 deletions
@@ -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()
+31 -16
View File
@@ -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({
+9 -7
View File
@@ -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 {