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:
co-authored by
Claude Fable 5
parent
e029376c08
commit
bb78f8fce8
@@ -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>
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user