feat: add foreign currency support, refactor bookkeeping engine, and improve invoice inbox document classification
- Add currency-utils module for SEK conversion with exchange rates - Refactor createJournalEntry to use draft+commit flow preventing voucher number gaps (BFL 5 kap. 7§) - Add foreign currency support to invoice entries with per-line SEK conversion - Centralize category-to-account mapping into single source of truth - Refactor invoice inbox to use shared document analyzer with document type classification (receipt, supplier invoice, government letter) - Update mapping engine, supplier invoice entries, and transaction entries - Fix report component rendering issues - Add new validation schemas and tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
547fd053ec
commit
6f4573f380
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntry
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType } from '@/types'
|
||||
@@ -363,7 +364,7 @@ export default function JournalEntryForm({
|
||||
|
||||
{!isBalanced && totalDebit > 0 && (
|
||||
<p className="text-sm text-red-600">
|
||||
Differens: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr
|
||||
Differens: {formatCurrency(Math.abs(totalDebit - totalCredit))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { AgingBucketsArtifact } from '@/types/chat'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
const BUCKET_COLORS = [
|
||||
'bg-green-500',
|
||||
@@ -14,10 +15,6 @@ interface ChatAgingBucketsProps {
|
||||
artifact: AgingBucketsArtifact
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE').format(Math.round(amount))
|
||||
}
|
||||
|
||||
export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
|
||||
const { title, buckets, total } = artifact
|
||||
|
||||
@@ -28,7 +25,7 @@ export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
<span className="text-sm font-bold tabular-nums">
|
||||
{formatAmount(total)} kr
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +40,7 @@ export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
|
||||
key={i}
|
||||
className={`${BUCKET_COLORS[i % BUCKET_COLORS.length]} transition-all`}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
title={`${bucket.label}: ${formatAmount(bucket.amount)} kr`}
|
||||
title={`${bucket.label}: ${formatCurrency(bucket.amount)}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -65,7 +62,7 @@ export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
|
||||
{bucket.count} st
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatAmount(bucket.amount)} kr
|
||||
{formatCurrency(bucket.amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye } from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -291,16 +292,16 @@ export function BankReconciliationView() {
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Banktransaktioner (summa)</span>
|
||||
<span className="font-mono">{formatAmount(status.bank_transaction_total)} kr</span>
|
||||
<span className="font-mono">{formatCurrency(status.bank_transaction_total)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span><AccountNumber number="1930" /> saldo (huvudbok)</span>
|
||||
<span className="font-mono">{formatAmount(status.gl_1930_balance)} kr</span>
|
||||
<span className="font-mono">{formatCurrency(status.gl_1930_balance)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t font-semibold">
|
||||
<span>Differens</span>
|
||||
<span className={status.is_reconciled ? 'text-green-600' : 'text-red-600'}>
|
||||
{formatAmount(status.difference)} kr
|
||||
{formatCurrency(status.difference)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-2 text-xs text-muted-foreground">
|
||||
@@ -433,7 +434,7 @@ export function BankReconciliationView() {
|
||||
<td className="py-2">{tx.date}</td>
|
||||
<td className="py-2 truncate max-w-[200px]">{tx.description}</td>
|
||||
<td className={`py-2 text-right font-mono ${tx.amount >= 0 ? 'text-green-600' : ''}`}>
|
||||
{formatAmount(tx.amount)} kr
|
||||
{formatCurrency(tx.amount)}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-muted-foreground">{tx.reference || '—'}</td>
|
||||
<td className="py-2">
|
||||
@@ -449,7 +450,7 @@ export function BankReconciliationView() {
|
||||
const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
||||
return (
|
||||
<option key={line.line_id} value={line.journal_entry_id}>
|
||||
{line.voucher_series}{line.voucher_number} | {line.entry_date} | {formatAmount(lineAmount)} kr | {line.entry_description}
|
||||
{line.voucher_series}{line.voucher_number} | {line.entry_date} | {formatCurrency(lineAmount)} | {line.entry_description}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
@@ -506,7 +507,7 @@ export function BankReconciliationView() {
|
||||
{line.line_description || line.entry_description}
|
||||
</td>
|
||||
<td className={`py-2 text-right font-mono ${amount >= 0 ? 'text-green-600' : ''}`}>
|
||||
{formatAmount(amount)} kr
|
||||
{formatCurrency(amount)}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-muted-foreground">{line.source_type}</td>
|
||||
</tr>
|
||||
@@ -554,7 +555,7 @@ export function BankReconciliationView() {
|
||||
<td className="py-2">{tx.date}</td>
|
||||
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
|
||||
<td className={`py-2 text-right font-mono ${tx.amount >= 0 ? 'text-green-600' : ''}`}>
|
||||
{formatAmount(tx.amount)} kr
|
||||
{formatCurrency(tx.amount)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{tx.reconciliation_method && (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
export interface MonthlyDataPoint {
|
||||
label: string
|
||||
@@ -34,7 +35,7 @@ export function IncomeExpenseChart({ months }: IncomeExpenseChartProps) {
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value, name) => [
|
||||
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
|
||||
formatCurrency(Number(value)),
|
||||
name === 'income' ? 'Intäkter' : 'Kostnader',
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -7,10 +7,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Download, AlertCircle } from 'lucide-react'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import type { NEDeclaration } from '@/lib/reports/ne-bilaga/types'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
export function NEDeclarationView({ periodId }: { periodId: string }) {
|
||||
const [data, setData] = useState<NEDeclaration | null>(null)
|
||||
@@ -156,9 +153,9 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
|
||||
<tr className="border-t-2 font-semibold">
|
||||
<td className="py-2">Summa intäkter</td>
|
||||
<td className="py-2 text-right">
|
||||
{formatAmount(
|
||||
{formatCurrency(
|
||||
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
|
||||
)} kr
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -193,10 +190,10 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
|
||||
<tr className="border-t-2 font-semibold">
|
||||
<td className="py-2">Summa kostnader</td>
|
||||
<td className="py-2 text-right">
|
||||
-{formatAmount(
|
||||
-{formatCurrency(
|
||||
data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
|
||||
data.rutor.R8 + data.rutor.R9 + data.rutor.R10
|
||||
)} kr
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -217,7 +214,7 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
|
||||
data.rutor.R11 >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{formatAmount(data.rutor.R11)} kr
|
||||
{formatCurrency(data.rutor.R11)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -270,7 +267,7 @@ function NEDeclarationRow({
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{isExpense && amount > 0 ? '-' : ''}{formatAmount(Math.abs(amount))} kr
|
||||
{isExpense && amount > 0 ? '-' : ''}{formatCurrency(Math.abs(amount))}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && accounts.length > 0 && (
|
||||
@@ -283,7 +280,7 @@ function NEDeclarationRow({
|
||||
<td className="py-1"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
|
||||
<td className="py-1">{acc.accountName}</td>
|
||||
<td className="py-1 text-right">
|
||||
{isExpense && acc.amount > 0 ? '-' : ''}{formatAmount(Math.abs(acc.amount))} kr
|
||||
{isExpense && acc.amount > 0 ? '-' : ''}{formatCurrency(Math.abs(acc.amount))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -8,10 +8,7 @@ import { Download, AlertCircle } from 'lucide-react'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import type { SRUExportResult } from '@/lib/reports/sru-export/types'
|
||||
import type { SRUCoverageStats } from '@/lib/reports/sru-export/sru-engine'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
export function SRUExportView({ periodId }: { periodId: string }) {
|
||||
const [data, setData] = useState<SRUExportResult | null>(null)
|
||||
@@ -221,7 +218,7 @@ function SRUBalanceRow({
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">{formatAmount(balance.amount)} kr</td>
|
||||
<td className="py-2 text-right">{formatCurrency(balance.amount)}</td>
|
||||
</tr>
|
||||
{expanded && balance.accounts.length > 0 && (
|
||||
<tr>
|
||||
@@ -232,7 +229,7 @@ function SRUBalanceRow({
|
||||
<tr key={acc.accountNumber}>
|
||||
<td className="py-1 w-16"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
|
||||
<td className="py-1">{acc.accountName}</td>
|
||||
<td className="py-1 text-right">{formatAmount(acc.amount)} kr</td>
|
||||
<td className="py-1 text-right">{formatCurrency(acc.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { TrialBalanceRow } from '@/types'
|
||||
|
||||
interface TrialBalanceChartProps {
|
||||
@@ -48,7 +49,7 @@ export function TrialBalanceChart({ rows }: TrialBalanceChartProps) {
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
|
||||
formatCurrency(Number(value)),
|
||||
'Netto',
|
||||
]}
|
||||
labelFormatter={(label) => chartData.find((d) => d.account === String(label))?.name || String(label)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { VatDeclarationRutor } from '@/types'
|
||||
|
||||
interface VatCompositionChartProps {
|
||||
@@ -52,7 +53,7 @@ export function VatCompositionChart({ rutor }: VatCompositionChartProps) {
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
|
||||
formatCurrency(Number(value)),
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { Supplier } from '@/types'
|
||||
|
||||
interface ReviewLineItem {
|
||||
@@ -209,21 +210,21 @@ export function SupplierInvoiceReviewContent({
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Netto (exkl. moms)</span>
|
||||
<span>{formatAmount(subtotal)} kr</span>
|
||||
<span>{formatCurrency(subtotal, currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span>{formatAmount(totalVat)} kr</span>
|
||||
<span>{formatCurrency(totalVat, currency)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between font-bold text-2xl">
|
||||
<span>Totalt</span>
|
||||
<span>{formatAmount(total)} kr</span>
|
||||
<span>{formatCurrency(total, currency)}</span>
|
||||
</div>
|
||||
{currency !== 'SEK' && exchangeRate && (
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>SEK-belopp (vid kurs {exchangeRate})</span>
|
||||
<span>{formatAmount(total * parseFloat(exchangeRate))} kr</span>
|
||||
<span>{formatCurrency(total * parseFloat(exchangeRate))}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { InvoiceExtractionResult } from './types'
|
||||
import type { InvoiceInboxItem, SupplierInvoice } from '@/types'
|
||||
import { analyzeInvoice } from './lib/invoice-analyzer'
|
||||
import { analyzeDocument } from '@/lib/ai/document-analyzer'
|
||||
import { matchSupplier } from './lib/supplier-matcher'
|
||||
import { getSettings, saveSettings } from './index'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
@@ -190,96 +190,33 @@ async function processInboxItem(
|
||||
const supabase = await getSupabase()
|
||||
|
||||
try {
|
||||
console.log(`[invoice-inbox] Processing item=${itemId}: starting AI extraction (${mimeType})`)
|
||||
const extraction = await analyzeInvoice(base64, mimeType)
|
||||
console.log(`[invoice-inbox] Processing item=${itemId}: starting AI analysis (${mimeType})`)
|
||||
const result = await analyzeDocument(base64, mimeType)
|
||||
|
||||
console.log(`[invoice-inbox] item=${itemId} extraction complete:`, {
|
||||
confidence: extraction.confidence,
|
||||
suggestedTemplateId: extraction.suggestedTemplateId || null,
|
||||
supplier: extraction.supplier?.name || null,
|
||||
total: extraction.totals?.total || null,
|
||||
invoiceDate: extraction.invoice?.invoiceDate || null,
|
||||
dueDate: extraction.invoice?.dueDate || null,
|
||||
paymentRef: extraction.invoice?.paymentReference || null,
|
||||
})
|
||||
const { classification } = result
|
||||
console.log(`[invoice-inbox] item=${itemId} classified as: ${classification.type} (confidence=${classification.confidence}, reasoning=${classification.reasoning})`)
|
||||
|
||||
// Supplier matching
|
||||
const settings = await getSettings(userId)
|
||||
let matchedSupplierId: string | null = null
|
||||
|
||||
if (settings.autoMatchSupplierEnabled) {
|
||||
const { data: suppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (suppliers && suppliers.length > 0) {
|
||||
const match = matchSupplier(extraction, suppliers)
|
||||
if (match && match.confidence >= settings.supplierMatchThreshold) {
|
||||
matchedSupplierId = match.supplierId
|
||||
console.log(`[invoice-inbox] item=${itemId} supplier matched: id=${match.supplierId} confidence=${match.confidence}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store extraction result with template suggestion
|
||||
const updateData: Record<string, unknown> = {
|
||||
status: 'ready',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
}
|
||||
|
||||
if (extraction.suggestedTemplateId) {
|
||||
updateData.suggested_template_id = extraction.suggestedTemplateId
|
||||
updateData.suggested_template_confidence = extraction.confidence
|
||||
console.log(`[invoice-inbox] item=${itemId} template suggestion: ${extraction.suggestedTemplateId} (confidence=${extraction.confidence})`)
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update(updateData)
|
||||
.eq('id', itemId)
|
||||
|
||||
// Fetch the updated item for event emission and matching
|
||||
const { data: updatedItem } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('id', itemId)
|
||||
.single()
|
||||
|
||||
if (updatedItem) {
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.extracted',
|
||||
payload: {
|
||||
inboxItem: updatedItem,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
|
||||
// Document-to-transaction matching
|
||||
try {
|
||||
const matchResult = await matchDocumentToTransactions(
|
||||
supabase,
|
||||
userId,
|
||||
updatedItem as InvoiceInboxItem
|
||||
)
|
||||
|
||||
if (matchResult) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
matched_transaction_id: matchResult.transactionId,
|
||||
match_confidence: matchResult.confidence,
|
||||
match_method: matchResult.method,
|
||||
})
|
||||
.eq('id', itemId)
|
||||
}
|
||||
} catch (matchError) {
|
||||
// Non-blocking: log but don't fail the item
|
||||
console.error('[invoice-inbox] Transaction matching failed:', matchError)
|
||||
}
|
||||
// Handle based on document type
|
||||
if (classification.type === 'receipt' && result.receipt) {
|
||||
await processAsReceipt(supabase, itemId, userId, result.receipt)
|
||||
} else if (classification.type === 'supplier_invoice' && result.invoice) {
|
||||
await processAsInvoice(supabase, itemId, userId, result.invoice)
|
||||
} else if (classification.type === 'government_letter' || classification.type === 'unknown') {
|
||||
// Store classification but no extraction — user must review manually
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
document_type: classification.type,
|
||||
confidence: classification.confidence,
|
||||
})
|
||||
.eq('id', itemId)
|
||||
} else {
|
||||
// Classification gave a type but extraction failed — fall back to supplier_invoice extraction
|
||||
console.warn(`[invoice-inbox] item=${itemId}: classified as ${classification.type} but no extraction data, falling back`)
|
||||
const { extractInvoice } = await import('@/lib/ai/document-analyzer')
|
||||
const fallbackExtraction = await extractInvoice(base64, mimeType)
|
||||
await processAsInvoice(supabase, itemId, userId, fallbackExtraction)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
@@ -290,6 +227,235 @@ async function processInboxItem(
|
||||
}
|
||||
}
|
||||
|
||||
async function processAsReceipt(
|
||||
supabase: Awaited<ReturnType<typeof getSupabase>>,
|
||||
itemId: string,
|
||||
userId: string,
|
||||
extraction: import('@/types').ReceiptExtractionResult
|
||||
): Promise<void> {
|
||||
console.log(`[invoice-inbox] item=${itemId} processing as receipt: merchant=${extraction.merchant?.name}, total=${extraction.totals?.total}`)
|
||||
|
||||
// Fetch the inbox item to get document_id for the receipt image_url
|
||||
const { data: inboxItem } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('document_id, document:document_attachments(storage_path)')
|
||||
.eq('id', itemId)
|
||||
.single()
|
||||
|
||||
let imageUrl: string | null = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const document = inboxItem?.document as any
|
||||
if (document?.storage_path) {
|
||||
const { data: urlData } = supabase.storage
|
||||
.from('documents')
|
||||
.getPublicUrl(document.storage_path)
|
||||
imageUrl = urlData?.publicUrl || null
|
||||
}
|
||||
|
||||
// Create receipt record
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
image_url: imageUrl,
|
||||
status: 'extracted',
|
||||
extraction_confidence: extraction.confidence,
|
||||
merchant_name: extraction.merchant?.name || null,
|
||||
merchant_org_number: extraction.merchant?.orgNumber || null,
|
||||
merchant_vat_number: extraction.merchant?.vatNumber || null,
|
||||
receipt_date: extraction.receipt?.date || null,
|
||||
receipt_time: extraction.receipt?.time || null,
|
||||
total_amount: extraction.totals?.total || null,
|
||||
currency: extraction.receipt?.currency || 'SEK',
|
||||
vat_amount: extraction.totals?.vatAmount || null,
|
||||
is_restaurant: extraction.flags?.isRestaurant || false,
|
||||
is_systembolaget: extraction.flags?.isSystembolaget || false,
|
||||
is_foreign_merchant: extraction.flags?.isForeignMerchant || false,
|
||||
raw_extraction: extraction,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
console.error(`[invoice-inbox] item=${itemId} receipt creation failed:`, receiptError)
|
||||
// Fall back: store as receipt type without linked receipt
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
document_type: 'receipt',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
confidence: extraction.confidence,
|
||||
})
|
||||
.eq('id', itemId)
|
||||
return
|
||||
}
|
||||
|
||||
// Insert receipt line items
|
||||
if (extraction.lineItems?.length > 0) {
|
||||
const lineItemsToInsert = extraction.lineItems.map((item, index) => ({
|
||||
receipt_id: receipt.id,
|
||||
description: item.description || '',
|
||||
quantity: item.quantity || 1,
|
||||
unit_price: item.unitPrice,
|
||||
line_total: item.lineTotal || 0,
|
||||
vat_rate: item.vatRate,
|
||||
vat_amount:
|
||||
item.vatRate && item.lineTotal
|
||||
? Math.round((item.lineTotal * item.vatRate) / (100 + item.vatRate) * 100) / 100
|
||||
: null,
|
||||
suggested_category: item.suggestedCategory || null,
|
||||
sort_order: index,
|
||||
}))
|
||||
|
||||
await supabase.from('receipt_line_items').insert(lineItemsToInsert)
|
||||
}
|
||||
|
||||
// Update inbox item: mark as receipt with linked receipt
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
document_type: 'receipt',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
confidence: extraction.confidence,
|
||||
linked_receipt_id: receipt.id,
|
||||
})
|
||||
.eq('id', itemId)
|
||||
|
||||
// Emit receipt.extracted event (non-blocking)
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'receipt.extracted',
|
||||
payload: {
|
||||
receipt,
|
||||
documentId: inboxItem?.document_id || null,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Non-blocking
|
||||
}
|
||||
|
||||
// Try to match receipt to a transaction
|
||||
try {
|
||||
const { data: updatedItem } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('id', itemId)
|
||||
.single()
|
||||
|
||||
if (updatedItem) {
|
||||
const matchResult = await matchDocumentToTransactions(
|
||||
supabase,
|
||||
userId,
|
||||
updatedItem as InvoiceInboxItem
|
||||
)
|
||||
if (matchResult) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
matched_transaction_id: matchResult.transactionId,
|
||||
match_confidence: matchResult.confidence,
|
||||
match_method: matchResult.method,
|
||||
})
|
||||
.eq('id', itemId)
|
||||
}
|
||||
}
|
||||
} catch (matchError) {
|
||||
console.error('[invoice-inbox] Receipt transaction matching failed:', matchError)
|
||||
}
|
||||
}
|
||||
|
||||
async function processAsInvoice(
|
||||
supabase: Awaited<ReturnType<typeof getSupabase>>,
|
||||
itemId: string,
|
||||
userId: string,
|
||||
extraction: InvoiceExtractionResult
|
||||
): Promise<void> {
|
||||
console.log(`[invoice-inbox] item=${itemId} processing as supplier_invoice: supplier=${extraction.supplier?.name}, total=${extraction.totals?.total}`)
|
||||
|
||||
// Supplier matching
|
||||
const settings = await getSettings(userId)
|
||||
let matchedSupplierId: string | null = null
|
||||
|
||||
if (settings.autoMatchSupplierEnabled) {
|
||||
const { data: suppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (suppliers && suppliers.length > 0) {
|
||||
const match = matchSupplier(extraction, suppliers)
|
||||
if (match && match.confidence >= settings.supplierMatchThreshold) {
|
||||
matchedSupplierId = match.supplierId
|
||||
console.log(`[invoice-inbox] item=${itemId} supplier matched: id=${match.supplierId} confidence=${match.confidence}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store extraction result with template suggestion
|
||||
const updateData: Record<string, unknown> = {
|
||||
status: 'ready',
|
||||
document_type: 'supplier_invoice',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
}
|
||||
|
||||
if (extraction.suggestedTemplateId) {
|
||||
updateData.suggested_template_id = extraction.suggestedTemplateId
|
||||
updateData.suggested_template_confidence = extraction.confidence
|
||||
console.log(`[invoice-inbox] item=${itemId} template suggestion: ${extraction.suggestedTemplateId} (confidence=${extraction.confidence})`)
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update(updateData)
|
||||
.eq('id', itemId)
|
||||
|
||||
// Fetch the updated item for event emission and matching
|
||||
const { data: updatedItem } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('id', itemId)
|
||||
.single()
|
||||
|
||||
if (updatedItem) {
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.extracted',
|
||||
payload: {
|
||||
inboxItem: updatedItem,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
|
||||
// Document-to-transaction matching
|
||||
try {
|
||||
const matchResult = await matchDocumentToTransactions(
|
||||
supabase,
|
||||
userId,
|
||||
updatedItem as InvoiceInboxItem
|
||||
)
|
||||
|
||||
if (matchResult) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
matched_transaction_id: matchResult.transactionId,
|
||||
match_confidence: matchResult.confidence,
|
||||
match_method: matchResult.method,
|
||||
})
|
||||
.eq('id', itemId)
|
||||
}
|
||||
} catch (matchError) {
|
||||
console.error('[invoice-inbox] Transaction matching failed:', matchError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GET /inbox/:id — Get single inbox item
|
||||
// ============================================================
|
||||
@@ -470,89 +636,55 @@ async function handleProcessInboxItem(
|
||||
const arrayBuffer = await fileData.arrayBuffer()
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
||||
|
||||
// Analyze
|
||||
const extraction = await analyzeInvoice(base64, document.mime_type)
|
||||
// Reset previous classification on re-process
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
matched_transaction_id: null,
|
||||
match_confidence: null,
|
||||
match_method: null,
|
||||
linked_receipt_id: null,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
// Supplier matching
|
||||
const settings = await getSettings(userId)
|
||||
let matchedSupplierId: string | null = null
|
||||
// Analyze with classification
|
||||
const result = await analyzeDocument(base64, document.mime_type)
|
||||
const { classification } = result
|
||||
|
||||
if (settings.autoMatchSupplierEnabled) {
|
||||
const { data: suppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
console.log(`[invoice-inbox] Re-process item=${id} classified as: ${classification.type} (confidence=${classification.confidence})`)
|
||||
|
||||
if (suppliers && suppliers.length > 0) {
|
||||
const match = matchSupplier(extraction, suppliers)
|
||||
if (match && match.confidence >= settings.supplierMatchThreshold) {
|
||||
matchedSupplierId = match.supplierId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update inbox item with extraction + template suggestion
|
||||
const updateData: Record<string, unknown> = {
|
||||
status: 'ready',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
error_message: null,
|
||||
// Reset previous match on re-process
|
||||
matched_transaction_id: null,
|
||||
match_confidence: null,
|
||||
match_method: null,
|
||||
}
|
||||
|
||||
if (extraction.suggestedTemplateId) {
|
||||
updateData.suggested_template_id = extraction.suggestedTemplateId
|
||||
updateData.suggested_template_confidence = extraction.confidence
|
||||
if (classification.type === 'receipt' && result.receipt) {
|
||||
await processAsReceipt(supabase, id, userId, result.receipt)
|
||||
} else if (classification.type === 'supplier_invoice' && result.invoice) {
|
||||
await processAsInvoice(supabase, id, userId, result.invoice)
|
||||
} else if (classification.type === 'government_letter' || classification.type === 'unknown') {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
document_type: classification.type,
|
||||
confidence: classification.confidence,
|
||||
error_message: null,
|
||||
})
|
||||
.eq('id', id)
|
||||
} else {
|
||||
// Fallback: extract as invoice
|
||||
const { extractInvoice } = await import('@/lib/ai/document-analyzer')
|
||||
const fallbackExtraction = await extractInvoice(base64, document.mime_type)
|
||||
await processAsInvoice(supabase, id, userId, fallbackExtraction)
|
||||
}
|
||||
|
||||
// Fetch final state
|
||||
const { data: updatedItem, error: updateError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update(updateData)
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
if (updatedItem) {
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.extracted',
|
||||
payload: {
|
||||
inboxItem: updatedItem,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
|
||||
// Document-to-transaction matching (non-blocking)
|
||||
try {
|
||||
const matchResult = await matchDocumentToTransactions(
|
||||
supabase,
|
||||
userId,
|
||||
updatedItem as InvoiceInboxItem
|
||||
)
|
||||
|
||||
if (matchResult) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
matched_transaction_id: matchResult.transactionId,
|
||||
match_confidence: matchResult.confidence,
|
||||
match_method: matchResult.method,
|
||||
})
|
||||
.eq('id', id)
|
||||
}
|
||||
} catch (matchError) {
|
||||
console.error('[invoice-inbox] Transaction matching failed:', matchError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updatedItem })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Processing failed'
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"dataPattern": "manual",
|
||||
"hasOwnData": true,
|
||||
"description": "Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost",
|
||||
"longDescription": "Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument."
|
||||
"longDescription": "Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument.",
|
||||
"quickAction": {
|
||||
"label": "Dokumentinkorg",
|
||||
"description": "Granska inkommande dokument",
|
||||
"icon": "Inbox",
|
||||
"href": "/e/general/invoice-inbox"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"?
|
||||
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -172,6 +172,12 @@ export const CreateCreditNoteSchema = z.object({
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export const MarkInvoicePaidSchema = z.object({
|
||||
payment_date: isoDate.optional(),
|
||||
exchange_rate_difference: z.number().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Customer schemas
|
||||
// ============================================================
|
||||
|
||||
@@ -23,6 +23,13 @@ describe('getCategoryAccountMapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('expense_office maps to 6110 (Kontorsförbrukning)', () => {
|
||||
it('maps expense_office to 6110 (not 5010 Lokalhyra)', () => {
|
||||
const result = getCategoryAccountMapping('expense_office', -500, true)
|
||||
expect(result.debitAccount).toBe('6110')
|
||||
})
|
||||
})
|
||||
|
||||
describe('expense_education entity-type-aware', () => {
|
||||
it('defaults to 6991 for enskild_firma', () => {
|
||||
const result = getCategoryAccountMapping('expense_education', -500, true, 'enskild_firma')
|
||||
@@ -48,6 +55,7 @@ describe('getExpenseAccountForCategory', () => {
|
||||
|
||||
it('returns correct accounts for expense categories', () => {
|
||||
expect(getExpenseAccountForCategory('expense_equipment')).toBe('5410')
|
||||
expect(getExpenseAccountForCategory('expense_office')).toBe('6110')
|
||||
expect(getExpenseAccountForCategory('expense_bank_fees')).toBe('6570')
|
||||
})
|
||||
})
|
||||
@@ -57,6 +65,7 @@ describe('getDefaultAccountForCategory', () => {
|
||||
expect(getDefaultAccountForCategory('expense_equipment')).toBe('5410')
|
||||
expect(getDefaultAccountForCategory('expense_software')).toBe('5420')
|
||||
expect(getDefaultAccountForCategory('expense_travel')).toBe('5800')
|
||||
expect(getDefaultAccountForCategory('expense_office')).toBe('6110')
|
||||
expect(getDefaultAccountForCategory('expense_bank_fees')).toBe('6570')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveSekAmount, buildCurrencyMetadata } from '../currency-utils'
|
||||
|
||||
describe('resolveSekAmount', () => {
|
||||
it('returns amount as-is for SEK currency', () => {
|
||||
expect(resolveSekAmount(1000, null, 'SEK', null)).toBe(1000)
|
||||
})
|
||||
|
||||
it('returns amount as-is when currency is null (legacy data)', () => {
|
||||
expect(resolveSekAmount(1000, null, null, null)).toBe(1000)
|
||||
})
|
||||
|
||||
it('returns amount as-is when currency is undefined', () => {
|
||||
expect(resolveSekAmount(1000, null, undefined, null)).toBe(1000)
|
||||
})
|
||||
|
||||
it('returns amountSek when populated for foreign currency', () => {
|
||||
expect(resolveSekAmount(100, 1150, 'EUR', 11.5)).toBe(1150)
|
||||
})
|
||||
|
||||
it('rounds amountSek to 2 decimals', () => {
|
||||
expect(resolveSekAmount(100, 1150.456, 'EUR', 11.5)).toBe(1150.46)
|
||||
})
|
||||
|
||||
it('computes via exchangeRate when amountSek is null', () => {
|
||||
expect(resolveSekAmount(100, null, 'EUR', 11.5)).toBe(1150)
|
||||
})
|
||||
|
||||
it('rounds computed amount to 2 decimals', () => {
|
||||
// 100.33 * 11.5 = 1153.795 → rounds to 1153.8
|
||||
expect(resolveSekAmount(100.33, null, 'EUR', 11.5)).toBe(1153.8)
|
||||
})
|
||||
|
||||
it('falls back to original amount when both amountSek and exchangeRate are null', () => {
|
||||
expect(resolveSekAmount(100, null, 'EUR', null)).toBe(100)
|
||||
})
|
||||
|
||||
it('falls back when exchangeRate is 0', () => {
|
||||
expect(resolveSekAmount(100, null, 'EUR', 0)).toBe(100)
|
||||
})
|
||||
|
||||
it('handles negative amounts correctly', () => {
|
||||
expect(resolveSekAmount(-100, null, 'EUR', 11.5)).toBe(-1150)
|
||||
})
|
||||
|
||||
it('prefers amountSek over exchangeRate computation', () => {
|
||||
// amountSek = 1200, but exchangeRate would give 1150
|
||||
expect(resolveSekAmount(100, 1200, 'EUR', 11.5)).toBe(1200)
|
||||
})
|
||||
|
||||
it('handles zero amount', () => {
|
||||
expect(resolveSekAmount(0, null, 'EUR', 11.5)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCurrencyMetadata', () => {
|
||||
it('returns empty object for SEK', () => {
|
||||
expect(buildCurrencyMetadata('SEK', 1000, null)).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty object for null currency', () => {
|
||||
expect(buildCurrencyMetadata(null, 1000, null)).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty object for undefined currency', () => {
|
||||
expect(buildCurrencyMetadata(undefined, 1000, null)).toEqual({})
|
||||
})
|
||||
|
||||
it('returns currency metadata for foreign currency', () => {
|
||||
expect(buildCurrencyMetadata('EUR', 100, 11.5)).toEqual({
|
||||
currency: 'EUR',
|
||||
amount_in_currency: 100,
|
||||
exchange_rate: 11.5,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits amount_in_currency when null', () => {
|
||||
expect(buildCurrencyMetadata('EUR', null, 11.5)).toEqual({
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.5,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits exchange_rate when null', () => {
|
||||
expect(buildCurrencyMetadata('EUR', 100, null)).toEqual({
|
||||
currency: 'EUR',
|
||||
amount_in_currency: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits exchange_rate when 0', () => {
|
||||
expect(buildCurrencyMetadata('EUR', 100, 0)).toEqual({
|
||||
currency: 'EUR',
|
||||
amount_in_currency: 100,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { validateBalance } from '../engine'
|
||||
import { validateBalance, getSwedishLocalDate } from '../engine'
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
|
||||
describe('validateBalance', () => {
|
||||
@@ -62,3 +62,16 @@ describe('validateBalance', () => {
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSwedishLocalDate', () => {
|
||||
it('returns a date string in YYYY-MM-DD format', () => {
|
||||
const date = getSwedishLocalDate()
|
||||
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
})
|
||||
|
||||
it('returns a valid date', () => {
|
||||
const date = getSwedishLocalDate()
|
||||
const parsed = new Date(date)
|
||||
expect(parsed.toString()).not.toBe('Invalid Date')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,7 @@ const mockedCreateEntry = vi.mocked(createJournalEntry)
|
||||
// Import functions under test AFTER mocks are set up
|
||||
const {
|
||||
createInvoiceJournalEntry,
|
||||
createInvoicePaymentJournalEntry,
|
||||
createCreditNoteJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} = await import('../invoice-entries')
|
||||
@@ -388,3 +389,188 @@ describe('createInvoiceCashEntry — per-line VAT', () => {
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createInvoiceJournalEntry — EUR foreign currency', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('EUR invoice converts amounts to SEK using exchange rate', async () => {
|
||||
// EUR 1,000 + EUR 250 VAT = EUR 1,250 total, rate 11.5
|
||||
const invoice = makeInvoice({
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.5,
|
||||
subtotal: 1000,
|
||||
subtotal_sek: 11500,
|
||||
vat_amount: 250,
|
||||
vat_amount_sek: 2875,
|
||||
total: 1250,
|
||||
total_sek: 14375,
|
||||
vat_treatment: 'standard_25',
|
||||
items: [
|
||||
makeItem({ line_total: 1000, vat_rate: 25, vat_amount: 250 }),
|
||||
],
|
||||
})
|
||||
|
||||
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
|
||||
|
||||
expect(mockedCreateEntry).toHaveBeenCalledOnce()
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
// All amounts should be in SEK
|
||||
const debit1510 = input.lines.find((l) => l.account_number === '1510')
|
||||
expect(debit1510?.debit_amount).toBe(14375) // 1000*11.5 + 250*11.5 = 14375
|
||||
|
||||
const credit3001 = input.lines.find((l) => l.account_number === '3001')
|
||||
expect(credit3001?.credit_amount).toBe(11500) // 1000 * 11.5
|
||||
|
||||
const credit2611 = input.lines.find((l) => l.account_number === '2611')
|
||||
expect(credit2611?.credit_amount).toBe(2875) // 250 * 11.5
|
||||
|
||||
// 1510 line should have currency metadata
|
||||
expect(debit1510?.currency).toBe('EUR')
|
||||
expect(debit1510?.amount_in_currency).toBe(1250)
|
||||
expect(debit1510?.exchange_rate).toBe(11.5)
|
||||
|
||||
// Balance check
|
||||
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
|
||||
it('EUR invoice uses total_sek when available', async () => {
|
||||
// Edge case: total_sek differs slightly from computed (e.g. pre-computed at different rate)
|
||||
const invoice = makeInvoice({
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.5,
|
||||
subtotal: 1000,
|
||||
subtotal_sek: null,
|
||||
vat_amount: 0,
|
||||
vat_amount_sek: null,
|
||||
total: 1000,
|
||||
total_sek: null,
|
||||
vat_treatment: 'export',
|
||||
items: [
|
||||
makeItem({ line_total: 1000, vat_rate: 0, vat_amount: 0 }),
|
||||
],
|
||||
})
|
||||
|
||||
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
// Revenue should be computed via exchange rate
|
||||
const credit3305 = input.lines.find((l) => l.account_number === '3305')
|
||||
expect(credit3305?.credit_amount).toBe(11500)
|
||||
|
||||
const debit1510 = input.lines.find((l) => l.account_number === '1510')
|
||||
expect(debit1510?.debit_amount).toBe(11500)
|
||||
})
|
||||
|
||||
it('SEK invoice still works unchanged (backward compatibility)', async () => {
|
||||
const invoice = makeInvoice({
|
||||
subtotal: 800,
|
||||
vat_amount: 200,
|
||||
total: 1000,
|
||||
vat_treatment: 'standard_25',
|
||||
items: [
|
||||
makeItem({ line_total: 800, vat_rate: 25, vat_amount: 200 }),
|
||||
],
|
||||
})
|
||||
|
||||
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
const debit1510 = input.lines.find((l) => l.account_number === '1510')
|
||||
expect(debit1510?.debit_amount).toBe(1000)
|
||||
|
||||
// No currency metadata for SEK
|
||||
expect(debit1510?.currency).toBeUndefined()
|
||||
expect(debit1510?.amount_in_currency).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createInvoicePaymentJournalEntry — exchange rate difference', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('SEK payment creates simple 2-line entry', async () => {
|
||||
const invoice = makeInvoice({ total: 1250 })
|
||||
|
||||
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15')
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.lines).toHaveLength(2)
|
||||
|
||||
const debit1930 = input.lines.find((l) => l.account_number === '1930')
|
||||
expect(debit1930?.debit_amount).toBe(1250)
|
||||
|
||||
const credit1510 = input.lines.find((l) => l.account_number === '1510')
|
||||
expect(credit1510?.credit_amount).toBe(1250)
|
||||
})
|
||||
|
||||
it('EUR payment with positive exchange rate difference (gain) creates 3 lines', async () => {
|
||||
const invoice = makeInvoice({
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.5,
|
||||
total: 1000,
|
||||
total_sek: 11500,
|
||||
})
|
||||
|
||||
// Gain of 200 SEK (received more than booked)
|
||||
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15', 200)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.lines).toHaveLength(3)
|
||||
|
||||
// Debit 1930: actual SEK received = 11500 + 200 = 11700
|
||||
const debit1930 = input.lines.find((l) => l.account_number === '1930')
|
||||
expect(debit1930?.debit_amount).toBe(11700)
|
||||
|
||||
// Credit 1510: original booked amount
|
||||
const credit1510 = input.lines.find((l) => l.account_number === '1510')
|
||||
expect(credit1510?.credit_amount).toBe(11500)
|
||||
|
||||
// Credit 3960: exchange rate gain
|
||||
const credit3960 = input.lines.find((l) => l.account_number === '3960')
|
||||
expect(credit3960?.credit_amount).toBe(200)
|
||||
|
||||
// Balance check
|
||||
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
|
||||
it('EUR payment with negative exchange rate difference (loss) creates 3 lines', async () => {
|
||||
const invoice = makeInvoice({
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.5,
|
||||
total: 1000,
|
||||
total_sek: 11500,
|
||||
})
|
||||
|
||||
// Loss of 300 SEK (received less than booked)
|
||||
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15', -300)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.lines).toHaveLength(3)
|
||||
|
||||
// Debit 1930: actual SEK received = 11500 + (-300) = 11200
|
||||
const debit1930 = input.lines.find((l) => l.account_number === '1930')
|
||||
expect(debit1930?.debit_amount).toBe(11200)
|
||||
|
||||
// Credit 1510: original booked amount
|
||||
const credit1510 = input.lines.find((l) => l.account_number === '1510')
|
||||
expect(credit1510?.credit_amount).toBe(11500)
|
||||
|
||||
// Debit 7960: exchange rate loss
|
||||
const debit7960 = input.lines.find((l) => l.account_number === '7960')
|
||||
expect(debit7960?.debit_amount).toBe(300)
|
||||
|
||||
// Balance check
|
||||
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('mapping-engine', () => {
|
||||
})
|
||||
|
||||
describe('evaluateMappingRules', () => {
|
||||
it('returns default result when no rules match', async () => {
|
||||
it('returns default result when no rules match (expense)', async () => {
|
||||
const { evaluateMappingRules } = await import('../mapping-engine')
|
||||
|
||||
const tx = makeTransaction({ amount: -100, merchant_name: 'Unknown' })
|
||||
@@ -85,6 +85,206 @@ describe('mapping-engine', () => {
|
||||
expect(result.requires_review).toBe(true)
|
||||
})
|
||||
|
||||
it('returns VAT-neutral 3900 as default income account (not 3001)', async () => {
|
||||
const { evaluateMappingRules } = await import('../mapping-engine')
|
||||
|
||||
const tx = makeTransaction({ amount: 500, merchant_name: 'Unknown' })
|
||||
mockResult({ data: [], error: null })
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
|
||||
expect(result.debit_account).toBe('1930')
|
||||
expect(result.credit_account).toBe('3900')
|
||||
expect(result.requires_review).toBe(true)
|
||||
})
|
||||
|
||||
it('uses 2893 for default_private with aktiebolag entity type', async () => {
|
||||
const { evaluateMappingRules } = await import('../mapping-engine')
|
||||
|
||||
const tx = makeTransaction({ amount: -500, merchant_name: 'Private Purchase' })
|
||||
mockResult({
|
||||
data: [
|
||||
{
|
||||
id: 'rule-private',
|
||||
user_id: null,
|
||||
rule_name: 'Private fallback',
|
||||
rule_type: 'merchant_name',
|
||||
priority: 100,
|
||||
mcc_codes: null,
|
||||
merchant_pattern: 'Private',
|
||||
description_pattern: null,
|
||||
amount_min: null,
|
||||
amount_max: null,
|
||||
debit_account: null,
|
||||
credit_account: null,
|
||||
vat_treatment: null,
|
||||
vat_debit_account: null,
|
||||
vat_credit_account: null,
|
||||
risk_level: 'LOW',
|
||||
default_private: true,
|
||||
requires_review: false,
|
||||
confidence_score: 0.8,
|
||||
capitalization_threshold: null,
|
||||
capitalized_debit_account: null,
|
||||
is_active: true,
|
||||
source: 'system',
|
||||
user_description: null,
|
||||
template_id: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'aktiebolag')
|
||||
expect(result.debit_account).toBe('2893')
|
||||
expect(result.default_private).toBe(true)
|
||||
})
|
||||
|
||||
it('uses 2013 for default_private with enskild_firma entity type', async () => {
|
||||
const { evaluateMappingRules } = await import('../mapping-engine')
|
||||
|
||||
const tx = makeTransaction({ amount: -500, merchant_name: 'Private Purchase' })
|
||||
mockResult({
|
||||
data: [
|
||||
{
|
||||
id: 'rule-private',
|
||||
user_id: null,
|
||||
rule_name: 'Private fallback',
|
||||
rule_type: 'merchant_name',
|
||||
priority: 100,
|
||||
mcc_codes: null,
|
||||
merchant_pattern: 'Private',
|
||||
description_pattern: null,
|
||||
amount_min: null,
|
||||
amount_max: null,
|
||||
debit_account: null,
|
||||
credit_account: null,
|
||||
vat_treatment: null,
|
||||
vat_debit_account: null,
|
||||
vat_credit_account: null,
|
||||
risk_level: 'LOW',
|
||||
default_private: true,
|
||||
requires_review: false,
|
||||
confidence_score: 0.8,
|
||||
capitalization_threshold: null,
|
||||
capitalized_debit_account: null,
|
||||
is_active: true,
|
||||
source: 'system',
|
||||
user_description: null,
|
||||
template_id: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
expect(result.debit_account).toBe('2013')
|
||||
})
|
||||
|
||||
it('applies year-based capitalization threshold from prisbasbelopp', async () => {
|
||||
const { evaluateMappingRules } = await import('../mapping-engine')
|
||||
|
||||
// 2024 threshold = 28,650. This amount exceeds it.
|
||||
const tx = makeTransaction({
|
||||
amount: -30000,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'Equipment Store',
|
||||
})
|
||||
|
||||
mockResult({
|
||||
data: [
|
||||
{
|
||||
id: 'rule-cap',
|
||||
user_id: null,
|
||||
rule_name: 'Equipment',
|
||||
rule_type: 'merchant_name',
|
||||
priority: 50,
|
||||
mcc_codes: null,
|
||||
merchant_pattern: 'Equipment',
|
||||
description_pattern: null,
|
||||
amount_min: null,
|
||||
amount_max: null,
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
vat_treatment: null,
|
||||
vat_debit_account: null,
|
||||
vat_credit_account: null,
|
||||
risk_level: 'LOW',
|
||||
default_private: false,
|
||||
requires_review: false,
|
||||
confidence_score: 0.9,
|
||||
capitalization_threshold: null,
|
||||
capitalized_debit_account: '1250',
|
||||
is_active: true,
|
||||
source: 'system',
|
||||
user_description: null,
|
||||
template_id: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
// 30,000 > 28,650 (2024 half-PBB) → should capitalize to 1250
|
||||
expect(result.debit_account).toBe('1250')
|
||||
})
|
||||
|
||||
it('uses 2025 threshold for 2025 transactions', async () => {
|
||||
const { evaluateMappingRules } = await import('../mapping-engine')
|
||||
|
||||
// 2025 threshold = 29,400. Amount of 29,000 is below it.
|
||||
const tx = makeTransaction({
|
||||
amount: -29000,
|
||||
date: '2025-03-15',
|
||||
merchant_name: 'Equipment Store',
|
||||
})
|
||||
|
||||
mockResult({
|
||||
data: [
|
||||
{
|
||||
id: 'rule-cap',
|
||||
user_id: null,
|
||||
rule_name: 'Equipment',
|
||||
rule_type: 'merchant_name',
|
||||
priority: 50,
|
||||
mcc_codes: null,
|
||||
merchant_pattern: 'Equipment',
|
||||
description_pattern: null,
|
||||
amount_min: null,
|
||||
amount_max: null,
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
vat_treatment: null,
|
||||
vat_debit_account: null,
|
||||
vat_credit_account: null,
|
||||
risk_level: 'LOW',
|
||||
default_private: false,
|
||||
requires_review: false,
|
||||
confidence_score: 0.9,
|
||||
capitalization_threshold: null,
|
||||
capitalized_debit_account: '1250',
|
||||
is_active: true,
|
||||
source: 'system',
|
||||
user_description: null,
|
||||
template_id: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
// 29,000 < 29,400 (2025 half-PBB) → should NOT capitalize
|
||||
expect(result.debit_account).toBe('5410')
|
||||
})
|
||||
|
||||
it('matches merchant_pattern rule', async () => {
|
||||
const { evaluateMappingRules } = await import('../mapping-engine')
|
||||
|
||||
|
||||
@@ -36,6 +36,42 @@ const PRIVATE_ACCOUNTS: Record<EntityType, string> = {
|
||||
aktiebolag: '2893', // Skuld till aktieägare/delägare
|
||||
}
|
||||
|
||||
// Single source of truth for category -> expense account mapping
|
||||
const EXPENSE_ACCOUNTS: Record<string, string> = {
|
||||
expense_equipment: '5410', // Förbrukningsinventarier
|
||||
expense_software: '5420', // Programvaror
|
||||
expense_travel: '5800', // Resekostnader
|
||||
expense_office: '6110', // Kontorsförbrukning
|
||||
expense_marketing: '5910', // Annonsering
|
||||
expense_professional_services: '6530', // Redovisningstjänster
|
||||
expense_representation: '6071', // Representation, avdragsgill
|
||||
expense_consumables: '5460', // Förbrukningsvaror
|
||||
expense_vehicle: '5611', // Drivmedel bil
|
||||
expense_telecom: '6200', // Telefon och internet
|
||||
expense_bank_fees: '6570', // Bankavgifter
|
||||
expense_card_fees: '6570', // Kortavgifter
|
||||
expense_currency_exchange: '7960', // Valutakursförluster
|
||||
expense_other: '6991', // Övriga avdragsgilla kostnader
|
||||
}
|
||||
|
||||
// Income account mapping
|
||||
const INCOME_ACCOUNTS: Record<string, string> = {
|
||||
income_services: '3001', // Försäljning tjänster 25%
|
||||
income_products: '3001', // Försäljning varor 25% moms
|
||||
income_other: '3900', // Övriga rörelseintäkter
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expense account for a category, with entity-specific overrides.
|
||||
* Education (expense_education) differs: AB uses 7610, EF uses 6991.
|
||||
*/
|
||||
function getExpenseAccount(category: string, entityType: EntityType = 'enskild_firma'): string {
|
||||
if (category === 'expense_education') {
|
||||
return entityType === 'aktiebolag' ? '7610' : '6991'
|
||||
}
|
||||
return EXPENSE_ACCOUNTS[category] || '6991'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account mapping for a transaction category
|
||||
*
|
||||
@@ -61,36 +97,9 @@ export function getCategoryAccountMapping(
|
||||
}
|
||||
}
|
||||
|
||||
// Business expense categories
|
||||
const educationAccount = entityType === 'aktiebolag' ? '7610' : '6991' // Utbildning (AB) / Övriga avdragsgilla kostnader (EF)
|
||||
const expenseMapping: Record<string, string> = {
|
||||
expense_equipment: '5410', // Förbrukningsinventarier
|
||||
expense_software: '5420', // Programvaror
|
||||
expense_travel: '5800', // Resekostnader
|
||||
expense_office: '5010', // Lokalhyra
|
||||
expense_marketing: '5910', // Annonsering
|
||||
expense_professional_services: '6530', // Redovisningstjänster
|
||||
expense_education: educationAccount,
|
||||
expense_representation: '6071', // Representation, avdragsgill
|
||||
expense_consumables: '5460', // Förbrukningsvaror
|
||||
expense_vehicle: '5611', // Drivmedel bil
|
||||
expense_telecom: '6200', // Telefon och internet
|
||||
expense_bank_fees: '6570', // Bankavgifter
|
||||
expense_card_fees: '6570', // Kortavgifter
|
||||
expense_currency_exchange: '7960', // Valutakursförluster
|
||||
expense_other: '6991', // Övriga avdragsgilla kostnader
|
||||
}
|
||||
|
||||
// Business income categories
|
||||
const incomeMapping: Record<string, string> = {
|
||||
income_services: '3001', // Försäljning tjänster 25%
|
||||
income_products: '3001', // Försäljning varor 25% moms
|
||||
income_other: '3900', // Övriga rörelseintäkter
|
||||
}
|
||||
|
||||
// Check if it's an expense category
|
||||
if (category.startsWith('expense_')) {
|
||||
const expenseAccount = expenseMapping[category] || '6991'
|
||||
const expenseAccount = getExpenseAccount(category, entityType)
|
||||
|
||||
// Bank fees, card fees, and currency exchange are VAT-exempt in Sweden
|
||||
const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange']
|
||||
@@ -110,7 +119,7 @@ export function getCategoryAccountMapping(
|
||||
|
||||
// Check if it's an income category
|
||||
if (category.startsWith('income_')) {
|
||||
const incomeAccount = incomeMapping[category] || '3900'
|
||||
const incomeAccount = INCOME_ACCOUNTS[category] || '3900'
|
||||
|
||||
// Use provided vatTreatment, or default to standard_25
|
||||
const resolvedVat = vatTreatment ?? 'standard_25'
|
||||
@@ -262,24 +271,8 @@ export function buildMappingResultFromCategory(
|
||||
* Useful for creating mapping rules
|
||||
*/
|
||||
export function getExpenseAccountForCategory(category: TransactionCategory): string | null {
|
||||
const mapping: Record<string, string> = {
|
||||
expense_equipment: '5410',
|
||||
expense_software: '5420',
|
||||
expense_travel: '5800',
|
||||
expense_office: '5010',
|
||||
expense_marketing: '5910',
|
||||
expense_professional_services: '6530',
|
||||
expense_education: '6991',
|
||||
expense_representation: '6071',
|
||||
expense_consumables: '5460',
|
||||
expense_vehicle: '5611',
|
||||
expense_telecom: '6200',
|
||||
expense_bank_fees: '6570',
|
||||
expense_card_fees: '6570',
|
||||
expense_currency_exchange: '7960',
|
||||
expense_other: '6991',
|
||||
}
|
||||
return mapping[category] || null
|
||||
if (category === 'expense_education') return '6991'
|
||||
return EXPENSE_ACCOUNTS[category] || null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,36 +289,12 @@ export function getDefaultAccountForCategory(
|
||||
return PRIVATE_ACCOUNTS[entityType] || PRIVATE_ACCOUNTS.enskild_firma
|
||||
}
|
||||
|
||||
const expenseMapping: Record<string, string> = {
|
||||
expense_equipment: '5410',
|
||||
expense_software: '5420',
|
||||
expense_travel: '5800',
|
||||
expense_office: '5010',
|
||||
expense_marketing: '5910',
|
||||
expense_professional_services: '6530',
|
||||
expense_education: entityType === 'aktiebolag' ? '7610' : '6991',
|
||||
expense_representation: '6071',
|
||||
expense_consumables: '5460',
|
||||
expense_vehicle: '5611',
|
||||
expense_telecom: '6200',
|
||||
expense_bank_fees: '6570',
|
||||
expense_card_fees: '6570',
|
||||
expense_currency_exchange: '7960',
|
||||
expense_other: '6991',
|
||||
}
|
||||
|
||||
if (category.startsWith('expense_')) {
|
||||
return expenseMapping[category] || '6991'
|
||||
}
|
||||
|
||||
const incomeMapping: Record<string, string> = {
|
||||
income_services: '3001',
|
||||
income_products: '3001',
|
||||
income_other: '3900',
|
||||
return getExpenseAccount(category, entityType)
|
||||
}
|
||||
|
||||
if (category.startsWith('income_')) {
|
||||
return incomeMapping[category] || '3900'
|
||||
return INCOME_ACCOUNTS[category] || '3900'
|
||||
}
|
||||
|
||||
// uncategorized
|
||||
|
||||
@@ -42,6 +42,7 @@ const ACCOUNT_NAMES: Record<string, string> = {
|
||||
|
||||
// Other external expenses (6xxx)
|
||||
'6071': 'Representation',
|
||||
'6110': 'Kontorsforbrukning',
|
||||
'6200': 'Telefon & internet',
|
||||
'6530': 'Redovisningstjanster',
|
||||
'6570': 'Bankavgifter',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Currency conversion helpers for journal entry generators.
|
||||
*
|
||||
* All journal entry line amounts (debit_amount / credit_amount) must be in SEK.
|
||||
* These helpers resolve the correct SEK amount from the various currency fields
|
||||
* available on invoices, transactions, and supplier invoices.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve the SEK amount for a journal entry line.
|
||||
*
|
||||
* Priority:
|
||||
* 1. If currency is SEK → return amount as-is
|
||||
* 2. If amountSek is populated → return it (pre-computed SEK value)
|
||||
* 3. If exchangeRate is available → compute amount * exchangeRate
|
||||
* 4. Fallback → return amount (legacy data safety — assumes SEK)
|
||||
*/
|
||||
export function resolveSekAmount(
|
||||
amount: number,
|
||||
amountSek: number | null | undefined,
|
||||
currency: string | null | undefined,
|
||||
exchangeRate: number | null | undefined
|
||||
): number {
|
||||
if (!currency || currency === 'SEK') {
|
||||
return amount
|
||||
}
|
||||
|
||||
if (amountSek != null) {
|
||||
return Math.round(amountSek * 100) / 100
|
||||
}
|
||||
|
||||
if (exchangeRate != null && exchangeRate > 0) {
|
||||
return Math.round(amount * exchangeRate * 100) / 100
|
||||
}
|
||||
|
||||
// Fallback: legacy data without conversion info — return original amount
|
||||
return amount
|
||||
}
|
||||
|
||||
/**
|
||||
* Build currency metadata fields for a journal entry line.
|
||||
* Returns an empty object for SEK transactions (no metadata needed).
|
||||
*/
|
||||
export function buildCurrencyMetadata(
|
||||
currency: string | null | undefined,
|
||||
amountInCurrency: number | null | undefined,
|
||||
exchangeRate: number | null | undefined
|
||||
): {
|
||||
currency?: string
|
||||
amount_in_currency?: number
|
||||
exchange_rate?: number
|
||||
} {
|
||||
if (!currency || currency === 'SEK') {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
...(currency ? { currency } : {}),
|
||||
...(amountInCurrency != null ? { amount_in_currency: amountInCurrency } : {}),
|
||||
...(exchangeRate != null && exchangeRate > 0 ? { exchange_rate: exchangeRate } : {}),
|
||||
}
|
||||
}
|
||||
+16
-85
@@ -270,96 +270,24 @@ export async function commitEntry(
|
||||
/**
|
||||
* Create a journal entry with lines (verifikation)
|
||||
* Convenience wrapper: creates draft + commits in one step.
|
||||
* Validates balance, resolves account IDs, assigns voucher number, inserts atomically.
|
||||
* The voucher number is only assigned after lines are successfully inserted,
|
||||
* preventing gaps in the voucher sequence (BFL 5 kap. 7§).
|
||||
*/
|
||||
export async function createJournalEntry(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
input: CreateJournalEntryInput
|
||||
): Promise<JournalEntry> {
|
||||
// Validate balance
|
||||
const balance = validateBalance(input.lines)
|
||||
if (!balance.valid) {
|
||||
throw new Error(
|
||||
`Journal entry is not balanced: debits (${balance.totalDebit}) != credits (${balance.totalCredit})`
|
||||
)
|
||||
}
|
||||
const draft = await createDraftEntry(supabase, userId, input)
|
||||
return commitEntry(supabase, userId, draft.id)
|
||||
}
|
||||
|
||||
// Resolve account IDs
|
||||
const accountIdMap = await resolveAccountIds(supabase, userId, input.lines)
|
||||
|
||||
// Get next voucher number
|
||||
const voucherNumber = await getNextVoucherNumber(
|
||||
supabase,
|
||||
userId,
|
||||
input.fiscal_period_id,
|
||||
input.voucher_series || 'A'
|
||||
)
|
||||
|
||||
// Insert journal entry header
|
||||
const { data: entry, error: entryError } = await supabase
|
||||
.from('journal_entries')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
fiscal_period_id: input.fiscal_period_id,
|
||||
voucher_number: voucherNumber,
|
||||
voucher_series: input.voucher_series || 'A',
|
||||
entry_date: input.entry_date,
|
||||
description: input.description,
|
||||
source_type: input.source_type,
|
||||
source_id: input.source_id || null,
|
||||
status: 'draft',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (entryError || !entry) {
|
||||
throw new Error(`Failed to create journal entry: ${entryError?.message}`)
|
||||
}
|
||||
|
||||
// Insert journal entry lines with dimensions
|
||||
const lineInserts = buildLineInserts(entry.id, input.lines, accountIdMap)
|
||||
|
||||
const { error: linesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.insert(lineInserts)
|
||||
|
||||
if (linesError) {
|
||||
// Rollback entry
|
||||
await supabase.from('journal_entries').delete().eq('id', entry.id)
|
||||
throw new Error(`Failed to create journal entry lines: ${linesError.message}`)
|
||||
}
|
||||
|
||||
// Post the entry (triggers balance validation + committed_at in DB)
|
||||
const { data: postedEntry, error: postError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'posted' })
|
||||
.eq('id', entry.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (postError) {
|
||||
// Rollback
|
||||
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', entry.id)
|
||||
await supabase.from('journal_entries').delete().eq('id', entry.id)
|
||||
throw new Error(`Failed to post journal entry: ${postError.message}`)
|
||||
}
|
||||
|
||||
// Fetch complete entry with lines
|
||||
const { data: completeEntry } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('id', entry.id)
|
||||
.single()
|
||||
|
||||
const result = completeEntry as JournalEntry
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: result, userId },
|
||||
})
|
||||
|
||||
return result
|
||||
/**
|
||||
* Get the current date in Swedish timezone (Europe/Stockholm).
|
||||
* Avoids UTC date shift when server runs in a different timezone.
|
||||
*/
|
||||
export function getSwedishLocalDate(): string {
|
||||
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Stockholm' }).format(new Date())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -369,7 +297,8 @@ export async function createJournalEntry(
|
||||
export async function reverseEntry(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
entryId: string
|
||||
entryId: string,
|
||||
reversalDate?: string
|
||||
): Promise<JournalEntry> {
|
||||
|
||||
// Fetch original entry with lines
|
||||
@@ -406,6 +335,8 @@ export async function reverseEntry(
|
||||
project: line.project || undefined,
|
||||
}))
|
||||
|
||||
const entryDate = reversalDate || getSwedishLocalDate()
|
||||
|
||||
// Get voucher number for the reversal
|
||||
const voucherNumber = await getNextVoucherNumber(
|
||||
supabase,
|
||||
@@ -425,7 +356,7 @@ export async function reverseEntry(
|
||||
fiscal_period_id: original.fiscal_period_id,
|
||||
voucher_number: voucherNumber,
|
||||
voucher_series: original.voucher_series || 'A',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
entry_date: entryDate,
|
||||
description: `Makulering: ${original.description}`,
|
||||
source_type: 'storno',
|
||||
source_id: original.source_id || null,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||||
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
|
||||
import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries'
|
||||
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
@@ -23,9 +24,21 @@ function generatePerRateLines(
|
||||
items: InvoiceItem[],
|
||||
invoiceVatTreatment: VatTreatment,
|
||||
entityType: EntityType,
|
||||
invoiceNumber: string
|
||||
invoiceNumber: string,
|
||||
currency?: string | null,
|
||||
exchangeRate?: number | null
|
||||
): CreateJournalEntryLineInput[] {
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const isForeign = currency != null && currency !== 'SEK'
|
||||
|
||||
// Helper: convert item amount to SEK when dealing with foreign currency
|
||||
const toSek = (amount: number): number => {
|
||||
if (!isForeign) return amount
|
||||
if (exchangeRate != null && exchangeRate > 0) {
|
||||
return Math.round(amount * exchangeRate * 100) / 100
|
||||
}
|
||||
return amount // fallback for legacy data
|
||||
}
|
||||
|
||||
// Check if items have per-line vat_rate set (new invoices)
|
||||
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
|
||||
@@ -34,21 +47,34 @@ function generatePerRateLines(
|
||||
// Legacy fallback: single rate from invoice level
|
||||
const revenueAccount = getRevenueAccount(invoiceVatTreatment, entityType)
|
||||
const subtotal = items.reduce((sum, item) => sum + item.line_total, 0)
|
||||
const subtotalSek = toSek(subtotal)
|
||||
lines.push({
|
||||
account_number: revenueAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: subtotal,
|
||||
credit_amount: subtotalSek,
|
||||
line_description: `Försäljning faktura ${invoiceNumber}`,
|
||||
})
|
||||
|
||||
const totalVat = items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
|
||||
if (totalVat > 0) {
|
||||
const vatLines = generateSalesVatLines({
|
||||
vatTreatment: invoiceVatTreatment,
|
||||
baseAmount: subtotal,
|
||||
direction: 'sales',
|
||||
})
|
||||
lines.push(...vatLines)
|
||||
if (isForeign) {
|
||||
// For foreign currency, compute VAT in SEK directly
|
||||
const vatSek = toSek(totalVat)
|
||||
const vatAccount = getOutputVatAccount(invoiceVatTreatment)
|
||||
lines.push({
|
||||
account_number: vatAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: vatSek,
|
||||
line_description: `Utgående moms`,
|
||||
})
|
||||
} else {
|
||||
const vatLines = generateSalesVatLines({
|
||||
vatTreatment: invoiceVatTreatment,
|
||||
baseAmount: subtotal,
|
||||
direction: 'sales',
|
||||
})
|
||||
lines.push(...vatLines)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -69,7 +95,7 @@ function generatePerRateLines(
|
||||
? invoiceVatTreatment
|
||||
: getVatTreatmentForRate(rate)
|
||||
const revenueAccount = getRevenueAccount(treatment, entityType)
|
||||
const roundedSubtotal = Math.round(group.subtotal * 100) / 100
|
||||
const roundedSubtotal = Math.round(toSek(group.subtotal) * 100) / 100
|
||||
|
||||
lines.push({
|
||||
account_number: revenueAccount,
|
||||
@@ -78,7 +104,7 @@ function generatePerRateLines(
|
||||
line_description: `Försäljning faktura ${invoiceNumber}`,
|
||||
})
|
||||
|
||||
const roundedVat = Math.round(group.vatAmount * 100) / 100
|
||||
const roundedVat = Math.round(toSek(group.vatAmount) * 100) / 100
|
||||
if (roundedVat !== 0) {
|
||||
const vatAccount = getOutputVatAccount(treatment)
|
||||
lines.push({
|
||||
@@ -125,41 +151,65 @@ export async function createInvoiceJournalEntry(
|
||||
}
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const isForeign = invoice.currency !== 'SEK'
|
||||
|
||||
// Debit: Kundfordringar (total including VAT)
|
||||
lines.push({
|
||||
account_number: '1510',
|
||||
debit_amount: invoice.total,
|
||||
credit_amount: 0,
|
||||
line_description: `Faktura ${invoice.invoice_number}`,
|
||||
currency: invoice.currency,
|
||||
amount_in_currency: invoice.currency !== 'SEK' ? invoice.total : undefined,
|
||||
exchange_rate: invoice.exchange_rate || undefined,
|
||||
})
|
||||
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
|
||||
const creditLines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Credit lines: revenue + VAT per rate group
|
||||
if (invoice.items && invoice.items.length > 0) {
|
||||
lines.push(...generatePerRateLines(invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number))
|
||||
creditLines.push(...generatePerRateLines(
|
||||
invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number,
|
||||
invoice.currency, invoice.exchange_rate
|
||||
))
|
||||
} else {
|
||||
// Fallback: no items available, use invoice-level amounts
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
|
||||
lines.push({
|
||||
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
|
||||
|
||||
creditLines.push({
|
||||
account_number: revenueAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: invoice.subtotal,
|
||||
credit_amount: subtotalSek,
|
||||
line_description: `Försäljning faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
|
||||
if (invoice.vat_amount > 0) {
|
||||
const vatLines = generateSalesVatLines({
|
||||
vatTreatment: invoice.vat_treatment,
|
||||
baseAmount: invoice.subtotal,
|
||||
direction: 'sales',
|
||||
})
|
||||
lines.push(...vatLines)
|
||||
if (isForeign) {
|
||||
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
|
||||
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
|
||||
creditLines.push({
|
||||
account_number: vatAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: vatSek,
|
||||
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
} else {
|
||||
const vatLines = generateSalesVatLines({
|
||||
vatTreatment: invoice.vat_treatment,
|
||||
baseAmount: invoice.subtotal,
|
||||
direction: 'sales',
|
||||
})
|
||||
creditLines.push(...vatLines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debit: Kundfordringar — balance guarantee: debit = sum of all credit lines
|
||||
const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
const debitAmount = isForeign
|
||||
? Math.round(totalCredits * 100) / 100
|
||||
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
|
||||
|
||||
lines.push({
|
||||
account_number: '1510',
|
||||
debit_amount: debitAmount,
|
||||
credit_amount: 0,
|
||||
line_description: `Faktura ${invoice.invoice_number}`,
|
||||
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
|
||||
})
|
||||
|
||||
lines.push(...creditLines)
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: invoice.invoice_date,
|
||||
@@ -182,7 +232,8 @@ export async function createInvoicePaymentJournalEntry(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
invoice: Invoice,
|
||||
paymentDate: string
|
||||
paymentDate: string,
|
||||
exchangeRateDifference?: number
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
@@ -190,25 +241,71 @@ export async function createInvoicePaymentJournalEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = [
|
||||
{
|
||||
account_number: '1930', // Företagskonto
|
||||
debit_amount: invoice.total,
|
||||
const desc = `Betalning faktura ${invoice.invoice_number}`
|
||||
const bookedSekAmount = resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
if (exchangeRateDifference && exchangeRateDifference !== 0) {
|
||||
// Foreign currency with exchange rate difference
|
||||
// For receivables: positive diff = gain (received more), negative = loss (received less)
|
||||
const actualSekReceived = bookedSekAmount + exchangeRateDifference
|
||||
|
||||
// Debit: Bank at actual SEK received
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
debit_amount: Math.round(actualSekReceived * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: `Betalning faktura ${invoice.invoice_number}`,
|
||||
},
|
||||
{
|
||||
account_number: '1510', // Kundfordringar
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
// Credit: Clear kundfordringar at original booked SEK amount
|
||||
lines.push({
|
||||
account_number: '1510',
|
||||
debit_amount: 0,
|
||||
credit_amount: invoice.total,
|
||||
line_description: `Betalning faktura ${invoice.invoice_number}`,
|
||||
},
|
||||
]
|
||||
credit_amount: Math.round(bookedSekAmount * 100) / 100,
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
// Exchange rate difference
|
||||
if (exchangeRateDifference > 0) {
|
||||
// Gain: Credit 3960 (received more than booked)
|
||||
lines.push({
|
||||
account_number: '3960',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(exchangeRateDifference * 100) / 100,
|
||||
line_description: 'Valutakursvinst',
|
||||
})
|
||||
} else {
|
||||
// Loss: Debit 7960 (received less than booked)
|
||||
lines.push({
|
||||
account_number: '7960',
|
||||
debit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: 'Valutakursförlust',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Standard SEK payment or no exchange rate difference
|
||||
lines.push(
|
||||
{
|
||||
account_number: '1930',
|
||||
debit_amount: Math.round(bookedSekAmount * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
},
|
||||
{
|
||||
account_number: '1510',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(bookedSekAmount * 100) / 100,
|
||||
line_description: desc,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: paymentDate,
|
||||
description: `Betalning faktura ${invoice.invoice_number}`,
|
||||
description: desc,
|
||||
source_type: 'invoice_paid',
|
||||
source_id: invoice.id,
|
||||
lines,
|
||||
@@ -237,15 +334,19 @@ export async function createCreditNoteJournalEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
const absTotal = Math.abs(creditNote.total)
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Generate reversed revenue + VAT lines per rate group
|
||||
// Generate reversed revenue + VAT lines per rate group (debit side for credit notes)
|
||||
const debitLines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
if (creditNote.items && creditNote.items.length > 0) {
|
||||
const creditLines = generatePerRateLines(creditNote.items, creditNote.vat_treatment, entityType, creditNote.invoice_number)
|
||||
// Swap debit/credit for credit note reversal (make amounts absolute first)
|
||||
// Use absolute items for generatePerRateLines, then swap debit/credit
|
||||
const creditLines = generatePerRateLines(
|
||||
creditNote.items, creditNote.vat_treatment, entityType, creditNote.invoice_number,
|
||||
creditNote.currency, creditNote.exchange_rate
|
||||
)
|
||||
for (const line of creditLines) {
|
||||
lines.push({
|
||||
debitLines.push({
|
||||
...line,
|
||||
debit_amount: Math.abs(line.credit_amount),
|
||||
credit_amount: Math.abs(line.debit_amount),
|
||||
@@ -255,10 +356,10 @@ export async function createCreditNoteJournalEntry(
|
||||
} else {
|
||||
// Fallback: invoice-level amounts
|
||||
const revenueAccount = getRevenueAccount(creditNote.vat_treatment, entityType)
|
||||
const absSubtotal = Math.abs(creditNote.subtotal)
|
||||
const absVat = Math.abs(creditNote.vat_amount)
|
||||
const absSubtotal = Math.abs(resolveSekAmount(creditNote.subtotal, creditNote.subtotal_sek, creditNote.currency, creditNote.exchange_rate))
|
||||
const absVat = Math.abs(resolveSekAmount(creditNote.vat_amount, creditNote.vat_amount_sek, creditNote.currency, creditNote.exchange_rate))
|
||||
|
||||
lines.push({
|
||||
debitLines.push({
|
||||
account_number: revenueAccount,
|
||||
debit_amount: absSubtotal,
|
||||
credit_amount: 0,
|
||||
@@ -267,7 +368,7 @@ export async function createCreditNoteJournalEntry(
|
||||
|
||||
if (absVat > 0) {
|
||||
const vatAccount = getOutputVatAccount(creditNote.vat_treatment)
|
||||
lines.push({
|
||||
debitLines.push({
|
||||
account_number: vatAccount,
|
||||
debit_amount: absVat,
|
||||
credit_amount: 0,
|
||||
@@ -276,11 +377,14 @@ export async function createCreditNoteJournalEntry(
|
||||
}
|
||||
}
|
||||
|
||||
// Credit: Kundfordringar (reverse the debit)
|
||||
lines.push(...debitLines)
|
||||
|
||||
// Credit: Kundfordringar — balance guarantee: credit = sum of all debit lines
|
||||
const totalDebits = debitLines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
lines.push({
|
||||
account_number: '1510',
|
||||
debit_amount: 0,
|
||||
credit_amount: absTotal,
|
||||
credit_amount: Math.round(totalDebits * 100) / 100,
|
||||
line_description: `Kreditfaktura ${creditNote.invoice_number}`,
|
||||
})
|
||||
|
||||
@@ -318,39 +422,51 @@ export async function createInvoiceCashEntry(
|
||||
}
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const isForeign = invoice.currency !== 'SEK'
|
||||
|
||||
// Debit: Företagskonto (total received)
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
debit_amount: invoice.total,
|
||||
credit_amount: 0,
|
||||
line_description: `Betalning faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
|
||||
const creditLines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Credit lines: revenue + VAT per rate group
|
||||
if (invoice.items && invoice.items.length > 0) {
|
||||
lines.push(...generatePerRateLines(invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number))
|
||||
creditLines.push(...generatePerRateLines(
|
||||
invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number,
|
||||
invoice.currency, invoice.exchange_rate
|
||||
))
|
||||
} else {
|
||||
// Fallback: invoice-level amounts
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
|
||||
lines.push({
|
||||
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
|
||||
|
||||
creditLines.push({
|
||||
account_number: revenueAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: invoice.subtotal,
|
||||
credit_amount: subtotalSek,
|
||||
line_description: `Försäljning faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
|
||||
if (invoice.vat_amount > 0) {
|
||||
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
|
||||
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
|
||||
lines.push({
|
||||
creditLines.push({
|
||||
account_number: vatAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: invoice.vat_amount,
|
||||
credit_amount: vatSek,
|
||||
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Debit: Företagskonto — balance guarantee: debit = sum of credit lines
|
||||
const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
debit_amount: isForeign ? Math.round(totalCredits * 100) / 100 : resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate),
|
||||
credit_amount: 0,
|
||||
line_description: `Betalning faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
|
||||
lines.push(...creditLines)
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: paymentDate,
|
||||
|
||||
@@ -14,9 +14,24 @@ import type {
|
||||
EntityType,
|
||||
VatJournalLine,
|
||||
} from '@/types'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
// Capitalization threshold in SEK (half-year rule: 29,400 for 2024)
|
||||
const CAPITALIZATION_THRESHOLD = 29400
|
||||
const log = createLogger('mapping-engine')
|
||||
|
||||
// Half of prisbasbelopp per year (used for capitalization threshold)
|
||||
const PRISBASBELOPP_HALVES: Record<number, number> = {
|
||||
2024: 28650, // PBB 57,300
|
||||
2025: 29400, // PBB 58,800
|
||||
2026: 29600, // PBB 59,200
|
||||
}
|
||||
const LATEST_KNOWN_YEAR = 2026
|
||||
|
||||
function getCapitalizationThreshold(year: number): number {
|
||||
const threshold = PRISBASBELOPP_HALVES[year]
|
||||
if (threshold) return threshold
|
||||
log.warn(`No prisbasbelopp for ${year}, using ${LATEST_KNOWN_YEAR} value`)
|
||||
return PRISBASBELOPP_HALVES[LATEST_KNOWN_YEAR]
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all mapping rules against a transaction and return the best match
|
||||
@@ -52,7 +67,7 @@ export async function evaluateMappingRules(
|
||||
// Evaluate each rule in priority order
|
||||
for (const rule of rules as MappingRule[]) {
|
||||
if (matchesRule(rule, transaction)) {
|
||||
return buildResult(rule, transaction)
|
||||
return buildResult(rule, transaction, entityType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,25 +156,23 @@ function matchesRule(rule: MappingRule, transaction: Transaction): boolean {
|
||||
/**
|
||||
* Build a MappingResult from a matched rule
|
||||
*/
|
||||
function buildResult(rule: MappingRule, transaction: Transaction): MappingResult {
|
||||
function buildResult(rule: MappingRule, transaction: Transaction, entityType?: EntityType): MappingResult {
|
||||
const absAmount = Math.abs(transaction.amount)
|
||||
const isExpense = transaction.amount < 0
|
||||
|
||||
let debitAccount = rule.debit_account || (isExpense ? '6991' : '1930')
|
||||
let creditAccount = rule.credit_account || (isExpense ? '1930' : '3001')
|
||||
const creditAccount = rule.credit_account || (isExpense ? '1930' : '3900')
|
||||
|
||||
// Check capitalization threshold for equipment
|
||||
if (
|
||||
rule.capitalization_threshold &&
|
||||
absAmount > rule.capitalization_threshold &&
|
||||
rule.capitalized_debit_account
|
||||
) {
|
||||
const year = new Date(transaction.date).getFullYear()
|
||||
const threshold = rule.capitalization_threshold ?? getCapitalizationThreshold(year)
|
||||
if (absAmount > threshold && rule.capitalized_debit_account) {
|
||||
debitAccount = rule.capitalized_debit_account
|
||||
}
|
||||
|
||||
// If default_private, override to 2013
|
||||
// If default_private, use entity-specific private account
|
||||
if (rule.default_private && isExpense) {
|
||||
debitAccount = '2013'
|
||||
debitAccount = entityType === 'aktiebolag' ? '2893' : '2013'
|
||||
}
|
||||
|
||||
// Generate VAT lines if applicable
|
||||
@@ -215,7 +228,7 @@ function getDefaultResult(transaction: Transaction): MappingResult {
|
||||
return {
|
||||
rule: null,
|
||||
debit_account: isExpense ? '6991' : '1930',
|
||||
credit_account: isExpense ? '1930' : '3001',
|
||||
credit_account: isExpense ? '1930' : '3900',
|
||||
risk_level: 'MEDIUM',
|
||||
confidence: 0.1,
|
||||
requires_review: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||||
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
|
||||
import { generateReverseChargeLines } from './vat-entries'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
@@ -41,48 +42,53 @@ export async function createSupplierInvoiceRegistrationEntry(
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const desc = `Lev.faktura ${invoice.supplier_invoice_number} (ankomst ${invoice.arrival_number})`
|
||||
const isForeign = invoice.currency !== 'SEK'
|
||||
|
||||
// Aggregate expense amounts by account number
|
||||
// Aggregate expense amounts by account number and convert to SEK
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const current = expenseByAccount.get(item.account_number) || 0
|
||||
expenseByAccount.set(item.account_number, current + item.line_total)
|
||||
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
|
||||
expenseByAccount.set(item.account_number, current + itemSek)
|
||||
}
|
||||
|
||||
// Debit: Expense accounts
|
||||
// Debit: Expense accounts (in SEK)
|
||||
const debitLines: CreateJournalEntryLineInput[] = []
|
||||
for (const [accountNumber, amount] of expenseByAccount) {
|
||||
lines.push({
|
||||
debitLines.push({
|
||||
account_number: accountNumber,
|
||||
debit_amount: Math.round(amount * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
})
|
||||
}
|
||||
lines.push(...debitLines)
|
||||
|
||||
if (supplierType === 'eu_business' && invoice.reverse_charge) {
|
||||
// EU reverse charge: fiktiv moms entries
|
||||
// EU reverse charge: fiktiv moms entries (computed on SEK subtotal)
|
||||
const vatRate = getDefaultVatRate(invoice.vat_treatment)
|
||||
const reverseChargeLines = generateReverseChargeLines(invoice.subtotal, vatRate)
|
||||
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
|
||||
const reverseChargeLines = generateReverseChargeLines(subtotalSek, vatRate)
|
||||
lines.push(...reverseChargeLines)
|
||||
} else if (invoice.vat_amount > 0) {
|
||||
// Domestic: Debit ingående moms
|
||||
// Domestic: Debit ingående moms (in SEK)
|
||||
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
|
||||
lines.push({
|
||||
account_number: '2641',
|
||||
debit_amount: Math.round(invoice.vat_amount * 100) / 100,
|
||||
debit_amount: Math.round(vatSek * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: `Ingående moms ${desc}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Credit: Leverantörsskulder
|
||||
// Credit: Leverantörsskulder — balance guarantee: credit = sum of all debit lines
|
||||
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(invoice.total * 100) / 100,
|
||||
credit_amount: Math.round(totalDebits * 100) / 100,
|
||||
line_description: desc,
|
||||
currency: invoice.currency !== 'SEK' ? invoice.currency : undefined,
|
||||
amount_in_currency: invoice.currency !== 'SEK' ? invoice.total : undefined,
|
||||
exchange_rate: invoice.exchange_rate || undefined,
|
||||
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
|
||||
})
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
@@ -218,14 +224,15 @@ export async function createSupplierInvoiceCashEntry(
|
||||
const desc = `Betalning lev.faktura ${invoice.supplier_invoice_number} (kontantmetoden)`
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Aggregate expense amounts by account number
|
||||
// Aggregate expense amounts by account number and convert to SEK
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const current = expenseByAccount.get(item.account_number) || 0
|
||||
expenseByAccount.set(item.account_number, current + item.line_total)
|
||||
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
|
||||
expenseByAccount.set(item.account_number, current + itemSek)
|
||||
}
|
||||
|
||||
// Debit: Expense accounts
|
||||
// Debit: Expense accounts (in SEK)
|
||||
for (const [accountNumber, amount] of expenseByAccount) {
|
||||
lines.push({
|
||||
account_number: accountNumber,
|
||||
@@ -236,25 +243,28 @@ export async function createSupplierInvoiceCashEntry(
|
||||
}
|
||||
|
||||
if (supplierType === 'eu_business' && invoice.reverse_charge) {
|
||||
// EU reverse charge: fiktiv moms entries
|
||||
// EU reverse charge: fiktiv moms entries (computed on SEK subtotal)
|
||||
const vatRate = getDefaultVatRate(invoice.vat_treatment)
|
||||
const reverseChargeLines = generateReverseChargeLines(invoice.subtotal, vatRate)
|
||||
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
|
||||
const reverseChargeLines = generateReverseChargeLines(subtotalSek, vatRate)
|
||||
lines.push(...reverseChargeLines)
|
||||
} else if (invoice.vat_amount > 0) {
|
||||
// Domestic: Debit ingående moms
|
||||
// Domestic: Debit ingående moms (in SEK)
|
||||
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
|
||||
lines.push({
|
||||
account_number: '2641',
|
||||
debit_amount: Math.round(invoice.vat_amount * 100) / 100,
|
||||
debit_amount: Math.round(vatSek * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: `Ingående moms ${desc}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Credit: Företagskonto
|
||||
// Credit: Företagskonto — balance guarantee: credit = sum of all debit lines
|
||||
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(invoice.total * 100) / 100,
|
||||
credit_amount: Math.round(totalDebits * 100) / 100,
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
@@ -293,26 +303,17 @@ export async function createSupplierCreditNoteEntry(
|
||||
const desc = `Kreditfaktura lev. ${creditNote.supplier_invoice_number} (ankomst ${creditNote.arrival_number})`
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
const absTotal = Math.abs(creditNote.total)
|
||||
const absVat = Math.abs(creditNote.vat_amount)
|
||||
|
||||
// Debit: Leverantörsskulder
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: Math.round(absTotal * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
// Credit: Expense accounts (reverse)
|
||||
// Credit: Expense accounts (reverse, in SEK)
|
||||
const creditLines: CreateJournalEntryLineInput[] = []
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const current = expenseByAccount.get(item.account_number) || 0
|
||||
expenseByAccount.set(item.account_number, current + Math.abs(item.line_total))
|
||||
const itemSek = Math.abs(resolveSekAmount(item.line_total, null, creditNote.currency, creditNote.exchange_rate))
|
||||
expenseByAccount.set(item.account_number, current + itemSek)
|
||||
}
|
||||
|
||||
for (const [accountNumber, amount] of expenseByAccount) {
|
||||
lines.push({
|
||||
creditLines.push({
|
||||
account_number: accountNumber,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(amount * 100) / 100,
|
||||
@@ -323,29 +324,46 @@ export async function createSupplierCreditNoteEntry(
|
||||
if (supplierType === 'eu_business' && creditNote.reverse_charge) {
|
||||
// Reverse the fiktiv moms (swap debit/credit from registration)
|
||||
const vatRate = getDefaultVatRate(creditNote.vat_treatment)
|
||||
const vatAmount = Math.round(Math.abs(creditNote.subtotal) * vatRate * 100) / 100
|
||||
lines.push({
|
||||
const absSubtotalSek = Math.abs(resolveSekAmount(creditNote.subtotal, creditNote.subtotal_sek, creditNote.currency, creditNote.exchange_rate))
|
||||
const vatAmount = Math.round(absSubtotalSek * vatRate * 100) / 100
|
||||
creditLines.push({
|
||||
account_number: '2645',
|
||||
debit_amount: 0,
|
||||
credit_amount: vatAmount,
|
||||
line_description: `Omvänd fiktiv ingående moms ${desc}`,
|
||||
})
|
||||
// 2614 is a debit (reversal of the output VAT credit)
|
||||
lines.push({
|
||||
account_number: '2614',
|
||||
debit_amount: vatAmount,
|
||||
credit_amount: 0,
|
||||
line_description: `Omvänd fiktiv utgående moms ${desc}`,
|
||||
})
|
||||
} else if (absVat > 0) {
|
||||
// Credit: Ingående moms (reverse)
|
||||
lines.push({
|
||||
account_number: '2641',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(absVat * 100) / 100,
|
||||
line_description: `Ingående moms ${desc}`,
|
||||
})
|
||||
} else {
|
||||
const absVat = Math.abs(resolveSekAmount(creditNote.vat_amount, creditNote.vat_amount_sek, creditNote.currency, creditNote.exchange_rate))
|
||||
if (absVat > 0) {
|
||||
// Credit: Ingående moms (reverse)
|
||||
creditLines.push({
|
||||
account_number: '2641',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(absVat * 100) / 100,
|
||||
line_description: `Ingående moms ${desc}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(...creditLines)
|
||||
|
||||
// Debit: Leverantörsskulder — balance guarantee: debit = sum of credits minus other debits
|
||||
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
lines.unshift({
|
||||
account_number: '2440',
|
||||
debit_amount: Math.round((totalCredits - totalDebits) * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: creditNote.invoice_date,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||||
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
|
||||
import { generateInputVatLine, generateReverseChargeLines, extractNetAmount, extractVatAmount } from './vat-entries'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
@@ -56,8 +57,17 @@ export async function createTransactionJournalEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
const absAmount = Math.abs(transaction.amount)
|
||||
const absAmountSek = Math.abs(resolveSekAmount(
|
||||
transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate
|
||||
))
|
||||
const absAmount = absAmountSek
|
||||
const isExpense = transaction.amount < 0
|
||||
const isForeign = transaction.currency !== 'SEK'
|
||||
const currencyMeta = buildCurrencyMetadata(
|
||||
transaction.currency,
|
||||
isForeign ? Math.abs(transaction.amount) : undefined,
|
||||
transaction.exchange_rate
|
||||
)
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
if (mappingResult.default_private) {
|
||||
@@ -121,6 +131,7 @@ export async function createTransactionJournalEntry(
|
||||
debit_amount: 0,
|
||||
credit_amount: absAmount,
|
||||
line_description: transaction.description,
|
||||
...(creditAccount === '1930' ? currencyMeta : {}),
|
||||
})
|
||||
} else {
|
||||
// Income
|
||||
|
||||
@@ -29,6 +29,7 @@ function makeClient() {
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
validateBalance: vi.fn().mockReturnValue({ valid: true, totalDebit: 1000, totalCredit: 1000 }),
|
||||
getNextVoucherNumber: vi.fn(async () => ++resultIdx), // just increment
|
||||
getSwedishLocalDate: vi.fn().mockReturnValue('2024-06-15'),
|
||||
}))
|
||||
|
||||
import { correctEntry } from '../storno-service'
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
JournalEntry,
|
||||
JournalEntryLine,
|
||||
} from '@/types'
|
||||
import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
|
||||
import { validateBalance, getNextVoucherNumber, getSwedishLocalDate } from '@/lib/bookkeeping/engine'
|
||||
|
||||
/**
|
||||
* Storno Service - 3-step correction flow per Bokföringslagen
|
||||
@@ -69,7 +69,7 @@ export async function correctEntry(
|
||||
fiscal_period_id: original.fiscal_period_id,
|
||||
voucher_number: reversalVoucherNumber,
|
||||
voucher_series: original.voucher_series || 'A',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
entry_date: getSwedishLocalDate(),
|
||||
description: `Storno: ${original.description}`,
|
||||
source_type: 'storno',
|
||||
reverses_id: originalEntryId,
|
||||
@@ -172,7 +172,7 @@ export async function correctEntry(
|
||||
fiscal_period_id: original.fiscal_period_id,
|
||||
voucher_number: correctedVoucherNumber,
|
||||
voucher_series: original.voucher_series || 'A',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
entry_date: getSwedishLocalDate(),
|
||||
description: `Rättelse: ${original.description}`,
|
||||
source_type: 'correction',
|
||||
correction_of_id: originalEntryId,
|
||||
|
||||
@@ -86,7 +86,13 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"dataPattern": "manual",
|
||||
"description": "Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost",
|
||||
"longDescription": "Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument.",
|
||||
"hasOwnData": true
|
||||
"hasOwnData": true,
|
||||
"quickAction": {
|
||||
"label": "Dokumentinkorg",
|
||||
"description": "Granska inkommande dokument",
|
||||
"icon": "Inbox",
|
||||
"href": "/e/general/invoice-inbox"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "calendar",
|
||||
|
||||
@@ -4,8 +4,9 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent
|
||||
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
|
||||
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
|
||||
import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { fetchMultipleRates } from '@/lib/currency/riksbanken'
|
||||
import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import type { Transaction, RawTransaction, IngestResult, SupplierInvoice } from '@/types'
|
||||
import type { Transaction, RawTransaction, IngestResult, SupplierInvoice, Currency, ExchangeRate } from '@/types'
|
||||
|
||||
// Re-export types for backward compatibility
|
||||
export type { RawTransaction, IngestResult } from '@/types'
|
||||
@@ -108,6 +109,21 @@ export async function ingestTransactions(
|
||||
// Non-critical — supplier invoice matching will be skipped
|
||||
}
|
||||
|
||||
// Pre-fetch exchange rates for non-SEK currencies (non-critical)
|
||||
let exchangeRates = new Map<Currency, ExchangeRate>()
|
||||
try {
|
||||
const uniqueCurrencies = [...new Set(
|
||||
rawTransactions
|
||||
.map(t => t.currency)
|
||||
.filter((c): c is Currency => c != null && c !== 'SEK')
|
||||
)]
|
||||
if (uniqueCurrencies.length > 0) {
|
||||
exchangeRates = await fetchMultipleRates(uniqueCurrencies)
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — amount_sek fields will stay null
|
||||
}
|
||||
|
||||
for (const raw of rawTransactions) {
|
||||
// 1. Check for duplicates via external_id
|
||||
const { data: existing } = await supabase
|
||||
@@ -132,7 +148,14 @@ export async function ingestTransactions(
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Insert new transaction
|
||||
// 2. Insert new transaction (with SEK conversion for foreign currencies)
|
||||
const rateInfo = raw.currency && raw.currency !== 'SEK'
|
||||
? exchangeRates.get(raw.currency as Currency)
|
||||
: undefined
|
||||
const amountSek = rateInfo
|
||||
? Math.round(raw.amount * rateInfo.rate * 100) / 100
|
||||
: null
|
||||
|
||||
const { data: newTransaction, error: insertError } = await supabase
|
||||
.from('transactions')
|
||||
.insert({
|
||||
@@ -143,6 +166,9 @@ export async function ingestTransactions(
|
||||
description: raw.description,
|
||||
amount: raw.amount,
|
||||
currency: raw.currency,
|
||||
amount_sek: amountSek,
|
||||
exchange_rate: rateInfo?.rate ?? null,
|
||||
exchange_rate_date: rateInfo?.date ?? null,
|
||||
category: 'uncategorized',
|
||||
is_business: null,
|
||||
mcc_code: raw.mcc_code || null,
|
||||
|
||||
Reference in New Issue
Block a user