feat: add foreign currency support, refactor bookkeeping engine, and improve invoice inbox document classification

- Add currency-utils module for SEK conversion with exchange rates
- Refactor createJournalEntry to use draft+commit flow preventing voucher number gaps (BFL 5 kap. 7§)
- Add foreign currency support to invoice entries with per-line SEK conversion
- Centralize category-to-account mapping into single source of truth
- Refactor invoice inbox to use shared document analyzer with document type classification (receipt, supplier invoice, government letter)
- Update mapping engine, supplier invoice entries, and transaction entries
- Fix report component rendering issues
- Add new validation schemas and tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-27 09:50:06 +01:00
co-authored by Claude Opus 4.6
parent 547fd053ec
commit 6f4573f380
33 changed files with 1365 additions and 497 deletions
+2 -1
View File
@@ -12,6 +12,7 @@ import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntry
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType } from '@/types'
@@ -363,7 +364,7 @@ export default function JournalEntryForm({
{!isBalanced && totalDebit > 0 && (
<p className="text-sm text-red-600">
Differens: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr
Differens: {formatCurrency(Math.abs(totalDebit - totalCredit))}
</p>
)}
@@ -1,6 +1,7 @@
'use client'
import type { AgingBucketsArtifact } from '@/types/chat'
import { formatCurrency } from '@/lib/utils'
const BUCKET_COLORS = [
'bg-green-500',
@@ -14,10 +15,6 @@ interface ChatAgingBucketsProps {
artifact: AgingBucketsArtifact
}
function formatAmount(amount: number): string {
return new Intl.NumberFormat('sv-SE').format(Math.round(amount))
}
export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
const { title, buckets, total } = artifact
@@ -28,7 +25,7 @@ export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
<div className="flex items-baseline justify-between mb-3">
<h4 className="text-sm font-semibold">{title}</h4>
<span className="text-sm font-bold tabular-nums">
{formatAmount(total)} kr
{formatCurrency(total)}
</span>
</div>
@@ -43,7 +40,7 @@ export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
key={i}
className={`${BUCKET_COLORS[i % BUCKET_COLORS.length]} transition-all`}
style={{ width: `${widthPercent}%` }}
title={`${bucket.label}: ${formatAmount(bucket.amount)} kr`}
title={`${bucket.label}: ${formatCurrency(bucket.amount)}`}
/>
)
})}
@@ -65,7 +62,7 @@ export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
{bucket.count} st
</span>
<span className="font-medium tabular-nums">
{formatAmount(bucket.amount)} kr
{formatCurrency(bucket.amount)}
</span>
</div>
</div>
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { AccountNumber } from '@/components/ui/account-number'
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -291,16 +292,16 @@ export function BankReconciliationView() {
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Banktransaktioner (summa)</span>
<span className="font-mono">{formatAmount(status.bank_transaction_total)} kr</span>
<span className="font-mono">{formatCurrency(status.bank_transaction_total)}</span>
</div>
<div className="flex justify-between">
<span><AccountNumber number="1930" /> saldo (huvudbok)</span>
<span className="font-mono">{formatAmount(status.gl_1930_balance)} kr</span>
<span className="font-mono">{formatCurrency(status.gl_1930_balance)}</span>
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
<span>Differens</span>
<span className={status.is_reconciled ? 'text-green-600' : 'text-red-600'}>
{formatAmount(status.difference)} kr
{formatCurrency(status.difference)}
</span>
</div>
<div className="flex gap-4 pt-2 text-xs text-muted-foreground">
@@ -433,7 +434,7 @@ export function BankReconciliationView() {
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[200px]">{tx.description}</td>
<td className={`py-2 text-right font-mono ${tx.amount >= 0 ? 'text-green-600' : ''}`}>
{formatAmount(tx.amount)} kr
{formatCurrency(tx.amount)}
</td>
<td className="py-2 text-xs text-muted-foreground">{tx.reference || '—'}</td>
<td className="py-2">
@@ -449,7 +450,7 @@ export function BankReconciliationView() {
const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
return (
<option key={line.line_id} value={line.journal_entry_id}>
{line.voucher_series}{line.voucher_number} | {line.entry_date} | {formatAmount(lineAmount)} kr | {line.entry_description}
{line.voucher_series}{line.voucher_number} | {line.entry_date} | {formatCurrency(lineAmount)} | {line.entry_description}
</option>
)
})}
@@ -506,7 +507,7 @@ export function BankReconciliationView() {
{line.line_description || line.entry_description}
</td>
<td className={`py-2 text-right font-mono ${amount >= 0 ? 'text-green-600' : ''}`}>
{formatAmount(amount)} kr
{formatCurrency(amount)}
</td>
<td className="py-2 text-xs text-muted-foreground">{line.source_type}</td>
</tr>
@@ -554,7 +555,7 @@ export function BankReconciliationView() {
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
<td className={`py-2 text-right font-mono ${tx.amount >= 0 ? 'text-green-600' : ''}`}>
{formatAmount(tx.amount)} kr
{formatCurrency(tx.amount)}
</td>
<td className="py-2">
{tx.reconciliation_method && (
+2 -1
View File
@@ -2,6 +2,7 @@
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCurrency } from '@/lib/utils'
export interface MonthlyDataPoint {
label: string
@@ -34,7 +35,7 @@ export function IncomeExpenseChart({ months }: IncomeExpenseChartProps) {
/>
<Tooltip
formatter={(value, name) => [
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
formatCurrency(Number(value)),
name === 'income' ? 'Intäkter' : 'Kostnader',
]}
/>
+8 -11
View File
@@ -7,10 +7,7 @@ import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { NEDeclaration } from '@/lib/reports/ne-bilaga/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
import { formatCurrency } from '@/lib/utils'
export function NEDeclarationView({ periodId }: { periodId: string }) {
const [data, setData] = useState<NEDeclaration | null>(null)
@@ -156,9 +153,9 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa intäkter</td>
<td className="py-2 text-right">
{formatAmount(
{formatCurrency(
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
)} kr
)}
</td>
</tr>
</tfoot>
@@ -193,10 +190,10 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa kostnader</td>
<td className="py-2 text-right">
-{formatAmount(
-{formatCurrency(
data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
data.rutor.R8 + data.rutor.R9 + data.rutor.R10
)} kr
)}
</td>
</tr>
</tfoot>
@@ -217,7 +214,7 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
data.rutor.R11 >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{formatAmount(data.rutor.R11)} kr
{formatCurrency(data.rutor.R11)}
</span>
</div>
</CardContent>
@@ -270,7 +267,7 @@ function NEDeclarationRow({
)}
</td>
<td className="py-2 text-right">
{isExpense && amount > 0 ? '-' : ''}{formatAmount(Math.abs(amount))} kr
{isExpense && amount > 0 ? '-' : ''}{formatCurrency(Math.abs(amount))}
</td>
</tr>
{expanded && accounts.length > 0 && (
@@ -283,7 +280,7 @@ function NEDeclarationRow({
<td className="py-1"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">
{isExpense && acc.amount > 0 ? '-' : ''}{formatAmount(Math.abs(acc.amount))} kr
{isExpense && acc.amount > 0 ? '-' : ''}{formatCurrency(Math.abs(acc.amount))}
</td>
</tr>
))}
+3 -6
View File
@@ -8,10 +8,7 @@ import { Download, AlertCircle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { SRUExportResult } from '@/lib/reports/sru-export/types'
import type { SRUCoverageStats } from '@/lib/reports/sru-export/sru-engine'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
import { formatCurrency } from '@/lib/utils'
export function SRUExportView({ periodId }: { periodId: string }) {
const [data, setData] = useState<SRUExportResult | null>(null)
@@ -221,7 +218,7 @@ function SRUBalanceRow({
</span>
)}
</td>
<td className="py-2 text-right">{formatAmount(balance.amount)} kr</td>
<td className="py-2 text-right">{formatCurrency(balance.amount)}</td>
</tr>
{expanded && balance.accounts.length > 0 && (
<tr>
@@ -232,7 +229,7 @@ function SRUBalanceRow({
<tr key={acc.accountNumber}>
<td className="py-1 w-16"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">{formatAmount(acc.amount)} kr</td>
<td className="py-1 text-right">{formatCurrency(acc.amount)}</td>
</tr>
))}
</tbody>
+2 -1
View File
@@ -3,6 +3,7 @@
import { useMemo } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCurrency } from '@/lib/utils'
import type { TrialBalanceRow } from '@/types'
interface TrialBalanceChartProps {
@@ -48,7 +49,7 @@ export function TrialBalanceChart({ rows }: TrialBalanceChartProps) {
/>
<Tooltip
formatter={(value) => [
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
formatCurrency(Number(value)),
'Netto',
]}
labelFormatter={(label) => chartData.find((d) => d.account === String(label))?.name || String(label)}
+2 -1
View File
@@ -3,6 +3,7 @@
import { useMemo } from 'react'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCurrency } from '@/lib/utils'
import type { VatDeclarationRutor } from '@/types'
interface VatCompositionChartProps {
@@ -52,7 +53,7 @@ export function VatCompositionChart({ rutor }: VatCompositionChartProps) {
</Pie>
<Tooltip
formatter={(value) => [
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
formatCurrency(Number(value)),
]}
/>
<Legend />
@@ -3,6 +3,7 @@
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { AccountNumber } from '@/components/ui/account-number'
import { formatCurrency } from '@/lib/utils'
import type { Supplier } from '@/types'
interface ReviewLineItem {
@@ -209,21 +210,21 @@ export function SupplierInvoiceReviewContent({
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span>{formatAmount(subtotal)} kr</span>
<span>{formatCurrency(subtotal, currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{formatAmount(totalVat)} kr</span>
<span>{formatCurrency(totalVat, currency)}</span>
</div>
<Separator />
<div className="flex justify-between font-bold text-2xl">
<span>Totalt</span>
<span>{formatAmount(total)} kr</span>
<span>{formatCurrency(total, currency)}</span>
</div>
{currency !== 'SEK' && exchangeRate && (
<div className="flex justify-between text-muted-foreground">
<span>SEK-belopp (vid kurs {exchangeRate})</span>
<span>{formatAmount(total * parseFloat(exchangeRate))} kr</span>
<span>{formatCurrency(total * parseFloat(exchangeRate))}</span>
</div>
)}
</div>