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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-25 12:58:47 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e029376c08
commit bb78f8fce8
4 changed files with 110 additions and 14 deletions
+14 -6
View File
@@ -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<BankAccount[]>([])
const [selectedAccount, setSelectedAccount] = useState('1930')
@@ -128,18 +132,22 @@ export default function BankFileConfirmStep({
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Inkomster</span>
</div>
<p className="text-xl font-display tabular-nums">
{formatCurrency(stats.total_income)}
</p>
{(currencyTotals.length ? currencyTotals : [{ currency: 'SEK', total_income: 0, total_expenses: 0 }]).map((row) => (
<p key={row.currency} className="text-xl font-display tabular-nums">
{formatCurrency(row.total_income, row.currency)}
</p>
))}
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Utgifter</span>
</div>
<p className="text-xl font-display tabular-nums">
{formatCurrency(stats.total_expenses)}
</p>
{(currencyTotals.length ? currencyTotals : [{ currency: 'SEK', total_income: 0, total_expenses: 0 }]).map((row) => (
<p key={row.currency} className="text-xl font-display tabular-nums">
{formatCurrency(row.total_expenses, row.currency)}
</p>
))}
</div>
</div>
+16 -8
View File
@@ -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 (
<div className="space-y-6">
@@ -74,9 +78,11 @@ export default function BankFilePreviewStep({
<TrendingUp className="h-4 w-4" />
<span className="text-sm">Inkomster</span>
</div>
<p className="text-lg font-display tabular-nums">
{formatCurrency(stats.total_income)}
</p>
{(currencyTotals.length ? currencyTotals : [{ currency: 'SEK', total_income: 0, total_expenses: 0 }]).map((row) => (
<p key={row.currency} className="text-lg font-display tabular-nums">
{formatCurrency(row.total_income, row.currency)}
</p>
))}
</CardContent>
</Card>
@@ -86,9 +92,11 @@ export default function BankFilePreviewStep({
<TrendingDown className="h-4 w-4" />
<span className="text-sm">Utgifter</span>
</div>
<p className="text-lg font-display tabular-nums">
{formatCurrency(stats.total_expenses)}
</p>
{(currencyTotals.length ? currencyTotals : [{ currency: 'SEK', total_income: 0, total_expenses: 0 }]).map((row) => (
<p key={row.currency} className="text-lg font-display tabular-nums">
{formatCurrency(row.total_expenses, row.currency)}
</p>
))}
</CardContent>
</Card>
</div>
@@ -151,11 +159,11 @@ export default function BankFilePreviewStep({
<TableCell
className="text-right font-mono text-sm"
>
{formatCurrency(tx.amount)}
{formatCurrency(tx.amount, tx.currency || 'SEK')}
</TableCell>
{transactions.some((t) => t.balance != null) && (
<TableCell className="text-right font-mono text-sm text-muted-foreground">
{tx.balance != null ? formatCurrency(tx.balance) : '-'}
{tx.balance != null ? formatCurrency(tx.balance, tx.currency || 'SEK') : '-'}
</TableCell>
)}
{transactions.some((t) => t.reference) && (
@@ -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>): 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([])
})
})
+36
View File
@@ -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<string, { total_income: number; total_expenses: number }>()
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))
}