* 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>
429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
/**
|
||
* 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)
|
||
},
|
||
}
|