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
@@ -168,3 +168,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-16] Two bugs from one customer report (an AB). Bug 1 (acct 2893 showed 2393's "langfristig del" memo after an andringsverifikation): root cause = CorrectionEntryDialog never re-derived line_description on account change (JournalEntryForm does). Fixed forward via a pure helper (correction-line-description.ts) that refreshes the memo only when it is empty or still equals the prev account's name (preserves hand-typed memos). Chose NO prod data repair: the wrong memo sits on a POSTED verifikat (immutable per migration-017 trigger); it is cosmetic (account number + amounts correct, all reports key off the number); ~26 posted lines across 11 cos share this stale-echo pattern, all fix-forward only. Deferred the twin entry-level header fix (#1031). Bug 2 (auto tax-deadlines never appeared): root cause = generation only fired on a settings save where a TAX field CHANGED value (didTaxFieldsChange); settings are filled once at onboarding so re-saving generated nothing -> only 5/776 real cos had system deadlines. Chose count-based self-heal (regenerate when the company has 0 system deadlines) over always-regenerate, because generateTaxDeadlinesForUser deletes+reinserts and would reset is_completed/status on every unrelated save. Also wired the /deadlines empty-state to the existing (dead) /api/tax-deadlines/generate route, and fixed a 1000-row PostgREST cap in the annual cron. Backfilled 771 real cos with zero system deadlines via scripts/backfill-tax-deadlines.ts. Deferred moms_period=yearly config (#1030, 295 filers, largest VAT cohort): helarsmoms deadline (SFL 26 kap. 33-33b) depends on EU-trade status (no flag in CompanySettingsForDeadlines) and, for AB, the income-tax-return date.
|
||||
[2026-07-15] Repaired the single legacy paid credit note blocking invoices_credit_note_not_paid validation by normalizing its invoice metadata to sent, clearing payment fields, setting zero payable remainder, and linking its existing balanced posted V44 reversal: the immutable voucher already exactly reversed V42 and was not edited or duplicated.
|
||||
[2026-07-14] Issue #1016 (create_transactions never binds cash_account_id): fixed forward-only via an optional ledger_account hint on gnubok_create_transactions + commitCreateTransaction, resolved through a new ensureManualCashAccount find-or-create helper (lib/cash-accounts/service.ts). No migration: cash_accounts.bank_connection_id is already nullable and source='manual' already exists (the seeded 1930 is a manual row), so the handoff's premise that manual kassakonton need a schema change was wrong. Pre-creating a manual row does NOT race upsertFromPsd2 (the ingest.ts "never auto-create" worry): a later PSD2 connection promotes a manual holder in place, the intended flow (#916/#56). Hint restricted to ^19\d{2}$ (BAS kassa/bank group) so a transaction can't bind to a non-cash account. Scoped to the MCP create path per user decision; POST /api/cash-accounts + settings UI, relaxing ingest.ts's settlement_account auto-create, and historical backfill of cash_account_id=null rows (deferred to #1001) are follow-ups.
|
||||
[2026-07-14] Wise (TransferWise) CSV import added as a bank-file format plugin (lib/import/bank-file/formats/wise.ts), flowing through the existing upload->preview->confirm->execute wizard. Decisions: (1) parser preserves NATIVE currency per row (multi-currency statement); SEK conversion is left to the downstream FX/booking pipeline (Riksbanken), not done in the parser, so nothing here converts. Booking in the account's own currency via the Wise API rate is a deferred/extended feature. (2) Direction IN/OUT drives the sign; the row is booked on the side that moved (target for IN, source for OUT). (3) Non-zero Wise fees become their OWN negative "Wise avgift" transaction row (source and target fee both handled) rather than being folded or dropped, so the balance ties out and the fee lands in the inbox to categorize (e.g. 6570). (4) Only COMPLETED rows import; pending/cancelled/refunded skipped. (5) external_id keys on the stable Wise ID (TRANSFER-.../PLAN_ORDER-...) carried in raw_line, with a -fee/-tgtfee suffix for fee rows, so re-importing dedups exactly instead of via row hash (generateExternalId gains a 'wise' branch mirroring camt053). Scoped to the format plugin per user decision; per-currency auto-routing to manual kassakonton (would pair with #1016) not done here.
|
||||
|
||||
@@ -60,7 +60,7 @@ registerEndpoint({
|
||||
'SIE bookkeeping import (use /imports/sie). Auto-bank sync (use the enable-banking extension). Single-transaction creation (use POST /transactions/ingest with a 1-element array).',
|
||||
pitfalls: [
|
||||
'File size cap: 10 MB. Larger files require splitting client-side.',
|
||||
'`format` query parameter is optional; auto-detection works for all supported banks. Pass `format` only to force a specific format. Accepted values: seb, swedbank, handelsbanken, nordea, nordea_business, lansforsakringar, ica_banken, skandia, lunar, northmill, generic_csv, camt053.',
|
||||
'`format` query parameter is optional; auto-detection works for all supported banks. Pass `format` only to force a specific format. Accepted values: seb, swedbank, handelsbanken, nordea, nordea_business, lansforsakringar, ica_banken, skandia, lunar, northmill, wise, generic_csv, camt053.',
|
||||
'Duplicate detection is by external_id (composed from date + amount + counterparty); a re-import of the same file with the same flag set typically deduplicates rather than creating doubles.',
|
||||
'BFL 5 kap 6-7 §§ note: this endpoint creates `transactions` rows (the underlag for a verifikation), NOT verifikationer themselves. The verifikation content requirements are in BFL 5 kap 6-7 §§; until each transaction is matched to an invoice/supplier-invoice (POST /transactions/{id}/match-*) or categorised (POST /transactions/{id}/categorize), the bookkeeping obligation isn\'t discharged. A successful import here means the data is ingested: not booked.',
|
||||
'A successful import returns operation_id; poll /operations/{id} for the final ingested/duplicates/errors counts.',
|
||||
@@ -135,6 +135,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'skandia',
|
||||
'lunar',
|
||||
'northmill',
|
||||
'wise',
|
||||
'generic_csv',
|
||||
'camt053',
|
||||
])
|
||||
|
||||
@@ -32,6 +32,7 @@ const FORMAT_NAMES: Record<string, string> = {
|
||||
skandia: 'Skandia',
|
||||
lunar: 'Lunar',
|
||||
northmill: 'Northmill',
|
||||
wise: 'Wise',
|
||||
generic_csv: 'CSV (manuell mappning)',
|
||||
camt053: 'ISO 20022 camt.053',
|
||||
}
|
||||
@@ -135,6 +136,7 @@ export default function BankFileUploadStep({
|
||||
<SelectItem value="skandia">Skandia</SelectItem>
|
||||
<SelectItem value="lunar">Lunar</SelectItem>
|
||||
<SelectItem value="northmill">Northmill</SelectItem>
|
||||
<SelectItem value="wise">Wise</SelectItem>
|
||||
<SelectItem value="camt053">ISO 20022 camt.053 (XML)</SelectItem>
|
||||
<SelectItem value="generic_csv">Annan CSV (manuell mappning)</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
@@ -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