feat(import): support Wise balance statements (#1368)

* feat(import): support Wise balance statements

* fix(import): fail closed on ambiguous Wise rows

* fix(import): guard Wise statement netted-fee assumption with running-balance continuity check

Swedish accounting review asked whether balance-statement Total fees is
netted into Amount. It is: Running Balance moves by exactly the signed
Amount per row, so a separate fee row would double-count the cost. Codify
the assumption with a pairwise continuity warning (order-agnostic, chain
resets across skipped rows) and document the decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): cap bank-import validation payload and harden issue assertion

CodeRabbit review: bound the VALIDATION_ERROR issues array to 20 entries
with issue_count carrying the full total, so a large malformed file cannot
balloon the response or log sink. Gate stays format-agnostic on purpose:
error severity means do-not-ingest for every parser, and no non-Wise parser
emits per-row errors alongside parsed transactions today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-03 17:55:09 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 0510d4c13f
commit 24911abde0
12 changed files with 741 additions and 23 deletions
@@ -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)
@@ -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,