* 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>
273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
/**
|
|
* Wise (TransferWise) transaction-history CSV parser.
|
|
*
|
|
* Wise exports a single multi-currency statement (one row per balance movement)
|
|
* with a header like:
|
|
* ID,Status,Direction,"Created on","Finished on","Source fee amount",
|
|
* "Source fee currency","Target fee amount","Target fee currency",
|
|
* "Source name","Source amount (after fees)","Source currency",
|
|
* "Target name","Target amount (after fees)","Target currency",
|
|
* "Exchange rate",Reference,Batch,"Created by",Category,Note
|
|
*
|
|
* Design notes:
|
|
* - Comma-delimited, `.` decimal, fields quoted (dates contain a space, so a
|
|
* quote-aware splitter is required: parseCSVLine).
|
|
* - Direction IN/OUT drives the sign. We book the row in the currency that
|
|
* actually moved on the balance: the target side for IN, the source side for
|
|
* OUT. Amounts stay in their native currency; SEK conversion happens
|
|
* downstream at booking time via the existing FX pipeline (Riksbanken), so
|
|
* this parser never converts.
|
|
* - Wise fees are a real cost, so a non-zero source/target fee becomes its OWN
|
|
* negative transaction ("Wise avgift") rather than being folded or dropped.
|
|
* - Only COMPLETED rows are imported; other statuses are skipped with a visible
|
|
* parse issue so refunds and chargebacks are never silently lost.
|
|
* - The stable Wise ID (TRANSFER-…, PLAN_ORDER-…) is carried in `raw_line` so
|
|
* generateExternalId can key dedup on it instead of a row hash (fee rows get
|
|
* an `<id>-fee` / `<id>-tgtfee` suffix).
|
|
*/
|
|
|
|
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
|
import { prepareContent } from '../../shared/encoding'
|
|
import { normalizeDate } from '../date-utils'
|
|
import { parseCSVLine } from './nordea'
|
|
|
|
/** Money rule: round to two decimals without toFixed. */
|
|
function round2(n: number): number {
|
|
return Math.round(n * 100) / 100
|
|
}
|
|
|
|
/**
|
|
* Parse a Wise amount ("2500.0", "520.00", "-2.20"). Wise amounts are plain
|
|
* decimals: optional sign, digits, optional `.` fraction, no thousands
|
|
* separator. Reject anything else: bare parseFloat would silently turn "12abc"
|
|
* into 12 and "1,234" into 1, corrupting the imported amount. NaN = reject.
|
|
*/
|
|
function parseWiseAmount(value: string | undefined): number {
|
|
if (!value) return NaN
|
|
const cleaned = value.trim()
|
|
if (!/^-?\d+(\.\d+)?$/.test(cleaned)) return NaN
|
|
return parseFloat(cleaned)
|
|
}
|
|
|
|
/** Wise datetimes are "YYYY-MM-DD HH:MM:SS"; keep the date part only. */
|
|
function wiseDate(value: string | undefined): string | null {
|
|
if (!value) return null
|
|
return normalizeDate(value.trim().split(/[ T]/)[0])
|
|
}
|
|
|
|
const HEADER_TOKENS = ['direction', 'source amount (after fees)', 'target amount (after fees)']
|
|
|
|
export const wiseFormat: BankFileFormat = {
|
|
id: 'wise',
|
|
name: 'Wise',
|
|
description: 'Wise (TransferWise) transaction history CSV, multi-currency',
|
|
fileExtensions: ['.csv'],
|
|
|
|
detect(content: string, _filename: string): boolean {
|
|
const firstLine = prepareContent(content).split('\n')[0]?.toLowerCase() || ''
|
|
// Wise's header is distinctive: the "(after fees)" amount columns plus a
|
|
// Direction column don't appear in any Swedish-bank export.
|
|
return firstLine.includes(',') && HEADER_TOKENS.every((t) => firstLine.includes(t))
|
|
},
|
|
|
|
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
|
|
|
|
const headers = parseCSVLine(lines[0] || '', ',').map((h) =>
|
|
h.trim().toLowerCase().replace(/^"|"$/g, ''),
|
|
)
|
|
const col = (name: string) => headers.findIndex((h) => h === name)
|
|
|
|
const idx = {
|
|
id: col('id'),
|
|
status: col('status'),
|
|
direction: col('direction'),
|
|
createdOn: col('created on'),
|
|
finishedOn: col('finished on'),
|
|
sourceFeeAmount: col('source fee amount'),
|
|
sourceFeeCurrency: col('source fee currency'),
|
|
targetFeeAmount: col('target fee amount'),
|
|
targetFeeCurrency: col('target fee currency'),
|
|
sourceName: col('source name'),
|
|
sourceAmount: col('source amount (after fees)'),
|
|
sourceCurrency: col('source currency'),
|
|
targetName: col('target name'),
|
|
targetAmount: col('target amount (after fees)'),
|
|
targetCurrency: col('target currency'),
|
|
reference: col('reference'),
|
|
category: col('category'),
|
|
note: col('note'),
|
|
}
|
|
|
|
if (idx.direction === -1 || idx.sourceAmount === -1 || idx.targetAmount === -1) {
|
|
issues.push({ row: 1, message: 'Could not identify required Wise columns', severity: 'error' })
|
|
return {
|
|
format: 'wise',
|
|
format_name: 'Wise',
|
|
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 fields = parseCSVLine(lines[i], ',').map((f) => f.trim().replace(/^"|"$/g, ''))
|
|
const at = (j: number) => (j >= 0 ? fields[j] ?? '' : '')
|
|
|
|
const wiseId = at(idx.id)
|
|
const status = at(idx.status).toUpperCase()
|
|
|
|
// Only settled movements affect the balance. A blank/missing status is
|
|
// NOT completed, so it must not slip through: require an exact match.
|
|
if (status !== 'COMPLETED') {
|
|
const isKnownNonSettledStatus = status === 'CANCELLED' || status === 'PENDING'
|
|
issues.push({
|
|
row: i + 1,
|
|
message: `Wise row ${wiseId || i + 1} has unsupported status "${status || 'blank'}"; skipped`,
|
|
// Cancelled and pending rows have not settled. A refund, chargeback,
|
|
// blank status, or new Wise status can represent a real movement, so
|
|
// it must block the file until a real export pins its sign semantics.
|
|
severity: isKnownNonSettledStatus ? 'warning' : 'error',
|
|
})
|
|
skippedRows++
|
|
continue
|
|
}
|
|
|
|
// Direction drives the sign. An unrecognized value (blank, or NEUTRAL for
|
|
// a balance conversion/cashback, or anything Wise adds later) must NOT be
|
|
// guessed: silently treating it as income mis-signs real money. Skip only
|
|
// this row and surface the reason so other valid rows remain importable.
|
|
const direction = at(idx.direction).toUpperCase()
|
|
if (direction !== 'IN' && direction !== 'OUT') {
|
|
issues.push({
|
|
row: i + 1,
|
|
message: `Wise row ${wiseId || i + 1} has unsupported Direction "${at(idx.direction) || 'blank'}"; skipped`,
|
|
severity: 'error',
|
|
})
|
|
skippedRows++
|
|
continue
|
|
}
|
|
const isOut = direction === 'OUT'
|
|
|
|
const sourceCurrency = at(idx.sourceCurrency).trim().toUpperCase()
|
|
const targetCurrency = at(idx.targetCurrency).trim().toUpperCase()
|
|
if (
|
|
/^[A-Z]{3}$/.test(sourceCurrency) &&
|
|
/^[A-Z]{3}$/.test(targetCurrency) &&
|
|
sourceCurrency !== targetCurrency
|
|
) {
|
|
issues.push({
|
|
row: i + 1,
|
|
message: `Cross-currency Wise row ${wiseId || i + 1} (${sourceCurrency} to ${targetCurrency}) requires per-currency balance statements; skipped`,
|
|
severity: 'error',
|
|
})
|
|
skippedRows++
|
|
continue
|
|
}
|
|
|
|
// Book the side that moved on the balance: target for IN, source for OUT.
|
|
// Never invent a currency: a missing/malformed one is a bad row, skip it.
|
|
const currency = isOut ? sourceCurrency : targetCurrency
|
|
if (!/^[A-Z]{3}$/.test(currency)) {
|
|
issues.push({ row: i + 1, message: `Missing/invalid currency on ${wiseId || 'row'}`, severity: 'warning' })
|
|
skippedRows++
|
|
continue
|
|
}
|
|
const rawAmount = parseWiseAmount(isOut ? at(idx.sourceAmount) : at(idx.targetAmount))
|
|
|
|
const date = wiseDate(at(idx.finishedOn)) || wiseDate(at(idx.createdOn))
|
|
if (!date) {
|
|
issues.push({ row: i + 1, message: `Invalid date on ${wiseId || 'row'}`, severity: 'warning' })
|
|
skippedRows++
|
|
continue
|
|
}
|
|
if (!Number.isFinite(rawAmount)) {
|
|
issues.push({ row: i + 1, message: `Invalid amount on ${wiseId || 'row'}`, severity: 'warning' })
|
|
skippedRows++
|
|
continue
|
|
}
|
|
|
|
const counterparty = (isOut ? at(idx.targetName) : at(idx.sourceName)).trim()
|
|
const note = at(idx.note).trim()
|
|
const reference = at(idx.reference).trim()
|
|
const category = at(idx.category).trim()
|
|
const description =
|
|
[counterparty, note].filter(Boolean).join(' - ') || reference || category || 'Wise-transaktion'
|
|
|
|
// Signed movement: OUT leaves the balance (negative), IN enters it.
|
|
const amount = round2(isOut ? -Math.abs(rawAmount) : Math.abs(rawAmount))
|
|
|
|
transactions.push({
|
|
date,
|
|
description,
|
|
amount,
|
|
currency,
|
|
balance: null,
|
|
reference: reference || null,
|
|
counterparty: counterparty || null,
|
|
// Stable Wise ID drives dedup (see generateExternalId).
|
|
raw_line: wiseId || undefined,
|
|
})
|
|
|
|
// Fees are a real cost: emit each non-zero fee as its own negative row so
|
|
// it lands in the inbox to categorize (e.g. 6570) and the balance ties out.
|
|
const feeSpecs: Array<{ amountCol: number; currencyCol: number; suffix: string }> = [
|
|
{ amountCol: idx.sourceFeeAmount, currencyCol: idx.sourceFeeCurrency, suffix: 'fee' },
|
|
{ amountCol: idx.targetFeeAmount, currencyCol: idx.targetFeeCurrency, suffix: 'tgtfee' },
|
|
]
|
|
for (const spec of feeSpecs) {
|
|
const feeRaw = at(spec.amountCol).trim()
|
|
if (!feeRaw) continue // No fee column value: normal, most rows.
|
|
const fee = parseWiseAmount(feeRaw)
|
|
if (fee === 0) continue // Explicit 0.00 fee: nothing to book, no warning.
|
|
if (!Number.isFinite(fee) || fee < 0) {
|
|
issues.push({ row: i + 1, message: `Invalid fee amount "${feeRaw}" on ${wiseId || 'row'}`, severity: 'warning' })
|
|
continue
|
|
}
|
|
// Never inherit the movement currency for the fee: a fee amount without
|
|
// its own currency is a bad row, so flag and skip it rather than guess.
|
|
const feeCurrency = at(spec.currencyCol).trim().toUpperCase()
|
|
if (!/^[A-Z]{3}$/.test(feeCurrency)) {
|
|
issues.push({ row: i + 1, message: `Fee on ${wiseId || 'row'} has no currency; skipped`, severity: 'warning' })
|
|
continue
|
|
}
|
|
transactions.push({
|
|
date,
|
|
description: `Wise avgift${counterparty ? ` (${counterparty})` : ''}`,
|
|
amount: round2(-Math.abs(fee)),
|
|
currency: feeCurrency,
|
|
balance: null,
|
|
reference: reference || null,
|
|
counterparty: 'Wise',
|
|
raw_line: wiseId ? `${wiseId}-${spec.suffix}` : undefined,
|
|
})
|
|
}
|
|
}
|
|
|
|
const dates = transactions.map((t) => t.date).sort()
|
|
|
|
return {
|
|
format: 'wise',
|
|
format_name: 'Wise',
|
|
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: round2(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0)),
|
|
total_expenses: round2(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0)),
|
|
},
|
|
}
|
|
},
|
|
}
|