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:
Mattsson
2026-08-03 17:55:09 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 0510d4c13f
commit 24911abde0
12 changed files with 741 additions and 23 deletions
+314 -3
View File
@@ -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),
),
},
}
},
}
+37 -8
View File
@@ -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++
+6
View File
@@ -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)
+1
View File
@@ -54,6 +54,7 @@ export type BankFileFormatId =
| 'lunar'
| 'northmill'
| 'wise'
| 'wise_statement'
| 'generic_csv'
| 'camt053'