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:
co-authored by
Claude Fable 5
parent
0510d4c13f
commit
24911abde0
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user