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>
This commit is contained in:
co-authored by
Claude Opus 4.8
Jakob Wennberg
parent
f8033cb32d
commit
1dc85736d8
@@ -2045,3 +2045,125 @@ describe('parseCSVLine: unclosed quote handling', () => {
|
||||
expect(fields).toEqual(['2024-01-15', 'SPOTIFY AB', '-99,00'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wise (TransferWise) multi-currency transaction history
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WISE_HEADER =
|
||||
'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'
|
||||
|
||||
const WISE_CSV = [
|
||||
WISE_HEADER,
|
||||
'TRANSFER-2247230173,COMPLETED,IN,"2026-07-13 13:39:01","2026-07-13 13:39:08",,,,,"Bluedot Impact Ltd",2500.0,USD,"Aligned Intelligence AB",2500.0,USD,1,Facilitation,,,"Money added",',
|
||||
'TRANSFER-2214309703,COMPLETED,IN,"2026-06-26 20:53:33","2026-06-26 20:54:14",2.20,SEK,,,"Aligned Intelligence AB",200.0,SEK,"Aligned Intelligence AB",200.0,SEK,1.0,,,"Peter Alexander Reinthal","Money added",',
|
||||
'PLAN_ORDER-28688820,COMPLETED,OUT,"2026-06-24 06:13:45","2026-06-24 06:41:51",,,,,,520.00,SEK,TransferWise,520.00,SEK,1.00000000,28688820,,"Peter Alexander Reinthal",General,',
|
||||
'TRANSFER-2208605608,COMPLETED,IN,"2026-06-24 06:13:45","2026-06-24 06:41:50",0.00,SEK,,,"Aligned Intelligence AB",520.0,SEK,"Aligned Intelligence AB",520.0,SEK,1.0,invoice-28688820,,"Peter Alexander Reinthal","Money added",',
|
||||
].join('\n')
|
||||
|
||||
describe('Wise format', () => {
|
||||
it('auto-detects the Wise header', () => {
|
||||
const format = detectFileFormat(WISE_CSV, 'transactionhistory.csv')
|
||||
expect(format?.id).toBe('wise')
|
||||
})
|
||||
|
||||
const byId = (txs: ParsedBankTransaction[], id: string) => txs.find((t) => t.raw_line === id)
|
||||
|
||||
it('signs IN as income and OUT as expense, on the moved-side currency', () => {
|
||||
const result = parseBankFile(WISE_CSV, 'wise.csv')
|
||||
expect(result.format).toBe('wise')
|
||||
|
||||
const usdIn = byId(result.transactions, 'TRANSFER-2247230173')
|
||||
expect(usdIn).toMatchObject({ amount: 2500, currency: 'USD', date: '2026-07-13' })
|
||||
|
||||
const sekOut = byId(result.transactions, 'PLAN_ORDER-28688820')
|
||||
expect(sekOut).toMatchObject({ amount: -520, currency: 'SEK', date: '2026-06-24' })
|
||||
expect(sekOut?.counterparty).toBe('TransferWise')
|
||||
})
|
||||
|
||||
it('emits a non-zero fee as its own negative "Wise avgift" row', () => {
|
||||
const result = parseBankFile(WISE_CSV, 'wise.csv')
|
||||
const fee = byId(result.transactions, 'TRANSFER-2214309703-fee')
|
||||
expect(fee).toBeDefined()
|
||||
expect(fee?.amount).toBe(-2.2)
|
||||
expect(fee?.currency).toBe('SEK')
|
||||
expect(fee?.description).toMatch(/^Wise avgift/)
|
||||
|
||||
// A 0.00 fee produces no extra row.
|
||||
expect(byId(result.transactions, 'TRANSFER-2208605608-fee')).toBeUndefined()
|
||||
// 4 movements + 1 fee row.
|
||||
expect(result.transactions).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('keys external_id on the stable Wise ID, including fee rows', () => {
|
||||
const result = parseBankFile(WISE_CSV, 'wise.csv')
|
||||
const main = byId(result.transactions, 'TRANSFER-2247230173')!
|
||||
const fee = byId(result.transactions, 'TRANSFER-2214309703-fee')!
|
||||
expect(generateExternalId(main, 'wise', 0)).toBe('wise_TRANSFER-2247230173')
|
||||
expect(generateExternalId(fee, 'wise', 1)).toBe('wise_TRANSFER-2214309703-fee')
|
||||
})
|
||||
|
||||
it('skips rows that are not COMPLETED', () => {
|
||||
const withCancelled = [
|
||||
WISE_HEADER,
|
||||
'TRANSFER-9,CANCELLED,IN,"2026-06-01 10:00:00","2026-06-01 10:00:00",,,,,"X",100.0,SEK,"Y",100.0,SEK,1,,,,General,',
|
||||
'TRANSFER-2247230173,COMPLETED,IN,"2026-07-13 13:39:01","2026-07-13 13:39:08",,,,,"Bluedot Impact Ltd",2500.0,USD,"Aligned Intelligence AB",2500.0,USD,1,Facilitation,,,"Money added",',
|
||||
].join('\n')
|
||||
const result = parseBankFile(withCancelled, 'wise.csv')
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.transactions[0].raw_line).toBe('TRANSFER-2247230173')
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Wise format hardening', () => {
|
||||
const row = (over: Partial<Record<string, string>> = {}) => {
|
||||
const f: Record<string, string> = {
|
||||
id: 'TRANSFER-1', status: 'COMPLETED', direction: 'IN',
|
||||
created: '2026-06-01 10:00:00', finished: '2026-06-01 10:00:00',
|
||||
sfeeA: '', sfeeC: '', tfeeA: '', tfeeC: '',
|
||||
sname: 'X', samt: '100.0', scur: 'SEK', tname: 'Y', tamt: '100.0', tcur: 'SEK',
|
||||
rate: '1', ref: '', batch: '', by: '', cat: 'General', note: '', ...over,
|
||||
}
|
||||
return [
|
||||
f.id, f.status, f.direction, `"${f.created}"`, `"${f.finished}"`,
|
||||
f.sfeeA, f.sfeeC, f.tfeeA, f.tfeeC, `"${f.sname}"`, f.samt, f.scur,
|
||||
`"${f.tname}"`, f.tamt, f.tcur, f.rate, f.ref, f.batch, `"${f.by}"`, f.cat, f.note,
|
||||
].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('does not import a row with a blank status', () => {
|
||||
const csv = [WISE_HEADER, row({ status: '' })].join('\n')
|
||||
const result = parseBankFile(csv, 'wise.csv')
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects a partially numeric amount instead of coercing it', () => {
|
||||
const csv = [WISE_HEADER, row({ samt: '12abc', tamt: '12abc' })].join('\n')
|
||||
const result = parseBankFile(csv, 'wise.csv')
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.issues.some((iss) => /Invalid amount/.test(iss.message))).toBe(true)
|
||||
})
|
||||
|
||||
it('skips a row with no movement currency rather than defaulting to SEK', () => {
|
||||
const csv = [WISE_HEADER, row({ direction: 'IN', tcur: '' })].join('\n')
|
||||
const result = parseBankFile(csv, 'wise.csv')
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.issues.some((iss) => /Missing\/invalid currency/.test(iss.message))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not inherit the movement currency for a fee with no currency', () => {
|
||||
const csv = [WISE_HEADER, row({ sfeeA: '2.20', sfeeC: '' })].join('\n')
|
||||
const result = parseBankFile(csv, 'wise.csv')
|
||||
// Main row still imports; the fee is dropped with a warning, not booked in SEK.
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.transactions[0].raw_line).toBe('TRANSFER-1')
|
||||
expect(result.issues.some((iss) => /no currency/.test(iss.message))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 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; pending/cancelled/refunded rows are skipped.
|
||||
* - 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') {
|
||||
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.
|
||||
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}`}`,
|
||||
)
|
||||
}
|
||||
const isOut = direction === 'OUT'
|
||||
|
||||
// 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()
|
||||
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)),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -17,6 +17,7 @@ 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'
|
||||
|
||||
@@ -38,6 +39,7 @@ const FORMATS: BankFileFormat[] = [
|
||||
skandiaFormat,
|
||||
lunarFormat,
|
||||
northmillFormat,
|
||||
wiseFormat,
|
||||
genericCSVFormat,
|
||||
]
|
||||
|
||||
@@ -142,6 +144,13 @@ export function generateExternalId(
|
||||
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)
|
||||
|
||||
@@ -53,6 +53,7 @@ export type BankFileFormatId =
|
||||
| 'skandia'
|
||||
| 'lunar'
|
||||
| 'northmill'
|
||||
| 'wise'
|
||||
| 'generic_csv'
|
||||
| 'camt053'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user