diff --git a/DECISIONS.md b/DECISIONS.md index 9edd3624..ba3de17f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -168,3 +168,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-16] Two bugs from one customer report (an AB). Bug 1 (acct 2893 showed 2393's "langfristig del" memo after an andringsverifikation): root cause = CorrectionEntryDialog never re-derived line_description on account change (JournalEntryForm does). Fixed forward via a pure helper (correction-line-description.ts) that refreshes the memo only when it is empty or still equals the prev account's name (preserves hand-typed memos). Chose NO prod data repair: the wrong memo sits on a POSTED verifikat (immutable per migration-017 trigger); it is cosmetic (account number + amounts correct, all reports key off the number); ~26 posted lines across 11 cos share this stale-echo pattern, all fix-forward only. Deferred the twin entry-level header fix (#1031). Bug 2 (auto tax-deadlines never appeared): root cause = generation only fired on a settings save where a TAX field CHANGED value (didTaxFieldsChange); settings are filled once at onboarding so re-saving generated nothing -> only 5/776 real cos had system deadlines. Chose count-based self-heal (regenerate when the company has 0 system deadlines) over always-regenerate, because generateTaxDeadlinesForUser deletes+reinserts and would reset is_completed/status on every unrelated save. Also wired the /deadlines empty-state to the existing (dead) /api/tax-deadlines/generate route, and fixed a 1000-row PostgREST cap in the annual cron. Backfilled 771 real cos with zero system deadlines via scripts/backfill-tax-deadlines.ts. Deferred moms_period=yearly config (#1030, 295 filers, largest VAT cohort): helarsmoms deadline (SFL 26 kap. 33-33b) depends on EU-trade status (no flag in CompanySettingsForDeadlines) and, for AB, the income-tax-return date. [2026-07-15] Repaired the single legacy paid credit note blocking invoices_credit_note_not_paid validation by normalizing its invoice metadata to sent, clearing payment fields, setting zero payable remainder, and linking its existing balanced posted V44 reversal: the immutable voucher already exactly reversed V42 and was not edited or duplicated. [2026-07-14] Issue #1016 (create_transactions never binds cash_account_id): fixed forward-only via an optional ledger_account hint on gnubok_create_transactions + commitCreateTransaction, resolved through a new ensureManualCashAccount find-or-create helper (lib/cash-accounts/service.ts). No migration: cash_accounts.bank_connection_id is already nullable and source='manual' already exists (the seeded 1930 is a manual row), so the handoff's premise that manual kassakonton need a schema change was wrong. Pre-creating a manual row does NOT race upsertFromPsd2 (the ingest.ts "never auto-create" worry): a later PSD2 connection promotes a manual holder in place, the intended flow (#916/#56). Hint restricted to ^19\d{2}$ (BAS kassa/bank group) so a transaction can't bind to a non-cash account. Scoped to the MCP create path per user decision; POST /api/cash-accounts + settings UI, relaxing ingest.ts's settlement_account auto-create, and historical backfill of cash_account_id=null rows (deferred to #1001) are follow-ups. +[2026-07-14] Wise (TransferWise) CSV import added as a bank-file format plugin (lib/import/bank-file/formats/wise.ts), flowing through the existing upload->preview->confirm->execute wizard. Decisions: (1) parser preserves NATIVE currency per row (multi-currency statement); SEK conversion is left to the downstream FX/booking pipeline (Riksbanken), not done in the parser, so nothing here converts. Booking in the account's own currency via the Wise API rate is a deferred/extended feature. (2) Direction IN/OUT drives the sign; the row is booked on the side that moved (target for IN, source for OUT). (3) Non-zero Wise fees become their OWN negative "Wise avgift" transaction row (source and target fee both handled) rather than being folded or dropped, so the balance ties out and the fee lands in the inbox to categorize (e.g. 6570). (4) Only COMPLETED rows import; pending/cancelled/refunded skipped. (5) external_id keys on the stable Wise ID (TRANSFER-.../PLAN_ORDER-...) carried in raw_line, with a -fee/-tgtfee suffix for fee rows, so re-importing dedups exactly instead of via row hash (generateExternalId gains a 'wise' branch mirroring camt053). Scoped to the format plugin per user decision; per-currency auto-routing to manual kassakonton (would pair with #1016) not done here. diff --git a/app/api/v1/companies/[companyId]/imports/bank/route.ts b/app/api/v1/companies/[companyId]/imports/bank/route.ts index 1f71a869..f9604fe3 100644 --- a/app/api/v1/companies/[companyId]/imports/bank/route.ts +++ b/app/api/v1/companies/[companyId]/imports/bank/route.ts @@ -60,7 +60,7 @@ registerEndpoint({ 'SIE bookkeeping import (use /imports/sie). Auto-bank sync (use the enable-banking extension). Single-transaction creation (use POST /transactions/ingest with a 1-element array).', pitfalls: [ 'File size cap: 10 MB. Larger files require splitting client-side.', - '`format` query parameter is optional; auto-detection works for all supported banks. Pass `format` only to force a specific format. Accepted values: seb, swedbank, handelsbanken, nordea, nordea_business, lansforsakringar, ica_banken, skandia, lunar, northmill, generic_csv, camt053.', + '`format` query parameter is optional; auto-detection works for all supported banks. Pass `format` only to force a specific format. Accepted values: seb, swedbank, handelsbanken, nordea, nordea_business, lansforsakringar, ica_banken, skandia, lunar, northmill, wise, generic_csv, camt053.', 'Duplicate detection is by external_id (composed from date + amount + counterparty); a re-import of the same file with the same flag set typically deduplicates rather than creating doubles.', 'BFL 5 kap 6-7 §§ note: this endpoint creates `transactions` rows (the underlag for a verifikation), NOT verifikationer themselves. The verifikation content requirements are in BFL 5 kap 6-7 §§; until each transaction is matched to an invoice/supplier-invoice (POST /transactions/{id}/match-*) or categorised (POST /transactions/{id}/categorize), the bookkeeping obligation isn\'t discharged. A successful import here means the data is ingested: not booked.', 'A successful import returns operation_id; poll /operations/{id} for the final ingested/duplicates/errors counts.', @@ -135,6 +135,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( 'skandia', 'lunar', 'northmill', + 'wise', 'generic_csv', 'camt053', ]) diff --git a/components/import/BankFileUploadStep.tsx b/components/import/BankFileUploadStep.tsx index 86432b7d..c645b650 100644 --- a/components/import/BankFileUploadStep.tsx +++ b/components/import/BankFileUploadStep.tsx @@ -32,6 +32,7 @@ const FORMAT_NAMES: Record = { skandia: 'Skandia', lunar: 'Lunar', northmill: 'Northmill', + wise: 'Wise', generic_csv: 'CSV (manuell mappning)', camt053: 'ISO 20022 camt.053', } @@ -135,6 +136,7 @@ export default function BankFileUploadStep({ Skandia Lunar Northmill + Wise ISO 20022 camt.053 (XML) Annan CSV (manuell mappning) diff --git a/lib/import/bank-file/__tests__/parser.test.ts b/lib/import/bank-file/__tests__/parser.test.ts index 191dfe3d..47b0382b 100644 --- a/lib/import/bank-file/__tests__/parser.test.ts +++ b/lib/import/bank-file/__tests__/parser.test.ts @@ -2045,3 +2045,125 @@ describe('parseCSVLine: unclosed quote handling', () => { expect(fields).toEqual(['2024-01-15', 'SPOTIFY AB', '-99,00']) }) }) + +// --------------------------------------------------------------------------- +// Wise (TransferWise) multi-currency transaction history +// --------------------------------------------------------------------------- + +const WISE_HEADER = + 'ID,Status,Direction,"Created on","Finished on","Source fee amount","Source fee currency","Target fee amount","Target fee currency","Source name","Source amount (after fees)","Source currency","Target name","Target amount (after fees)","Target currency","Exchange rate",Reference,Batch,"Created by",Category,Note' + +const WISE_CSV = [ + WISE_HEADER, + 'TRANSFER-2247230173,COMPLETED,IN,"2026-07-13 13:39:01","2026-07-13 13:39:08",,,,,"Bluedot Impact Ltd",2500.0,USD,"Aligned Intelligence AB",2500.0,USD,1,Facilitation,,,"Money added",', + 'TRANSFER-2214309703,COMPLETED,IN,"2026-06-26 20:53:33","2026-06-26 20:54:14",2.20,SEK,,,"Aligned Intelligence AB",200.0,SEK,"Aligned Intelligence AB",200.0,SEK,1.0,,,"Peter Alexander Reinthal","Money added",', + 'PLAN_ORDER-28688820,COMPLETED,OUT,"2026-06-24 06:13:45","2026-06-24 06:41:51",,,,,,520.00,SEK,TransferWise,520.00,SEK,1.00000000,28688820,,"Peter Alexander Reinthal",General,', + 'TRANSFER-2208605608,COMPLETED,IN,"2026-06-24 06:13:45","2026-06-24 06:41:50",0.00,SEK,,,"Aligned Intelligence AB",520.0,SEK,"Aligned Intelligence AB",520.0,SEK,1.0,invoice-28688820,,"Peter Alexander Reinthal","Money added",', +].join('\n') + +describe('Wise format', () => { + it('auto-detects the Wise header', () => { + const format = detectFileFormat(WISE_CSV, 'transactionhistory.csv') + expect(format?.id).toBe('wise') + }) + + const byId = (txs: ParsedBankTransaction[], id: string) => txs.find((t) => t.raw_line === id) + + it('signs IN as income and OUT as expense, on the moved-side currency', () => { + const result = parseBankFile(WISE_CSV, 'wise.csv') + expect(result.format).toBe('wise') + + const usdIn = byId(result.transactions, 'TRANSFER-2247230173') + expect(usdIn).toMatchObject({ amount: 2500, currency: 'USD', date: '2026-07-13' }) + + const sekOut = byId(result.transactions, 'PLAN_ORDER-28688820') + expect(sekOut).toMatchObject({ amount: -520, currency: 'SEK', date: '2026-06-24' }) + expect(sekOut?.counterparty).toBe('TransferWise') + }) + + it('emits a non-zero fee as its own negative "Wise avgift" row', () => { + const result = parseBankFile(WISE_CSV, 'wise.csv') + const fee = byId(result.transactions, 'TRANSFER-2214309703-fee') + expect(fee).toBeDefined() + expect(fee?.amount).toBe(-2.2) + expect(fee?.currency).toBe('SEK') + expect(fee?.description).toMatch(/^Wise avgift/) + + // A 0.00 fee produces no extra row. + expect(byId(result.transactions, 'TRANSFER-2208605608-fee')).toBeUndefined() + // 4 movements + 1 fee row. + expect(result.transactions).toHaveLength(5) + }) + + it('keys external_id on the stable Wise ID, including fee rows', () => { + const result = parseBankFile(WISE_CSV, 'wise.csv') + const main = byId(result.transactions, 'TRANSFER-2247230173')! + const fee = byId(result.transactions, 'TRANSFER-2214309703-fee')! + expect(generateExternalId(main, 'wise', 0)).toBe('wise_TRANSFER-2247230173') + expect(generateExternalId(fee, 'wise', 1)).toBe('wise_TRANSFER-2214309703-fee') + }) + + it('skips rows that are not COMPLETED', () => { + const withCancelled = [ + WISE_HEADER, + 'TRANSFER-9,CANCELLED,IN,"2026-06-01 10:00:00","2026-06-01 10:00:00",,,,,"X",100.0,SEK,"Y",100.0,SEK,1,,,,General,', + 'TRANSFER-2247230173,COMPLETED,IN,"2026-07-13 13:39:01","2026-07-13 13:39:08",,,,,"Bluedot Impact Ltd",2500.0,USD,"Aligned Intelligence AB",2500.0,USD,1,Facilitation,,,"Money added",', + ].join('\n') + const result = parseBankFile(withCancelled, 'wise.csv') + expect(result.transactions).toHaveLength(1) + expect(result.transactions[0].raw_line).toBe('TRANSFER-2247230173') + expect(result.stats.skipped_rows).toBe(1) + }) +}) + +describe('Wise format hardening', () => { + const row = (over: Partial> = {}) => { + const f: Record = { + id: 'TRANSFER-1', status: 'COMPLETED', direction: 'IN', + created: '2026-06-01 10:00:00', finished: '2026-06-01 10:00:00', + sfeeA: '', sfeeC: '', tfeeA: '', tfeeC: '', + sname: 'X', samt: '100.0', scur: 'SEK', tname: 'Y', tamt: '100.0', tcur: 'SEK', + rate: '1', ref: '', batch: '', by: '', cat: 'General', note: '', ...over, + } + return [ + f.id, f.status, f.direction, `"${f.created}"`, `"${f.finished}"`, + f.sfeeA, f.sfeeC, f.tfeeA, f.tfeeC, `"${f.sname}"`, f.samt, f.scur, + `"${f.tname}"`, f.tamt, f.tcur, f.rate, f.ref, f.batch, `"${f.by}"`, f.cat, f.note, + ].join(',') + } + + it('fails hard on an unsupported Direction (e.g. NEUTRAL conversion)', () => { + const csv = [WISE_HEADER, row({ id: 'PLAN_ORDER-9', direction: 'NEUTRAL', scur: 'USD', tcur: 'SEK' })].join('\n') + expect(() => parseBankFile(csv, 'wise.csv')).toThrow(/unsupported Direction "NEUTRAL"/) + }) + + it('does not import a row with a blank status', () => { + const csv = [WISE_HEADER, row({ status: '' })].join('\n') + const result = parseBankFile(csv, 'wise.csv') + expect(result.transactions).toHaveLength(0) + expect(result.stats.skipped_rows).toBe(1) + }) + + it('rejects a partially numeric amount instead of coercing it', () => { + const csv = [WISE_HEADER, row({ samt: '12abc', tamt: '12abc' })].join('\n') + const result = parseBankFile(csv, 'wise.csv') + expect(result.transactions).toHaveLength(0) + expect(result.issues.some((iss) => /Invalid amount/.test(iss.message))).toBe(true) + }) + + it('skips a row with no movement currency rather than defaulting to SEK', () => { + const csv = [WISE_HEADER, row({ direction: 'IN', tcur: '' })].join('\n') + const result = parseBankFile(csv, 'wise.csv') + expect(result.transactions).toHaveLength(0) + expect(result.issues.some((iss) => /Missing\/invalid currency/.test(iss.message))).toBe(true) + }) + + it('does not inherit the movement currency for a fee with no currency', () => { + const csv = [WISE_HEADER, row({ sfeeA: '2.20', sfeeC: '' })].join('\n') + const result = parseBankFile(csv, 'wise.csv') + // Main row still imports; the fee is dropped with a warning, not booked in SEK. + expect(result.transactions).toHaveLength(1) + expect(result.transactions[0].raw_line).toBe('TRANSFER-1') + expect(result.issues.some((iss) => /no currency/.test(iss.message))).toBe(true) + }) +}) diff --git a/lib/import/bank-file/formats/wise.ts b/lib/import/bank-file/formats/wise.ts new file mode 100644 index 00000000..61e44835 --- /dev/null +++ b/lib/import/bank-file/formats/wise.ts @@ -0,0 +1,243 @@ +/** + * Wise (TransferWise) transaction-history CSV parser. + * + * Wise exports a single multi-currency statement (one row per balance movement) + * with a header like: + * ID,Status,Direction,"Created on","Finished on","Source fee amount", + * "Source fee currency","Target fee amount","Target fee currency", + * "Source name","Source amount (after fees)","Source currency", + * "Target name","Target amount (after fees)","Target currency", + * "Exchange rate",Reference,Batch,"Created by",Category,Note + * + * Design notes: + * - Comma-delimited, `.` decimal, fields quoted (dates contain a space, so a + * quote-aware splitter is required: parseCSVLine). + * - Direction IN/OUT drives the sign. We book the row in the currency that + * actually moved on the balance: the target side for IN, the source side for + * OUT. Amounts stay in their native currency; SEK conversion happens + * downstream at booking time via the existing FX pipeline (Riksbanken), so + * this parser never converts. + * - Wise fees are a real cost, so a non-zero source/target fee becomes its OWN + * negative transaction ("Wise avgift") rather than being folded or dropped. + * - Only COMPLETED rows are imported; pending/cancelled/refunded rows are skipped. + * - The stable Wise ID (TRANSFER-…, PLAN_ORDER-…) is carried in `raw_line` so + * generateExternalId can key dedup on it instead of a row hash (fee rows get + * an `-fee` / `-tgtfee` suffix). + */ + +import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' +import { prepareContent } from '../../shared/encoding' +import { normalizeDate } from '../date-utils' +import { parseCSVLine } from './nordea' + +/** Money rule: round to two decimals without toFixed. */ +function round2(n: number): number { + return Math.round(n * 100) / 100 +} + +/** + * Parse a Wise amount ("2500.0", "520.00", "-2.20"). Wise amounts are plain + * decimals: optional sign, digits, optional `.` fraction, no thousands + * separator. Reject anything else: bare parseFloat would silently turn "12abc" + * into 12 and "1,234" into 1, corrupting the imported amount. NaN = reject. + */ +function parseWiseAmount(value: string | undefined): number { + if (!value) return NaN + const cleaned = value.trim() + if (!/^-?\d+(\.\d+)?$/.test(cleaned)) return NaN + return parseFloat(cleaned) +} + +/** Wise datetimes are "YYYY-MM-DD HH:MM:SS"; keep the date part only. */ +function wiseDate(value: string | undefined): string | null { + if (!value) return null + return normalizeDate(value.trim().split(/[ T]/)[0]) +} + +const HEADER_TOKENS = ['direction', 'source amount (after fees)', 'target amount (after fees)'] + +export const wiseFormat: BankFileFormat = { + id: 'wise', + name: 'Wise', + description: 'Wise (TransferWise) transaction history CSV, multi-currency', + fileExtensions: ['.csv'], + + detect(content: string, _filename: string): boolean { + const firstLine = prepareContent(content).split('\n')[0]?.toLowerCase() || '' + // Wise's header is distinctive: the "(after fees)" amount columns plus a + // Direction column don't appear in any Swedish-bank export. + return firstLine.includes(',') && HEADER_TOKENS.every((t) => firstLine.includes(t)) + }, + + parse(content: string): BankFileParseResult { + const prepared = prepareContent(content) + const lines = prepared.split('\n').filter((line) => line.trim() !== '') + + const transactions: ParsedBankTransaction[] = [] + const issues: BankFileParseIssue[] = [] + let skippedRows = 0 + + const headers = parseCSVLine(lines[0] || '', ',').map((h) => + h.trim().toLowerCase().replace(/^"|"$/g, ''), + ) + const col = (name: string) => headers.findIndex((h) => h === name) + + const idx = { + id: col('id'), + status: col('status'), + direction: col('direction'), + createdOn: col('created on'), + finishedOn: col('finished on'), + sourceFeeAmount: col('source fee amount'), + sourceFeeCurrency: col('source fee currency'), + targetFeeAmount: col('target fee amount'), + targetFeeCurrency: col('target fee currency'), + sourceName: col('source name'), + sourceAmount: col('source amount (after fees)'), + sourceCurrency: col('source currency'), + targetName: col('target name'), + targetAmount: col('target amount (after fees)'), + targetCurrency: col('target currency'), + reference: col('reference'), + category: col('category'), + note: col('note'), + } + + if (idx.direction === -1 || idx.sourceAmount === -1 || idx.targetAmount === -1) { + issues.push({ row: 1, message: 'Could not identify required Wise columns', severity: 'error' }) + return { + format: 'wise', + format_name: 'Wise', + transactions: [], + date_from: null, + date_to: null, + issues, + stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 }, + } + } + + for (let i = 1; i < lines.length; i++) { + const fields = parseCSVLine(lines[i], ',').map((f) => f.trim().replace(/^"|"$/g, '')) + const at = (j: number) => (j >= 0 ? fields[j] ?? '' : '') + + const wiseId = at(idx.id) + const status = at(idx.status).toUpperCase() + + // Only settled movements affect the balance. A blank/missing status is + // NOT completed, so it must not slip through: require an exact match. + if (status !== 'COMPLETED') { + skippedRows++ + continue + } + + // Direction drives the sign. An unrecognized value (blank, or NEUTRAL for + // a balance conversion/cashback, or anything Wise adds later) must NOT be + // guessed: silently treating it as income mis-signs real money. Fail the + // whole import so it surfaces (the parse route turns this throw into + // BANK_FILE_PARSE_FAILED). Proper conversion handling is tracked in #1019. + const direction = at(idx.direction).toUpperCase() + if (direction !== 'IN' && direction !== 'OUT') { + throw new Error( + `Wise import: unsupported Direction "${at(idx.direction)}" on ${wiseId || `row ${i + 1}`}`, + ) + } + const isOut = direction === 'OUT' + + // Book the side that moved on the balance: target for IN, source for OUT. + // Never invent a currency: a missing/malformed one is a bad row, skip it. + const currency = (isOut ? at(idx.sourceCurrency) : at(idx.targetCurrency)).trim().toUpperCase() + if (!/^[A-Z]{3}$/.test(currency)) { + issues.push({ row: i + 1, message: `Missing/invalid currency on ${wiseId || 'row'}`, severity: 'warning' }) + skippedRows++ + continue + } + const rawAmount = parseWiseAmount(isOut ? at(idx.sourceAmount) : at(idx.targetAmount)) + + const date = wiseDate(at(idx.finishedOn)) || wiseDate(at(idx.createdOn)) + if (!date) { + issues.push({ row: i + 1, message: `Invalid date on ${wiseId || 'row'}`, severity: 'warning' }) + skippedRows++ + continue + } + if (!Number.isFinite(rawAmount)) { + issues.push({ row: i + 1, message: `Invalid amount on ${wiseId || 'row'}`, severity: 'warning' }) + skippedRows++ + continue + } + + const counterparty = (isOut ? at(idx.targetName) : at(idx.sourceName)).trim() + const note = at(idx.note).trim() + const reference = at(idx.reference).trim() + const category = at(idx.category).trim() + const description = + [counterparty, note].filter(Boolean).join(' - ') || reference || category || 'Wise-transaktion' + + // Signed movement: OUT leaves the balance (negative), IN enters it. + const amount = round2(isOut ? -Math.abs(rawAmount) : Math.abs(rawAmount)) + + transactions.push({ + date, + description, + amount, + currency, + balance: null, + reference: reference || null, + counterparty: counterparty || null, + // Stable Wise ID drives dedup (see generateExternalId). + raw_line: wiseId || undefined, + }) + + // Fees are a real cost: emit each non-zero fee as its own negative row so + // it lands in the inbox to categorize (e.g. 6570) and the balance ties out. + const feeSpecs: Array<{ amountCol: number; currencyCol: number; suffix: string }> = [ + { amountCol: idx.sourceFeeAmount, currencyCol: idx.sourceFeeCurrency, suffix: 'fee' }, + { amountCol: idx.targetFeeAmount, currencyCol: idx.targetFeeCurrency, suffix: 'tgtfee' }, + ] + for (const spec of feeSpecs) { + const feeRaw = at(spec.amountCol).trim() + if (!feeRaw) continue // No fee column value: normal, most rows. + const fee = parseWiseAmount(feeRaw) + if (fee === 0) continue // Explicit 0.00 fee: nothing to book, no warning. + if (!Number.isFinite(fee) || fee < 0) { + issues.push({ row: i + 1, message: `Invalid fee amount "${feeRaw}" on ${wiseId || 'row'}`, severity: 'warning' }) + continue + } + // Never inherit the movement currency for the fee: a fee amount without + // its own currency is a bad row, so flag and skip it rather than guess. + const feeCurrency = at(spec.currencyCol).trim().toUpperCase() + if (!/^[A-Z]{3}$/.test(feeCurrency)) { + issues.push({ row: i + 1, message: `Fee on ${wiseId || 'row'} has no currency; skipped`, severity: 'warning' }) + continue + } + transactions.push({ + date, + description: `Wise avgift${counterparty ? ` (${counterparty})` : ''}`, + amount: round2(-Math.abs(fee)), + currency: feeCurrency, + balance: null, + reference: reference || null, + counterparty: 'Wise', + raw_line: wiseId ? `${wiseId}-${spec.suffix}` : undefined, + }) + } + } + + const dates = transactions.map((t) => t.date).sort() + + return { + format: 'wise', + format_name: 'Wise', + transactions, + date_from: dates[0] || null, + date_to: dates[dates.length - 1] || null, + issues, + stats: { + total_rows: lines.length - 1, + parsed_rows: transactions.length, + skipped_rows: skippedRows, + total_income: round2(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0)), + total_expenses: round2(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0)), + }, + } + }, +} diff --git a/lib/import/bank-file/parser.ts b/lib/import/bank-file/parser.ts index 790cdf6a..af438e91 100644 --- a/lib/import/bank-file/parser.ts +++ b/lib/import/bank-file/parser.ts @@ -17,6 +17,7 @@ import { icaBankenFormat } from './formats/ica-banken' import { skandiaFormat } from './formats/skandia' import { lunarFormat } from './formats/lunar' import { northmillFormat } from './formats/northmill' +import { wiseFormat } from './formats/wise' import { camt053Format } from './formats/camt053' import { genericCSVFormat } from './formats/generic-csv' @@ -38,6 +39,7 @@ const FORMATS: BankFileFormat[] = [ skandiaFormat, lunarFormat, northmillFormat, + wiseFormat, genericCSVFormat, ] @@ -142,6 +144,13 @@ export function generateExternalId( return `camt053_${tx.raw_line}` } + // Wise carries the stable transfer ID (TRANSFER-…, PLAN_ORDER-…, plus a + // `-fee` suffix for fee rows) in raw_line: use it so re-importing the same + // statement dedups exactly instead of relying on the row hash. + if (formatId === 'wise' && tx.raw_line) { + return `wise_${tx.raw_line}` + } + // For CSV formats, create a composite hash const composite = `${formatId}|${tx.date}|${tx.description}|${tx.amount}|${rowIndex}` const hash = crypto.createHash('sha256').update(composite).digest('hex').substring(0, 16) diff --git a/lib/import/bank-file/types.ts b/lib/import/bank-file/types.ts index b913de42..a94f7698 100644 --- a/lib/import/bank-file/types.ts +++ b/lib/import/bank-file/types.ts @@ -53,6 +53,7 @@ export type BankFileFormatId = | 'skandia' | 'lunar' | 'northmill' + | 'wise' | 'generic_csv' | 'camt053'