Files
accounted/lib/import/bank-file/formats/lunar.ts
T
Jakob WennbergandClaude Fable 5 b21aa84268 fix(import): parse the real 2026 Lunar CSV export (issue #915) (#952)
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>
2026-07-09 21:10:05 +02:00

158 lines
5.6 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:
* - 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 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'
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 headers)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// Lunar: comma-delimited with English headers
// 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('title') || firstLine.includes('text')) &&
firstLine.includes('amount') &&
firstLine.includes('balance')
)
},
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
const headerLine = lines[0] || ''
const headers = parseCSVLine(headerLine, ',').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
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')
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, ',').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,
},
}
},
}