Files
accounted/lib/import/bank-file/parser.ts
T
1dc85736d8 feat(import): add Wise (TransferWise) CSV import format (#1018)
* feat(import): add Wise (TransferWise) CSV import format

Wise exports a single multi-currency transaction history (one row per balance
movement). Add it as a bank-file format plugin so it flows through the existing
upload -> preview -> confirm -> execute wizard.

- lib/import/bank-file/formats/wise.ts: quote-aware parse (dates contain a
  space), Direction IN/OUT drives the sign, booked on the moved side (target
  for IN, source for OUT). Native currency preserved; SEK conversion is left to
  the downstream FX/booking pipeline (Riksbanken).
- Non-zero Wise fees become their own negative "Wise avgift" row (source and
  target), so the fee books separately and the balance ties out.
- Only COMPLETED rows import. external_id keys on the stable Wise ID
  (TRANSFER-/PLAN_ORDER-, -fee suffix for fee rows) via a new 'wise' branch in
  generateExternalId, so re-imports dedup exactly.
- Register the format (types, parser list), add it to the manual-format picker
  and the v1 /imports/bank format enum.

Tests cover detection, IN/OUT signing + currency, fee splitting, stable
external_id, and COMPLETED-only filtering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Alexander Reinthal <email@reinthal.me>

* fix(import): harden Wise parser against malformed rows (CodeRabbit #1018)

- Strict amount parsing: reject "12abc"/"1,234" instead of parseFloat coercing
  them to 12/1 and silently corrupting the imported amount.
- Require Status to be exactly COMPLETED: a blank/missing status no longer
  slips through the completed-only filter.
- Fail hard on an unsupported Direction: a blank or non-IN/OUT value (e.g.
  NEUTRAL for a balance conversion) throws instead of being guessed as income;
  the parse route surfaces it as BANK_FILE_PARSE_FAILED. Proper conversion
  support is tracked in #1019.
- Never invent currencies: a missing movement currency skips the row with a
  warning (no SEK default), and a fee with no currency of its own is dropped
  with a warning rather than inheriting the movement currency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Alexander Reinthal <email@reinthal.me>

---------

Signed-off-by: Alexander Reinthal <email@reinthal.me>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
2026-07-16 16:22:32 +02:00

166 lines
5.3 KiB
TypeScript

/**
* Bank file parser: main entry point
*
* Auto-detects Swedish bank file formats and parses to normalized transactions.
* Supports Nordea, SEB, Swedbank, Handelsbanken CSV and ISO 20022 camt.053 XML.
*/
import * as crypto from 'crypto'
import type { BankFileFormat, BankFileFormatId, BankFileParseResult, ParsedBankTransaction } from './types'
import { nordeaFormat } from './formats/nordea'
import { nordeaBusinessFormat } from './formats/nordea-business'
import { sebFormat } from './formats/seb'
import { swedbankFormat } from './formats/swedbank'
import { handelsbankenFormat } from './formats/handelsbanken'
import { lansforsakringarFormat } from './formats/lansforsakringar'
import { icaBankenFormat } from './formats/ica-banken'
import { skandiaFormat } from './formats/skandia'
import { lunarFormat } from './formats/lunar'
import { northmillFormat } from './formats/northmill'
import { wiseFormat } from './formats/wise'
import { camt053Format } from './formats/camt053'
import { genericCSVFormat } from './formats/generic-csv'
/**
* Ordered list of format detectors.
* camt.053 first (XML detection is unambiguous), then bank-specific CSV formats.
* New bank formats go after existing ones but before generic_csv.
* Generic CSV is last: it never auto-detects (manual fallback only).
*/
const FORMATS: BankFileFormat[] = [
camt053Format,
nordeaFormat,
nordeaBusinessFormat,
sebFormat,
swedbankFormat,
handelsbankenFormat,
lansforsakringarFormat,
icaBankenFormat,
skandiaFormat,
lunarFormat,
northmillFormat,
wiseFormat,
genericCSVFormat,
]
/**
* Get a format by its ID
*/
export function getFormat(id: BankFileFormatId): BankFileFormat | undefined {
return FORMATS.find((f) => f.id === id)
}
/**
* Get all available formats
*/
export function getAllFormats(): BankFileFormat[] {
return FORMATS
}
/**
* Auto-detect the bank file format from content and filename
*
* Returns the first matching format, or null if no format matches.
* Uses filename extension as a hint (e.g. .xml for camt.053).
*/
export function detectFileFormat(content: string, filename: string): BankFileFormat | null {
for (const format of FORMATS) {
if (format.detect(content, filename)) {
return format
}
}
return null
}
/**
* Parse a bank file with auto-detection or explicit format
*
* @param content - File content as string (already decoded)
* @param filename - Original filename (used for format detection hints)
* @param formatId - Optional explicit format to use (skips auto-detection)
*/
export function parseBankFile(
content: string,
filename: string,
formatId?: BankFileFormatId
): BankFileParseResult {
let format: BankFileFormat | undefined
if (formatId) {
format = getFormat(formatId)
if (!format) {
return {
format: formatId,
format_name: 'Unknown',
transactions: [],
date_from: null,
date_to: null,
issues: [{ row: 0, message: `Unknown format: ${formatId}`, severity: 'error' }],
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
} else {
format = detectFileFormat(content, filename) || undefined
if (!format) {
// Build diagnostic message listing which formats were tried
const tried = FORMATS
.filter(f => f.id !== 'generic_csv')
.map(f => f.name)
const firstLine = content.split('\n')[0]?.substring(0, 80) || ''
return {
format: 'generic_csv',
format_name: 'Unknown',
transactions: [],
date_from: null,
date_to: null,
issues: [{
row: 0,
message: `Kunde inte identifiera bankformat. Testade: ${tried.join(', ')}. Första raden: "${firstLine}". Välj bank manuellt eller använd "Annan CSV".`,
severity: 'error',
}],
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
}
return format.parse(content)
}
/**
* Generate a stable external_id for a parsed bank transaction.
*
* For CSV files: SHA-256 of (format + date + description + amount + row_index)
* For camt.053: Uses the entry reference from the XML if available
*
* Two identical transactions on the same day will get different IDs due to row_index.
*/
export function generateExternalId(
tx: ParsedBankTransaction,
formatId: BankFileFormatId,
rowIndex: number
): string {
// For camt.053, prefer the raw_line which contains the entry reference
if (formatId === 'camt053' && tx.raw_line && !tx.raw_line.startsWith('camt053_entry_')) {
return `camt053_${tx.raw_line}`
}
// Wise carries the stable transfer ID (TRANSFER-…, PLAN_ORDER-…, plus a
// `-fee` suffix for fee rows) in raw_line: use it so re-importing the same
// statement dedups exactly instead of relying on the row hash.
if (formatId === 'wise' && tx.raw_line) {
return `wise_${tx.raw_line}`
}
// For CSV formats, create a composite hash
const composite = `${formatId}|${tx.date}|${tx.description}|${tx.amount}|${rowIndex}`
const hash = crypto.createHash('sha256').update(composite).digest('hex').substring(0, 16)
return `${formatId}_${hash}`
}
/**
* Generate a file hash for dedup of the same file being uploaded twice
*/
export function generateFileHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex')
}