From bb78f8fce821f817083de667468d0ceb622d9d62 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:58:47 +0200 Subject: [PATCH] fix(import): per-currency totals and row currency in bank-file preview (#1178) * fix(import): per-currency totals and row currency in bank-file preview Fixes #1170. ParsedBankTransaction carries a per-row currency (Wise emits genuinely mixed rows; camt.053 reads Ccy per entry), but the preview and confirm steps formatted every amount as kr and rendered parser-level income/expense totals that sum across currencies. Adds summarizeByCurrency() (income positive / expenses negative, ore rounding, SEK default) and renders one total line per currency on both steps; preview table rows format amount and balance with the row's own currency. Co-Authored-By: Claude Fable 5 * fix(import): use roundOre from lib/money (antipattern ratchet) The naive Math.round(x * 100) / 100 form is blocked by check:guards (subtly wrong on exact-half values); lib/money.roundOre is canonical. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- components/import/BankFileConfirmStep.tsx | 20 ++++++--- components/import/BankFilePreviewStep.tsx | 24 ++++++---- .../__tests__/currency-summary.test.ts | 44 +++++++++++++++++++ lib/import/bank-file/currency-summary.ts | 36 +++++++++++++++ 4 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 lib/import/bank-file/__tests__/currency-summary.test.ts create mode 100644 lib/import/bank-file/currency-summary.ts diff --git a/components/import/BankFileConfirmStep.tsx b/components/import/BankFileConfirmStep.tsx index 6962107a..dac925c2 100644 --- a/components/import/BankFileConfirmStep.tsx +++ b/components/import/BankFileConfirmStep.tsx @@ -16,6 +16,7 @@ import { AlertTriangle, } from 'lucide-react' import { formatCurrency } from '@/lib/utils' +import { summarizeByCurrency } from '@/lib/import/bank-file/currency-summary' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' import type { BankFileParseResult } from '@/lib/import/bank-file/types' @@ -41,6 +42,9 @@ export default function BankFileConfirmStep({ const { transactions, stats, date_from, date_to, issues } = parseResult const refsCount = transactions.filter((t) => t.reference).length const warnings = issues.filter((i) => i.severity === 'warning') + // Same per-currency grouping as the preview step: parser-level totals sum + // across currencies, which misleads on Wise/camt.053 multi-currency files. + const currencyTotals = summarizeByCurrency(transactions) const [bankAccounts, setBankAccounts] = useState([]) const [selectedAccount, setSelectedAccount] = useState('1930') @@ -128,18 +132,22 @@ export default function BankFileConfirmStep({
Inkomster
-

- {formatCurrency(stats.total_income)} -

+ {(currencyTotals.length ? currencyTotals : [{ currency: 'SEK', total_income: 0, total_expenses: 0 }]).map((row) => ( +

+ {formatCurrency(row.total_income, row.currency)} +

+ ))}
Utgifter
-

- {formatCurrency(stats.total_expenses)} -

+ {(currencyTotals.length ? currencyTotals : [{ currency: 'SEK', total_income: 0, total_expenses: 0 }]).map((row) => ( +

+ {formatCurrency(row.total_expenses, row.currency)} +

+ ))}
diff --git a/components/import/BankFilePreviewStep.tsx b/components/import/BankFilePreviewStep.tsx index 92f62c51..fe36dc19 100644 --- a/components/import/BankFilePreviewStep.tsx +++ b/components/import/BankFilePreviewStep.tsx @@ -20,6 +20,7 @@ import { FileText, } from 'lucide-react' import { formatCurrency } from '@/lib/utils' +import { summarizeByCurrency } from '@/lib/import/bank-file/currency-summary' import type { BankFileParseResult } from '@/lib/import/bank-file/types' interface BankFilePreviewStepProps { @@ -36,6 +37,9 @@ export default function BankFilePreviewStep({ const { transactions, stats, issues, date_from, date_to } = parseResult const hasIssues = issues.filter((i) => i.severity === 'error').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. + const currencyTotals = summarizeByCurrency(transactions) return (
@@ -74,9 +78,11 @@ export default function BankFilePreviewStep({ Inkomster
-

- {formatCurrency(stats.total_income)} -

+ {(currencyTotals.length ? currencyTotals : [{ currency: 'SEK', total_income: 0, total_expenses: 0 }]).map((row) => ( +

+ {formatCurrency(row.total_income, row.currency)} +

+ ))} @@ -86,9 +92,11 @@ export default function BankFilePreviewStep({ Utgifter -

- {formatCurrency(stats.total_expenses)} -

+ {(currencyTotals.length ? currencyTotals : [{ currency: 'SEK', total_income: 0, total_expenses: 0 }]).map((row) => ( +

+ {formatCurrency(row.total_expenses, row.currency)} +

+ ))} @@ -151,11 +159,11 @@ export default function BankFilePreviewStep({ - {formatCurrency(tx.amount)} + {formatCurrency(tx.amount, tx.currency || 'SEK')} {transactions.some((t) => t.balance != null) && ( - {tx.balance != null ? formatCurrency(tx.balance) : '-'} + {tx.balance != null ? formatCurrency(tx.balance, tx.currency || 'SEK') : '-'} )} {transactions.some((t) => t.reference) && ( diff --git a/lib/import/bank-file/__tests__/currency-summary.test.ts b/lib/import/bank-file/__tests__/currency-summary.test.ts new file mode 100644 index 00000000..caf7fc2a --- /dev/null +++ b/lib/import/bank-file/__tests__/currency-summary.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { summarizeByCurrency } from '../currency-summary' +import type { ParsedBankTransaction } from '../types' + +function tx(overrides: Partial): ParsedBankTransaction { + return { + date: '2026-07-01', + description: 'Test', + amount: 0, + currency: 'SEK', + ...overrides, + } +} + +describe('summarizeByCurrency', () => { + it('groups income and expenses per currency instead of mixing them', () => { + const result = summarizeByCurrency([ + tx({ amount: 2500, currency: 'USD' }), + tx({ amount: -100.5, currency: 'USD' }), + tx({ amount: 1000, currency: 'SEK' }), + tx({ amount: -250, currency: 'SEK' }), + tx({ amount: -30, currency: 'EUR' }), + ]) + + expect(result).toEqual([ + { currency: 'EUR', total_income: 0, total_expenses: -30 }, + { currency: 'SEK', total_income: 1000, total_expenses: -250 }, + { currency: 'USD', total_income: 2500, total_expenses: -100.5 }, + ]) + }) + + it('defaults a missing currency to SEK and rounds to ore', () => { + const result = summarizeByCurrency([ + tx({ amount: 0.105, currency: '' }), + tx({ amount: 0.105, currency: '' }), + ]) + + expect(result).toEqual([{ currency: 'SEK', total_income: 0.21, total_expenses: 0 }]) + }) + + it('returns an empty list for no transactions', () => { + expect(summarizeByCurrency([])).toEqual([]) + }) +}) diff --git a/lib/import/bank-file/currency-summary.ts b/lib/import/bank-file/currency-summary.ts new file mode 100644 index 00000000..66d900ce --- /dev/null +++ b/lib/import/bank-file/currency-summary.ts @@ -0,0 +1,36 @@ +import { roundOre } from '@/lib/money' +import type { ParsedBankTransaction } from './types' + +export interface BankFileCurrencyTotals { + currency: string + total_income: number + total_expenses: number +} + +/** + * Per-currency income/expense totals for a parsed bank file. + * + * The parser-level stats (`stats.total_income` / `stats.total_expenses`) sum + * every row regardless of currency, which is meaningless for multi-currency + * exports (Wise emits per-row currencies, camt.053 reads Ccy per entry). + * Sign convention matches the parsers: income positive, expenses negative. + */ +export function summarizeByCurrency( + transactions: ParsedBankTransaction[], +): BankFileCurrencyTotals[] { + const perCurrency = new Map() + for (const tx of transactions) { + const currency = tx.currency || 'SEK' + const row = perCurrency.get(currency) ?? { total_income: 0, total_expenses: 0 } + if (tx.amount > 0) row.total_income += tx.amount + else row.total_expenses += tx.amount + perCurrency.set(currency, row) + } + return [...perCurrency.entries()] + .map(([currency, totals]) => ({ + currency, + total_income: roundOre(totals.total_income), + total_expenses: roundOre(totals.total_expenses), + })) + .sort((a, b) => a.currency.localeCompare(b.currency)) +}