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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0510d4c13f
commit
24911abde0
@@ -2161,6 +2161,12 @@ describe('Wise format', () => {
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.transactions[0].raw_line).toBe('TRANSFER-2247230173')
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warning',
|
||||
message: expect.stringMatching(/unsupported status "CANCELLED"/),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2180,9 +2186,54 @@ describe('Wise format hardening', () => {
|
||||
].join(',')
|
||||
}
|
||||
|
||||
it('fails hard on an unsupported Direction (e.g. NEUTRAL conversion)', () => {
|
||||
const csv = [WISE_HEADER, row({ id: 'PLAN_ORDER-9', direction: 'NEUTRAL', scur: 'USD', tcur: 'SEK' })].join('\n')
|
||||
expect(() => parseBankFile(csv, 'wise.csv')).toThrow(/unsupported Direction "NEUTRAL"/)
|
||||
it('skips and surfaces an unsupported Direction without aborting valid rows', () => {
|
||||
const csv = [
|
||||
WISE_HEADER,
|
||||
row({ id: 'PLAN_ORDER-9', direction: 'NEUTRAL', scur: 'USD', tcur: 'SEK' }),
|
||||
row({ id: 'TRANSFER-2' }),
|
||||
].join('\n')
|
||||
const result = parseBankFile(csv, 'wise.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.transactions[0].raw_line).toBe('TRANSFER-2')
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
message: expect.stringMatching(/unsupported Direction "NEUTRAL"/),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('skips and surfaces a cross-currency row instead of importing one side', () => {
|
||||
const csv = [
|
||||
WISE_HEADER,
|
||||
row({ id: 'TRANSFER-FX', direction: 'OUT', samt: '100', scur: 'USD', tamt: '900', tcur: 'SEK' }),
|
||||
].join('\n')
|
||||
const result = parseBankFile(csv, 'wise.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
message: expect.stringMatching(/Cross-currency Wise row TRANSFER-FX \(USD to SEK\)/),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces a REFUNDED row instead of silently dropping it', () => {
|
||||
const csv = [WISE_HEADER, row({ id: 'TRANSFER-REFUND', status: 'REFUNDED' })].join('\n')
|
||||
const result = parseBankFile(csv, 'wise.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
message: expect.stringMatching(/unsupported status "REFUNDED"/),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not import a row with a blank status', () => {
|
||||
@@ -2190,6 +2241,7 @@ describe('Wise format hardening', () => {
|
||||
const result = parseBankFile(csv, 'wise.csv')
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
expect(result.issues[0].severity).toBe('error')
|
||||
})
|
||||
|
||||
it('rejects a partially numeric amount instead of coercing it', () => {
|
||||
@@ -2215,3 +2267,262 @@ describe('Wise format hardening', () => {
|
||||
expect(result.issues.some((iss) => /no currency/.test(iss.message))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wise per-currency balance statement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WISE_STATEMENT_HEADER =
|
||||
'"TransferWise ID",Date,Amount,Currency,Description,"Payment Reference","Running Balance","Exchange From","Exchange To","Exchange Rate","Payer Name","Payee Name","Payee Account Number",Merchant,"Card Last Four Digits","Card Holder Full Name",Attachment,Note,"Total fees"'
|
||||
|
||||
function wiseStatementRow(over: Partial<Record<string, string>> = {}): string {
|
||||
const fields: Record<string, string> = {
|
||||
id: 'TRANSFER-100',
|
||||
date: '01/08/2026',
|
||||
amount: '1250.50',
|
||||
currency: 'SEK',
|
||||
description: 'Received money from Example AB',
|
||||
reference: 'INV-100',
|
||||
balance: '5000.50',
|
||||
exchangeFrom: '',
|
||||
exchangeTo: '',
|
||||
exchangeRate: '',
|
||||
payerName: 'Example AB',
|
||||
payeeName: '',
|
||||
payeeAccount: '',
|
||||
merchant: '',
|
||||
cardLastFour: '',
|
||||
cardHolder: '',
|
||||
attachment: '',
|
||||
note: '',
|
||||
totalFees: '0',
|
||||
...over,
|
||||
}
|
||||
return [
|
||||
fields.id,
|
||||
fields.date,
|
||||
fields.amount,
|
||||
fields.currency,
|
||||
fields.description,
|
||||
fields.reference,
|
||||
fields.balance,
|
||||
fields.exchangeFrom,
|
||||
fields.exchangeTo,
|
||||
fields.exchangeRate,
|
||||
fields.payerName,
|
||||
fields.payeeName,
|
||||
fields.payeeAccount,
|
||||
fields.merchant,
|
||||
fields.cardLastFour,
|
||||
fields.cardHolder,
|
||||
fields.attachment,
|
||||
fields.note,
|
||||
fields.totalFees,
|
||||
].join(',')
|
||||
}
|
||||
|
||||
const WISE_STATEMENT_CSV = [
|
||||
WISE_STATEMENT_HEADER,
|
||||
wiseStatementRow(),
|
||||
wiseStatementRow({
|
||||
id: 'CARD-200',
|
||||
date: '02-08-2026',
|
||||
amount: '-49.90',
|
||||
description: '',
|
||||
reference: '',
|
||||
balance: '4950.60',
|
||||
payerName: '',
|
||||
merchant: 'Corner Shop',
|
||||
note: 'Lunch',
|
||||
}),
|
||||
wiseStatementRow({
|
||||
id: 'FEE-TRANSFER-300',
|
||||
date: '03.08.2026',
|
||||
amount: '-2.20',
|
||||
description: 'Wise Charges for: TRANSFER-300',
|
||||
reference: '',
|
||||
balance: '4948.40',
|
||||
payerName: '',
|
||||
payeeName: 'Wise',
|
||||
}),
|
||||
wiseStatementRow({
|
||||
id: 'TRANSFER-300',
|
||||
date: '2026-08-04',
|
||||
amount: '-100',
|
||||
description: 'Sent money to Supplier AB',
|
||||
reference: 'BILL-300',
|
||||
balance: '4848.40',
|
||||
payerName: '',
|
||||
payeeName: 'Supplier AB',
|
||||
totalFees: '0.35',
|
||||
}),
|
||||
].join('\n')
|
||||
|
||||
describe('Wise balance statement format', () => {
|
||||
const byId = (transactions: ParsedBankTransaction[], id: string) =>
|
||||
transactions.find((transaction) => transaction.raw_line === id)
|
||||
|
||||
it('auto-detects the distinct balance statement header', () => {
|
||||
const format = detectFileFormat(WISE_STATEMENT_CSV, 'statement_123_SEK_2026.csv')
|
||||
expect(format?.id).toBe('wise_statement')
|
||||
})
|
||||
|
||||
it('parses signed movements, balances, counterparties, notes, and date variants', () => {
|
||||
const result = parseBankFile(WISE_STATEMENT_CSV, 'statement_123_SEK_2026.csv')
|
||||
|
||||
expect(result.format).toBe('wise_statement')
|
||||
expect(result.transactions).toHaveLength(4)
|
||||
expect(byId(result.transactions, 'TRANSFER-100')).toMatchObject({
|
||||
date: '2026-08-01',
|
||||
amount: 1250.5,
|
||||
currency: 'SEK',
|
||||
balance: 5000.5,
|
||||
reference: 'INV-100',
|
||||
counterparty: 'Example AB',
|
||||
})
|
||||
expect(byId(result.transactions, 'CARD-200')).toMatchObject({
|
||||
date: '2026-08-02',
|
||||
amount: -49.9,
|
||||
description: 'Corner Shop - Lunch',
|
||||
counterparty: 'Corner Shop',
|
||||
})
|
||||
expect(byId(result.transactions, 'FEE-TRANSFER-300')).toMatchObject({
|
||||
date: '2026-08-03',
|
||||
amount: -2.2,
|
||||
counterparty: 'Wise',
|
||||
})
|
||||
expect(byId(result.transactions, 'TRANSFER-300')?.description).toContain(
|
||||
'Wise avgift: 0.35 SEK',
|
||||
)
|
||||
expect(result.date_from).toBe('2026-08-01')
|
||||
expect(result.date_to).toBe('2026-08-04')
|
||||
expect(result.stats).toMatchObject({
|
||||
total_rows: 4,
|
||||
parsed_rows: 4,
|
||||
skipped_rows: 0,
|
||||
total_income: 1250.5,
|
||||
total_expenses: -152.1,
|
||||
})
|
||||
})
|
||||
|
||||
it('imports explicit fee rows exactly once and does not synthesize extra movements', () => {
|
||||
const result = parseBankFile(WISE_STATEMENT_CSV, 'statement.csv')
|
||||
|
||||
expect(result.transactions.filter((transaction) => transaction.amount === -2.2)).toHaveLength(1)
|
||||
expect(result.transactions).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('shares ordinary movement IDs with transaction history across formats', () => {
|
||||
const statement = parseBankFile(
|
||||
[
|
||||
WISE_STATEMENT_HEADER,
|
||||
wiseStatementRow({
|
||||
id: 'TRANSFER-2247230173',
|
||||
currency: 'USD',
|
||||
amount: '2500',
|
||||
balance: '5000',
|
||||
}),
|
||||
].join('\n'),
|
||||
'statement_USD.csv',
|
||||
).transactions[0]
|
||||
const history = parseBankFile(WISE_CSV, 'wise.csv').transactions.find(
|
||||
(transaction) => transaction.raw_line === 'TRANSFER-2247230173',
|
||||
)!
|
||||
|
||||
expect(generateExternalId(statement, 'wise_statement', 0)).toBe(
|
||||
generateExternalId(history, 'wise', 0),
|
||||
)
|
||||
})
|
||||
|
||||
it('qualifies conversion legs by statement currency', () => {
|
||||
const conversion = (currency: string) =>
|
||||
parseBankFile(
|
||||
[
|
||||
WISE_STATEMENT_HEADER,
|
||||
wiseStatementRow({
|
||||
id: 'PLAN_ORDER-9',
|
||||
currency,
|
||||
exchangeFrom: '100 USD',
|
||||
exchangeTo: '900 SEK',
|
||||
exchangeRate: '9',
|
||||
}),
|
||||
].join('\n'),
|
||||
`statement_${currency}.csv`,
|
||||
).transactions[0]
|
||||
|
||||
expect(generateExternalId(conversion('SEK'), 'wise_statement', 0)).toBe(
|
||||
'wise_PLAN_ORDER-9:SEK',
|
||||
)
|
||||
expect(generateExternalId(conversion('USD'), 'wise_statement', 0)).toBe(
|
||||
'wise_PLAN_ORDER-9:USD',
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks a duplicate scoped Wise movement ID within one statement', () => {
|
||||
const result = parseBankFile(
|
||||
[WISE_STATEMENT_HEADER, wiseStatementRow(), wiseStatementRow()].join('\n'),
|
||||
'statement.csv',
|
||||
)
|
||||
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
message: 'Duplicate Wise movement ID TRANSFER-100; skipped',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts netted-fee statements in either ordering without continuity warnings', () => {
|
||||
const oldestFirst = parseBankFile(WISE_STATEMENT_CSV, 'statement.csv')
|
||||
const rows = WISE_STATEMENT_CSV.split('\n')
|
||||
const newestFirst = parseBankFile(
|
||||
[rows[0], ...rows.slice(1).reverse()].join('\n'),
|
||||
'statement.csv',
|
||||
)
|
||||
|
||||
for (const result of [oldestFirst, newestFirst]) {
|
||||
expect(result.transactions).toHaveLength(4)
|
||||
expect(result.issues.filter((issue) => /Running balance break/.test(issue.message))).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('warns when the balance moves by more than Amount (fees not netted)', () => {
|
||||
const csv = [
|
||||
WISE_STATEMENT_HEADER,
|
||||
wiseStatementRow({ id: 'IN-1', amount: '100', balance: '1100' }),
|
||||
wiseStatementRow({
|
||||
id: 'OUT-2',
|
||||
date: '02/08/2026',
|
||||
amount: '-50',
|
||||
balance: '1049.65',
|
||||
totalFees: '0.35',
|
||||
}),
|
||||
].join('\n')
|
||||
const result = parseBankFile(csv, 'statement.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(2)
|
||||
expect(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.severity === 'warning' && /Running balance break at OUT-2/.test(issue.message),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('skips malformed movements while retaining non-fatal metadata warnings', () => {
|
||||
const csv = [
|
||||
WISE_STATEMENT_HEADER,
|
||||
wiseStatementRow({ id: 'BAD-AMOUNT', amount: '12abc' }),
|
||||
wiseStatementRow({ id: 'GOOD', balance: 'not-a-balance', totalFees: 'fee?' }),
|
||||
].join('\n')
|
||||
const result = parseBankFile(csv, 'statement.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
expect(result.issues.some((issue) => /Invalid amount on BAD-AMOUNT/.test(issue.message))).toBe(true)
|
||||
expect(result.issues.some((issue) => /Invalid running balance on GOOD/.test(issue.message))).toBe(true)
|
||||
expect(result.issues.some((issue) => /Invalid total fees on GOOD/.test(issue.message))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 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),
|
||||
),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -19,7 +19,8 @@
|
||||
* 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; pending/cancelled/refunded rows are skipped.
|
||||
* - 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).
|
||||
@@ -126,26 +127,54 @@ export const wiseFormat: BankFileFormat = {
|
||||
// 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. Fail the
|
||||
// whole import so it surfaces (the parse route turns this throw into
|
||||
// BANK_FILE_PARSE_FAILED). Proper conversion handling is tracked in #1019.
|
||||
// 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') {
|
||||
throw new Error(
|
||||
`Wise import: unsupported Direction "${at(idx.direction)}" on ${wiseId || `row ${i + 1}`}`,
|
||||
)
|
||||
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 ? at(idx.sourceCurrency) : at(idx.targetCurrency)).trim().toUpperCase()
|
||||
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++
|
||||
|
||||
@@ -18,6 +18,7 @@ import { skandiaFormat } from './formats/skandia'
|
||||
import { lunarFormat } from './formats/lunar'
|
||||
import { northmillFormat } from './formats/northmill'
|
||||
import { wiseFormat } from './formats/wise'
|
||||
import { wiseStatementFormat } from './formats/wise-statement'
|
||||
import { camt053Format } from './formats/camt053'
|
||||
import { genericCSVFormat } from './formats/generic-csv'
|
||||
|
||||
@@ -40,6 +41,7 @@ const FORMATS: BankFileFormat[] = [
|
||||
lunarFormat,
|
||||
northmillFormat,
|
||||
wiseFormat,
|
||||
wiseStatementFormat,
|
||||
genericCSVFormat,
|
||||
]
|
||||
|
||||
@@ -151,6 +153,10 @@ export function generateExternalId(
|
||||
return `wise_${tx.raw_line}`
|
||||
}
|
||||
|
||||
if (formatId === 'wise_statement' && 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)
|
||||
|
||||
@@ -54,6 +54,7 @@ export type BankFileFormatId =
|
||||
| 'lunar'
|
||||
| 'northmill'
|
||||
| 'wise'
|
||||
| 'wise_statement'
|
||||
| 'generic_csv'
|
||||
| 'camt053'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user