Files
accounted/lib/import/bank-file/formats/wise-statement.ts
T
Mattsson 24911abde0 feat(import): support Wise balance statements (#1368)
* feat(import): support Wise balance statements

* fix(import): fail closed on ambiguous Wise rows

* fix(import): guard Wise statement netted-fee assumption with running-balance continuity check

Swedish accounting review asked whether balance-statement Total fees is
netted into Amount. It is: Running Balance moves by exactly the signed
Amount per row, so a separate fee row would double-count the cost. Codify
the assumption with a pairwise continuity warning (order-agnostic, chain
resets across skipped rows) and document the decision.

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

* fix(api): cap bank-import validation payload and harden issue assertion

CodeRabbit review: bound the VALIDATION_ERROR issues array to 20 entries
with issue_count carrying the full total, so a large malformed file cannot
balloon the response or log sink. Gate stays format-agnostic on purpose:
error severity means do-not-ingest for every parser, and no non-Wise parser
emits per-row errors alongside parsed transactions today.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:55:09 +02:00

287 lines
10 KiB
TypeScript

/**
* Wise per-currency balance-statement CSV parser.
*
* Unlike Wise's multi-currency transaction-history export, balance statements
* carry a signed Amount for one balance. That signed value is the authoritative
* bank movement, so this parser never derives the sign from transfer metadata.
*
* "Total fees" is a breakdown of Amount, not an extra charge: the Running
* Balance moves by exactly Amount per row, so booking the fee as its own
* transaction (as wise.ts does for the "(after fees)" history export) would
* double-count it here. The fee is kept in the description as underlag, and a
* running-balance continuity check warns if a statement ever violates the
* netted-fee assumption.
*/
import type {
BankFileFormat,
BankFileParseIssue,
BankFileParseResult,
ParsedBankTransaction,
} from '../types'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
import { parseCSVLine } from './nordea'
import { roundOre } from '@/lib/money'
function parseWiseStatementAmount(value: string | undefined): number {
if (!value) return NaN
const cleaned = value.trim()
if (!/^-?\d+(\.\d+)?$/.test(cleaned)) return NaN
return Number.parseFloat(cleaned)
}
function wiseStatementDate(value: string | undefined): string | null {
if (!value) return null
const datePart = value.trim().split(/[ T]/)[0]
const normalized = normalizeDate(datePart)
if (normalized) return normalized
const dashDate = datePart.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/)
if (!dashDate) return null
return normalizeDate(`${dashDate[1]}.${dashDate[2]}.${dashDate[3]}`)
}
const REQUIRED_HEADERS = [
'transferwise id',
'date',
'amount',
'currency',
'description',
'running balance',
'total fees',
]
export const wiseStatementFormat: BankFileFormat = {
id: 'wise_statement',
name: 'Wise balance statement',
description: 'Wise per-currency balance statement CSV',
fileExtensions: ['.csv'],
detect(content: string, _filename: string): boolean {
const firstLine = prepareContent(content).split('\n')[0] || ''
const headers = parseCSVLine(firstLine, ',').map((header) => header.trim().toLowerCase())
return REQUIRED_HEADERS.every((header) => headers.includes(header))
},
parse(content: string): BankFileParseResult {
const lines = prepareContent(content)
.split('\n')
.filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
const headers = parseCSVLine(lines[0] || '', ',').map((header) =>
header.trim().toLowerCase(),
)
const col = (name: string) => headers.findIndex((header) => header === name)
const idx = {
id: col('transferwise id'),
date: col('date'),
amount: col('amount'),
currency: col('currency'),
description: col('description'),
paymentReference: col('payment reference'),
runningBalance: col('running balance'),
exchangeFrom: col('exchange from'),
exchangeTo: col('exchange to'),
payerName: col('payer name'),
payeeName: col('payee name'),
merchant: col('merchant'),
note: col('note'),
totalFees: col('total fees'),
}
const seenWiseMovements = new Set<string>()
// Continuity chain for the netted-fee guard: reset whenever a row is
// skipped or lacks a balance, so gaps never produce false warnings.
let previousMovement: { balance: number; amount: number } | null = null
if (REQUIRED_HEADERS.some((header) => col(header) === -1)) {
issues.push({
row: 1,
message: 'Could not identify required Wise balance statement columns',
severity: 'error',
})
return {
format: 'wise_statement',
format_name: 'Wise balance statement',
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 lineIndex = 1; lineIndex < lines.length; lineIndex++) {
const fields = parseCSVLine(lines[lineIndex], ',').map((field) => field.trim())
const at = (columnIndex: number) =>
columnIndex >= 0 ? fields[columnIndex] ?? '' : ''
const rowNumber = lineIndex + 1
const wiseId = at(idx.id).trim()
const rowLabel = wiseId || `row ${rowNumber}`
const amount = parseWiseStatementAmount(at(idx.amount))
const currency = at(idx.currency).trim().toUpperCase()
const date = wiseStatementDate(at(idx.date))
if (!date) {
issues.push({ row: rowNumber, message: `Invalid date on ${rowLabel}`, severity: 'warning' })
skippedRows++
previousMovement = null
continue
}
if (!Number.isFinite(amount) || amount === 0) {
issues.push({ row: rowNumber, message: `Invalid amount on ${rowLabel}`, severity: 'warning' })
skippedRows++
previousMovement = null
continue
}
if (!/^[A-Z]{3}$/.test(currency)) {
issues.push({
row: rowNumber,
message: `Missing/invalid currency on ${rowLabel}`,
severity: 'warning',
})
skippedRows++
previousMovement = null
continue
}
const rawBalance = at(idx.runningBalance).trim()
const parsedBalance = rawBalance ? parseWiseStatementAmount(rawBalance) : NaN
let balance: number | null = null
if (rawBalance && !Number.isFinite(parsedBalance)) {
issues.push({
row: rowNumber,
message: `Invalid running balance on ${rowLabel}`,
severity: 'warning',
})
} else if (Number.isFinite(parsedBalance)) {
balance = roundOre(parsedBalance)
}
const roundedAmount = roundOre(amount)
const payerName = at(idx.payerName).trim()
const payeeName = at(idx.payeeName).trim()
const merchant = at(idx.merchant).trim()
const counterparty =
merchant ||
(roundedAmount < 0 ? payeeName : payerName) ||
(roundedAmount < 0 ? payerName : payeeName)
const reference = at(idx.paymentReference).trim()
const note = at(idx.note).trim()
const exportedDescription = at(idx.description).trim()
const primaryDescription =
exportedDescription || counterparty || reference || 'Wise transaction'
const descriptionParts = [primaryDescription]
if (note && !primaryDescription.includes(note)) descriptionParts.push(note)
const rawTotalFees = at(idx.totalFees).trim()
if (rawTotalFees) {
const totalFees = parseWiseStatementAmount(rawTotalFees)
if (!Number.isFinite(totalFees) || totalFees < 0) {
issues.push({
row: rowNumber,
message: `Invalid total fees on ${rowLabel}`,
severity: 'warning',
})
} else if (
totalFees > 0 &&
!/wise charges|wise avgift|\bfee\b/i.test(primaryDescription)
) {
descriptionParts.push(`Wise avgift: ${roundOre(totalFees)} ${currency}`)
}
}
// Ordinary movements use the same canonical Wise ID as the transaction
// history export, preventing an overlapping import from creating a
// second transaction. Conversions can reuse one ID across currency
// statements, so their independently signed legs stay currency-scoped.
const hasExchangeDetails = Boolean(
at(idx.exchangeFrom).trim() || at(idx.exchangeTo).trim(),
)
const stableMovementId = wiseId
? hasExchangeDetails
? `${wiseId}:${currency}`
: wiseId
: undefined
if (stableMovementId && seenWiseMovements.has(stableMovementId)) {
issues.push({
row: rowNumber,
message: `Duplicate Wise movement ID ${stableMovementId}; skipped`,
severity: 'error',
})
skippedRows++
previousMovement = null
continue
}
if (stableMovementId) seenWiseMovements.add(stableMovementId)
// Netted-fee guard: on adjacent parsed rows the balance must move by
// exactly the signed Amount (statements can be oldest-first or
// newest-first, so accept either direction). A break means Amount does
// not equal the balance movement, e.g. fees charged on top of Amount,
// and the file needs manual review before booking.
if (balance !== null && previousMovement !== null) {
const oldestFirst =
roundOre(previousMovement.balance + roundedAmount) === balance
const newestFirst =
roundOre(balance + previousMovement.amount) === previousMovement.balance
if (!oldestFirst && !newestFirst) {
issues.push({
row: rowNumber,
message: `Running balance break at ${rowLabel}: the signed amount does not match the balance change (fees may not be netted into Amount); verify against the Wise balance before booking`,
severity: 'warning',
})
}
}
previousMovement = balance !== null ? { balance, amount: roundedAmount } : null
transactions.push({
date,
description: descriptionParts.join(' - '),
amount: roundedAmount,
currency,
balance,
reference: reference || null,
counterparty: counterparty || null,
raw_line: stableMovementId,
})
}
const dates = transactions.map((transaction) => transaction.date).sort()
return {
format: 'wise_statement',
format_name: 'Wise balance statement',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length > 0 ? lines.length - 1 : 0,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: roundOre(
transactions
.filter((transaction) => transaction.amount > 0)
.reduce((sum, transaction) => sum + transaction.amount, 0),
),
total_expenses: roundOre(
transactions
.filter((transaction) => transaction.amount < 0)
.reduce((sum, transaction) => sum + transaction.amount, 0),
),
},
}
},
}