From 24911abde0724709b9953356e05e2d6f15df75fb Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:55:09 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 2 + .../imports/bank/__tests__/route.test.ts | 57 +++- .../[companyId]/imports/bank/route.ts | 22 +- components/import/BankFilePreviewStep.tsx | 15 +- components/import/BankFileUploadStep.tsx | 7 +- lib/import/bank-file/__tests__/parser.test.ts | 317 +++++++++++++++++- .../bank-file/formats/wise-statement.ts | 286 ++++++++++++++++ lib/import/bank-file/formats/wise.ts | 45 ++- lib/import/bank-file/parser.ts | 6 + lib/import/bank-file/types.ts | 1 + messages/en.json | 3 +- messages/sv.json | 3 +- 12 files changed, 741 insertions(+), 23 deletions(-) create mode 100644 lib/import/bank-file/formats/wise-statement.ts diff --git a/DECISIONS.md b/DECISIONS.md index 2b8f412a..c8e224c4 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -746,6 +746,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-02] Out-of-order SIE IB resync requires exact date adjacency: the nearest later fiscal period can sit beyond a missing middle year, and replacing its authoritative IB with a non-adjacent UB would make that later period temporarily wrong until the gap was imported. [2026-08-03] Issue #1360 keeps EUR annual reports fail-closed at general eligibility rather than only digital filing: the ledger and annual-report model are SEK-denominated, so allowing a EUR profile to lock a paper version would mislabel SEK amounts; full EUR support requires a company accounting-currency model across the ledger, report builders, and iXBRL. [2026-08-03] Issue #1267 localizes the latest-posted-voucher label in the web views while PDF and spreadsheet exports remain Swedish: translating one export label would create mixed-language files, so full export localization stays a separate surface-wide change. +[2026-08-03] Wise imports fail closed on refunded or unknown statuses, unknown directions, and cross-currency transaction-history rows: the available export contract cannot establish their signed balance effect, and v1 must not silently discard a business event. Ordinary balance-statement rows share the canonical wise_ID external ID with transaction history to prevent overlapping cross-format imports; only conversion legs with explicit Exchange From/To metadata add the statement currency because the same Wise ID represents one movement per balance. +[2026-08-03] Wise balance-statement "Total fees" stays description-only, guarded by a running-balance continuity warning: Running Balance moves by exactly the signed Amount per row, so the fee is a breakdown of Amount and booking it separately (as wise.ts does for the "(after fees)" history export) would double-count the cost and desync the imported account from the real Wise balance. [2026-08-03] Shared format contracts centralised in lib/invariants/ (org number, BAS account number, ISO date, fiscal year), each with its rationale recorded next to the rule. Trigger: four Skatteverket/Bolagsverket-bound export paths (KU10, AGI, SRU redovisare, iXBRL preflight) each had their own idea of a valid organisationsnummer, so a company stored with a space or in 12-digit form could file AGI all year and fail at the arsredovisning deadline. normalizeOrgNumber moved from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both old paths re-export. The iXBRL check-digit verdict is warn, not error: we do not block a statutory filing on a Luhn assumption unverified against a primary source. KU10 12-digit passthrough pinned by test, not changed (open domain question). ROT/RUT brf_org_number left alone: different documented contract. Ratchet guard 8 holds the remaining 114 inline copies. diff --git a/app/api/v1/companies/[companyId]/imports/bank/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/imports/bank/__tests__/route.test.ts index ddfe921f..baf98bc4 100644 --- a/app/api/v1/companies/[companyId]/imports/bank/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/imports/bank/__tests__/route.test.ts @@ -67,6 +67,17 @@ const SEB_CSV = [ '2024-01-13;2024-01-13;12347;LÖNEUTBETALNING;25000,00;12877,17', ].join('\n') +const WISE_STATEMENT_CSV = [ + '"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"', + 'TRANSFER-100,01/08/2026,1250.50,SEK,Received money from Example AB,INV-100,5000.50,,,,Example AB,,,,,,,,0', +].join('\n') + +const WISE_TRANSACTION_HISTORY_WITH_UNSAFE_ROW = [ + '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', + 'PLAN_ORDER-9,COMPLETED,NEUTRAL,"2026-08-01 10:00:00","2026-08-01 10:00:00",,,,,Wise,100,USD,Wise,900,SEK,9,,,,General,', + 'TRANSFER-2,COMPLETED,IN,"2026-08-02 10:00:00","2026-08-02 10:00:00",,,,,Example AB,100,SEK,Accounted AB,100,SEK,1,,,,General,', +].join('\n') + type MockResult = { data?: unknown; error?: unknown } type RecordedCall = { table: string; method: string; args: unknown[] } @@ -100,9 +111,18 @@ function makeRequest(options?: { body?: FormData | string auth?: boolean search?: string + fileContent?: string + filename?: string }): Request { const fd = new FormData() - fd.append('file', new File([SEB_CSV], 'kontoutdrag.csv', { type: 'text/csv' })) + fd.append( + 'file', + new File( + [options?.fileContent ?? SEB_CSV], + options?.filename ?? 'kontoutdrag.csv', + { type: 'text/csv' }, + ), + ) const init: RequestInit = { method: 'POST', body: options?.body ?? fd, @@ -185,6 +205,41 @@ describe('POST /api/v1/companies/:companyId/imports/bank', () => { expect(ingestMock).not.toHaveBeenCalled() }) + it('accepts a forced Wise balance statement and preserves its scoped provenance', async () => { + const res = await callRoute({ + search: '?format=wise_statement', + fileContent: WISE_STATEMENT_CSV, + filename: 'statement_123_SEK_2026.csv', + }) + + expect(res.status).toBe(202) + expect(ingestedRows()).toHaveLength(1) + expect(ingestedRows()[0]).toMatchObject({ + import_source: 'csv_wise_statement', + external_id: 'wise_TRANSFER-100', + amount: 1250.5, + currency: 'SEK', + }) + }) + + it('rejects a Wise file when any movement cannot be imported safely', async () => { + const res = await callRoute({ + search: '?format=wise', + fileContent: WISE_TRANSACTION_HISTORY_WITH_UNSAFE_ROW, + filename: 'transaction-history.csv', + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.issues).toContainEqual( + expect.objectContaining({ severity: 'error', message: expect.stringMatching(/NEUTRAL/) }), + ) + expect(body.error.details.issues.length).toBeLessThanOrEqual(20) + expect(body.error.details.issue_count).toBe(1) + expect(ingestMock).not.toHaveBeenCalled() + }) + it('returns 404 when the key user is not a member of the company in the URL', async () => { supabase = makeSupabase({ company_members: { data: null, error: null } }) mockServiceClient.mockReturnValue(supabase) diff --git a/app/api/v1/companies/[companyId]/imports/bank/route.ts b/app/api/v1/companies/[companyId]/imports/bank/route.ts index 8b285324..a8d8af2d 100644 --- a/app/api/v1/companies/[companyId]/imports/bank/route.ts +++ b/app/api/v1/companies/[companyId]/imports/bank/route.ts @@ -6,7 +6,7 @@ * 1. Decodes the file (UTF-8 / Windows-1252 auto-detected). * 2. Detects the bank file format (SEB / Swedbank / Nordea / Handelsbanken * / Lansforsakringar / Lunar / ICA Banken / Skandia / CAMT053 / - * Nordea Business / generic CSV), or honors the optional `format` + * Nordea Business / Wise / generic CSV), or honors the optional `format` * override. * 3. Parses transactions. * 4. Records a `bank_file_imports` row and ingests transactions via @@ -54,14 +54,15 @@ registerEndpoint({ path: '/api/v1/companies/:companyId/imports/bank', summary: 'Import a bank-file (CSV / XML / CAMT053).', description: - 'Accepts a bank statement file (UTF-8 / Windows-1252, up to 10 MB) as multipart/form-data. Auto-detects the bank format (SEB, Swedbank, Handelsbanken, Nordea, Nordea Business, Lansforsakringar, Lunar, ICA Banken, Skandia, CAMT053, generic CSV) or honors a `format` override. Parses transactions, ingests them into the `transactions` table (NOT into journal entries: see BFL note in pitfalls), and emits `transaction.synced` events. Returns operation_id for polling.', + 'Accepts a bank statement file (UTF-8 / Windows-1252, up to 10 MB) as multipart/form-data. Auto-detects the bank format (SEB, Swedbank, Handelsbanken, Nordea, Nordea Business, Lansforsakringar, Lunar, ICA Banken, Skandia, Wise transaction history, Wise balance statement, CAMT053, generic CSV) or honors a `format` override. Parses transactions, ingests them into the `transactions` table (NOT into journal entries: see BFL note in pitfalls), and emits `transaction.synced` events. Returns operation_id for polling.', useWhen: 'Importing a bank statement export for a period. Common with PSD2 bank connections that don\'t auto-sync, or for legacy bank accounts.', doNotUseFor: '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, wise, 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, wise_statement, generic_csv, camt053.', + 'Wise transaction-history rows with refunded or unknown statuses, unknown directions, or different source and target currencies are rejected instead of guessed. Import the matching per-currency Wise balance statements.', 'Duplicate detection is by external_id (composed from format + date + description + amount + row index, or the camt.053 entry reference / Wise transfer id where the file carries one); a re-import of the same file 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.', @@ -137,6 +138,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( 'lunar', 'northmill', 'wise', + 'wise_statement', 'generic_csv', 'camt053', ]) @@ -176,6 +178,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( } const parseResult = parseBankFile(content, file.name, format) + const blockingIssues = parseResult.issues.filter((issue) => issue.severity === 'error') + if (blockingIssues.length > 0) { + // Cap the reported rows so a large malformed file cannot balloon the + // error payload or the log sink; issue_count carries the full total. + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'file', + message: 'The bank file contains rows that cannot be imported safely.', + issues: blockingIssues.slice(0, 20), + issue_count: blockingIssues.length, + }, + }) + } if (parseResult.transactions.length === 0) { return v1ErrorResponseFromCode('BANK_FILE_NO_TRANSACTIONS', ctx.log, { requestId: ctx.requestId, diff --git a/components/import/BankFilePreviewStep.tsx b/components/import/BankFilePreviewStep.tsx index fe36dc19..f9e999c7 100644 --- a/components/import/BankFilePreviewStep.tsx +++ b/components/import/BankFilePreviewStep.tsx @@ -35,7 +35,8 @@ export default function BankFilePreviewStep({ onBack, }: BankFilePreviewStepProps) { const { transactions, stats, issues, date_from, date_to } = parseResult - const hasIssues = issues.filter((i) => i.severity === 'error').length > 0 + const errors = issues.filter((i) => i.severity === 'error') + const hasIssues = errors.length > 0 const warnings = issues.filter((i) => i.severity === 'warning') // Wise/camt.053 files can mix currencies per row: the parser-level totals // sum across currencies, so income/expenses are grouped per currency here. @@ -190,11 +191,15 @@ export default function BankFilePreviewStep({
-
+

Filen innehåller fel som förhindrar import

-

- Kontrollera felet ovan och försök ladda upp en korrigerad fil. -

+
+ {errors.map((issue, i) => ( +

+ Rad {issue.row}: {issue.message} +

+ ))} +
diff --git a/components/import/BankFileUploadStep.tsx b/components/import/BankFileUploadStep.tsx index c645b650..60ce2b6c 100644 --- a/components/import/BankFileUploadStep.tsx +++ b/components/import/BankFileUploadStep.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useCallback } from 'react' +import { useTranslations } from 'next-intl' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' @@ -54,6 +55,7 @@ export default function BankFileUploadStep({ detectedFormat, detectedFormatName, }: BankFileUploadStepProps) { + const t = useTranslations('import') const [isDragging, setIsDragging] = useState(false) const [selectedFile, setSelectedFile] = useState(null) const [formatOverride, setFormatOverride] = useState(undefined) @@ -137,6 +139,7 @@ export default function BankFileUploadStep({ Lunar Northmill Wise + {t('bank_format_wise_statement')} ISO 20022 camt.053 (XML) Annan CSV (manuell mappning) @@ -181,7 +184,9 @@ export default function BankFileUploadStep({

- {detectedFormatName || FORMAT_NAMES[detectedFormat] || detectedFormat} + {detectedFormat === 'wise_statement' + ? t('bank_format_wise_statement') + : detectedFormatName || FORMAT_NAMES[detectedFormat] || detectedFormat}
diff --git a/lib/import/bank-file/__tests__/parser.test.ts b/lib/import/bank-file/__tests__/parser.test.ts index 50d9629f..802b8bac 100644 --- a/lib/import/bank-file/__tests__/parser.test.ts +++ b/lib/import/bank-file/__tests__/parser.test.ts @@ -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> = {}): string { + const fields: Record = { + 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) + }) +}) diff --git a/lib/import/bank-file/formats/wise-statement.ts b/lib/import/bank-file/formats/wise-statement.ts new file mode 100644 index 00000000..e5f70fb1 --- /dev/null +++ b/lib/import/bank-file/formats/wise-statement.ts @@ -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() + // 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), + ), + }, + } + }, +} diff --git a/lib/import/bank-file/formats/wise.ts b/lib/import/bank-file/formats/wise.ts index 61e44835..0b93f2c9 100644 --- a/lib/import/bank-file/formats/wise.ts +++ b/lib/import/bank-file/formats/wise.ts @@ -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 `-fee` / `-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++ diff --git a/lib/import/bank-file/parser.ts b/lib/import/bank-file/parser.ts index af438e91..ad83ec7e 100644 --- a/lib/import/bank-file/parser.ts +++ b/lib/import/bank-file/parser.ts @@ -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) diff --git a/lib/import/bank-file/types.ts b/lib/import/bank-file/types.ts index a94f7698..f749579b 100644 --- a/lib/import/bank-file/types.ts +++ b/lib/import/bank-file/types.ts @@ -54,6 +54,7 @@ export type BankFileFormatId = | 'lunar' | 'northmill' | 'wise' + | 'wise_statement' | 'generic_csv' | 'camt053' diff --git a/messages/en.json b/messages/en.json index b5c874e0..9a599a49 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6128,7 +6128,8 @@ "cloud_row_title": "Cloud sync", "cloud_row_description": "Continuous backup of the archive to Google Drive", "help_text": "Every way in and out lives here: bank connections, file imports and migrations from other systems, plus SIE export and backups. Every import is reviewed before anything is booked.", - "pgnote": "Every import goes through the same steps: upload, map columns, review, result. Nothing is booked without you seeing it first." + "pgnote": "Every import goes through the same steps: upload, map columns, review, result. Nothing is booked without you seeing it first.", + "bank_format_wise_statement": "Wise balance statement" }, "annualReportStudio": { "choose": "Välj", diff --git a/messages/sv.json b/messages/sv.json index 3ca5b2a9..8359fe36 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -6128,7 +6128,8 @@ "cloud_row_title": "Molnsynkronisering", "cloud_row_description": "Löpande säkerhetskopia av arkivet till Google Drive", "help_text": "Här samlas alla vägar in och ut: bankkoppling, filimporter och flytt från andra system, samt export av SIE och säkerhetskopior. Varje import granskas innan något bokförs.", - "pgnote": "Varje import går genom samma steg: ladda upp, mappa kolumner, granska, resultat. Inget bokförs utan att du ser det först." + "pgnote": "Varje import går genom samma steg: ladda upp, mappa kolumner, granska, resultat. Inget bokförs utan att du ser det först.", + "bank_format_wise_statement": "Wise kontoutdrag" }, "annualReportStudio": { "choose": "Välj",