@@ -433,7 +434,7 @@ export function BankReconciliationView() {
{tx.date} |
{tx.description} |
= 0 ? 'text-green-600' : ''}`}>
- {formatAmount(tx.amount)} kr
+ {formatCurrency(tx.amount)}
|
{tx.reference || '—'} |
@@ -449,7 +450,7 @@ export function BankReconciliationView() {
const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
return (
)
})}
@@ -506,7 +507,7 @@ export function BankReconciliationView() {
{line.line_description || line.entry_description}
|
= 0 ? 'text-green-600' : ''}`}>
- {formatAmount(amount)} kr
+ {formatCurrency(amount)}
|
{line.source_type} |
@@ -554,7 +555,7 @@ export function BankReconciliationView() {
{tx.date} |
{tx.description} |
= 0 ? 'text-green-600' : ''}`}>
- {formatAmount(tx.amount)} kr
+ {formatCurrency(tx.amount)}
|
{tx.reconciliation_method && (
diff --git a/components/reports/IncomeExpenseChart.tsx b/components/reports/IncomeExpenseChart.tsx
index 72b58d88..c4ab81ca 100644
--- a/components/reports/IncomeExpenseChart.tsx
+++ b/components/reports/IncomeExpenseChart.tsx
@@ -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) {
/>
[
- new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
+ formatCurrency(Number(value)),
name === 'income' ? 'Intäkter' : 'Kostnader',
]}
/>
diff --git a/components/reports/NEDeclarationView.tsx b/components/reports/NEDeclarationView.tsx
index eaa8140a..dbb9b779 100644
--- a/components/reports/NEDeclarationView.tsx
+++ b/components/reports/NEDeclarationView.tsx
@@ -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(null)
@@ -156,9 +153,9 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
| Summa intäkter |
- {formatAmount(
+ {formatCurrency(
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
- )} kr
+ )}
|
@@ -193,10 +190,10 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
| Summa kostnader |
- -{formatAmount(
+ -{formatCurrency(
data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
data.rutor.R8 + data.rutor.R9 + data.rutor.R10
- )} kr
+ )}
|
@@ -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)}
@@ -270,7 +267,7 @@ function NEDeclarationRow({
)}
|
- {isExpense && amount > 0 ? '-' : ''}{formatAmount(Math.abs(amount))} kr
+ {isExpense && amount > 0 ? '-' : ''}{formatCurrency(Math.abs(amount))}
|
{expanded && accounts.length > 0 && (
@@ -283,7 +280,7 @@ function NEDeclarationRow({
|
{acc.accountName} |
- {isExpense && acc.amount > 0 ? '-' : ''}{formatAmount(Math.abs(acc.amount))} kr
+ {isExpense && acc.amount > 0 ? '-' : ''}{formatCurrency(Math.abs(acc.amount))}
|
))}
diff --git a/components/reports/SRUExportView.tsx b/components/reports/SRUExportView.tsx
index 88d373f0..e249d903 100644
--- a/components/reports/SRUExportView.tsx
+++ b/components/reports/SRUExportView.tsx
@@ -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
(null)
@@ -221,7 +218,7 @@ function SRUBalanceRow({
)}
- {formatAmount(balance.amount)} kr |
+ {formatCurrency(balance.amount)} |
{expanded && balance.accounts.length > 0 && (
@@ -232,7 +229,7 @@ function SRUBalanceRow({
|
{acc.accountName} |
- {formatAmount(acc.amount)} kr |
+ {formatCurrency(acc.amount)} |
))}
diff --git a/components/reports/TrialBalanceChart.tsx b/components/reports/TrialBalanceChart.tsx
index 045fe731..45f46d98 100644
--- a/components/reports/TrialBalanceChart.tsx
+++ b/components/reports/TrialBalanceChart.tsx
@@ -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) {
/>
[
- 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)}
diff --git a/components/reports/VatCompositionChart.tsx b/components/reports/VatCompositionChart.tsx
index fcff2727..3b3e2c70 100644
--- a/components/reports/VatCompositionChart.tsx
+++ b/components/reports/VatCompositionChart.tsx
@@ -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) {
[
- new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
+ formatCurrency(Number(value)),
]}
/>
diff --git a/components/suppliers/SupplierInvoiceReviewContent.tsx b/components/suppliers/SupplierInvoiceReviewContent.tsx
index 1dc7e3c0..db8c6471 100644
--- a/components/suppliers/SupplierInvoiceReviewContent.tsx
+++ b/components/suppliers/SupplierInvoiceReviewContent.tsx
@@ -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({
Netto (exkl. moms)
- {formatAmount(subtotal)} kr
+ {formatCurrency(subtotal, currency)}
Moms
- {formatAmount(totalVat)} kr
+ {formatCurrency(totalVat, currency)}
Totalt
- {formatAmount(total)} kr
+ {formatCurrency(total, currency)}
{currency !== 'SEK' && exchangeRate && (
SEK-belopp (vid kurs {exchangeRate})
- {formatAmount(total * parseFloat(exchangeRate))} kr
+ {formatCurrency(total * parseFloat(exchangeRate))}
)}
diff --git a/extensions/general/invoice-inbox/api-routes.ts b/extensions/general/invoice-inbox/api-routes.ts
index ee34c415..e069e9e5 100644
--- a/extensions/general/invoice-inbox/api-routes.ts
+++ b/extensions/general/invoice-inbox/api-routes.ts
@@ -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 = {
- status: 'ready',
- extracted_data: extraction as unknown as Record,
- 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>,
+ itemId: string,
+ userId: string,
+ extraction: import('@/types').ReceiptExtractionResult
+): Promise {
+ 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,
+ 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,
+ 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>,
+ itemId: string,
+ userId: string,
+ extraction: InvoiceExtractionResult
+): Promise {
+ 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 = {
+ status: 'ready',
+ document_type: 'supplier_invoice',
+ extracted_data: extraction as unknown as Record,
+ 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 = {
- status: 'ready',
- extracted_data: extraction as unknown as Record,
- 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'
diff --git a/extensions/general/invoice-inbox/manifest.json b/extensions/general/invoice-inbox/manifest.json
index d18859b5..26bcc2aa 100644
--- a/extensions/general/invoice-inbox/manifest.json
+++ b/extensions/general/invoice-inbox/manifest.json
@@ -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"
+ }
}
}
diff --git a/lib/ai/document-analyzer.ts b/lib/ai/document-analyzer.ts
index 133d370b..17db1433 100644
--- a/lib/ai/document-analyzer.ts
+++ b/lib/ai/document-analyzer.ts
@@ -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"?
diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts
index 4204f77e..acfce34e 100644
--- a/lib/api/__tests__/schemas.test.ts
+++ b/lib/api/__tests__/schemas.test.ts
@@ -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
// ============================================================
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index 40655f4b..f52d078e 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -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
// ============================================================
diff --git a/lib/bookkeeping/__tests__/category-mapping.test.ts b/lib/bookkeeping/__tests__/category-mapping.test.ts
index 3e05b61d..2309f63f 100644
--- a/lib/bookkeeping/__tests__/category-mapping.test.ts
+++ b/lib/bookkeeping/__tests__/category-mapping.test.ts
@@ -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')
})
diff --git a/lib/bookkeeping/__tests__/currency-utils.test.ts b/lib/bookkeeping/__tests__/currency-utils.test.ts
new file mode 100644
index 00000000..45941f29
--- /dev/null
+++ b/lib/bookkeeping/__tests__/currency-utils.test.ts
@@ -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,
+ })
+ })
+})
diff --git a/lib/bookkeeping/__tests__/engine.test.ts b/lib/bookkeeping/__tests__/engine.test.ts
index 9ae0e755..c53804ab 100644
--- a/lib/bookkeeping/__tests__/engine.test.ts
+++ b/lib/bookkeeping/__tests__/engine.test.ts
@@ -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')
+ })
+})
diff --git a/lib/bookkeeping/__tests__/invoice-entries.test.ts b/lib/bookkeeping/__tests__/invoice-entries.test.ts
index 513cd345..582a09d2 100644
--- a/lib/bookkeeping/__tests__/invoice-entries.test.ts
+++ b/lib/bookkeeping/__tests__/invoice-entries.test.ts
@@ -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)
+ })
+})
diff --git a/lib/bookkeeping/__tests__/mapping-engine.test.ts b/lib/bookkeeping/__tests__/mapping-engine.test.ts
index ddb3ce51..d9ad1545 100644
--- a/lib/bookkeeping/__tests__/mapping-engine.test.ts
+++ b/lib/bookkeeping/__tests__/mapping-engine.test.ts
@@ -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')
diff --git a/lib/bookkeeping/category-mapping.ts b/lib/bookkeeping/category-mapping.ts
index 5980cf88..8a09e629 100644
--- a/lib/bookkeeping/category-mapping.ts
+++ b/lib/bookkeeping/category-mapping.ts
@@ -36,6 +36,42 @@ const PRIVATE_ACCOUNTS: Record = {
aktiebolag: '2893', // Skuld till aktieägare/delägare
}
+// Single source of truth for category -> expense account mapping
+const EXPENSE_ACCOUNTS: Record = {
+ 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 = {
+ 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 = {
- 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 = {
- 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 = {
- 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 = {
- 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 = {
- 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
diff --git a/lib/bookkeeping/client-account-names.ts b/lib/bookkeeping/client-account-names.ts
index 6359e217..30f24df4 100644
--- a/lib/bookkeeping/client-account-names.ts
+++ b/lib/bookkeeping/client-account-names.ts
@@ -42,6 +42,7 @@ const ACCOUNT_NAMES: Record = {
// Other external expenses (6xxx)
'6071': 'Representation',
+ '6110': 'Kontorsforbrukning',
'6200': 'Telefon & internet',
'6530': 'Redovisningstjanster',
'6570': 'Bankavgifter',
diff --git a/lib/bookkeeping/currency-utils.ts b/lib/bookkeeping/currency-utils.ts
new file mode 100644
index 00000000..a02501b9
--- /dev/null
+++ b/lib/bookkeeping/currency-utils.ts
@@ -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 } : {}),
+ }
+}
diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts
index 5e070aa3..de05f87d 100644
--- a/lib/bookkeeping/engine.ts
+++ b/lib/bookkeeping/engine.ts
@@ -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 {
- // 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 {
// 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,
diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts
index b4d7c417..b1a7d94e 100644
--- a/lib/bookkeeping/invoice-entries.ts
+++ b/lib/bookkeeping/invoice-entries.ts
@@ -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 {
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,
diff --git a/lib/bookkeeping/mapping-engine.ts b/lib/bookkeeping/mapping-engine.ts
index a5bc1844..b2db7de7 100644
--- a/lib/bookkeeping/mapping-engine.ts
+++ b/lib/bookkeeping/mapping-engine.ts
@@ -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 = {
+ 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,
diff --git a/lib/bookkeeping/supplier-invoice-entries.ts b/lib/bookkeeping/supplier-invoice-entries.ts
index 64460922..4a0226ca 100644
--- a/lib/bookkeeping/supplier-invoice-entries.ts
+++ b/lib/bookkeeping/supplier-invoice-entries.ts
@@ -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()
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()
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()
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,
diff --git a/lib/bookkeeping/transaction-entries.ts b/lib/bookkeeping/transaction-entries.ts
index d55343e8..4be60f64 100644
--- a/lib/bookkeeping/transaction-entries.ts
+++ b/lib/bookkeeping/transaction-entries.ts
@@ -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
diff --git a/lib/core/bookkeeping/__tests__/storno-service.test.ts b/lib/core/bookkeeping/__tests__/storno-service.test.ts
index 2abab472..b81d3fce 100644
--- a/lib/core/bookkeeping/__tests__/storno-service.test.ts
+++ b/lib/core/bookkeeping/__tests__/storno-service.test.ts
@@ -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'
diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts
index 9ce1d872..7522c579 100644
--- a/lib/core/bookkeeping/storno-service.ts
+++ b/lib/core/bookkeeping/storno-service.ts
@@ -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,
diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts
index 2f36dcd9..baedd0fd 100644
--- a/lib/extensions/_generated/sector-definitions.ts
+++ b/lib/extensions/_generated/sector-definitions.ts
@@ -86,7 +86,13 @@ export const EXTENSION_DEFINITIONS: Record = {
"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",
diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts
index 7ce846b9..0731ffb8 100644
--- a/lib/transactions/ingest.ts
+++ b/lib/transactions/ingest.ts
@@ -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()
+ 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,