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
@@ -126,7 +126,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect.anything(),
'user-1',
expect.objectContaining({ id: 'inv-1' }),
expect.any(String)
expect.any(String),
undefined
)
})
+20 -2
View File
@@ -4,6 +4,7 @@ import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import type { EntityType, Invoice } from '@/types'
/**
@@ -49,8 +50,24 @@ export async function POST(
)
}
// Parse optional body (backward compatible — body may be empty)
let exchangeRateDifference: number | undefined
let bodyPaymentDate: string | undefined
try {
const text = await request.text()
if (text) {
const parsed = MarkInvoicePaidSchema.safeParse(JSON.parse(text))
if (parsed.success) {
exchangeRateDifference = parsed.data.exchange_rate_difference
bodyPaymentDate = parsed.data.payment_date
}
}
} catch {
// No body or invalid JSON — use defaults
}
const now = new Date().toISOString()
const paymentDate = now.split('T')[0]
const paymentDate = bodyPaymentDate || now.split('T')[0]
// Update status to paid
const { error: updateError } = await supabase
@@ -89,7 +106,8 @@ export async function POST(
supabase,
user.id,
invoice as Invoice,
paymentDate
paymentDate,
exchangeRateDifference
)
journalEntryId = journalEntry?.id ?? null
} else {
+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>
+290 -158
View File
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import type { InvoiceExtractionResult } from './types'
import type { InvoiceInboxItem, SupplierInvoice } from '@/types'
import { analyzeInvoice } from './lib/invoice-analyzer'
import { analyzeDocument } from '@/lib/ai/document-analyzer'
import { matchSupplier } from './lib/supplier-matcher'
import { getSettings, saveSettings } from './index'
import { eventBus } from '@/lib/events/bus'
@@ -190,96 +190,33 @@ async function processInboxItem(
const supabase = await getSupabase()
try {
console.log(`[invoice-inbox] Processing item=${itemId}: starting AI extraction (${mimeType})`)
const extraction = await analyzeInvoice(base64, mimeType)
console.log(`[invoice-inbox] Processing item=${itemId}: starting AI analysis (${mimeType})`)
const result = await analyzeDocument(base64, mimeType)
console.log(`[invoice-inbox] item=${itemId} extraction complete:`, {
confidence: extraction.confidence,
suggestedTemplateId: extraction.suggestedTemplateId || null,
supplier: extraction.supplier?.name || null,
total: extraction.totals?.total || null,
invoiceDate: extraction.invoice?.invoiceDate || null,
dueDate: extraction.invoice?.dueDate || null,
paymentRef: extraction.invoice?.paymentReference || null,
})
const { classification } = result
console.log(`[invoice-inbox] item=${itemId} classified as: ${classification.type} (confidence=${classification.confidence}, reasoning=${classification.reasoning})`)
// Supplier matching
const settings = await getSettings(userId)
let matchedSupplierId: string | null = null
if (settings.autoMatchSupplierEnabled) {
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('user_id', userId)
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
if (match && match.confidence >= settings.supplierMatchThreshold) {
matchedSupplierId = match.supplierId
console.log(`[invoice-inbox] item=${itemId} supplier matched: id=${match.supplierId} confidence=${match.confidence}`)
}
}
}
// Store extraction result with template suggestion
const updateData: Record<string, unknown> = {
status: 'ready',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
}
if (extraction.suggestedTemplateId) {
updateData.suggested_template_id = extraction.suggestedTemplateId
updateData.suggested_template_confidence = extraction.confidence
console.log(`[invoice-inbox] item=${itemId} template suggestion: ${extraction.suggestedTemplateId} (confidence=${extraction.confidence})`)
}
await supabase
.from('invoice_inbox_items')
.update(updateData)
.eq('id', itemId)
// Fetch the updated item for event emission and matching
const { data: updatedItem } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', itemId)
.single()
if (updatedItem) {
await eventBus.emit({
type: 'supplier_invoice.extracted',
payload: {
inboxItem: updatedItem,
confidence: extraction.confidence,
userId,
},
})
// Document-to-transaction matching
try {
const matchResult = await matchDocumentToTransactions(
supabase,
userId,
updatedItem as InvoiceInboxItem
)
if (matchResult) {
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: matchResult.transactionId,
match_confidence: matchResult.confidence,
match_method: matchResult.method,
})
.eq('id', itemId)
}
} catch (matchError) {
// Non-blocking: log but don't fail the item
console.error('[invoice-inbox] Transaction matching failed:', matchError)
}
// Handle based on document type
if (classification.type === 'receipt' && result.receipt) {
await processAsReceipt(supabase, itemId, userId, result.receipt)
} else if (classification.type === 'supplier_invoice' && result.invoice) {
await processAsInvoice(supabase, itemId, userId, result.invoice)
} else if (classification.type === 'government_letter' || classification.type === 'unknown') {
// Store classification but no extraction — user must review manually
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
document_type: classification.type,
confidence: classification.confidence,
})
.eq('id', itemId)
} else {
// Classification gave a type but extraction failed — fall back to supplier_invoice extraction
console.warn(`[invoice-inbox] item=${itemId}: classified as ${classification.type} but no extraction data, falling back`)
const { extractInvoice } = await import('@/lib/ai/document-analyzer')
const fallbackExtraction = await extractInvoice(base64, mimeType)
await processAsInvoice(supabase, itemId, userId, fallbackExtraction)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
@@ -290,6 +227,235 @@ async function processInboxItem(
}
}
async function processAsReceipt(
supabase: Awaited<ReturnType<typeof getSupabase>>,
itemId: string,
userId: string,
extraction: import('@/types').ReceiptExtractionResult
): Promise<void> {
console.log(`[invoice-inbox] item=${itemId} processing as receipt: merchant=${extraction.merchant?.name}, total=${extraction.totals?.total}`)
// Fetch the inbox item to get document_id for the receipt image_url
const { data: inboxItem } = await supabase
.from('invoice_inbox_items')
.select('document_id, document:document_attachments(storage_path)')
.eq('id', itemId)
.single()
let imageUrl: string | null = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const document = inboxItem?.document as any
if (document?.storage_path) {
const { data: urlData } = supabase.storage
.from('documents')
.getPublicUrl(document.storage_path)
imageUrl = urlData?.publicUrl || null
}
// Create receipt record
const { data: receipt, error: receiptError } = await supabase
.from('receipts')
.insert({
user_id: userId,
image_url: imageUrl,
status: 'extracted',
extraction_confidence: extraction.confidence,
merchant_name: extraction.merchant?.name || null,
merchant_org_number: extraction.merchant?.orgNumber || null,
merchant_vat_number: extraction.merchant?.vatNumber || null,
receipt_date: extraction.receipt?.date || null,
receipt_time: extraction.receipt?.time || null,
total_amount: extraction.totals?.total || null,
currency: extraction.receipt?.currency || 'SEK',
vat_amount: extraction.totals?.vatAmount || null,
is_restaurant: extraction.flags?.isRestaurant || false,
is_systembolaget: extraction.flags?.isSystembolaget || false,
is_foreign_merchant: extraction.flags?.isForeignMerchant || false,
raw_extraction: extraction,
})
.select()
.single()
if (receiptError || !receipt) {
console.error(`[invoice-inbox] item=${itemId} receipt creation failed:`, receiptError)
// Fall back: store as receipt type without linked receipt
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
document_type: 'receipt',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
})
.eq('id', itemId)
return
}
// Insert receipt line items
if (extraction.lineItems?.length > 0) {
const lineItemsToInsert = extraction.lineItems.map((item, index) => ({
receipt_id: receipt.id,
description: item.description || '',
quantity: item.quantity || 1,
unit_price: item.unitPrice,
line_total: item.lineTotal || 0,
vat_rate: item.vatRate,
vat_amount:
item.vatRate && item.lineTotal
? Math.round((item.lineTotal * item.vatRate) / (100 + item.vatRate) * 100) / 100
: null,
suggested_category: item.suggestedCategory || null,
sort_order: index,
}))
await supabase.from('receipt_line_items').insert(lineItemsToInsert)
}
// Update inbox item: mark as receipt with linked receipt
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
document_type: 'receipt',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
linked_receipt_id: receipt.id,
})
.eq('id', itemId)
// Emit receipt.extracted event (non-blocking)
try {
await eventBus.emit({
type: 'receipt.extracted',
payload: {
receipt,
documentId: inboxItem?.document_id || null,
confidence: extraction.confidence,
userId,
},
})
} catch {
// Non-blocking
}
// Try to match receipt to a transaction
try {
const { data: updatedItem } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', itemId)
.single()
if (updatedItem) {
const matchResult = await matchDocumentToTransactions(
supabase,
userId,
updatedItem as InvoiceInboxItem
)
if (matchResult) {
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: matchResult.transactionId,
match_confidence: matchResult.confidence,
match_method: matchResult.method,
})
.eq('id', itemId)
}
}
} catch (matchError) {
console.error('[invoice-inbox] Receipt transaction matching failed:', matchError)
}
}
async function processAsInvoice(
supabase: Awaited<ReturnType<typeof getSupabase>>,
itemId: string,
userId: string,
extraction: InvoiceExtractionResult
): Promise<void> {
console.log(`[invoice-inbox] item=${itemId} processing as supplier_invoice: supplier=${extraction.supplier?.name}, total=${extraction.totals?.total}`)
// Supplier matching
const settings = await getSettings(userId)
let matchedSupplierId: string | null = null
if (settings.autoMatchSupplierEnabled) {
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('user_id', userId)
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
if (match && match.confidence >= settings.supplierMatchThreshold) {
matchedSupplierId = match.supplierId
console.log(`[invoice-inbox] item=${itemId} supplier matched: id=${match.supplierId} confidence=${match.confidence}`)
}
}
}
// Store extraction result with template suggestion
const updateData: Record<string, unknown> = {
status: 'ready',
document_type: 'supplier_invoice',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
}
if (extraction.suggestedTemplateId) {
updateData.suggested_template_id = extraction.suggestedTemplateId
updateData.suggested_template_confidence = extraction.confidence
console.log(`[invoice-inbox] item=${itemId} template suggestion: ${extraction.suggestedTemplateId} (confidence=${extraction.confidence})`)
}
await supabase
.from('invoice_inbox_items')
.update(updateData)
.eq('id', itemId)
// Fetch the updated item for event emission and matching
const { data: updatedItem } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', itemId)
.single()
if (updatedItem) {
await eventBus.emit({
type: 'supplier_invoice.extracted',
payload: {
inboxItem: updatedItem,
confidence: extraction.confidence,
userId,
},
})
// Document-to-transaction matching
try {
const matchResult = await matchDocumentToTransactions(
supabase,
userId,
updatedItem as InvoiceInboxItem
)
if (matchResult) {
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: matchResult.transactionId,
match_confidence: matchResult.confidence,
match_method: matchResult.method,
})
.eq('id', itemId)
}
} catch (matchError) {
console.error('[invoice-inbox] Transaction matching failed:', matchError)
}
}
}
// ============================================================
// GET /inbox/:id — Get single inbox item
// ============================================================
@@ -470,89 +636,55 @@ async function handleProcessInboxItem(
const arrayBuffer = await fileData.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
// Analyze
const extraction = await analyzeInvoice(base64, document.mime_type)
// Reset previous classification on re-process
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: null,
match_confidence: null,
match_method: null,
linked_receipt_id: null,
})
.eq('id', id)
// Supplier matching
const settings = await getSettings(userId)
let matchedSupplierId: string | null = null
// Analyze with classification
const result = await analyzeDocument(base64, document.mime_type)
const { classification } = result
if (settings.autoMatchSupplierEnabled) {
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('user_id', userId)
console.log(`[invoice-inbox] Re-process item=${id} classified as: ${classification.type} (confidence=${classification.confidence})`)
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
if (match && match.confidence >= settings.supplierMatchThreshold) {
matchedSupplierId = match.supplierId
}
}
}
// Update inbox item with extraction + template suggestion
const updateData: Record<string, unknown> = {
status: 'ready',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
error_message: null,
// Reset previous match on re-process
matched_transaction_id: null,
match_confidence: null,
match_method: null,
}
if (extraction.suggestedTemplateId) {
updateData.suggested_template_id = extraction.suggestedTemplateId
updateData.suggested_template_confidence = extraction.confidence
if (classification.type === 'receipt' && result.receipt) {
await processAsReceipt(supabase, id, userId, result.receipt)
} else if (classification.type === 'supplier_invoice' && result.invoice) {
await processAsInvoice(supabase, id, userId, result.invoice)
} else if (classification.type === 'government_letter' || classification.type === 'unknown') {
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
document_type: classification.type,
confidence: classification.confidence,
error_message: null,
})
.eq('id', id)
} else {
// Fallback: extract as invoice
const { extractInvoice } = await import('@/lib/ai/document-analyzer')
const fallbackExtraction = await extractInvoice(base64, document.mime_type)
await processAsInvoice(supabase, id, userId, fallbackExtraction)
}
// Fetch final state
const { data: updatedItem, error: updateError } = await supabase
.from('invoice_inbox_items')
.update(updateData)
.select('*')
.eq('id', id)
.select()
.single()
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
if (updatedItem) {
await eventBus.emit({
type: 'supplier_invoice.extracted',
payload: {
inboxItem: updatedItem,
confidence: extraction.confidence,
userId,
},
})
// Document-to-transaction matching (non-blocking)
try {
const matchResult = await matchDocumentToTransactions(
supabase,
userId,
updatedItem as InvoiceInboxItem
)
if (matchResult) {
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: matchResult.transactionId,
match_confidence: matchResult.confidence,
match_method: matchResult.method,
})
.eq('id', id)
}
} catch (matchError) {
console.error('[invoice-inbox] Transaction matching failed:', matchError)
}
}
return NextResponse.json({ data: updatedItem })
} catch (error) {
const message = error instanceof Error ? error.message : 'Processing failed'
@@ -14,6 +14,12 @@
"dataPattern": "manual",
"hasOwnData": true,
"description": "Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost",
"longDescription": "Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument."
"longDescription": "Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument.",
"quickAction": {
"label": "Dokumentinkorg",
"description": "Granska inkommande dokument",
"icon": "Inbox",
"href": "/e/general/invoice-inbox"
}
}
}
+9 -2
View File
@@ -60,11 +60,18 @@ const CLASSIFY_SYSTEM_PROMPT = `Du är expert på att klassificera svenska affä
Din uppgift är att avgöra vilken typ av dokument som visas.
DOKUMENTTYPER:
- supplier_invoice: Leverantörsfaktura (har fakturanummer, bankgiro/plusgiro, förfallodatum, leverantörsuppgifter)
- receipt: Kvitto (butiks-/restaurangkvitto, kort betalningsbevis med artikelrader)
- supplier_invoice: Leverantörsfaktura — ett kreditdokument med betalningskrav. MÅSTE ha: fakturanummer, förfallodatum, och betalningsuppgifter (bankgiro/plusgiro/IBAN). Har ofta: organisationsnummer, OCR-referens, betalningsvillkor (t.ex. "30 dagar netto").
- receipt: Kvitto/kassakvitto — bevis på redan genomförd betalning. Kännetecken: "KVITTO", "Kontant", "Kort", kassamaskins-ID, klockslag, redan betalt. Typiskt från butiker, restauranger, bensinstationer, onlineköp. Har INTE förfallodatum eller bankgiro.
- government_letter: Myndighetspost (från Skatteverket, Bolagsverket, Försäkringskassan, kommun, etc.)
- unknown: Annat dokument som inte passar ovan
VIKTIGA SKILLNADER (receipt vs supplier_invoice):
- Ett kvitto visar en AVSLUTAD transaktion (betalning redan gjord). En faktura är ett KRAV på framtida betalning.
- Om dokumentet har bankgiro/plusgiro och förfallodatum → supplier_invoice
- Om dokumentet visar "Betalt", kortbetalning, Swish, eller kontant → receipt
- Prenumerationsbekräftelser, orderbekräftelser med "Betalt" → receipt
- Samlingsfakturor med förfallodatum → supplier_invoice
FÖR LEVERANTÖRSFAKTUROR - kontrollera även:
- Är fakturan från en utländsk/EU-leverantör utan svensk moms?
- Nämner dokumentet "reverse charge", "omvänd skattskyldighet", eller "artikel 196"?
+42
View File
@@ -23,6 +23,7 @@ import {
CreateInvoiceItemSchema,
CreateInvoiceSchema,
CreateCreditNoteSchema,
MarkInvoicePaidSchema,
// Customer schemas
CreateCustomerSchema,
// Supplier schemas
@@ -423,6 +424,47 @@ describe('CreateCreditNoteSchema', () => {
})
})
describe('MarkInvoicePaidSchema', () => {
it('accepts empty object (all fields optional)', () => {
const result = MarkInvoicePaidSchema.safeParse({})
expect(result.success).toBe(true)
})
it('accepts payment_date', () => {
const result = MarkInvoicePaidSchema.safeParse({ payment_date: '2024-07-15' })
expect(result.success).toBe(true)
})
it('accepts exchange_rate_difference (positive gain)', () => {
const result = MarkInvoicePaidSchema.safeParse({ exchange_rate_difference: 200 })
expect(result.success).toBe(true)
})
it('accepts exchange_rate_difference (negative loss)', () => {
const result = MarkInvoicePaidSchema.safeParse({ exchange_rate_difference: -300 })
expect(result.success).toBe(true)
})
it('accepts all fields together', () => {
const result = MarkInvoicePaidSchema.safeParse({
payment_date: '2024-07-15',
exchange_rate_difference: 150.50,
notes: 'Paid via Wise',
})
expect(result.success).toBe(true)
})
it('rejects invalid payment_date format', () => {
const result = MarkInvoicePaidSchema.safeParse({ payment_date: '15/07/2024' })
expect(result.success).toBe(false)
})
it('rejects non-number exchange_rate_difference', () => {
const result = MarkInvoicePaidSchema.safeParse({ exchange_rate_difference: 'big gain' })
expect(result.success).toBe(false)
})
})
// ============================================================
// Customer schemas
// ============================================================
+6
View File
@@ -172,6 +172,12 @@ export const CreateCreditNoteSchema = z.object({
reason: z.string().optional(),
})
export const MarkInvoicePaidSchema = z.object({
payment_date: isoDate.optional(),
exchange_rate_difference: z.number().optional(),
notes: z.string().optional(),
})
// ============================================================
// Customer schemas
// ============================================================
@@ -23,6 +23,13 @@ describe('getCategoryAccountMapping', () => {
})
})
describe('expense_office maps to 6110 (Kontorsförbrukning)', () => {
it('maps expense_office to 6110 (not 5010 Lokalhyra)', () => {
const result = getCategoryAccountMapping('expense_office', -500, true)
expect(result.debitAccount).toBe('6110')
})
})
describe('expense_education entity-type-aware', () => {
it('defaults to 6991 for enskild_firma', () => {
const result = getCategoryAccountMapping('expense_education', -500, true, 'enskild_firma')
@@ -48,6 +55,7 @@ describe('getExpenseAccountForCategory', () => {
it('returns correct accounts for expense categories', () => {
expect(getExpenseAccountForCategory('expense_equipment')).toBe('5410')
expect(getExpenseAccountForCategory('expense_office')).toBe('6110')
expect(getExpenseAccountForCategory('expense_bank_fees')).toBe('6570')
})
})
@@ -57,6 +65,7 @@ describe('getDefaultAccountForCategory', () => {
expect(getDefaultAccountForCategory('expense_equipment')).toBe('5410')
expect(getDefaultAccountForCategory('expense_software')).toBe('5420')
expect(getDefaultAccountForCategory('expense_travel')).toBe('5800')
expect(getDefaultAccountForCategory('expense_office')).toBe('6110')
expect(getDefaultAccountForCategory('expense_bank_fees')).toBe('6570')
})
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { resolveSekAmount, buildCurrencyMetadata } from '../currency-utils'
describe('resolveSekAmount', () => {
it('returns amount as-is for SEK currency', () => {
expect(resolveSekAmount(1000, null, 'SEK', null)).toBe(1000)
})
it('returns amount as-is when currency is null (legacy data)', () => {
expect(resolveSekAmount(1000, null, null, null)).toBe(1000)
})
it('returns amount as-is when currency is undefined', () => {
expect(resolveSekAmount(1000, null, undefined, null)).toBe(1000)
})
it('returns amountSek when populated for foreign currency', () => {
expect(resolveSekAmount(100, 1150, 'EUR', 11.5)).toBe(1150)
})
it('rounds amountSek to 2 decimals', () => {
expect(resolveSekAmount(100, 1150.456, 'EUR', 11.5)).toBe(1150.46)
})
it('computes via exchangeRate when amountSek is null', () => {
expect(resolveSekAmount(100, null, 'EUR', 11.5)).toBe(1150)
})
it('rounds computed amount to 2 decimals', () => {
// 100.33 * 11.5 = 1153.795 → rounds to 1153.8
expect(resolveSekAmount(100.33, null, 'EUR', 11.5)).toBe(1153.8)
})
it('falls back to original amount when both amountSek and exchangeRate are null', () => {
expect(resolveSekAmount(100, null, 'EUR', null)).toBe(100)
})
it('falls back when exchangeRate is 0', () => {
expect(resolveSekAmount(100, null, 'EUR', 0)).toBe(100)
})
it('handles negative amounts correctly', () => {
expect(resolveSekAmount(-100, null, 'EUR', 11.5)).toBe(-1150)
})
it('prefers amountSek over exchangeRate computation', () => {
// amountSek = 1200, but exchangeRate would give 1150
expect(resolveSekAmount(100, 1200, 'EUR', 11.5)).toBe(1200)
})
it('handles zero amount', () => {
expect(resolveSekAmount(0, null, 'EUR', 11.5)).toBe(0)
})
})
describe('buildCurrencyMetadata', () => {
it('returns empty object for SEK', () => {
expect(buildCurrencyMetadata('SEK', 1000, null)).toEqual({})
})
it('returns empty object for null currency', () => {
expect(buildCurrencyMetadata(null, 1000, null)).toEqual({})
})
it('returns empty object for undefined currency', () => {
expect(buildCurrencyMetadata(undefined, 1000, null)).toEqual({})
})
it('returns currency metadata for foreign currency', () => {
expect(buildCurrencyMetadata('EUR', 100, 11.5)).toEqual({
currency: 'EUR',
amount_in_currency: 100,
exchange_rate: 11.5,
})
})
it('omits amount_in_currency when null', () => {
expect(buildCurrencyMetadata('EUR', null, 11.5)).toEqual({
currency: 'EUR',
exchange_rate: 11.5,
})
})
it('omits exchange_rate when null', () => {
expect(buildCurrencyMetadata('EUR', 100, null)).toEqual({
currency: 'EUR',
amount_in_currency: 100,
})
})
it('omits exchange_rate when 0', () => {
expect(buildCurrencyMetadata('EUR', 100, 0)).toEqual({
currency: 'EUR',
amount_in_currency: 100,
})
})
})
+14 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { validateBalance } from '../engine'
import { validateBalance, getSwedishLocalDate } from '../engine'
import type { CreateJournalEntryLineInput } from '@/types'
describe('validateBalance', () => {
@@ -62,3 +62,16 @@ describe('validateBalance', () => {
expect(result.valid).toBe(false)
})
})
describe('getSwedishLocalDate', () => {
it('returns a date string in YYYY-MM-DD format', () => {
const date = getSwedishLocalDate()
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
it('returns a valid date', () => {
const date = getSwedishLocalDate()
const parsed = new Date(date)
expect(parsed.toString()).not.toBe('Invalid Date')
})
})
@@ -39,6 +39,7 @@ const mockedCreateEntry = vi.mocked(createJournalEntry)
// Import functions under test AFTER mocks are set up
const {
createInvoiceJournalEntry,
createInvoicePaymentJournalEntry,
createCreditNoteJournalEntry,
createInvoiceCashEntry,
} = await import('../invoice-entries')
@@ -388,3 +389,188 @@ describe('createInvoiceCashEntry — per-line VAT', () => {
expect(totalDebit).toBe(totalCredit)
})
})
describe('createInvoiceJournalEntry — EUR foreign currency', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('EUR invoice converts amounts to SEK using exchange rate', async () => {
// EUR 1,000 + EUR 250 VAT = EUR 1,250 total, rate 11.5
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
subtotal: 1000,
subtotal_sek: 11500,
vat_amount: 250,
vat_amount_sek: 2875,
total: 1250,
total_sek: 14375,
vat_treatment: 'standard_25',
items: [
makeItem({ line_total: 1000, vat_rate: 25, vat_amount: 250 }),
],
})
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][2]
// All amounts should be in SEK
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(14375) // 1000*11.5 + 250*11.5 = 14375
const credit3001 = input.lines.find((l) => l.account_number === '3001')
expect(credit3001?.credit_amount).toBe(11500) // 1000 * 11.5
const credit2611 = input.lines.find((l) => l.account_number === '2611')
expect(credit2611?.credit_amount).toBe(2875) // 250 * 11.5
// 1510 line should have currency metadata
expect(debit1510?.currency).toBe('EUR')
expect(debit1510?.amount_in_currency).toBe(1250)
expect(debit1510?.exchange_rate).toBe(11.5)
// Balance check
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit)
})
it('EUR invoice uses total_sek when available', async () => {
// Edge case: total_sek differs slightly from computed (e.g. pre-computed at different rate)
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
subtotal: 1000,
subtotal_sek: null,
vat_amount: 0,
vat_amount_sek: null,
total: 1000,
total_sek: null,
vat_treatment: 'export',
items: [
makeItem({ line_total: 1000, vat_rate: 0, vat_amount: 0 }),
],
})
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][2]
// Revenue should be computed via exchange rate
const credit3305 = input.lines.find((l) => l.account_number === '3305')
expect(credit3305?.credit_amount).toBe(11500)
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(11500)
})
it('SEK invoice still works unchanged (backward compatibility)', async () => {
const invoice = makeInvoice({
subtotal: 800,
vat_amount: 200,
total: 1000,
vat_treatment: 'standard_25',
items: [
makeItem({ line_total: 800, vat_rate: 25, vat_amount: 200 }),
],
})
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][2]
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(1000)
// No currency metadata for SEK
expect(debit1510?.currency).toBeUndefined()
expect(debit1510?.amount_in_currency).toBeUndefined()
})
})
describe('createInvoicePaymentJournalEntry — exchange rate difference', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('SEK payment creates simple 2-line entry', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.lines).toHaveLength(2)
const debit1930 = input.lines.find((l) => l.account_number === '1930')
expect(debit1930?.debit_amount).toBe(1250)
const credit1510 = input.lines.find((l) => l.account_number === '1510')
expect(credit1510?.credit_amount).toBe(1250)
})
it('EUR payment with positive exchange rate difference (gain) creates 3 lines', async () => {
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
total_sek: 11500,
})
// Gain of 200 SEK (received more than booked)
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15', 200)
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.lines).toHaveLength(3)
// Debit 1930: actual SEK received = 11500 + 200 = 11700
const debit1930 = input.lines.find((l) => l.account_number === '1930')
expect(debit1930?.debit_amount).toBe(11700)
// Credit 1510: original booked amount
const credit1510 = input.lines.find((l) => l.account_number === '1510')
expect(credit1510?.credit_amount).toBe(11500)
// Credit 3960: exchange rate gain
const credit3960 = input.lines.find((l) => l.account_number === '3960')
expect(credit3960?.credit_amount).toBe(200)
// Balance check
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit)
})
it('EUR payment with negative exchange rate difference (loss) creates 3 lines', async () => {
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
total_sek: 11500,
})
// Loss of 300 SEK (received less than booked)
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15', -300)
const input = mockedCreateEntry.mock.calls[0][2]
expect(input.lines).toHaveLength(3)
// Debit 1930: actual SEK received = 11500 + (-300) = 11200
const debit1930 = input.lines.find((l) => l.account_number === '1930')
expect(debit1930?.debit_amount).toBe(11200)
// Credit 1510: original booked amount
const credit1510 = input.lines.find((l) => l.account_number === '1510')
expect(credit1510?.credit_amount).toBe(11500)
// Debit 7960: exchange rate loss
const debit7960 = input.lines.find((l) => l.account_number === '7960')
expect(debit7960?.debit_amount).toBe(300)
// Balance check
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit)
})
})
@@ -71,7 +71,7 @@ describe('mapping-engine', () => {
})
describe('evaluateMappingRules', () => {
it('returns default result when no rules match', async () => {
it('returns default result when no rules match (expense)', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({ amount: -100, merchant_name: 'Unknown' })
@@ -85,6 +85,206 @@ describe('mapping-engine', () => {
expect(result.requires_review).toBe(true)
})
it('returns VAT-neutral 3900 as default income account (not 3001)', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({ amount: 500, merchant_name: 'Unknown' })
mockResult({ data: [], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('1930')
expect(result.credit_account).toBe('3900')
expect(result.requires_review).toBe(true)
})
it('uses 2893 for default_private with aktiebolag entity type', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({ amount: -500, merchant_name: 'Private Purchase' })
mockResult({
data: [
{
id: 'rule-private',
user_id: null,
rule_name: 'Private fallback',
rule_type: 'merchant_name',
priority: 100,
mcc_codes: null,
merchant_pattern: 'Private',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: null,
credit_account: null,
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: true,
requires_review: false,
confidence_score: 0.8,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'aktiebolag')
expect(result.debit_account).toBe('2893')
expect(result.default_private).toBe(true)
})
it('uses 2013 for default_private with enskild_firma entity type', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({ amount: -500, merchant_name: 'Private Purchase' })
mockResult({
data: [
{
id: 'rule-private',
user_id: null,
rule_name: 'Private fallback',
rule_type: 'merchant_name',
priority: 100,
mcc_codes: null,
merchant_pattern: 'Private',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: null,
credit_account: null,
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: true,
requires_review: false,
confidence_score: 0.8,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('2013')
})
it('applies year-based capitalization threshold from prisbasbelopp', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
// 2024 threshold = 28,650. This amount exceeds it.
const tx = makeTransaction({
amount: -30000,
date: '2024-06-15',
merchant_name: 'Equipment Store',
})
mockResult({
data: [
{
id: 'rule-cap',
user_id: null,
rule_name: 'Equipment',
rule_type: 'merchant_name',
priority: 50,
mcc_codes: null,
merchant_pattern: 'Equipment',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '5410',
credit_account: '1930',
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: false,
requires_review: false,
confidence_score: 0.9,
capitalization_threshold: null,
capitalized_debit_account: '1250',
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// 30,000 > 28,650 (2024 half-PBB) → should capitalize to 1250
expect(result.debit_account).toBe('1250')
})
it('uses 2025 threshold for 2025 transactions', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
// 2025 threshold = 29,400. Amount of 29,000 is below it.
const tx = makeTransaction({
amount: -29000,
date: '2025-03-15',
merchant_name: 'Equipment Store',
})
mockResult({
data: [
{
id: 'rule-cap',
user_id: null,
rule_name: 'Equipment',
rule_type: 'merchant_name',
priority: 50,
mcc_codes: null,
merchant_pattern: 'Equipment',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '5410',
credit_account: '1930',
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: false,
requires_review: false,
confidence_score: 0.9,
capitalization_threshold: null,
capitalized_debit_account: '1250',
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// 29,000 < 29,400 (2025 half-PBB) → should NOT capitalize
expect(result.debit_account).toBe('5410')
})
it('matches merchant_pattern rule', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
+42 -73
View File
@@ -36,6 +36,42 @@ const PRIVATE_ACCOUNTS: Record<EntityType, string> = {
aktiebolag: '2893', // Skuld till aktieägare/delägare
}
// Single source of truth for category -> expense account mapping
const EXPENSE_ACCOUNTS: Record<string, string> = {
expense_equipment: '5410', // Förbrukningsinventarier
expense_software: '5420', // Programvaror
expense_travel: '5800', // Resekostnader
expense_office: '6110', // Kontorsförbrukning
expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster
expense_representation: '6071', // Representation, avdragsgill
expense_consumables: '5460', // Förbrukningsvaror
expense_vehicle: '5611', // Drivmedel bil
expense_telecom: '6200', // Telefon och internet
expense_bank_fees: '6570', // Bankavgifter
expense_card_fees: '6570', // Kortavgifter
expense_currency_exchange: '7960', // Valutakursförluster
expense_other: '6991', // Övriga avdragsgilla kostnader
}
// Income account mapping
const INCOME_ACCOUNTS: Record<string, string> = {
income_services: '3001', // Försäljning tjänster 25%
income_products: '3001', // Försäljning varor 25% moms
income_other: '3900', // Övriga rörelseintäkter
}
/**
* Get the expense account for a category, with entity-specific overrides.
* Education (expense_education) differs: AB uses 7610, EF uses 6991.
*/
function getExpenseAccount(category: string, entityType: EntityType = 'enskild_firma'): string {
if (category === 'expense_education') {
return entityType === 'aktiebolag' ? '7610' : '6991'
}
return EXPENSE_ACCOUNTS[category] || '6991'
}
/**
* Get account mapping for a transaction category
*
@@ -61,36 +97,9 @@ export function getCategoryAccountMapping(
}
}
// Business expense categories
const educationAccount = entityType === 'aktiebolag' ? '7610' : '6991' // Utbildning (AB) / Övriga avdragsgilla kostnader (EF)
const expenseMapping: Record<string, string> = {
expense_equipment: '5410', // Förbrukningsinventarier
expense_software: '5420', // Programvaror
expense_travel: '5800', // Resekostnader
expense_office: '5010', // Lokalhyra
expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster
expense_education: educationAccount,
expense_representation: '6071', // Representation, avdragsgill
expense_consumables: '5460', // Förbrukningsvaror
expense_vehicle: '5611', // Drivmedel bil
expense_telecom: '6200', // Telefon och internet
expense_bank_fees: '6570', // Bankavgifter
expense_card_fees: '6570', // Kortavgifter
expense_currency_exchange: '7960', // Valutakursförluster
expense_other: '6991', // Övriga avdragsgilla kostnader
}
// Business income categories
const incomeMapping: Record<string, string> = {
income_services: '3001', // Försäljning tjänster 25%
income_products: '3001', // Försäljning varor 25% moms
income_other: '3900', // Övriga rörelseintäkter
}
// Check if it's an expense category
if (category.startsWith('expense_')) {
const expenseAccount = expenseMapping[category] || '6991'
const expenseAccount = getExpenseAccount(category, entityType)
// Bank fees, card fees, and currency exchange are VAT-exempt in Sweden
const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange']
@@ -110,7 +119,7 @@ export function getCategoryAccountMapping(
// Check if it's an income category
if (category.startsWith('income_')) {
const incomeAccount = incomeMapping[category] || '3900'
const incomeAccount = INCOME_ACCOUNTS[category] || '3900'
// Use provided vatTreatment, or default to standard_25
const resolvedVat = vatTreatment ?? 'standard_25'
@@ -262,24 +271,8 @@ export function buildMappingResultFromCategory(
* Useful for creating mapping rules
*/
export function getExpenseAccountForCategory(category: TransactionCategory): string | null {
const mapping: Record<string, string> = {
expense_equipment: '5410',
expense_software: '5420',
expense_travel: '5800',
expense_office: '5010',
expense_marketing: '5910',
expense_professional_services: '6530',
expense_education: '6991',
expense_representation: '6071',
expense_consumables: '5460',
expense_vehicle: '5611',
expense_telecom: '6200',
expense_bank_fees: '6570',
expense_card_fees: '6570',
expense_currency_exchange: '7960',
expense_other: '6991',
}
return mapping[category] || null
if (category === 'expense_education') return '6991'
return EXPENSE_ACCOUNTS[category] || null
}
/**
@@ -296,36 +289,12 @@ export function getDefaultAccountForCategory(
return PRIVATE_ACCOUNTS[entityType] || PRIVATE_ACCOUNTS.enskild_firma
}
const expenseMapping: Record<string, string> = {
expense_equipment: '5410',
expense_software: '5420',
expense_travel: '5800',
expense_office: '5010',
expense_marketing: '5910',
expense_professional_services: '6530',
expense_education: entityType === 'aktiebolag' ? '7610' : '6991',
expense_representation: '6071',
expense_consumables: '5460',
expense_vehicle: '5611',
expense_telecom: '6200',
expense_bank_fees: '6570',
expense_card_fees: '6570',
expense_currency_exchange: '7960',
expense_other: '6991',
}
if (category.startsWith('expense_')) {
return expenseMapping[category] || '6991'
}
const incomeMapping: Record<string, string> = {
income_services: '3001',
income_products: '3001',
income_other: '3900',
return getExpenseAccount(category, entityType)
}
if (category.startsWith('income_')) {
return incomeMapping[category] || '3900'
return INCOME_ACCOUNTS[category] || '3900'
}
// uncategorized
+1
View File
@@ -42,6 +42,7 @@ const ACCOUNT_NAMES: Record<string, string> = {
// Other external expenses (6xxx)
'6071': 'Representation',
'6110': 'Kontorsforbrukning',
'6200': 'Telefon & internet',
'6530': 'Redovisningstjanster',
'6570': 'Bankavgifter',
+62
View File
@@ -0,0 +1,62 @@
/**
* Currency conversion helpers for journal entry generators.
*
* All journal entry line amounts (debit_amount / credit_amount) must be in SEK.
* These helpers resolve the correct SEK amount from the various currency fields
* available on invoices, transactions, and supplier invoices.
*/
/**
* Resolve the SEK amount for a journal entry line.
*
* Priority:
* 1. If currency is SEK → return amount as-is
* 2. If amountSek is populated → return it (pre-computed SEK value)
* 3. If exchangeRate is available → compute amount * exchangeRate
* 4. Fallback → return amount (legacy data safety — assumes SEK)
*/
export function resolveSekAmount(
amount: number,
amountSek: number | null | undefined,
currency: string | null | undefined,
exchangeRate: number | null | undefined
): number {
if (!currency || currency === 'SEK') {
return amount
}
if (amountSek != null) {
return Math.round(amountSek * 100) / 100
}
if (exchangeRate != null && exchangeRate > 0) {
return Math.round(amount * exchangeRate * 100) / 100
}
// Fallback: legacy data without conversion info — return original amount
return amount
}
/**
* Build currency metadata fields for a journal entry line.
* Returns an empty object for SEK transactions (no metadata needed).
*/
export function buildCurrencyMetadata(
currency: string | null | undefined,
amountInCurrency: number | null | undefined,
exchangeRate: number | null | undefined
): {
currency?: string
amount_in_currency?: number
exchange_rate?: number
} {
if (!currency || currency === 'SEK') {
return {}
}
return {
...(currency ? { currency } : {}),
...(amountInCurrency != null ? { amount_in_currency: amountInCurrency } : {}),
...(exchangeRate != null && exchangeRate > 0 ? { exchange_rate: exchangeRate } : {}),
}
}
+16 -85
View File
@@ -270,96 +270,24 @@ export async function commitEntry(
/**
* Create a journal entry with lines (verifikation)
* Convenience wrapper: creates draft + commits in one step.
* Validates balance, resolves account IDs, assigns voucher number, inserts atomically.
* The voucher number is only assigned after lines are successfully inserted,
* preventing gaps in the voucher sequence (BFL 5 kap. 7§).
*/
export async function createJournalEntry(
supabase: SupabaseClient,
userId: string,
input: CreateJournalEntryInput
): Promise<JournalEntry> {
// Validate balance
const balance = validateBalance(input.lines)
if (!balance.valid) {
throw new Error(
`Journal entry is not balanced: debits (${balance.totalDebit}) != credits (${balance.totalCredit})`
)
}
const draft = await createDraftEntry(supabase, userId, input)
return commitEntry(supabase, userId, draft.id)
}
// Resolve account IDs
const accountIdMap = await resolveAccountIds(supabase, userId, input.lines)
// Get next voucher number
const voucherNumber = await getNextVoucherNumber(
supabase,
userId,
input.fiscal_period_id,
input.voucher_series || 'A'
)
// Insert journal entry header
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.insert({
user_id: userId,
fiscal_period_id: input.fiscal_period_id,
voucher_number: voucherNumber,
voucher_series: input.voucher_series || 'A',
entry_date: input.entry_date,
description: input.description,
source_type: input.source_type,
source_id: input.source_id || null,
status: 'draft',
})
.select()
.single()
if (entryError || !entry) {
throw new Error(`Failed to create journal entry: ${entryError?.message}`)
}
// Insert journal entry lines with dimensions
const lineInserts = buildLineInserts(entry.id, input.lines, accountIdMap)
const { error: linesError } = await supabase
.from('journal_entry_lines')
.insert(lineInserts)
if (linesError) {
// Rollback entry
await supabase.from('journal_entries').delete().eq('id', entry.id)
throw new Error(`Failed to create journal entry lines: ${linesError.message}`)
}
// Post the entry (triggers balance validation + committed_at in DB)
const { data: postedEntry, error: postError } = await supabase
.from('journal_entries')
.update({ status: 'posted' })
.eq('id', entry.id)
.select()
.single()
if (postError) {
// Rollback
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', entry.id)
await supabase.from('journal_entries').delete().eq('id', entry.id)
throw new Error(`Failed to post journal entry: ${postError.message}`)
}
// Fetch complete entry with lines
const { data: completeEntry } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', entry.id)
.single()
const result = completeEntry as JournalEntry
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: result, userId },
})
return result
/**
* Get the current date in Swedish timezone (Europe/Stockholm).
* Avoids UTC date shift when server runs in a different timezone.
*/
export function getSwedishLocalDate(): string {
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Stockholm' }).format(new Date())
}
/**
@@ -369,7 +297,8 @@ export async function createJournalEntry(
export async function reverseEntry(
supabase: SupabaseClient,
userId: string,
entryId: string
entryId: string,
reversalDate?: string
): Promise<JournalEntry> {
// Fetch original entry with lines
@@ -406,6 +335,8 @@ export async function reverseEntry(
project: line.project || undefined,
}))
const entryDate = reversalDate || getSwedishLocalDate()
// Get voucher number for the reversal
const voucherNumber = await getNextVoucherNumber(
supabase,
@@ -425,7 +356,7 @@ export async function reverseEntry(
fiscal_period_id: original.fiscal_period_id,
voucher_number: voucherNumber,
voucher_series: original.voucher_series || 'A',
entry_date: new Date().toISOString().split('T')[0],
entry_date: entryDate,
description: `Makulering: ${original.description}`,
source_type: 'storno',
source_id: original.source_id || null,
+184 -68
View File
@@ -1,4 +1,5 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { createLogger } from '@/lib/logger'
@@ -23,9 +24,21 @@ function generatePerRateLines(
items: InvoiceItem[],
invoiceVatTreatment: VatTreatment,
entityType: EntityType,
invoiceNumber: string
invoiceNumber: string,
currency?: string | null,
exchangeRate?: number | null
): CreateJournalEntryLineInput[] {
const lines: CreateJournalEntryLineInput[] = []
const isForeign = currency != null && currency !== 'SEK'
// Helper: convert item amount to SEK when dealing with foreign currency
const toSek = (amount: number): number => {
if (!isForeign) return amount
if (exchangeRate != null && exchangeRate > 0) {
return Math.round(amount * exchangeRate * 100) / 100
}
return amount // fallback for legacy data
}
// Check if items have per-line vat_rate set (new invoices)
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
@@ -34,21 +47,34 @@ function generatePerRateLines(
// Legacy fallback: single rate from invoice level
const revenueAccount = getRevenueAccount(invoiceVatTreatment, entityType)
const subtotal = items.reduce((sum, item) => sum + item.line_total, 0)
const subtotalSek = toSek(subtotal)
lines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: subtotal,
credit_amount: subtotalSek,
line_description: `Försäljning faktura ${invoiceNumber}`,
})
const totalVat = items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
if (totalVat > 0) {
const vatLines = generateSalesVatLines({
vatTreatment: invoiceVatTreatment,
baseAmount: subtotal,
direction: 'sales',
})
lines.push(...vatLines)
if (isForeign) {
// For foreign currency, compute VAT in SEK directly
const vatSek = toSek(totalVat)
const vatAccount = getOutputVatAccount(invoiceVatTreatment)
lines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: vatSek,
line_description: `Utgående moms`,
})
} else {
const vatLines = generateSalesVatLines({
vatTreatment: invoiceVatTreatment,
baseAmount: subtotal,
direction: 'sales',
})
lines.push(...vatLines)
}
}
return lines
}
@@ -69,7 +95,7 @@ function generatePerRateLines(
? invoiceVatTreatment
: getVatTreatmentForRate(rate)
const revenueAccount = getRevenueAccount(treatment, entityType)
const roundedSubtotal = Math.round(group.subtotal * 100) / 100
const roundedSubtotal = Math.round(toSek(group.subtotal) * 100) / 100
lines.push({
account_number: revenueAccount,
@@ -78,7 +104,7 @@ function generatePerRateLines(
line_description: `Försäljning faktura ${invoiceNumber}`,
})
const roundedVat = Math.round(group.vatAmount * 100) / 100
const roundedVat = Math.round(toSek(group.vatAmount) * 100) / 100
if (roundedVat !== 0) {
const vatAccount = getOutputVatAccount(treatment)
lines.push({
@@ -125,41 +151,65 @@ export async function createInvoiceJournalEntry(
}
const lines: CreateJournalEntryLineInput[] = []
const isForeign = invoice.currency !== 'SEK'
// Debit: Kundfordringar (total including VAT)
lines.push({
account_number: '1510',
debit_amount: invoice.total,
credit_amount: 0,
line_description: `Faktura ${invoice.invoice_number}`,
currency: invoice.currency,
amount_in_currency: invoice.currency !== 'SEK' ? invoice.total : undefined,
exchange_rate: invoice.exchange_rate || undefined,
})
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
const creditLines: CreateJournalEntryLineInput[] = []
// Credit lines: revenue + VAT per rate group
if (invoice.items && invoice.items.length > 0) {
lines.push(...generatePerRateLines(invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number))
creditLines.push(...generatePerRateLines(
invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number,
invoice.currency, invoice.exchange_rate
))
} else {
// Fallback: no items available, use invoice-level amounts
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
lines.push({
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
creditLines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: invoice.subtotal,
credit_amount: subtotalSek,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
})
if (invoice.vat_amount > 0) {
const vatLines = generateSalesVatLines({
vatTreatment: invoice.vat_treatment,
baseAmount: invoice.subtotal,
direction: 'sales',
})
lines.push(...vatLines)
if (isForeign) {
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
creditLines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: vatSek,
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
})
} else {
const vatLines = generateSalesVatLines({
vatTreatment: invoice.vat_treatment,
baseAmount: invoice.subtotal,
direction: 'sales',
})
creditLines.push(...vatLines)
}
}
}
// Debit: Kundfordringar — balance guarantee: debit = sum of all credit lines
const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount, 0)
const debitAmount = isForeign
? Math.round(totalCredits * 100) / 100
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
lines.push({
account_number: '1510',
debit_amount: debitAmount,
credit_amount: 0,
line_description: `Faktura ${invoice.invoice_number}`,
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
})
lines.push(...creditLines)
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
@@ -182,7 +232,8 @@ export async function createInvoicePaymentJournalEntry(
supabase: SupabaseClient,
userId: string,
invoice: Invoice,
paymentDate: string
paymentDate: string,
exchangeRateDifference?: number
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
if (!fiscalPeriodId) {
@@ -190,25 +241,71 @@ export async function createInvoicePaymentJournalEntry(
return null
}
const lines: CreateJournalEntryLineInput[] = [
{
account_number: '1930', // Företagskonto
debit_amount: invoice.total,
const desc = `Betalning faktura ${invoice.invoice_number}`
const bookedSekAmount = resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
const lines: CreateJournalEntryLineInput[] = []
if (exchangeRateDifference && exchangeRateDifference !== 0) {
// Foreign currency with exchange rate difference
// For receivables: positive diff = gain (received more), negative = loss (received less)
const actualSekReceived = bookedSekAmount + exchangeRateDifference
// Debit: Bank at actual SEK received
lines.push({
account_number: '1930',
debit_amount: Math.round(actualSekReceived * 100) / 100,
credit_amount: 0,
line_description: `Betalning faktura ${invoice.invoice_number}`,
},
{
account_number: '1510', // Kundfordringar
line_description: desc,
})
// Credit: Clear kundfordringar at original booked SEK amount
lines.push({
account_number: '1510',
debit_amount: 0,
credit_amount: invoice.total,
line_description: `Betalning faktura ${invoice.invoice_number}`,
},
]
credit_amount: Math.round(bookedSekAmount * 100) / 100,
line_description: desc,
})
// Exchange rate difference
if (exchangeRateDifference > 0) {
// Gain: Credit 3960 (received more than booked)
lines.push({
account_number: '3960',
debit_amount: 0,
credit_amount: Math.round(exchangeRateDifference * 100) / 100,
line_description: 'Valutakursvinst',
})
} else {
// Loss: Debit 7960 (received less than booked)
lines.push({
account_number: '7960',
debit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
credit_amount: 0,
line_description: 'Valutakursförlust',
})
}
} else {
// Standard SEK payment or no exchange rate difference
lines.push(
{
account_number: '1930',
debit_amount: Math.round(bookedSekAmount * 100) / 100,
credit_amount: 0,
line_description: desc,
},
{
account_number: '1510',
debit_amount: 0,
credit_amount: Math.round(bookedSekAmount * 100) / 100,
line_description: desc,
}
)
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: `Betalning faktura ${invoice.invoice_number}`,
description: desc,
source_type: 'invoice_paid',
source_id: invoice.id,
lines,
@@ -237,15 +334,19 @@ export async function createCreditNoteJournalEntry(
return null
}
const absTotal = Math.abs(creditNote.total)
const lines: CreateJournalEntryLineInput[] = []
// Generate reversed revenue + VAT lines per rate group
// Generate reversed revenue + VAT lines per rate group (debit side for credit notes)
const debitLines: CreateJournalEntryLineInput[] = []
if (creditNote.items && creditNote.items.length > 0) {
const creditLines = generatePerRateLines(creditNote.items, creditNote.vat_treatment, entityType, creditNote.invoice_number)
// Swap debit/credit for credit note reversal (make amounts absolute first)
// Use absolute items for generatePerRateLines, then swap debit/credit
const creditLines = generatePerRateLines(
creditNote.items, creditNote.vat_treatment, entityType, creditNote.invoice_number,
creditNote.currency, creditNote.exchange_rate
)
for (const line of creditLines) {
lines.push({
debitLines.push({
...line,
debit_amount: Math.abs(line.credit_amount),
credit_amount: Math.abs(line.debit_amount),
@@ -255,10 +356,10 @@ export async function createCreditNoteJournalEntry(
} else {
// Fallback: invoice-level amounts
const revenueAccount = getRevenueAccount(creditNote.vat_treatment, entityType)
const absSubtotal = Math.abs(creditNote.subtotal)
const absVat = Math.abs(creditNote.vat_amount)
const absSubtotal = Math.abs(resolveSekAmount(creditNote.subtotal, creditNote.subtotal_sek, creditNote.currency, creditNote.exchange_rate))
const absVat = Math.abs(resolveSekAmount(creditNote.vat_amount, creditNote.vat_amount_sek, creditNote.currency, creditNote.exchange_rate))
lines.push({
debitLines.push({
account_number: revenueAccount,
debit_amount: absSubtotal,
credit_amount: 0,
@@ -267,7 +368,7 @@ export async function createCreditNoteJournalEntry(
if (absVat > 0) {
const vatAccount = getOutputVatAccount(creditNote.vat_treatment)
lines.push({
debitLines.push({
account_number: vatAccount,
debit_amount: absVat,
credit_amount: 0,
@@ -276,11 +377,14 @@ export async function createCreditNoteJournalEntry(
}
}
// Credit: Kundfordringar (reverse the debit)
lines.push(...debitLines)
// Credit: Kundfordringar — balance guarantee: credit = sum of all debit lines
const totalDebits = debitLines.reduce((sum, l) => sum + l.debit_amount, 0)
lines.push({
account_number: '1510',
debit_amount: 0,
credit_amount: absTotal,
credit_amount: Math.round(totalDebits * 100) / 100,
line_description: `Kreditfaktura ${creditNote.invoice_number}`,
})
@@ -318,39 +422,51 @@ export async function createInvoiceCashEntry(
}
const lines: CreateJournalEntryLineInput[] = []
const isForeign = invoice.currency !== 'SEK'
// Debit: Företagskonto (total received)
lines.push({
account_number: '1930',
debit_amount: invoice.total,
credit_amount: 0,
line_description: `Betalning faktura ${invoice.invoice_number}`,
})
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
const creditLines: CreateJournalEntryLineInput[] = []
// Credit lines: revenue + VAT per rate group
if (invoice.items && invoice.items.length > 0) {
lines.push(...generatePerRateLines(invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number))
creditLines.push(...generatePerRateLines(
invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number,
invoice.currency, invoice.exchange_rate
))
} else {
// Fallback: invoice-level amounts
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
lines.push({
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
creditLines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: invoice.subtotal,
credit_amount: subtotalSek,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
})
if (invoice.vat_amount > 0) {
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
lines.push({
creditLines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: invoice.vat_amount,
credit_amount: vatSek,
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
})
}
}
// Debit: Företagskonto — balance guarantee: debit = sum of credit lines
const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: '1930',
debit_amount: isForeign ? Math.round(totalCredits * 100) / 100 : resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate),
credit_amount: 0,
line_description: `Betalning faktura ${invoice.invoice_number}`,
})
lines.push(...creditLines)
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
+26 -13
View File
@@ -14,9 +14,24 @@ import type {
EntityType,
VatJournalLine,
} from '@/types'
import { createLogger } from '@/lib/logger'
// Capitalization threshold in SEK (half-year rule: 29,400 for 2024)
const CAPITALIZATION_THRESHOLD = 29400
const log = createLogger('mapping-engine')
// Half of prisbasbelopp per year (used for capitalization threshold)
const PRISBASBELOPP_HALVES: Record<number, number> = {
2024: 28650, // PBB 57,300
2025: 29400, // PBB 58,800
2026: 29600, // PBB 59,200
}
const LATEST_KNOWN_YEAR = 2026
function getCapitalizationThreshold(year: number): number {
const threshold = PRISBASBELOPP_HALVES[year]
if (threshold) return threshold
log.warn(`No prisbasbelopp for ${year}, using ${LATEST_KNOWN_YEAR} value`)
return PRISBASBELOPP_HALVES[LATEST_KNOWN_YEAR]
}
/**
* Evaluate all mapping rules against a transaction and return the best match
@@ -52,7 +67,7 @@ export async function evaluateMappingRules(
// Evaluate each rule in priority order
for (const rule of rules as MappingRule[]) {
if (matchesRule(rule, transaction)) {
return buildResult(rule, transaction)
return buildResult(rule, transaction, entityType)
}
}
@@ -141,25 +156,23 @@ function matchesRule(rule: MappingRule, transaction: Transaction): boolean {
/**
* Build a MappingResult from a matched rule
*/
function buildResult(rule: MappingRule, transaction: Transaction): MappingResult {
function buildResult(rule: MappingRule, transaction: Transaction, entityType?: EntityType): MappingResult {
const absAmount = Math.abs(transaction.amount)
const isExpense = transaction.amount < 0
let debitAccount = rule.debit_account || (isExpense ? '6991' : '1930')
let creditAccount = rule.credit_account || (isExpense ? '1930' : '3001')
const creditAccount = rule.credit_account || (isExpense ? '1930' : '3900')
// Check capitalization threshold for equipment
if (
rule.capitalization_threshold &&
absAmount > rule.capitalization_threshold &&
rule.capitalized_debit_account
) {
const year = new Date(transaction.date).getFullYear()
const threshold = rule.capitalization_threshold ?? getCapitalizationThreshold(year)
if (absAmount > threshold && rule.capitalized_debit_account) {
debitAccount = rule.capitalized_debit_account
}
// If default_private, override to 2013
// If default_private, use entity-specific private account
if (rule.default_private && isExpense) {
debitAccount = '2013'
debitAccount = entityType === 'aktiebolag' ? '2893' : '2013'
}
// Generate VAT lines if applicable
@@ -215,7 +228,7 @@ function getDefaultResult(transaction: Transaction): MappingResult {
return {
rule: null,
debit_account: isExpense ? '6991' : '1930',
credit_account: isExpense ? '1930' : '3001',
credit_account: isExpense ? '1930' : '3900',
risk_level: 'MEDIUM',
confidence: 0.1,
requires_review: true,
+64 -46
View File
@@ -1,4 +1,5 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { generateReverseChargeLines } from './vat-entries'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
@@ -41,48 +42,53 @@ export async function createSupplierInvoiceRegistrationEntry(
const lines: CreateJournalEntryLineInput[] = []
const desc = `Lev.faktura ${invoice.supplier_invoice_number} (ankomst ${invoice.arrival_number})`
const isForeign = invoice.currency !== 'SEK'
// Aggregate expense amounts by account number
// Aggregate expense amounts by account number and convert to SEK
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
expenseByAccount.set(item.account_number, current + item.line_total)
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
expenseByAccount.set(item.account_number, current + itemSek)
}
// Debit: Expense accounts
// Debit: Expense accounts (in SEK)
const debitLines: CreateJournalEntryLineInput[] = []
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
debitLines.push({
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
}
lines.push(...debitLines)
if (supplierType === 'eu_business' && invoice.reverse_charge) {
// EU reverse charge: fiktiv moms entries
// EU reverse charge: fiktiv moms entries (computed on SEK subtotal)
const vatRate = getDefaultVatRate(invoice.vat_treatment)
const reverseChargeLines = generateReverseChargeLines(invoice.subtotal, vatRate)
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
const reverseChargeLines = generateReverseChargeLines(subtotalSek, vatRate)
lines.push(...reverseChargeLines)
} else if (invoice.vat_amount > 0) {
// Domestic: Debit ingående moms
// Domestic: Debit ingående moms (in SEK)
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
lines.push({
account_number: '2641',
debit_amount: Math.round(invoice.vat_amount * 100) / 100,
debit_amount: Math.round(vatSek * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${desc}`,
})
}
// Credit: Leverantörsskulder
// Credit: Leverantörsskulder — balance guarantee: credit = sum of all debit lines
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
lines.push({
account_number: '2440',
debit_amount: 0,
credit_amount: Math.round(invoice.total * 100) / 100,
credit_amount: Math.round(totalDebits * 100) / 100,
line_description: desc,
currency: invoice.currency !== 'SEK' ? invoice.currency : undefined,
amount_in_currency: invoice.currency !== 'SEK' ? invoice.total : undefined,
exchange_rate: invoice.exchange_rate || undefined,
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
})
const input: CreateJournalEntryInput = {
@@ -218,14 +224,15 @@ export async function createSupplierInvoiceCashEntry(
const desc = `Betalning lev.faktura ${invoice.supplier_invoice_number} (kontantmetoden)`
const lines: CreateJournalEntryLineInput[] = []
// Aggregate expense amounts by account number
// Aggregate expense amounts by account number and convert to SEK
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
expenseByAccount.set(item.account_number, current + item.line_total)
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
expenseByAccount.set(item.account_number, current + itemSek)
}
// Debit: Expense accounts
// Debit: Expense accounts (in SEK)
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
account_number: accountNumber,
@@ -236,25 +243,28 @@ export async function createSupplierInvoiceCashEntry(
}
if (supplierType === 'eu_business' && invoice.reverse_charge) {
// EU reverse charge: fiktiv moms entries
// EU reverse charge: fiktiv moms entries (computed on SEK subtotal)
const vatRate = getDefaultVatRate(invoice.vat_treatment)
const reverseChargeLines = generateReverseChargeLines(invoice.subtotal, vatRate)
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
const reverseChargeLines = generateReverseChargeLines(subtotalSek, vatRate)
lines.push(...reverseChargeLines)
} else if (invoice.vat_amount > 0) {
// Domestic: Debit ingående moms
// Domestic: Debit ingående moms (in SEK)
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
lines.push({
account_number: '2641',
debit_amount: Math.round(invoice.vat_amount * 100) / 100,
debit_amount: Math.round(vatSek * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${desc}`,
})
}
// Credit: Företagskonto
// Credit: Företagskonto — balance guarantee: credit = sum of all debit lines
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
lines.push({
account_number: '1930',
debit_amount: 0,
credit_amount: Math.round(invoice.total * 100) / 100,
credit_amount: Math.round(totalDebits * 100) / 100,
line_description: desc,
})
@@ -293,26 +303,17 @@ export async function createSupplierCreditNoteEntry(
const desc = `Kreditfaktura lev. ${creditNote.supplier_invoice_number} (ankomst ${creditNote.arrival_number})`
const lines: CreateJournalEntryLineInput[] = []
const absTotal = Math.abs(creditNote.total)
const absVat = Math.abs(creditNote.vat_amount)
// Debit: Leverantörsskulder
lines.push({
account_number: '2440',
debit_amount: Math.round(absTotal * 100) / 100,
credit_amount: 0,
line_description: desc,
})
// Credit: Expense accounts (reverse)
// Credit: Expense accounts (reverse, in SEK)
const creditLines: CreateJournalEntryLineInput[] = []
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
expenseByAccount.set(item.account_number, current + Math.abs(item.line_total))
const itemSek = Math.abs(resolveSekAmount(item.line_total, null, creditNote.currency, creditNote.exchange_rate))
expenseByAccount.set(item.account_number, current + itemSek)
}
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
creditLines.push({
account_number: accountNumber,
debit_amount: 0,
credit_amount: Math.round(amount * 100) / 100,
@@ -323,29 +324,46 @@ export async function createSupplierCreditNoteEntry(
if (supplierType === 'eu_business' && creditNote.reverse_charge) {
// Reverse the fiktiv moms (swap debit/credit from registration)
const vatRate = getDefaultVatRate(creditNote.vat_treatment)
const vatAmount = Math.round(Math.abs(creditNote.subtotal) * vatRate * 100) / 100
lines.push({
const absSubtotalSek = Math.abs(resolveSekAmount(creditNote.subtotal, creditNote.subtotal_sek, creditNote.currency, creditNote.exchange_rate))
const vatAmount = Math.round(absSubtotalSek * vatRate * 100) / 100
creditLines.push({
account_number: '2645',
debit_amount: 0,
credit_amount: vatAmount,
line_description: `Omvänd fiktiv ingående moms ${desc}`,
})
// 2614 is a debit (reversal of the output VAT credit)
lines.push({
account_number: '2614',
debit_amount: vatAmount,
credit_amount: 0,
line_description: `Omvänd fiktiv utgående moms ${desc}`,
})
} else if (absVat > 0) {
// Credit: Ingående moms (reverse)
lines.push({
account_number: '2641',
debit_amount: 0,
credit_amount: Math.round(absVat * 100) / 100,
line_description: `Ingående moms ${desc}`,
})
} else {
const absVat = Math.abs(resolveSekAmount(creditNote.vat_amount, creditNote.vat_amount_sek, creditNote.currency, creditNote.exchange_rate))
if (absVat > 0) {
// Credit: Ingående moms (reverse)
creditLines.push({
account_number: '2641',
debit_amount: 0,
credit_amount: Math.round(absVat * 100) / 100,
line_description: `Ingående moms ${desc}`,
})
}
}
lines.push(...creditLines)
// Debit: Leverantörsskulder — balance guarantee: debit = sum of credits minus other debits
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
lines.unshift({
account_number: '2440',
debit_amount: Math.round((totalCredits - totalDebits) * 100) / 100,
credit_amount: 0,
line_description: desc,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: creditNote.invoice_date,
+12 -1
View File
@@ -1,4 +1,5 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { generateInputVatLine, generateReverseChargeLines, extractNetAmount, extractVatAmount } from './vat-entries'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
@@ -56,8 +57,17 @@ export async function createTransactionJournalEntry(
return null
}
const absAmount = Math.abs(transaction.amount)
const absAmountSek = Math.abs(resolveSekAmount(
transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate
))
const absAmount = absAmountSek
const isExpense = transaction.amount < 0
const isForeign = transaction.currency !== 'SEK'
const currencyMeta = buildCurrencyMetadata(
transaction.currency,
isForeign ? Math.abs(transaction.amount) : undefined,
transaction.exchange_rate
)
const lines: CreateJournalEntryLineInput[] = []
if (mappingResult.default_private) {
@@ -121,6 +131,7 @@ export async function createTransactionJournalEntry(
debit_amount: 0,
credit_amount: absAmount,
line_description: transaction.description,
...(creditAccount === '1930' ? currencyMeta : {}),
})
} else {
// Income
@@ -29,6 +29,7 @@ function makeClient() {
vi.mock('@/lib/bookkeeping/engine', () => ({
validateBalance: vi.fn().mockReturnValue({ valid: true, totalDebit: 1000, totalCredit: 1000 }),
getNextVoucherNumber: vi.fn(async () => ++resultIdx), // just increment
getSwedishLocalDate: vi.fn().mockReturnValue('2024-06-15'),
}))
import { correctEntry } from '../storno-service'
+3 -3
View File
@@ -5,7 +5,7 @@ import type {
JournalEntry,
JournalEntryLine,
} from '@/types'
import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
import { validateBalance, getNextVoucherNumber, getSwedishLocalDate } from '@/lib/bookkeeping/engine'
/**
* Storno Service - 3-step correction flow per Bokföringslagen
@@ -69,7 +69,7 @@ export async function correctEntry(
fiscal_period_id: original.fiscal_period_id,
voucher_number: reversalVoucherNumber,
voucher_series: original.voucher_series || 'A',
entry_date: new Date().toISOString().split('T')[0],
entry_date: getSwedishLocalDate(),
description: `Storno: ${original.description}`,
source_type: 'storno',
reverses_id: originalEntryId,
@@ -172,7 +172,7 @@ export async function correctEntry(
fiscal_period_id: original.fiscal_period_id,
voucher_number: correctedVoucherNumber,
voucher_series: original.voucher_series || 'A',
entry_date: new Date().toISOString().split('T')[0],
entry_date: getSwedishLocalDate(),
description: `Rättelse: ${original.description}`,
source_type: 'correction',
correction_of_id: originalEntryId,
@@ -86,7 +86,13 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"dataPattern": "manual",
"description": "Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost",
"longDescription": "Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument.",
"hasOwnData": true
"hasOwnData": true,
"quickAction": {
"label": "Dokumentinkorg",
"description": "Granska inkommande dokument",
"icon": "Inbox",
"href": "/e/general/invoice-inbox"
}
},
{
"slug": "calendar",
+28 -2
View File
@@ -4,8 +4,9 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
import { fetchMultipleRates } from '@/lib/currency/riksbanken'
import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation'
import type { Transaction, RawTransaction, IngestResult, SupplierInvoice } from '@/types'
import type { Transaction, RawTransaction, IngestResult, SupplierInvoice, Currency, ExchangeRate } from '@/types'
// Re-export types for backward compatibility
export type { RawTransaction, IngestResult } from '@/types'
@@ -108,6 +109,21 @@ export async function ingestTransactions(
// Non-critical — supplier invoice matching will be skipped
}
// Pre-fetch exchange rates for non-SEK currencies (non-critical)
let exchangeRates = new Map<Currency, ExchangeRate>()
try {
const uniqueCurrencies = [...new Set(
rawTransactions
.map(t => t.currency)
.filter((c): c is Currency => c != null && c !== 'SEK')
)]
if (uniqueCurrencies.length > 0) {
exchangeRates = await fetchMultipleRates(uniqueCurrencies)
}
} catch {
// Non-critical — amount_sek fields will stay null
}
for (const raw of rawTransactions) {
// 1. Check for duplicates via external_id
const { data: existing } = await supabase
@@ -132,7 +148,14 @@ export async function ingestTransactions(
continue
}
// 2. Insert new transaction
// 2. Insert new transaction (with SEK conversion for foreign currencies)
const rateInfo = raw.currency && raw.currency !== 'SEK'
? exchangeRates.get(raw.currency as Currency)
: undefined
const amountSek = rateInfo
? Math.round(raw.amount * rateInfo.rate * 100) / 100
: null
const { data: newTransaction, error: insertError } = await supabase
.from('transactions')
.insert({
@@ -143,6 +166,9 @@ export async function ingestTransactions(
description: raw.description,
amount: raw.amount,
currency: raw.currency,
amount_sek: amountSek,
exchange_rate: rateInfo?.rate ?? null,
exchange_rate_date: rateInfo?.date ?? null,
category: 'uncategorized',
is_business: null,
mcc_code: raw.mcc_code || null,