fix(import): SEB CSV imports survive BOMs and bad format choices (#1565)

* fix(import): handle BOMs at the byte level in decodeFileContent

Inspect leading bytes before decoding: EF BB BF strips the UTF-8 BOM and
decodes the remainder (falling back to Windows-1252 for the remainder only,
so the fallback can no longer produce a literal mojibake prefix), and
FF FE / FE FF decode as UTF-16LE/BE. stripBOM additionally strips a literal
mojibake BOM prefix for string paths pre-decoded elsewhere.

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

* fix(import): make an explicit SEB choice at least as good as auto-detect

Three changes for the SEB bank CSV report:

- parseBankFile: when an explicit format parses 0 transactions, fall back
  to auto-detection; a different format that parses rows is returned with a
  prepended info issue naming both formats. A working explicit parse is
  never overridden, and explicit generic_csv (the manual mapping escape
  hatch) is exempt.
- SEB profile: sniff the header delimiter (';' vs ',') and split with the
  quote-aware parseCSVLine; accept a bare Datum date column as a lowest
  priority tier in parse only, never in detect. Its user-reachable issue
  strings are now Swedish.
- Import page: when a parse yields 0 transactions, show the parser's real
  issues instead of only the generic no-transactions hint.

The v1 agent route now decodes through the shared decodeFileContent and
stamps external ids, import_source, and the stored file format from the
format the parse result actually carries, so fallback imports dedup
identically to auto-detected ones.

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

* docs(api-v1): the bank import route also decodes UTF-16

CodeRabbit on #1565: decodeFileContent gained UTF-16LE/BE BOM support
but the route overview and the registered endpoint description still
listed only UTF-8 / Windows-1252. Skill regenerated (apiskill:generate).

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-13 15:22:19 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 0d3ba5268d
commit 9c891ee72d
10 changed files with 322 additions and 34 deletions
+14 -2
View File
@@ -278,8 +278,20 @@ function BankFileImportWizard() {
description: `${txCount} transaktioner hittades`,
})
} else {
// Format detected but no transactions parsed: parser couldn't extract rows
setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.')
// Format detected but no transactions parsed: surface the parser's
// real issues when it produced any, so the user sees WHY (wrong
// columns, invalid dates, ...) instead of only a generic hint.
const parseIssues: BankFileParseResult['issues'] = data.data.parse_result.issues ?? []
if (parseIssues.length > 0) {
setBankError(
parseIssues
.slice(0, 5)
.map((issue) => (issue.row > 0 ? `Rad ${issue.row}: ${issue.message}` : issue.message))
.join(' ')
)
} else {
setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.')
}
}
} catch (err) {
setBankError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
@@ -278,6 +278,25 @@ describe('POST /api/v1/companies/:companyId/imports/bank', () => {
}
})
it('stamps ids and provenance with the fallback format when an explicit override parses nothing', async () => {
// Swedbank file forced as `seb`: the parser falls back to the detected
// format, and external ids / import_source must follow the format the
// result carries so they equal what the auto-detect path would produce.
const swedbankCsv = [
'Kontouppgifter',
'Clearingnummer,Kontonummer,Datum,Text,Belopp,Saldo',
'8123,12345678,2024-01-15,SPOTIFY AB,-99.00,12345.67',
].join('\n')
const res = await callRoute({ fileContent: swedbankCsv, search: '?format=seb' })
expect(res.status).toBe(202)
const rows = ingestedRows()
expect(rows).toHaveLength(1)
expect(rows[0].import_source).toBe('csv_swedbank')
expect(rows[0].external_id).toMatch(/^swedbank_/)
})
it('never sends keys RawTransaction does not have (`source`, `counterparty`)', async () => {
await callRoute()
@@ -3,7 +3,8 @@
*
* Bank-file import. Multipart upload: the file is the request body. The
* route:
* 1. Decodes the file (UTF-8 / Windows-1252 auto-detected).
* 1. Decodes the file (UTF-8 / UTF-16 / Windows-1252 auto-detected;
* BOMs handled at the byte level).
* 2. Detects the bank file format (SEB / Swedbank / Nordea / Handelsbanken
* / Lansforsakringar / Lunar / ICA Banken / Skandia / CAMT053 /
* Nordea Business / Wise / generic CSV), or honors the optional `format`
@@ -36,6 +37,7 @@ import {
generateExternalId,
} from '@/lib/import/bank-file/parser'
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
import { decodeFileContent } from '@/lib/import/shared/encoding'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -54,7 +56,7 @@ registerEndpoint({
path: '/api/v1/companies/:companyId/imports/bank',
summary: 'Import a bank-file (CSV / XML / CAMT053).',
description:
'Accepts a bank statement file (UTF-8 / Windows-1252, up to 10 MB) as multipart/form-data. Auto-detects the bank format (SEB, Swedbank, Handelsbanken, Nordea, Nordea Business, Lansforsakringar, Lunar, ICA Banken, Skandia, Wise transaction history, Wise balance statement, CAMT053, generic CSV) or honors a `format` override. Parses transactions, ingests them into the `transactions` table (NOT into journal entries: see BFL note in pitfalls), and emits `transaction.synced` events. Returns operation_id for polling.',
'Accepts a bank statement file (UTF-8 / UTF-16 / Windows-1252, up to 10 MB) as multipart/form-data. Auto-detects the bank format (SEB, Swedbank, Handelsbanken, Nordea, Nordea Business, Lansforsakringar, Lunar, ICA Banken, Skandia, Wise transaction history, Wise balance statement, CAMT053, generic CSV) or honors a `format` override. Parses transactions, ingests them into the `transactions` table (NOT into journal entries: see BFL note in pitfalls), and emits `transaction.synced` events. Returns operation_id for polling.',
useWhen:
'Importing a bank statement export for a period. Common with PSD2 bank connections that don\'t auto-sync, or for legacy bank accounts.',
doNotUseFor:
@@ -158,13 +160,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
formatOverride = parsed.data
}
// Decode the file. Bank files are typically Windows-1252 or UTF-8; we
// try UTF-8 first and fall back if invalid replacement chars appear.
// Decode the file with the shared importer decode (BOM-aware UTF-8 /
// UTF-16 / Windows-1252): one decode implementation for every path.
const buffer = await file.arrayBuffer()
const utf8 = new TextDecoder('utf-8').decode(buffer)
const content = utf8.includes('�')
? new TextDecoder('windows-1252').decode(buffer)
: utf8
const content = decodeFileContent(buffer)
const fileHash = await generateFileHash(content)
@@ -178,6 +177,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
}
const parseResult = parseBankFile(content, file.name, format)
// parseBankFile may fall back to a detected sibling format when an
// explicit override parses 0 transactions. Everything downstream (external
// ids, import_source, stored file_format) must use the format the result
// actually carries so ids equal what the auto-detect path would produce.
const effectiveFormat = parseResult.format
const blockingIssues = parseResult.issues.filter((issue) => issue.severity === 'error')
if (blockingIssues.length > 0) {
// Cap the reported rows so a large malformed file cannot balloon the
@@ -195,7 +199,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
if (parseResult.transactions.length === 0) {
return v1ErrorResponseFromCode('BANK_FILE_NO_TRANSACTIONS', ctx.log, {
requestId: ctx.requestId,
details: { format, filename: file.name },
details: { format: effectiveFormat, filename: file.name },
})
}
@@ -208,7 +212,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
params: {
filename: file.name,
file_size: file.size,
format,
format: effectiveFormat,
file_hash: fileHash,
transaction_count: parseResult.transactions.length,
},
@@ -232,7 +236,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
company_id: ctx.companyId!,
filename: file.name,
file_hash: fileHash,
file_format: format,
file_format: effectiveFormat,
transaction_count: parseResult.transactions.length,
status: 'processing',
date_from: parseResult.date_from,
@@ -271,13 +275,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
// shared ingest contract, not in this route alone.
const raw: RawTransaction[] = parseResult.transactions.map(
(t, idx): RawTransaction => ({
external_id: generateExternalId(t, format, idx),
external_id: generateExternalId(t, effectiveFormat, idx),
date: t.date,
amount: t.amount,
currency: t.currency || 'SEK',
description: t.description,
reference: t.reference ?? null,
import_source: format === 'camt053' ? 'camt053' : `csv_${format}`,
import_source: effectiveFormat === 'camt053' ? 'camt053' : `csv_${effectiveFormat}`,
}),
)
@@ -329,7 +333,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
{
id: op.id,
result: {
format,
format: effectiveFormat,
file_hash: fileHash,
transactions_imported: ingestResult.imported,
transactions_duplicates: ingestResult.duplicates,
+131 -2
View File
@@ -1389,7 +1389,9 @@ describe('parseBankFile: camt.053 XML format', () => {
describe('parseBankFile: explicit format override', () => {
it('uses the specified format instead of auto-detection', () => {
// Force parsing Nordea content as SEB (will produce issues but should use SEB format)
// Nordea content forced as SEB: the widened SEB parser handles it via
// delimiter sniffing + the bare-Datum tier, and a working explicit parse
// is never overridden by the auto-detect fallback.
const result = parseBankFile(
'Datum,Transaktion,Kategori,Belopp,Saldo\n2024-01-15,Test,,"-100,00","5000,00"',
'nordea.csv',
@@ -1397,6 +1399,8 @@ describe('parseBankFile: explicit format override', () => {
)
expect(result.format).toBe('seb')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].amount).toBe(-100)
})
it('returns error for unknown formatId', () => {
@@ -1407,7 +1411,7 @@ describe('parseBankFile: explicit format override', () => {
expect(result.transactions).toHaveLength(0)
expect(result.issues).toHaveLength(1)
expect(result.issues[0].severity).toBe('error')
expect(result.issues[0].message).toContain('Unknown format')
expect(result.issues[0].message).toContain('Okänt format')
})
it('returns format detection error when no format matches and no override given', () => {
@@ -1430,6 +1434,131 @@ describe('parseBankFile: explicit format override', () => {
})
})
describe('parseBankFile: explicit format fallback to auto-detection', () => {
it('falls back to the detected format when the explicit choice parses 0 transactions', () => {
const result = parseBankFile(SWEDBANK_CSV, 'export.csv', 'seb')
expect(result.format).toBe('swedbank')
expect(result.transactions).toHaveLength(3)
expect(result.issues[0].severity).toBe('info')
expect(result.issues[0].message).toContain('SEB')
expect(result.issues[0].message).toContain('Swedbank')
})
it('falls back to Handelsbanken when a Handelsbanken file is forced as SEB', () => {
const result = parseBankFile(HANDELSBANKEN_CSV, 'export.csv', 'seb')
expect(result.format).toBe('handelsbanken')
expect(result.transactions).toHaveLength(3)
expect(result.issues[0].severity).toBe('info')
expect(result.issues[0].message).toContain('Handelsbanken')
})
it('never overrides a working explicit parse even when detection prefers another format', () => {
// NORDEA_CSV auto-detects as nordea, but forced-SEB parses it fine via
// delimiter sniffing + the bare-Datum tier: the user's choice stands.
expect(detectFileFormat(NORDEA_CSV, 'nordea.csv')!.id).toBe('nordea')
const result = parseBankFile(NORDEA_CSV, 'nordea.csv', 'seb')
expect(result.format).toBe('seb')
expect(result.transactions).toHaveLength(3)
expect(result.issues.filter((i) => i.severity === 'info')).toHaveLength(0)
})
it('keeps the explicit error result when no other format can parse the file', () => {
const result = parseBankFile(UNKNOWN_CSV, 'unknown.csv', 'seb')
expect(result.format).toBe('seb')
expect(result.transactions).toHaveLength(0)
expect(result.issues[0].severity).toBe('error')
expect(result.issues[0].message).toContain('Kunde inte identifiera nödvändiga kolumner')
})
it('does not fall back for explicit generic_csv (the manual mapping escape hatch)', () => {
// The default generic mapping parses 0 rows of a Nordea file, but the
// user chose "Annan CSV" to map columns manually: never reroute them.
const result = parseBankFile(NORDEA_CSV, 'nordea.csv', 'generic_csv')
expect(result.format).toBe('generic_csv')
})
})
describe('parseBankFile: BOM handling', () => {
it('auto-detects and parses SEB CSV with a real UTF-8 BOM (U+FEFF)', () => {
const content = '\uFEFF' + SEB_CSV
expect(detectFileFormat(content, 'seb.csv')!.id).toBe('seb')
const result = parseBankFile(content, 'seb.csv')
expect(result.format).toBe('seb')
expect(result.transactions).toHaveLength(3)
})
it('auto-detects and parses SEB privat CSV with a mojibake BOM prefix', () => {
const content = '' + SEB_PRIVAT_CSV
expect(detectFileFormat(content, 'kontoutdrag.csv')!.id).toBe('seb')
const result = parseBankFile(content, 'kontoutdrag.csv')
expect(result.format).toBe('seb')
expect(result.transactions).toHaveLength(3)
})
it('detects an exact-match Datum header behind a mojibake BOM prefix', () => {
// Exact-match header checks (h === 'datum') are the genuinely BOM-fragile
// ones: a surviving mojibake prefix used to make this file undetectable.
const content = '' + [
'Datum;Text;Belopp;Saldo',
'2026-01-15;SPOTIFY AB;-99,00;1000,00',
].join('\n')
const format = detectFileFormat(content, 'export.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('nordea_business')
const result = parseBankFile(content, 'export.csv')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].amount).toBe(-99)
})
})
describe('parseBankFile: SEB delimiter sniffing and bare-Datum tier', () => {
it('parses a comma-delimited SEB-labeled file under explicit seb', () => {
const commaSeb = [
'Bokföringsdag,Valutadag,Verifikationsnummer,Text,Belopp,Saldo',
'2024-01-15,2024-01-15,12345,SPOTIFY AB,"-99,00","12345,67"',
'2024-01-14,2024-01-14,12346,HEMKÖP,"-432,50","12444,67"',
].join('\n')
const result = parseBankFile(commaSeb, 'seb.csv', 'seb')
expect(result.format).toBe('seb')
expect(result.transactions).toHaveLength(2)
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].balance).toBe(12345.67)
})
it('parses a bare-Datum layout under explicit seb without claiming it in detect', () => {
const bareDatum = [
'Datum;Text;Belopp;Saldo',
'2026-02-01;SPOTIFY AB;-99,00;1000,00',
'2026-02-02;LÖN;25000,00;26000,00',
].join('\n')
// detect must NOT claim the bare-Datum layout: in auto-detection it
// belongs to other profiles (nordea_business format D family).
expect(getFormat('seb')!.detect(bareDatum, 'seb.csv')).toBe(false)
const result = parseBankFile(bareDatum, 'seb.csv', 'seb')
expect(result.format).toBe('seb')
expect(result.transactions).toHaveLength(2)
expect(result.transactions[1].amount).toBe(25000)
})
})
describe('generateExternalId', () => {
const baseTx: ParsedBankTransaction = {
date: '2024-01-15',
+24 -9
View File
@@ -1,7 +1,8 @@
/**
* SEB CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Format: Semicolon-delimited, comma decimal separator (parse also accepts
* comma-delimited variants via delimiter sniffing; detect stays strict)
* Columns vary but typically: Bokföringsdag, Valutadag, Verifikationsnummer,
* Text/mottagare, Belopp, Saldo
* Date format: YYYY-MM-DD
@@ -11,6 +12,7 @@
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
import { parseCSVLine } from './nordea'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
@@ -43,12 +45,25 @@ export const sebFormat: BankFileFormat = {
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header to find column indices
// Parse header to find column indices. SEB normally exports
// semicolon-delimited files, but some export surfaces use commas: sniff
// the delimiter from the header line instead of assuming ';'.
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const semicolons = (headerLine.match(/;/g) || []).length
const commas = (headerLine.match(/,/g) || []).length
const delimiter = commas > semicolons ? ',' : ';'
const headers = parseCSVLine(headerLine, delimiter).map((h) =>
h.trim().toLowerCase().replace(/"/g, '')
)
// Find column indices dynamically
const dateIdx = headers.findIndex((h) => /bokf(ö|o)ringsda(g|tum)/.test(h))
let dateIdx = headers.findIndex((h) => /bokf(ö|o)ringsda(g|tum)/.test(h))
if (dateIdx === -1) {
// Lowest-priority tier: a bare "Datum" column. Only honored here in
// parse (an explicit user choice), never in detect, so this profile
// cannot steal files from other bank profiles during auto-detection.
dateIdx = headers.findIndex((h) => h === 'datum')
}
const descIdx = headers.findIndex(
(h) => h.includes('text') || h.includes('mottagare') || h.includes('beskrivning')
)
@@ -58,7 +73,7 @@ export const sebFormat: BankFileFormat = {
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (date, amount)',
message: 'Kunde inte identifiera nödvändiga kolumner (datum, belopp)',
severity: 'error',
})
return {
@@ -76,7 +91,7 @@ export const sebFormat: BankFileFormat = {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const fields = parseCSVLine(line, delimiter).map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = fields[descIdx >= 0 ? descIdx : dateIdx + 1] || 'Unknown'
@@ -84,21 +99,21 @@ export const sebFormat: BankFileFormat = {
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
issues.push({ row: i + 1, message: 'Missing required fields', severity: 'warning' })
issues.push({ row: i + 1, message: 'Obligatoriska fält saknas', severity: 'warning' })
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
issues.push({ row: i + 1, message: `Ogiltigt belopp: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
const normalizedDate = normalizeDate(date)
if (!normalizedDate) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
issues.push({ row: i + 1, message: `Ogiltigt datum: ${date}`, severity: 'warning' })
skippedRows++
continue
}
+33 -1
View File
@@ -97,10 +97,42 @@ export function parseBankFile(
transactions: [],
date_from: null,
date_to: null,
issues: [{ row: 0, message: `Unknown format: ${formatId}`, severity: 'error' }],
issues: [{ row: 0, message: `Okänt format: ${formatId}`, severity: 'error' }],
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
const explicitResult = format.parse(content)
if (explicitResult.transactions.length > 0 || formatId === 'generic_csv') {
// A working explicit parse is never overridden. generic_csv is also
// exempt: it is the manual column-mapping escape hatch and its default
// mapping legitimately parses 0 rows before the user maps columns.
return explicitResult
}
// The explicit choice parsed nothing: fall back to auto-detection so an
// explicitly selected bank is never WORSE than "Automatisk identifiering".
// detectFileFormat can never return generic_csv (its detect() is always
// false), so this cannot reroute the UI into the mapping flow.
const detected = detectFileFormat(content, filename)
if (detected && detected.id !== formatId) {
const detectedResult = detected.parse(content)
if (detectedResult.transactions.length > 0) {
return {
...detectedResult,
issues: [
{
row: 0,
message: `Filen matchade inte det valda formatet (${format.name}) och tolkades istället som ${detected.name}.`,
severity: 'info',
},
...detectedResult.issues,
],
}
}
}
return explicitResult
} else {
format = detectFileFormat(content, filename) || undefined
if (!format) {
+1 -1
View File
@@ -49,7 +49,7 @@ export interface BankFileDuplicateInfo {
export interface BankFileParseIssue {
row: number
message: string
severity: 'warning' | 'error'
severity: 'info' | 'warning' | 'error'
}
/** Supported bank file format identifiers */
@@ -5,6 +5,7 @@ import {
hasEncodingIssues,
recoverStringWithFFFD,
recoverWordWithFFFD,
stripBOM,
} from '../encoding'
describe('decodeStringContent', () => {
@@ -78,6 +79,49 @@ describe('decodeFileContent', () => {
const cp1252 = buf([0x47, 0xd6, 0x54, 0x45, 0x42, 0x4f, 0x52, 0x47])
expect(decodeFileContent(cp1252)).toBe('GÖTEBORG')
})
it('strips a UTF-8 BOM (EF BB BF) and decodes the remainder as UTF-8', () => {
const payload = Array.from(new TextEncoder().encode('Bokföringsdag;Belopp'))
const text = decodeFileContent(buf([0xef, 0xbb, 0xbf, ...payload]))
expect(text).toBe('Bokföringsdag;Belopp')
expect(text.charCodeAt(0)).not.toBe(0xfeff)
})
it('never produces a mojibake BOM prefix when a BOM-ed file needs the Windows-1252 fallback', () => {
// 'Datum;' + 0xD6 (Ö in Windows-1252, invalid as a lone UTF-8 byte),
// behind a UTF-8 BOM. The BOM bytes must never re-enter the fallback
// decode, so the result starts with 'Datum', not the literal mojibake.
const bytes = buf([0xef, 0xbb, 0xbf, 0x44, 0x61, 0x74, 0x75, 0x6d, 0x3b, 0xd6])
const text = decodeFileContent(bytes)
expect(text.startsWith('')).toBe(false)
expect(text).toBe('Datum;Ö')
})
it('decodes UTF-16LE content behind a FF FE BOM', () => {
// 'Öre' in UTF-16LE: d6 00, 72 00, 65 00
const bytes = buf([0xff, 0xfe, 0xd6, 0x00, 0x72, 0x00, 0x65, 0x00])
expect(decodeFileContent(bytes)).toBe('Öre')
})
it('decodes UTF-16BE content behind a FE FF BOM', () => {
const bytes = buf([0xfe, 0xff, 0x00, 0xd6, 0x00, 0x72, 0x00, 0x65])
expect(decodeFileContent(bytes)).toBe('Öre')
})
})
describe('stripBOM', () => {
it('strips a leading U+FEFF', () => {
expect(stripBOM('\uFEFF' + 'Datum;Belopp')).toBe('Datum;Belopp')
})
it('strips a leading literal mojibake BOM ()', () => {
expect(stripBOM('Datum;Belopp')).toBe('Datum;Belopp')
})
it('is a no-op on clean content and never strips mid-string', () => {
expect(stripBOM('Datum;Belopp')).toBe('Datum;Belopp')
expect(stripBOM('Datum;Belopp')).toBe('Datum;Belopp')
})
})
// --- U+FFFD heuristic recovery ---
+37 -4
View File
@@ -7,12 +7,38 @@
*/
/**
* Decode file content, handling both UTF-8 and Windows-1252 encodings.
* Decode file content, handling BOMs plus UTF-8 and Windows-1252 encodings.
*
* Strategy: Try UTF-8 first. If the result contains replacement characters
* (U+FFFD) or garbled Swedish chars, fall back to Windows-1252.
* Strategy: inspect the leading BYTES first.
* - FF FE / FE FF: UTF-16LE/BE (e.g. Excel "Unicode text" re-saves). Decode
* as such; TextDecoder consumes the BOM itself.
* - EF BB BF (UTF-8 BOM): strip the three bytes and decode the REMAINDER.
* If the remainder still contains U+FFFD (invalid UTF-8 somewhere in the
* file), decode the remainder as Windows-1252. The BOM bytes are never
* re-included, so the fallback can no longer produce a literal mojibake
* "" prefix that breaks exact-match header detection.
* - No BOM: try UTF-8; fall back to Windows-1252 on replacement characters
* or garbled Swedish chars (unchanged behavior).
*/
export function decodeFileContent(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
return new TextDecoder('utf-16le', { fatal: false }).decode(buffer)
}
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
return new TextDecoder('utf-16be', { fatal: false }).decode(buffer)
}
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
const rest = bytes.subarray(3)
const utf8Rest = new TextDecoder('utf-8', { fatal: false }).decode(rest)
if (!utf8Rest.includes('�')) {
return utf8Rest
}
return new TextDecoder('windows-1252', { fatal: false }).decode(rest)
}
const utf8Decoder = new TextDecoder('utf-8', { fatal: false })
const utf8Result = utf8Decoder.decode(buffer)
@@ -72,12 +98,19 @@ export function normalizeLineEndings(content: string): string {
}
/**
* Strip BOM (Byte Order Mark) from start of content
* Strip BOM (Byte Order Mark) from start of content.
*
* Also strips the literal mojibake form "" (U+00EF U+00BB U+00BF): a
* UTF-8 BOM whose bytes were decoded as Windows-1252/Latin-1 upstream.
* Covers string-entry paths that were pre-decoded outside decodeFileContent.
*/
export function stripBOM(content: string): string {
if (content.charCodeAt(0) === 0xfeff) {
return content.slice(1)
}
if (content.startsWith('')) {
return content.slice(3)
}
return content
}
+1 -1
View File
@@ -12,7 +12,7 @@ are in SKILL.md and are not repeated per endpoint.
**Import a bank-file (CSV / XML / CAMT053).**
`scope:transactions:write · risk:medium · idempotent`
Accepts a bank statement file (UTF-8 / Windows-1252, up to 10 MB) as multipart/form-data. Auto-detects the bank format (SEB, Swedbank, Handelsbanken, Nordea, Nordea Business, Lansforsakringar, Lunar, ICA Banken, Skandia, Wise transaction history, Wise balance statement, CAMT053, generic CSV) or honors a `format` override. Parses transactions, ingests them into the `transactions` table (NOT into journal entries: see BFL note in pitfalls), and emits `transaction.synced` events. Returns operation_id for polling.
Accepts a bank statement file (UTF-8 / UTF-16 / Windows-1252, up to 10 MB) as multipart/form-data. Auto-detects the bank format (SEB, Swedbank, Handelsbanken, Nordea, Nordea Business, Lansforsakringar, Lunar, ICA Banken, Skandia, Wise transaction history, Wise balance statement, CAMT053, generic CSV) or honors a `format` override. Parses transactions, ingests them into the `transactions` table (NOT into journal entries: see BFL note in pitfalls), and emits `transaction.synced` events. Returns operation_id for polling.
**Use when:** Importing a bank statement export for a period. Common with PSD2 bank connections that don't auto-sync, or for legacy bank accounts.
**Do not use for:** SIE bookkeeping import (use /imports/sie). Auto-bank sync (use the enable-banking extension). Single-transaction creation (use POST /transactions/ingest with a 1-element array).