Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
234 lines
9.8 KiB
TypeScript
234 lines
9.8 KiB
TypeScript
/**
|
|
* Nordea Business CSV format parser
|
|
*
|
|
* Supports multiple Nordea Business / Internetbanken Företag export formats:
|
|
*
|
|
* Format A (classic): Semicolon-delimited, comma decimal separator
|
|
* Columns: Bokföringsdag, Belopp, Avsändare, Mottagare, Namn, Rubrik, Saldo, Valuta
|
|
*
|
|
* Format B (alternate): Semicolon-delimited
|
|
* Columns: Bokföringsdag, Värdedag, Betalningstyp, Betalare/Mottagare, Meddelande/Referens, Belopp, Saldo
|
|
*
|
|
* Format C (simple): Semicolon-delimited
|
|
* Columns: Bokföringsdatum, Valutadatum, Text, Belopp, Saldo
|
|
*
|
|
* Format D (Datum variant): Semicolon-delimited, slash dates
|
|
* Columns: Datum, Belopp, Avsändare, Mottagare, Namn, Ytterligare detaljer, Meddelande, Egna anteckningar, Saldo, Valuta
|
|
* Date format: YYYY/MM/DD (normalized to YYYY-MM-DD)
|
|
*
|
|
* Date format: YYYY-MM-DD (YYYY/MM/DD also accepted and normalized)
|
|
* Encoding: UTF-8 or Windows-1252
|
|
*/
|
|
|
|
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
|
import { prepareContent } from '../../shared/encoding'
|
|
import { normalizeDate } from '../date-utils'
|
|
|
|
function parseCommaDecimal(value: string): number {
|
|
const cleaned = value.replace(/\s/g, '').replace(',', '.')
|
|
return parseFloat(cleaned)
|
|
}
|
|
|
|
export const nordeaBusinessFormat: BankFileFormat = {
|
|
id: 'nordea_business',
|
|
name: 'Nordea Företag',
|
|
description: 'Nordea Företag CSV (semicolon-delimited business banking export)',
|
|
fileExtensions: ['.csv', '.txt'],
|
|
|
|
detect(content: string, _filename: string): boolean {
|
|
const prepared = prepareContent(content)
|
|
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
|
|
|
|
if (!firstLine.includes(';')) return false
|
|
|
|
// Must have a date column that looks like Nordea Business
|
|
const hasNordeaDateCol =
|
|
firstLine.includes('bokföringsdag') ||
|
|
firstLine.includes('bokforingsdag') ||
|
|
firstLine.includes('bokföringsdatum') ||
|
|
firstLine.includes('bokforingsdatum')
|
|
|
|
// Format D: standalone "Datum" column (not reskontradatum/transaktionsdatum)
|
|
// Check parsed headers for exact match to avoid false positives with Handelsbanken
|
|
// Note: firstLine is already lowercased, so no need for additional toLowerCase()
|
|
const headersDetect = firstLine.split(';').map(h => h.replace(/"/g, '').trim())
|
|
const hasStandaloneDatum = headersDetect.some(h => h === 'datum')
|
|
|
|
if (!hasNordeaDateCol && !hasStandaloneDatum) return false
|
|
|
|
// Exclude SEB (which also has bokföringsdag/bokföringsdatum but adds valutadag/verifikationsnummer)
|
|
if (firstLine.includes('valutadag') || firstLine.includes('verifikationsnummer')) return false
|
|
|
|
// Exclude Länsförsäkringar (has separate "datum" column alongside "bokföringsdag" + "typ")
|
|
// LF headers are quoted: "Datum";"Bokföringsdag";"Typ";"Text";"Belopp";"Saldo"
|
|
const headers = firstLine.split(';').map(h => h.replace(/"/g, '').trim())
|
|
const hasSeparateDatum = headers.some(h => h === 'datum')
|
|
if (hasSeparateDatum && headers.some(h => h === 'typ')) return false
|
|
|
|
// Accept any of these Nordea Business patterns:
|
|
return (
|
|
// Pattern 1: "rubrik" column (classic format)
|
|
firstLine.includes('rubrik') ||
|
|
// Pattern 2: separate "avsändare" + "mottagare" columns
|
|
(firstLine.includes('avsändare') && firstLine.includes('mottagare')) ||
|
|
(firstLine.includes('avsandare') && firstLine.includes('mottagare')) ||
|
|
// Pattern 3: "betalare" (e.g., combined "Betalare/Mottagare" column)
|
|
firstLine.includes('betalare') ||
|
|
// Pattern 4: "betalningstyp" column (Nordea business payment type indicator)
|
|
firstLine.includes('betalningstyp') ||
|
|
// Pattern 5: simple format with "text" + "belopp" (for Bokföringsdatum;...;Text;Belopp;Saldo)
|
|
(firstLine.includes('text') && firstLine.includes('belopp'))
|
|
)
|
|
},
|
|
|
|
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 to find column indices dynamically
|
|
const headerLine = lines[0] || ''
|
|
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
|
|
|
|
// Date column: accept multiple Nordea naming patterns (including standalone "datum" for Format D)
|
|
const dateIdx = headers.findIndex(
|
|
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdag') ||
|
|
h.includes('bokföringsdatum') || h.includes('bokforingsdatum') ||
|
|
h === 'datum'
|
|
)
|
|
const amountIdx = headers.findIndex((h) => h === 'belopp' || h.includes('belopp'))
|
|
const senderIdx = headers.findIndex((h) => h.includes('avsändare') || h.includes('avsandare'))
|
|
// Receiver: standalone "mottagare" (not combined "betalare/mottagare")
|
|
const receiverIdx = headers.findIndex(
|
|
(h) => h.includes('mottagare') && !h.includes('betalare') && !h.includes('/')
|
|
)
|
|
// Combined "Betalare/Mottagare" column
|
|
const combinedPartyIdx = headers.findIndex(
|
|
(h) => (h.includes('betalare') && h.includes('mottagare')) || h === 'betalare/mottagare'
|
|
)
|
|
const nameIdx = headers.findIndex((h) => h === 'namn')
|
|
const subjectIdx = headers.findIndex((h) => h === 'rubrik')
|
|
// Description fallbacks: "text", "meddelande", "meddelande/referens", "beskrivning"
|
|
const textIdx = headers.findIndex(
|
|
(h) => h === 'text' || h.includes('meddelande') || h.includes('beskrivning')
|
|
)
|
|
const ytterligareDetaljerIdx = headers.findIndex((h) => h === 'ytterligare detaljer')
|
|
const paymentTypeIdx = headers.findIndex((h) => h.includes('betalningstyp'))
|
|
const balanceIdx = headers.findIndex((h) => h === 'saldo' || h.includes('saldo'))
|
|
const currencyIdx = headers.findIndex((h) => h === 'valuta' || h.includes('valuta'))
|
|
|
|
if (dateIdx === -1 || amountIdx === -1) {
|
|
issues.push({
|
|
row: 1,
|
|
message: 'Could not identify required columns (Bokföringsdag/Bokföringsdatum/Datum, Belopp)',
|
|
severity: 'error',
|
|
})
|
|
return {
|
|
format: 'nordea_business',
|
|
format_name: 'Nordea Företag',
|
|
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 = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
|
|
|
|
const date = fields[dateIdx]?.trim()
|
|
const amountStr = fields[amountIdx]
|
|
|
|
if (!date || !amountStr) {
|
|
issues.push({ row: i + 1, message: 'Missing required fields', severity: 'warning' })
|
|
skippedRows++
|
|
continue
|
|
}
|
|
|
|
const amount = parseCommaDecimal(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
|
|
}
|
|
|
|
// Build description from available columns with fallback chain
|
|
const name = nameIdx >= 0 ? fields[nameIdx]?.trim() : ''
|
|
const subject = subjectIdx >= 0 ? fields[subjectIdx]?.trim() : ''
|
|
const text = textIdx >= 0 ? fields[textIdx]?.trim() : ''
|
|
const paymentType = paymentTypeIdx >= 0 ? fields[paymentTypeIdx]?.trim() : ''
|
|
const ytterligareDetaljer = ytterligareDetaljerIdx >= 0 ? fields[ytterligareDetaljerIdx]?.trim() : ''
|
|
|
|
let description: string
|
|
if (name || subject) {
|
|
// Classic format: Namn - Rubrik
|
|
description = [name, subject].filter(Boolean).join(' - ') || 'Unknown'
|
|
} else if (ytterligareDetaljer) {
|
|
// Format D: "Ytterligare detaljer" has the full description
|
|
description = ytterligareDetaljer
|
|
} else if (text) {
|
|
// Alternate format: use Text/Meddelande column
|
|
description = [paymentType, text].filter(Boolean).join(' - ') || text
|
|
} else {
|
|
description = 'Unknown'
|
|
}
|
|
|
|
// Counterparty from sender/receiver or combined column
|
|
let counterparty: string | null = null
|
|
if (combinedPartyIdx >= 0) {
|
|
counterparty = fields[combinedPartyIdx]?.trim() || null
|
|
} else {
|
|
const sender = senderIdx >= 0 ? fields[senderIdx]?.trim() : null
|
|
const receiver = receiverIdx >= 0 ? fields[receiverIdx]?.trim() : null
|
|
counterparty = (amount > 0 ? sender : receiver) || null
|
|
}
|
|
|
|
const balance = balanceIdx >= 0 && fields[balanceIdx] ? parseCommaDecimal(fields[balanceIdx]) : null
|
|
const currency = currencyIdx >= 0 && fields[currencyIdx] ? fields[currencyIdx].trim() : 'SEK'
|
|
|
|
transactions.push({
|
|
date: normalizedDate,
|
|
description,
|
|
amount,
|
|
currency: currency || 'SEK',
|
|
balance: isNaN(balance as number) ? null : balance,
|
|
reference: null,
|
|
counterparty: counterparty || null,
|
|
raw_line: line,
|
|
})
|
|
}
|
|
|
|
const dates = transactions.map((t) => t.date).sort()
|
|
|
|
return {
|
|
format: 'nordea_business',
|
|
format_name: 'Nordea Företag',
|
|
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,
|
|
},
|
|
}
|
|
},
|
|
}
|