Files
accounted/lib/import/bank-file/formats/generic-csv.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

429 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Generic CSV format parser
*
* Fallback parser that requires the user to map columns manually.
* Supports configurable delimiter, decimal separator, and column mapping.
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue, GenericCSVColumnMapping } from '../types'
import { prepareContent } from '../../shared/encoding'
import { parseCSVLine } from './nordea'
import { normalizeDate } from '../date-utils'
/**
* Normalize Unicode minus variants (U+2212 "−", U+2013 "–", U+2014 "—", U+2010 "‐")
* to ASCII hyphen-minus so parseFloat can read them. Northmill and some other
* banks export negatives with U+2212; parseFloat returns NaN for those.
*/
export function normalizeMinusSign(value: string): string {
return value.replace(/[\u2212\u2013\u2014\u2010]/g, '-')
}
/**
* Parse a generic CSV with user-provided column mapping
*/
export function parseGenericCSV(
content: string,
mapping: GenericCSVColumnMapping
): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Skip configured number of header/metadata rows
const startRow = mapping.skip_rows
// Detect decimal separator mismatch by sampling amount column
const sampleSize = Math.min(lines.length - startRow, 20)
let commaPattern = 0
let periodPattern = 0
for (let s = startRow; s < startRow + sampleSize && s < lines.length; s++) {
const sampleLine = lines[s]?.trim()
if (!sampleLine) continue
const sampleFields = parseCSVLine(sampleLine, mapping.delimiter).map(f => f.trim().replace(/^"|"$/g, ''))
const amtStr = sampleFields[mapping.amount] || ''
if (/\d,\d{1,2}$/.test(amtStr)) commaPattern++
if (/\d\.\d{1,2}$/.test(amtStr)) periodPattern++
}
if (mapping.decimal_separator === ',' && periodPattern > commaPattern && periodPattern >= 3) {
issues.push({
row: 0,
message: 'Decimalavgränsare verkar vara punkt (.) men komma (,) är valt. Kontrollera inställningen.',
severity: 'warning',
})
} else if (mapping.decimal_separator === '.' && commaPattern > periodPattern && commaPattern >= 3) {
issues.push({
row: 0,
message: 'Decimalavgränsare verkar vara komma (,) men punkt (.) är valt. Kontrollera inställningen.',
severity: 'warning',
})
}
for (let i = startRow; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, mapping.delimiter).map((f) =>
f.trim().replace(/^"|"$/g, '')
)
// Validate required column indices are within bounds
const maxRequired = Math.max(mapping.date, mapping.description, mapping.amount)
if (maxRequired >= fields.length) {
issues.push({
row: i + 1,
message: `Row has ${fields.length} columns but mapping requires column ${maxRequired + 1}`,
severity: 'warning',
})
skippedRows++
continue
}
const dateStr = fields[mapping.date]
const description = fields[mapping.description] || 'Unknown'
const amountStr = fields[mapping.amount]
const referenceStr = mapping.reference !== undefined ? fields[mapping.reference] : undefined
const counterpartyStr = mapping.counterparty !== undefined ? fields[mapping.counterparty] : undefined
const balanceStr = mapping.balance !== undefined ? fields[mapping.balance] : undefined
if (!dateStr || !amountStr) {
const missing = []
if (!dateStr) missing.push('datum')
if (!amountStr) missing.push('belopp')
issues.push({ row: i + 1, message: `Saknar ${missing.join(' och ')}`, severity: 'warning' })
skippedRows++
continue
}
// Parse amount based on configured decimal separator.
// Normalize Unicode minus first — some banks (e.g. Northmill) use U+2212
// instead of ASCII hyphen, which parseFloat treats as NaN.
const normalizedAmount = normalizeMinusSign(amountStr)
let amount: number
if (mapping.decimal_separator === ',') {
amount = parseFloat(normalizedAmount.replace(/\s/g, '').replace(',', '.'))
} else {
amount = parseFloat(normalizedAmount.replace(/\s/g, ''))
}
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
// Normalize date from multiple formats to YYYY-MM-DD
const date = normalizeDate(dateStr, mapping.date_format)
if (!date) {
issues.push({ row: i + 1, message: `Ogiltigt datumformat: ${dateStr.trim()}`, severity: 'warning' })
skippedRows++
continue
}
let balance: number | null = null
if (balanceStr) {
const normalizedBalance = normalizeMinusSign(balanceStr)
if (mapping.decimal_separator === ',') {
balance = parseFloat(normalizedBalance.replace(/\s/g, '').replace(',', '.'))
} else {
balance = parseFloat(normalizedBalance.replace(/\s/g, ''))
}
if (isNaN(balance)) balance = null
}
transactions.push({
date,
description: description.trim(),
amount,
currency: 'SEK',
balance,
reference: referenceStr?.trim() || null,
counterparty: counterpartyStr?.trim() || null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'generic_csv',
format_name: 'CSV (manuell mappning)',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - startRow,
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,
},
}
}
/**
* Get a preview of the first few rows of a CSV file
*/
export function getCSVPreview(content: string, delimiter: string = ',', rows: number = 5): string[][] {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
return lines.slice(0, rows).map((line) =>
parseCSVLine(line, delimiter).map((f) => f.trim().replace(/^"|"$/g, ''))
)
}
/** Column indices suggested for the manual mapping UI. -1 = not resolved. */
export interface SuggestedColumnMapping {
date: number
description: number
amount: number
balance: number
}
// A cell that looks like a date in one of the formats the importer accepts.
const SUGGEST_DATE_PATTERNS = [
/^\d{4}-\d{2}-\d{2}$/,
/^\d{2}[./]\d{2}[./]\d{4}$/,
/^\d{4}\/\d{2}\/\d{2}$/,
/^\d{8}$/,
]
/**
* Booking-date header labels: Bokföringsdag / Bokföringsdatum, tolerating an
* un-decoded 'o' for 'ö' and Swedbank's abbreviated 'Bokfdag'. Superset of the
* SEB and Länsförsäkringar parsers' detectors.
*/
const BOOKING_DATE_RE = /bokf((ö|o)rings)?da(g|tum)/
/**
* Pick the best date column by header label, preferring the BOOKING date.
*
* Tier order is booking-date first, which is what every dedicated parser in
* this directory emits: Handelsbanken picks Reskontradatum over
* Transaktionsdatum, Länsförsäkringar picks Bokföringsdag over Datum, and
* Swedbank picks Bokfdag even though Transdag sits right next to it in the
* file. A Handelsbanken or Swedbank export routed through the manual
* "Annan CSV" mapping must pre-select the same column the dedicated parser
* would, otherwise the two upload paths date the same affärshändelse
* differently.
*
* The emitted date is not cosmetic: it feeds generateExternalId and the
* exact-date content-dedup bucket. The PSD2 / Enable Banking feed for the same
* account keys every row on the ASPSP's booking_date and cannot be moved off it
* (Berlin Group exposes no stable transaction date, and the value_date path
* caused the June 2026 fleet-wide re-import; see
* extensions/general/enable-banking/lib/sync.ts). Booking date is also what the
* Saldo column ties to: dating a row on the card-swipe date desynchronizes the
* 19xx balance from the bank statement. A user who wants the purchase dated the
* 14th books it from the kvitto, not from the bank feed.
*
* A bare 'datum' outranks 'transaktionsdatum' because in every single-date
* export here (Nordea, Skandia, Nordea Business format D) the bare Datum column
* IS the posting date the Saldo ties to, while transaktionsdatum is explicitly
* labelled the swipe date. Files carrying both plus a real booking column
* resolve at tier 1 or 2 before either is reached.
*
* transaktionsdatum / transdag stay as the LAST tier: a fallback, not a
* preference. A file that carries only a transaction date must still map.
*
* Every tier skips headers containing 'valuta', so Valutadag / Valutadatum (the
* value date) and a plain Valuta (currency) column are never picked.
*/
function pickDateHeader(headers: string[]): number {
const tiers: Array<(h: string) => boolean> = [
(h) => BOOKING_DATE_RE.test(h),
(h) => h.includes('reskontradatum'),
(h) => h === 'datum' || h === 'date',
(h) => h.includes('transaktionsdatum') || h.includes('transdag'),
]
for (const match of tiers) {
const idx = headers.findIndex((h) => match(h) && !h.includes('valuta'))
if (idx >= 0) return idx
}
return headers.findIndex((h) => (h.includes('datum') || h.includes('date')) && !h.includes('valuta'))
}
/**
* Header labels that name a clock time, not a description: Lunar's 2026 export
* carries `Time` next to `Date`, and Swedish exports spell it Tid / Tidpunkt /
* Klockslag. Anchored at the end so compounds like Transaktionstid and
* "Transaction time" match too. A time-of-day column is never a description,
* whatever else the heuristics fail to resolve.
*/
const TIME_HEADER_RE = /(^|[^a-zåäö])(tidpunkt|klockslag|klocka|hour|hours|timestamp)$|tid$|time$/
/** A cell that looks like a clock time (HH:MM or HH:MM:SS). */
const TIME_VALUE_RE = /^\d{1,2}:\d{2}(:\d{2})?$/
/**
* Pick the best description column by header label.
*
* `title` / `titel` are the Lunar 2026 export's description column; without
* them the label pass missed and the positional fallback grabbed the Time
* column sitting between Date and Title (issue #1671). Time-ish labels are
* excluded outright so a keyword like `text` can never land on
* "Transaktionstid" either.
*/
function pickDescriptionHeader(headers: string[]): number {
const keywords = ['text', 'beskrivning', 'description', 'title', 'titel', 'rubrik', 'meddelande', 'referens', 'mottagare', 'namn']
for (const kw of keywords) {
const idx = headers.findIndex((h) => (h === kw || h.includes(kw)) && !TIME_HEADER_RE.test(h))
if (idx >= 0) return idx
}
return -1
}
/** Per-column value statistics across the sampled data rows. */
function analyzeColumns(dataRows: string[][], colCount: number) {
const acc = Array.from({ length: colCount }, () => ({ numeric: 0, date: 0, time: 0, negative: 0, nonEmpty: 0 }))
for (const row of dataRows.slice(0, 20)) {
for (let i = 0; i < colCount; i++) {
const raw = (row[i] ?? '').trim()
if (!raw) continue
acc[i].nonEmpty++
if (SUGGEST_DATE_PATTERNS.some((re) => re.test(raw))) acc[i].date++
if (TIME_VALUE_RE.test(raw)) acc[i].time++
const cleaned = normalizeMinusSign(raw).replace(/\s/g, '')
if (/^-?\d+([.,]\d+)?$/.test(cleaned)) {
acc[i].numeric++
if (cleaned.startsWith('-')) acc[i].negative++
}
}
}
return acc.map((s) => ({
isDate: s.nonEmpty > 0 && s.date / s.nonEmpty >= 0.5,
isTime: s.nonEmpty > 0 && s.time / s.nonEmpty >= 0.5,
isNumeric: s.nonEmpty > 0 && s.numeric / s.nonEmpty >= 0.5,
hasNegative: s.negative > 0,
}))
}
/**
* Suggest date / description / amount / balance column indices for the manual
* CSV mapping UI.
*
* When a header row is available, columns are matched by their label first
* (belopp → amount, saldo → balance, …) so a trailing running-balance column is
* never mistaken for the amount — the original positional heuristic walked the
* row right-to-left and grabbed Saldo as the amount on the common
* `…;Belopp;Saldo` layout. Falls back to value-based heuristics on the sample
* data for any column the labels don't resolve; there the amount guess
* explicitly skips the balance column and prefers a column carrying negative
* values (real transaction amounts swing negative, a running balance usually
* does not).
*
* @param headers Header labels, or null when the file has no header row.
* @param dataRows Sample data rows already split into cells.
*/
export function suggestColumnMapping(
headers: string[] | null,
dataRows: string[][]
): SuggestedColumnMapping {
const result: SuggestedColumnMapping = { date: -1, description: -1, amount: -1, balance: -1 }
const colCount = Math.max(
headers?.length ?? 0,
...dataRows.slice(0, 10).map((r) => r.length),
0
)
if (colCount === 0) return result
// 1. Label-based matching when we have a header row.
if (headers) {
const hdr = headers.map((h) => h.trim().toLowerCase().replace(/"/g, ''))
result.date = pickDateHeader(hdr)
result.balance = hdr.findIndex((h) => h.includes('saldo') || h.includes('balance'))
result.amount = hdr.findIndex(
(h, i) => i !== result.balance && (h === 'belopp' || h.includes('belopp') || h.includes('amount'))
)
result.description = pickDescriptionHeader(hdr)
}
// 2. Value-based fallback for anything the labels didn't resolve.
const stats = analyzeColumns(dataRows, colCount)
if (result.date === -1) {
result.date = stats.findIndex((s) => s.isDate)
}
if (result.amount === -1 || result.balance === -1) {
const numericCols = stats
.map((s, i) => ({ i, s }))
.filter(({ i, s }) => i !== result.date && s.isNumeric)
if (result.amount === -1) {
const candidates = numericCols.filter(({ i }) => i !== result.balance)
const negative = candidates.find(({ s }) => s.hasNegative)
result.amount = (negative ?? candidates[0])?.i ?? -1
}
if (result.balance === -1) {
// Remaining numeric column (typically the trailing running balance).
const remaining = numericCols.filter(({ i }) => i !== result.amount)
result.balance = remaining.length ? remaining[remaining.length - 1].i : -1
}
}
if (result.description === -1) {
// A clock-time column (Lunar's `Time`, a Swedish `Tid`) is text-shaped to
// the numeric/date tests, so it must be excluded explicitly: by header
// label when there is one, and by HH:MM values either way. Otherwise the
// positional fallback picks it as the description whenever it sits before
// the real text column (issue #1671).
const hdr = headers?.map((h) => h.trim().toLowerCase().replace(/"/g, '')) ?? []
const isTimeColumn = (i: number) => stats[i].isTime || (hdr[i] !== undefined && TIME_HEADER_RE.test(hdr[i]))
const unassigned = (i: number) => i !== result.date && i !== result.amount && i !== result.balance
const passes: Array<(i: number) => boolean> = [
(i) => unassigned(i) && !stats[i].isNumeric && !stats[i].isDate && !isTimeColumn(i),
(i) => unassigned(i) && !isTimeColumn(i),
// Last resort: anything not already assigned, so the UI still seeds a
// value the user can correct.
unassigned,
]
for (const pass of passes) {
const idx = stats.findIndex((_s, i) => pass(i))
if (idx >= 0) {
result.description = idx
break
}
}
}
return result
}
/**
* Generic CSV format definition (used for format detection)
* Always returns false for detect() since it's a fallback requiring user mapping
*/
export const genericCSVFormat: BankFileFormat = {
id: 'generic_csv',
name: 'CSV (manuell mappning)',
description: 'Generisk CSV-fil med manuell kolumnmappning',
fileExtensions: ['.csv', '.txt'],
detect(_content: string, _filename: string): boolean {
// Generic CSV never auto-detects — it's the manual fallback
return false
},
parse(content: string): BankFileParseResult {
// Default mapping for a basic CSV: date, description, amount
const defaultMapping: GenericCSVColumnMapping = {
date: 0,
description: 1,
amount: 2,
delimiter: ',',
decimal_separator: ',',
skip_rows: 1,
date_format: 'YYYY-MM-DD',
}
return parseGenericCSV(content, defaultMapping)
},
}