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:
co-authored by
Claude Opus 4.6
parent
547fd053ec
commit
6f4573f380
@@ -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,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',
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user