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
@@ -746,6 +746,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<p className="font-medium text-destructive">Filen innehåller fel som förhindrar import</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Kontrollera felet ovan och försök ladda upp en korrigerad fil.
|
||||
</p>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto">
|
||||
{errors.map((issue, i) => (
|
||||
<p key={i} className="text-xs text-muted-foreground">
|
||||
Rad {issue.row}: {issue.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -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<File | null>(null)
|
||||
const [formatOverride, setFormatOverride] = useState<BankFileFormatId | undefined>(undefined)
|
||||
@@ -137,6 +139,7 @@ export default function BankFileUploadStep({
|
||||
<SelectItem value="lunar">Lunar</SelectItem>
|
||||
<SelectItem value="northmill">Northmill</SelectItem>
|
||||
<SelectItem value="wise">Wise</SelectItem>
|
||||
<SelectItem value="wise_statement">{t('bank_format_wise_statement')}</SelectItem>
|
||||
<SelectItem value="camt053">ISO 20022 camt.053 (XML)</SelectItem>
|
||||
<SelectItem value="generic_csv">Annan CSV (manuell mappning)</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -181,7 +184,9 @@ export default function BankFileUploadStep({
|
||||
</p>
|
||||
<Badge variant="secondary" className="mt-2">
|
||||
<Building2 className="mr-1 h-3 w-3" />
|
||||
{detectedFormatName || FORMAT_NAMES[detectedFormat] || detectedFormat}
|
||||
{detectedFormat === 'wise_statement'
|
||||
? t('bank_format_wise_statement')
|
||||
: detectedFormatName || FORMAT_NAMES[detectedFormat] || detectedFormat}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<Record<string, string>> = {}): string {
|
||||
const fields: Record<string, string> = {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>()
|
||||
// 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),
|
||||
),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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 `<id>-fee` / `<id>-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++
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -54,6 +54,7 @@ export type BankFileFormatId =
|
||||
| 'lunar'
|
||||
| 'northmill'
|
||||
| 'wise'
|
||||
| 'wise_statement'
|
||||
| 'generic_csv'
|
||||
| 'camt053'
|
||||
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user