diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index 5db5dcf1..4f01d0aa 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -126,7 +126,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => { expect.anything(), 'user-1', expect.objectContaining({ id: 'inv-1' }), - expect.any(String) + expect.any(String), + undefined ) }) diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index 1dd544fe..fb9aad97 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -4,6 +4,7 @@ import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, } from '@/lib/bookkeeping/invoice-entries' +import { MarkInvoicePaidSchema } from '@/lib/api/schemas' import type { EntityType, Invoice } from '@/types' /** @@ -49,8 +50,24 @@ export async function POST( ) } + // Parse optional body (backward compatible — body may be empty) + let exchangeRateDifference: number | undefined + let bodyPaymentDate: string | undefined + try { + const text = await request.text() + if (text) { + const parsed = MarkInvoicePaidSchema.safeParse(JSON.parse(text)) + if (parsed.success) { + exchangeRateDifference = parsed.data.exchange_rate_difference + bodyPaymentDate = parsed.data.payment_date + } + } + } catch { + // No body or invalid JSON — use defaults + } + const now = new Date().toISOString() - const paymentDate = now.split('T')[0] + const paymentDate = bodyPaymentDate || now.split('T')[0] // Update status to paid const { error: updateError } = await supabase @@ -89,7 +106,8 @@ export async function POST( supabase, user.id, invoice as Invoice, - paymentDate + paymentDate, + exchangeRateDifference ) journalEntryId = journalEntry?.id ?? null } else { diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index eacaf04f..ea5aea82 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -12,6 +12,7 @@ import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntry import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { getErrorMessage } from '@/lib/errors/get-error-message' +import { formatCurrency } from '@/lib/utils' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType } from '@/types' @@ -363,7 +364,7 @@ export default function JournalEntryForm({ {!isBalanced && totalDebit > 0 && (

- Differens: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr + Differens: {formatCurrency(Math.abs(totalDebit - totalCredit))}

)} diff --git a/components/chat/artifacts/ChatAgingBuckets.tsx b/components/chat/artifacts/ChatAgingBuckets.tsx index 72b55755..f7791ef5 100644 --- a/components/chat/artifacts/ChatAgingBuckets.tsx +++ b/components/chat/artifacts/ChatAgingBuckets.tsx @@ -1,6 +1,7 @@ 'use client' import type { AgingBucketsArtifact } from '@/types/chat' +import { formatCurrency } from '@/lib/utils' const BUCKET_COLORS = [ 'bg-green-500', @@ -14,10 +15,6 @@ interface ChatAgingBucketsProps { artifact: AgingBucketsArtifact } -function formatAmount(amount: number): string { - return new Intl.NumberFormat('sv-SE').format(Math.round(amount)) -} - export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) { const { title, buckets, total } = artifact @@ -28,7 +25,7 @@ export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {

{title}

- {formatAmount(total)} kr + {formatCurrency(total)}
@@ -43,7 +40,7 @@ export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) { key={i} className={`${BUCKET_COLORS[i % BUCKET_COLORS.length]} transition-all`} style={{ width: `${widthPercent}%` }} - title={`${bucket.label}: ${formatAmount(bucket.amount)} kr`} + title={`${bucket.label}: ${formatCurrency(bucket.amount)}`} /> ) })} @@ -65,7 +62,7 @@ export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) { {bucket.count} st - {formatAmount(bucket.amount)} kr + {formatCurrency(bucket.amount)} diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index ec779771..b1f53a0c 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { AccountNumber } from '@/components/ui/account-number' import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye } from 'lucide-react' +import { formatCurrency } from '@/lib/utils' function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) @@ -291,16 +292,16 @@ export function BankReconciliationView() {
Banktransaktioner (summa) - {formatAmount(status.bank_transaction_total)} kr + {formatCurrency(status.bank_transaction_total)}
saldo (huvudbok) - {formatAmount(status.gl_1930_balance)} kr + {formatCurrency(status.gl_1930_balance)}
Differens - {formatAmount(status.difference)} kr + {formatCurrency(status.difference)}
@@ -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,