Files
accounted/lib/import/bank-file/formats/lunar.ts
T
68ca152127 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>
2026-08-19 22:18:58 +02:00

215 lines
7.9 KiB
TypeScript

/**
* Lunar CSV format parser
*
* Format: Comma-delimited, comma decimal separator (amounts are quoted)
* Columns (2026 export): Date, Time, Title, Amount, Balance, Transaction ID
* Columns (legacy): Date, Text, Amount, Balance
* Date format: YYYY-MM-DD
* Encoding: UTF-8, may start with a BOM
*
* Notes:
* - 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
* comma-delimited, but a semicolon- or tab-delimited copy of the same
* header set (a spreadsheet re-save, a localized export) carries the same
* distinctive English columns and must not fall through to the manual
* mapping flow, where the Time column used to be picked as the description
* (issue #1671)
* - 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'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
import { parseCSVLine } from './nordea'
const LUNAR_DELIMITERS = [',', ';', '\t']
/**
* Sniff the field delimiter from the header line. Comma is the documented
* Lunar export and wins ties; semicolon and tab are accepted when they split
* the header into more cells.
*/
function sniffLunarDelimiter(headerLine: string): string {
let best = ','
let bestCount = parseCSVLine(headerLine, ',').length
for (const delimiter of LUNAR_DELIMITERS.slice(1)) {
const count = parseCSVLine(headerLine, delimiter).length
if (count > bestCount) {
best = delimiter
bestCount = count
}
}
return best
}
function parseLunarHeader(headerLine: string, delimiter: string): string[] {
return parseCSVLine(headerLine, delimiter).map((h) => h.trim().toLowerCase().replace(/"/g, ''))
}
/**
* The Lunar header set: date, a description column ("title" in the 2026
* export, "text" in the legacy one), amount and balance, matched as whole
* cells so a Swedish "Datum" export or an English file with e.g. "Running
* 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 (
firstIndexOf(cells, DATE_HEADERS) !== -1 &&
firstIndexOf(cells, DESC_HEADERS) !== -1 &&
firstIndexOf(cells, AMOUNT_HEADERS) !== -1 &&
firstIndexOf(cells, BALANCE_HEADERS) !== -1
)
}
function parseLunarAmount(value: string): number {
// 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 = {
id: 'lunar',
name: 'Lunar',
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: 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)))
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header; the delimiter is sniffed from it and reused for every row
const headerLine = lines[0] || ''
const delimiter = sniffLunarDelimiter(headerLine)
const headers = parseLunarHeader(headerLine, delimiter)
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({
row: 1,
message: 'Could not identify required columns (date, amount)',
severity: 'error',
})
return {
format: 'lunar',
format_name: 'Lunar',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, delimiter).map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
const missing = []
if (!date) missing.push('datum')
if (!amountStr) missing.push('belopp')
issues.push({ row: i + 1, message: `Saknar ${missing.join(' och ')}`, severity: 'warning' })
skippedRows++
continue
}
const amount = parseLunarAmount(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
const normalizedDate = normalizeDate(date)
if (!normalizedDate) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseLunarAmount(balanceStr) : null
transactions.push({
date: normalizedDate,
description: (description || 'Unknown').trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'lunar',
format_name: 'Lunar',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}